diff --git a/README.md b/README.md index a961cd7f..bf4f0646 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@

Your second brain

+

Agent Harness · Spring Boot inside · One JAR to ship

+ [![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) @@ -31,6 +33,8 @@ > **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.** > > Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress. +> +> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't. Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product. diff --git a/README_zh.md b/README_zh.md index eb5e6d73..5b3f9789 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,6 +8,8 @@

你的超级大脑

+

Agent Harness · Spring Boot 内核 · 一个 JAR 交付

+ [![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) @@ -31,6 +33,8 @@ > **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。** > > 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。 +> +> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。 大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。 diff --git a/mateclaw-plugin-api/pom.xml b/mateclaw-plugin-api/pom.xml index 0221ea40..d1101b7a 100644 --- a/mateclaw-plugin-api/pom.xml +++ b/mateclaw-plugin-api/pom.xml @@ -4,36 +4,21 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - vip.mate + + vip.mate + mateclaw + ${revision} + ../pom.xml + + mateclaw-plugin-api - 1.1.0-SNAPSHOT jar MateClaw Plugin API - Plugin SDK contract for MateClaw — external plugins depend only on this module - - - 21 - 21 - 21 - UTF-8 - 1.1.4 - - - - - - org.springframework.ai - spring-ai-bom - ${spring-ai.version} - pom - import - - - + Plugin SDK contract for MateClaw - external plugins depend only on this module - + org.springframework.ai spring-ai-model @@ -44,7 +29,6 @@ org.slf4j slf4j-api - 2.0.16 provided @@ -52,16 +36,7 @@ com.fasterxml.jackson.core jackson-databind - 2.18.3 provided - - - - spring-milestones - https://repo.spring.io/milestone - false - - diff --git a/mateclaw-plugin-sample/pom.xml b/mateclaw-plugin-sample/pom.xml index a9149840..66277da1 100644 --- a/mateclaw-plugin-sample/pom.xml +++ b/mateclaw-plugin-sample/pom.xml @@ -4,40 +4,24 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - vip.mate + + vip.mate + mateclaw + ${revision} + ../pom.xml + + mateclaw-plugin-sample - 1.0.0 jar MateClaw Sample Plugin A sample plugin demonstrating the MateClaw Plugin SDK - - 21 - 21 - 21 - UTF-8 - 1.1.4 - - - - - - org.springframework.ai - spring-ai-bom - ${spring-ai.version} - pom - import - - - - vip.mate mateclaw-plugin-api - 1.1.0-SNAPSHOT provided @@ -52,16 +36,7 @@ org.slf4j slf4j-api - 2.0.16 provided - - - - spring-milestones - https://repo.spring.io/milestone - false - - diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index d6ec4146..05963794 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -35,28 +35,29 @@ FROM maven:3.9-eclipse-temurin-21 AS builder # Optional Maven extra flags passed at build time. # Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun -# repos first — speeds up builds dramatically inside mainland China. +# repos first. This speeds up builds inside mainland China. ARG MAVEN_FLAGS="" # Inject mirror settings to avoid Maven Central timeouts in restricted networks COPY mateclaw-server/settings.xml /root/.m2/settings.xml -# Build and install plugin-api into the local Maven cache first -WORKDIR /plugin-api -COPY mateclaw-plugin-api/pom.xml ./pom.xml -COPY mateclaw-plugin-api/src ./src -RUN mvn install -Dmaven.test.skip=true -q ${MAVEN_FLAGS} - -# Pre-fetch mateclaw-server dependencies (uses mirror, so this won't hang) +# Copy the root parent plus module POMs first for Docker layer caching. WORKDIR /build -COPY mateclaw-server/pom.xml . -RUN mvn dependency:go-offline -q ${MAVEN_FLAGS} +COPY pom.xml ./pom.xml +COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml +COPY mateclaw-server/pom.xml mateclaw-server/pom.xml +COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml + +# Pre-fetch backend dependencies through the reactor so the parent POM, +# dependencyManagement, and internal module versions all resolve consistently. +RUN mvn -pl mateclaw-server -am dependency:go-offline -q ${MAVEN_FLAGS} # Copy backend source and inject pre-built frontend into the right classpath location -COPY mateclaw-server/src ./src -COPY --from=frontend-builder /static ./src/main/resources/static +COPY mateclaw-plugin-api/src mateclaw-plugin-api/src +COPY mateclaw-server/src mateclaw-server/src +COPY --from=frontend-builder /static mateclaw-server/src/main/resources/static -RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS} +RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS} # Stage 3 — Runtime # @@ -65,11 +66,11 @@ RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS} # pre-installed. This avoids the `playwright install` step and the Alpine/musl # incompatibility that blocks browser_use on minimal images. # -# We pin to the exact Playwright version declared in pom.xml (1.52.0). If you +# We pin to the exact Playwright version declared in the root pom.xml. If you # bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each # tag with the matching driver, so mismatched versions cause the java driver to # re-download browsers at runtime (defeating the whole point of this image). -FROM mcr.microsoft.com/playwright:v1.52.0-noble +FROM mcr.microsoft.com/playwright:v1.59.0-noble WORKDIR /app # JDK 21 is NOT part of the base image (it ships Node for the JS driver). @@ -106,7 +107,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ TZ=Asia/Shanghai \ JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai" -COPY --from=builder /build/target/*.jar app.jar +COPY --from=builder /build/mateclaw-server/target/*.jar app.jar EXPOSE 18088 EXPOSE 1455 ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"] diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 6b638658..74cf5bb4 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -4,70 +4,33 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - vip.mate + + vip.mate + mateclaw + ${revision} + ../pom.xml + + mateclaw-server - 1.3.0 jar MateClaw Server MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba - - org.springframework.boot - spring-boot-starter-parent - 3.5.14 - - - - - 21 - UTF-8 - - 1.1.6 - - 1.1.2.3 - 3.5.16 - 5.8.26 - 2.8.16 - 0.12.6 - - - - - - - org.springframework.ai - spring-ai-bom - ${spring-ai.version} - pom - import - - - - org.springdoc - springdoc-openapi-bom - ${springdoc.version} - pom - import - - - - vip.mate mateclaw-plugin-api - 1.1.0-SNAPSHOT - + org.springframework.boot spring-boot-starter-web - + org.springframework.boot spring-boot-starter-actuator @@ -75,14 +38,13 @@ com.alibaba.cloud.ai spring-ai-alibaba-starter-dashscope - ${spring-ai-alibaba.version} - + org.springframework.boot @@ -91,11 +53,10 @@ - + com.alibaba.cloud.ai spring-ai-alibaba-graph-core - ${spring-ai-alibaba.version} @@ -104,16 +65,15 @@ spring-ai-openai - + org.springframework.ai spring-ai-anthropic - + org.springframework.ai @@ -126,31 +86,29 @@ - + com.h2database h2 runtime - + com.mysql mysql-connector-j runtime - + com.baomidou mybatis-plus-spring-boot3-starter - ${mybatis-plus.version} - + com.baomidou mybatis-plus-jsqlparser - ${mybatis-plus.version} @@ -163,55 +121,49 @@ io.jsonwebtoken jjwt-api - ${jjwt.version} io.jsonwebtoken jjwt-impl - ${jjwt.version} runtime io.jsonwebtoken jjwt-jackson - ${jjwt.version} runtime - + org.springdoc springdoc-openapi-starter-webmvc-ui - + cn.hutool hutool-all - ${hutool.version} - + com.dingtalk.open dingtalk-stream - 1.3.12 - + com.larksuite.oapi oapi-sdk - 2.6.1 - + com.github.ben-manes.caffeine caffeine - + org.yaml snakeyaml @@ -228,28 +180,24 @@ com.google.zxing core - 3.5.3 com.google.zxing javase - 3.5.3 com.microsoft.playwright playwright - 1.52.0 - + net.dv8tion JDA - 5.2.3 - + club.minnced opus-java @@ -257,27 +205,24 @@ - + org.springframework.boot spring-boot-starter-websocket - + com.slack.api slack-api-client - 1.44.2 com.slack.api bolt-socket-mode - 1.44.2 org.glassfish.tyrus.bundles tyrus-standalone-client - 2.2.0 @@ -288,7 +233,6 @@ org.apache.poi poi-ooxml - 5.4.1 @@ -302,60 +246,54 @@ org.apache.xmlgraphics batik-transcoder - 1.18 org.apache.xmlgraphics batik-codec - 1.18 - + org.jsoup jsoup - 1.18.3 - + org.apache.tika tika-core - 3.0.0 org.apache.tika tika-parser-pdf-module - 3.0.0 org.apache.tika tika-parser-microsoft-module - 3.0.0 @@ -413,33 +345,30 @@ test - com.tngtech.archunit archunit-junit5 - 1.3.0 test net.javacrumbs.shedlock shedlock-spring - 5.16.0 net.javacrumbs.shedlock shedlock-provider-jdbc-template - 5.16.0 - - - - maven-central - Maven Central - https://repo.maven.apache.org/maven2 - true - false - - - - google-maven-central - Google Maven Central Mirror - https://maven-central.storage-download.googleapis.com/maven2 - true - false - - - - aliyun-public - Aliyun Public - https://maven.aliyun.com/repository/public - true - false - - - - spring-milestones - Spring Milestones - https://repo.spring.io/milestone - true - false - - - - aliyun-spring - Aliyun Spring Mirror - https://maven.aliyun.com/repository/spring - true - false - - - - - - - maven-central - Maven Central - https://repo.maven.apache.org/maven2 - true - false - - - google-maven-central - Google Maven Central Mirror - https://maven-central.storage-download.googleapis.com/maven2 - true - false - - - aliyun-public - Aliyun Public - https://maven.aliyun.com/repository/public - true - false - - - - - - aliyun-first - - - aliyun-public-first - https://maven.aliyun.com/repository/public - true - false - - - aliyun-spring-first - https://maven.aliyun.com/repository/spring - true - false - - - - - aliyun-public-first - https://maven.aliyun.com/repository/public - true - false - - - - ) so that - * future Dream runs do not overwrite user modifications. + * When a user edits a memory entry, this service writes it back to the target + * memory file (MEMORY.md, PROFILE.md, SOUL.md, ...) with a hidden metadata + * marker ({@code }) so that future Dream runs + * do not overwrite user modifications. * * @author MateClaw Team */ @@ -24,53 +26,64 @@ import java.time.LocalDate; @RequiredArgsConstructor public class MemoryHilService { + /** Matches a whole-line user-edited marker so repeated edits do not accumulate markers. */ + private static final Pattern USER_EDITED_MARKER = + Pattern.compile("(?m)^[ \\t]*[ \\t]*\\r?\\n?"); + private final WorkspaceFileService workspaceFileService; private final ApplicationEventPublisher eventPublisher; /** - * Edit a section in MEMORY.md identified by key (section heading). + * Edit a section identified by key (section heading) inside {@code filename}. * Appends user-edited metadata so Dream prompts respect user changes. + * + * @param agentId the agent whose workspace file is edited + * @param filename the target memory file (e.g. MEMORY.md / PROFILE.md / SOUL.md) + * @param key the section heading (text after {@code ## }) + * @param newContent the new section body */ - public void editMemoryEntry(Long agentId, String key, String newContent) { - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md"); - if (file == null || file.getContent() == null) { - log.warn("[HiL] MEMORY.md not found for agent={}", agentId); - return; - } + public void editMemoryEntry(Long agentId, String filename, String key, String newContent) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + String fileContent = (file != null && file.getContent() != null) ? file.getContent() : ""; - String memoryContent = file.getContent(); + // Strip any pre-existing user-edited markers from the incoming body so a + // section edited multiple times does not pick up a stack of markers. + String cleanContent = USER_EDITED_MARKER.matcher(newContent).replaceAll("").trim(); + String metadata = ""; String sectionHeader = "## " + key; - int headerIdx = memoryContent.indexOf(sectionHeader); + int headerIdx = fileContent.indexOf(sectionHeader); + String updated; if (headerIdx < 0) { - // Section not found — append as new section - String metadata = ""; - String newSection = "\n\n" + sectionHeader + "\n" + newContent.trim() + "\n" + metadata; - memoryContent = memoryContent.trim() + newSection; + // Section not found — append as a new section. + String newSection = sectionHeader + "\n" + cleanContent + "\n" + metadata; + updated = fileContent.isBlank() ? newSection : fileContent.trim() + "\n\n" + newSection; } else { - // Find section boundaries - int contentStart = memoryContent.indexOf('\n', headerIdx) + 1; - int nextSection = memoryContent.indexOf("\n## ", contentStart); - int sectionEnd = nextSection > 0 ? nextSection : memoryContent.length(); - - // Replace section content - String metadata = ""; - String replacement = newContent.trim() + "\n" + metadata + "\n"; - memoryContent = memoryContent.substring(0, contentStart) + replacement - + memoryContent.substring(sectionEnd); + // Replace the existing section body, keeping the heading in place. + int contentStart = fileContent.indexOf('\n', headerIdx) + 1; + int nextSection = fileContent.indexOf("\n## ", contentStart); + int sectionEnd = nextSection > 0 ? nextSection : fileContent.length(); + String replacement = cleanContent + "\n" + metadata + "\n"; + updated = fileContent.substring(0, contentStart) + replacement + + fileContent.substring(sectionEnd); } - workspaceFileService.saveFile(agentId, "MEMORY.md", memoryContent); - eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "user-edit", newContent)); - log.info("[HiL] User edited MEMORY.md section '{}' for agent={}", key, agentId); + workspaceFileService.saveFile(agentId, filename, updated); + // SOUL.md auto-evolution counts canonical memory writes. A manual SOUL.md + // edit must not bump that counter, or a later auto-regeneration would + // discard the user's edit; PROFILE.md likewise is not a write trigger. + if ("MEMORY.md".equals(filename)) { + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "user-edit", cleanContent)); + } + log.info("[HiL] User edited {} section '{}' for agent={}", filename, key, agentId); } /** - * Check if a section heading exists in MEMORY.md. - * Used by DreamController to validate edit key before allowing write. + * Check if a section heading exists in {@code filename}. + * Used by DreamController to validate the edit key before allowing a write. */ - public boolean sectionExists(Long agentId, String key) { - WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md"); + public boolean sectionExists(Long agentId, String filename, String key) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); if (file == null || file.getContent() == null) return false; return file.getContent().contains("## " + key); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java index af8b3328..4af0007b 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java @@ -13,8 +13,6 @@ import java.util.List; *
  • Post-turn sync (async persistence)
  • *
  • Agent tools (Spring AI @Tool beans)
  • * - *

    - * Inspired by Hermes Agent's MemoryProvider architecture. * * @author MateClaw Team */ diff --git a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java new file mode 100644 index 00000000..1f1396ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java @@ -0,0 +1,72 @@ +package vip.mate.notification; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.agent.runtime.AgentRuntimeAggregator; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Aggregate counts that drive global UI attention signals (sidebar badges, + * future notification center). + * + *

    Designed so the frontend can poll a single endpoint instead of fan-out + * to every domain service. Fields with no settled "is it actually a problem" + * semantics (failed crons / down channels / down MCP servers) are returned + * as zero placeholders so the wire shape is stable and later phases can + * populate them without bumping the contract. + */ +@Slf4j +@Tag(name = "Notifications") +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final ApprovalWorkflowService approvalWorkflowService; + private final AgentRuntimeAggregator agentRuntimeAggregator; + + @Operation(summary = "Aggregated counts for the sidebar attention badges") + @GetMapping("/summary") + public R> summary(Authentication auth) { + boolean admin = isAdmin(auth); + + // Cast to int — counts won't exceed Integer.MAX_VALUE in practice + // and the project's global Jackson config serializes Long as a string + // (for ID precision), which would break the numeric UI badge. + int pendingApprovals = (int) Math.min(Integer.MAX_VALUE, approvalWorkflowService.countPendingFromDb()); + int stuckAgents = admin + ? agentRuntimeAggregator.snapshot().summary().stuck() + : 0; + + Map payload = new LinkedHashMap<>(); + payload.put("pendingApprovals", pendingApprovals); + payload.put("stuckAgents", stuckAgents); + // Reserved fields — wire shape stays stable so the frontend doesn't + // need a fan-out when these get real semantics later. + payload.put("failedCrons", 0); + payload.put("downChannels", 0); + payload.put("downMcps", 0); + return R.ok(payload); + } + + private boolean isAdmin(Authentication auth) { + if (auth == null) { + throw new MateClawException(401, "authentication required"); + } + return auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java index dd24ca49..3ccdfb46 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/controller/PluginController.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.plugin.PluginManager; import vip.mate.plugin.model.PluginInfo; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -26,18 +27,21 @@ public class PluginController { @Operation(summary = "List all plugins") @GetMapping + @RequireWorkspaceRole("admin") public R> list() { return R.ok(pluginManager.listPlugins()); } @Operation(summary = "Get plugin detail") @GetMapping("/{name}") + @RequireWorkspaceRole("admin") public R get(@PathVariable String name) { return R.ok(pluginManager.getPlugin(name)); } @Operation(summary = "Disable a plugin") @PostMapping("/{name}/disable") + @RequireWorkspaceRole("admin") public R disable(@PathVariable String name) { pluginManager.disablePlugin(name); return R.ok(); @@ -45,6 +49,7 @@ public class PluginController { @Operation(summary = "Enable a plugin") @PostMapping("/{name}/enable") + @RequireWorkspaceRole("admin") public R enable(@PathVariable String name) { pluginManager.enablePlugin(name); return R.ok(); @@ -52,6 +57,7 @@ public class PluginController { @Operation(summary = "Update plugin configuration") @PutMapping("/{name}/config") + @RequireWorkspaceRole("admin") public R updateConfig(@PathVariable String name, @RequestBody Map config) { pluginManager.updateConfig(name, config); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java index e5ae7a20..448289e9 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java @@ -58,23 +58,36 @@ import java.util.concurrent.ConcurrentHashMap; * page always shows current state. * * - *

    ID namespace: virtual skill ids use a high sentinel - * {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two - * id spaces never collide and a callsite can dispatch on which bridge - * owns an id without coordination. + *

    ID namespace: virtual ACP ids set both top bits of a {@code long} + * (bit 63 + bit 62) so they sit in a different type-tag than MCP + * (which sets only bit 63 — see + * {@link vip.mate.skill.mcp.McpSkillBridge}). The bottom 62 bits carry + * the underlying endpointId. The earlier {@code 8e18 + endpointId} + * addition scheme broke once Snowflake-issued endpoint ids crossed + * the {@code 1e17} bound, so the bit-tagged layout replaces it. + * + *

    {@code VIRTUAL_ID_BASE + smallId} still equals + * {@code VIRTUAL_ID_BASE | smallId} for any {@code smallId < 2^62}, so + * test fixtures that build virtual ids by addition continue to work. */ @Slf4j @Service public class AcpSkillBridge { + /** Type tag for ACP virtual ids: bits 63 + 62 set. */ + public static final long VIRTUAL_ID_BASE = 0xC000000000000000L; /** - * High sentinel for ACP virtual id space. Distinct from - * {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual - * spaces are partitionable by simple range checks. + * @deprecated The bound is implicit in the bit-tag layout — any id + * whose top two bits are both set is an ACP virtual id. Kept + * for source compatibility with earlier callers. */ - public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L; - /** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */ - public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L; + @Deprecated + public static final long VIRTUAL_ID_BOUND = -1L; // 0xFFFFFFFFFFFFFFFFL + + /** Selects the top-two type-tag bits. */ + private static final long TAG_MASK = 0xC000000000000000L; + /** Selects the bottom 62 bits that carry the original endpoint id. */ + private static final long ID_MASK = 0x3FFFFFFFFFFFFFFFL; private final AcpEndpointService endpointService; private final AcpDelegationService delegationService; @@ -100,16 +113,22 @@ public class AcpSkillBridge { } public static boolean isVirtualAcpSkillId(Long id) { - return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND; + return id != null && (id & TAG_MASK) == VIRTUAL_ID_BASE; } public static Long extractEndpointId(Long virtualId) { if (!isVirtualAcpSkillId(virtualId)) return null; - return virtualId - VIRTUAL_ID_BASE; + return virtualId & ID_MASK; } public static long virtualIdFor(AcpEndpointEntity endpoint) { - return VIRTUAL_ID_BASE + endpoint.getId(); + long eid = endpoint.getId(); + if ((eid & TAG_MASK) != 0L) { + throw new IllegalStateException( + "ACP endpoint id 0x" + Long.toHexString(eid) + + " uses the top two bits — would collide with the virtual id type tag"); + } + return VIRTUAL_ID_BASE | eid; } @PostConstruct @@ -212,7 +231,7 @@ public class AcpSkillBridge { private void registerWrappers(AcpEndpointEntity ep) { if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return; - String slug = slugify(ep.getName()); + String slug = slugForEndpoint(ep); if (slug.isEmpty()) { log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId()); return; @@ -289,7 +308,7 @@ public class AcpSkillBridge { private SkillEntity endpointToEntity(AcpEndpointEntity ep) { SkillEntity s = new SkillEntity(); s.setId(virtualIdFor(ep)); - s.setName(slugify(ep.getName())); + s.setName(slugForEndpoint(ep)); s.setNameEn(displayName(ep)); s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank() ? displayName(ep) : null); @@ -310,6 +329,7 @@ public class AcpSkillBridge { s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts s.setConfigJson(buildConfigJson(ep)); s.setManifestJson(serializeManifest(buildManifest(ep))); + s.setSkillContent(buildSkillContent(ep)); return s; } @@ -342,9 +362,9 @@ public class AcpSkillBridge { return ResolvedSkill.builder() .id(virtualIdFor(ep)) - .name(slugify(ep.getName())) + .name(slugForEndpoint(ep)) .description(buildDescription(ep)) - .content("") // no SKILL.md + .content(buildSkillContent(ep)) .source("acp") .skillDir(null) .configuredSkillDir(null) @@ -374,7 +394,7 @@ public class AcpSkillBridge { * it up the same way as a hand-authored skill manifest. */ private SkillManifest buildManifest(AcpEndpointEntity ep) { - String slug = slugify(ep.getName()); + String slug = slugForEndpoint(ep); String toolName = "acp_" + slug + "_prompt"; List tools = List.of(toolName); @@ -423,6 +443,72 @@ public class AcpSkillBridge { .build(); } + /** + * Synthesize a SKILL.md body for an ACP-derived virtual skill. + * + *

    ACP endpoints carry no hand-authored SKILL.md — they wrap an + * external coding-agent CLI rather than a skill package. Without a + * synthesized body, an agent that calls + * {@code readSkillFile(skillName=..., filePath="SKILL.md")} gets + * nothing beyond the one-line description and cannot tell how to + * drive the endpoint. + * + *

    This builds a markdown brief from the live endpoint row: what + * the endpoint is, the single wrapper tool it exposes, that tool's + * arguments, and usage notes — so the LLM can call + * {@code acp__prompt} correctly on the first attempt. + */ + private String buildSkillContent(AcpEndpointEntity ep) { + String slug = slugForEndpoint(ep); + String toolName = "acp_" + slug + "_prompt"; + StringBuilder sb = new StringBuilder(); + + sb.append("# ").append(displayName(ep)).append("\n\n"); + sb.append(buildDescription(ep)).append("\n\n"); + + sb.append("## Overview\n\n"); + sb.append("This skill delegates work to the **").append(ep.getName()) + .append("** ACP (Agent Communication Protocol) coding agent. ") + .append("The agent runs as an external CLI process spawned on demand: ") + .append("send it a single natural-language instruction and it returns ") + .append("its final reply.\n\n"); + + sb.append("## Tools\n\n"); + sb.append("### `").append(toolName).append("`\n\n"); + sb.append("Delegate a prompt to the '").append(ep.getName()) + .append("' coding agent and receive its final reply.\n\n"); + sb.append("Parameters:\n\n"); + sb.append("- `prompt` (string, required) — the instruction or question to send.\n"); + sb.append("- `cwd` (string, optional) — working directory; defaults to the ") + .append("endpoint's workspace base path when omitted.\n\n"); + + sb.append("## Usage notes\n\n"); + sb.append("- Call `").append(toolName).append("` with one self-contained instruction. ") + .append("The endpoint runs autonomously and returns only its final answer, ") + .append("not intermediate steps.\n"); + sb.append("- Omit `cwd` unless the task needs a specific directory — the server ") + .append("resolves the endpoint's bound workspace path.\n"); + if (Boolean.TRUE.equals(ep.getTrusted())) { + sb.append("- This endpoint is trusted: the agent's own tool calls are accepted ") + .append("without re-prompting for approval.\n"); + } else { + sb.append("- This endpoint is not trusted: the agent's tool calls may require ") + .append("human approval before they run.\n"); + } + String status = nullSafe(ep.getLastStatus()); + if ("OK".equalsIgnoreCase(status)) { + sb.append("- Last connection test: OK.\n"); + } else if ("ERROR".equalsIgnoreCase(status) + || (ep.getLastError() != null && !ep.getLastError().isBlank())) { + sb.append("- Last connection test failed: ").append(nullSafe(ep.getLastError())) + .append(". The CLI may not be installed or reachable.\n"); + } else { + sb.append("- Not yet tested — the CLI is spawned on the first call.\n"); + } + + return sb.toString(); + } + // ==================== Helpers ==================== private List safeListEnabled() { @@ -447,6 +533,28 @@ public class AcpSkillBridge { return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-"); } + /** + * Stable slug for an ACP endpoint. Falls back to {@code acp-{id}} when + * the source name has no ASCII letter/digit (e.g. pure CJK), because + * the naive slugify would otherwise return a run of dashes and two + * differently-named all-CJK endpoints would collide on the same slug, + * which is also the basis for the {@code acp__prompt} wrapper + * tool name registered in the global tool registry. + */ + private String slugForEndpoint(AcpEndpointEntity ep) { + String slug = slugify(ep.getName()); + return hasAsciiAlphaNumeric(slug) ? slug : "acp-" + ep.getId(); + } + + private static boolean hasAsciiAlphaNumeric(String s) { + if (s == null || s.isEmpty()) return false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) return true; + } + return false; + } + private String displayName(AcpEndpointEntity ep) { if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName(); return ep.getName() != null ? ep.getName() : "acp-" + ep.getId(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index d40d957d..ce3c0243 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.agent.AgentService; import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.agent.binding.repository.AgentSkillBindingMapper; import vip.mate.agent.binding.service.AgentBindingService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -25,7 +26,15 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.BundledSkillSyncer; import vip.mate.skill.workspace.SkillFileSyncer; import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.exception.MateClawException; +import vip.mate.skill.lifecycle.ConfirmRequiredException; +import vip.mate.skill.lifecycle.LifecycleTransition; +import vip.mate.skill.lifecycle.SkillCuratorJob; +import vip.mate.skill.lifecycle.SkillCuratorReport; +import vip.mate.skill.lifecycle.SkillCuratorReportStore; +import vip.mate.skill.lifecycle.SkillLifecycleService; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -59,10 +68,15 @@ public class SkillController { private final AgentBindingService agentBindingService; private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge; private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge; + private final SkillLifecycleService skillLifecycleService; + private final SkillCuratorJob skillCuratorJob; + private final SkillCuratorReportStore skillCuratorReportStore; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping + @RequireWorkspaceRole("member") public R> list( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String keyword, @@ -72,13 +86,20 @@ public class SkillController { @RequestParam(required = false) String sort, @RequestParam(required = false) String source, @RequestParam(required = false) String runtime, + @RequestParam(required = false) String lifecycleState, @RequestParam(required = false) Long agentId) { Set pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of(); if (pinnedSkillIds == null) pinnedSkillIds = Set.of(); IPage dbPage = skillService.pageSkills( - page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, pinnedSkillIds); - List virtualSkills = visibleVirtualSkills( - keyword, skillType, enabled, scanStatus, sort, source, runtime); + page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, + pinnedSkillIds, workspaceId, lifecycleState); + // Virtual MCP/ACP skills mirror live servers and carry no lifecycle + // state — exclude them whenever the caller filters by lifecycleState + // (stale / archived / active), otherwise they leak into every tab. + List virtualSkills = (lifecycleState != null && !lifecycleState.isBlank()) + ? List.of() + : visibleVirtualSkills( + workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime); if (!virtualSkills.isEmpty()) { VirtualPageMergeResult merged = mergeVirtualTailPageRecords( dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size); @@ -90,9 +111,11 @@ public class SkillController { @Operation(summary = "获取各类型技能计数(tab 徽章用)") @GetMapping("/counts") - public R> counts() { - Map result = skillService.countByType(); - Set realNames = realSkillNames(); + @RequireWorkspaceRole("member") + public R> counts( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + Map result = skillService.countByType(workspaceId); + Set realNames = realSkillNames(workspaceId); // RFC-090 §3.2 — virtual MCP-derived skills aren't in mate_skill, // so countByType() misses them. Fold in the live count so the // "MCP" and "all" tab badges match what the list endpoint shows. @@ -139,6 +162,14 @@ public class SkillController { return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size(); } + /** Keep only enabled rows — gates virtual skills into enabled-only endpoints. */ + static List enabledOnly(List skills) { + if (skills == null || skills.isEmpty()) return List.of(); + return skills.stream() + .filter(s -> s != null && Boolean.TRUE.equals(s.getEnabled())) + .toList(); + } + /** * Keep MyBatis-Plus as the source of truth for DB pagination and append * live virtual ACP/MCP rows after the DB rows. This produces one stable @@ -182,7 +213,8 @@ public class SkillController { record VirtualPageMergeResult(List records, long total) {} - private List visibleVirtualSkills(String keyword, + private List visibleVirtualSkills(Long workspaceId, + String keyword, String skillType, Boolean enabled, String scanStatus, @@ -194,7 +226,7 @@ public class SkillController { boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource); if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of(); - Set realNames = realSkillNames(); + Set realNames = realSkillNames(workspaceId); List result = new ArrayList<>(); if (includeMcpVirtuals) { try { @@ -242,16 +274,25 @@ public class SkillController { return value != null && value.toLowerCase().contains(lowerCaseNeedle); } - private Set realSkillNames() { - return skillService.listSkills().stream() + /** + * Names of every real {@code mate_skill} row visible in {@code + * workspaceId} (builtin + workspace-owned). Used to shadow same-named + * MCP/ACP virtual skills so the catalog never shows two cards for one + * capability. + */ + private Set realSkillNames(Long workspaceId) { + return skillService.listSkills(workspaceId).stream() .map(SkillEntity::getName) .collect(java.util.stream.Collectors.toSet()); } @Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)") @PostMapping("/{id}/rescan") - public R rescan(@PathVariable Long id) { + @RequireWorkspaceRole("admin") + public R rescan(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); return R.ok(skillService.rescanSecurity(id)); } @@ -260,9 +301,12 @@ public class SkillController { "in a multi-instance deployment. Pulls every mate_skill_file row owned by the skill " + "down to disk; if no rows exist yet but local files do, ingests them into the canonical store.") @PostMapping("/{id}/sync-files") - public R> syncFiles(@PathVariable Long id) { + @RequireWorkspaceRole("admin") + public R> syncFiles(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); var report = skillFileSyncer.syncOne(skill); Map body = new LinkedHashMap<>(); body.put("skillId", id); @@ -278,6 +322,7 @@ public class SkillController { description = "Bulk variant of /sync-files; primarily for ops debugging when you suspect " + "the local workspace is out of sync with the canonical store.") @PostMapping("/sync-files") + @RequireWorkspaceRole("admin") public R> syncAllFiles() { var report = skillFileSyncer.syncAll(); Map body = new LinkedHashMap<>(); @@ -308,27 +353,54 @@ public class SkillController { } } + /** + * Reject access to a skill that the request's workspace doesn't own. + * Builtin skills are global and exempt — every workspace may read and + * (where role permits) toggle them. The interceptor already verified + * the caller's role inside {@code workspaceId}; this guard closes the + * remaining gap where a member of workspace B targets a skill id that + * actually belongs to workspace A. + */ + private void verifyResourceWorkspace(SkillEntity skill, Long headerWorkspaceId) { + if (skill == null || Boolean.TRUE.equals(skill.getBuiltin())) { + return; + } + long requested = headerWorkspaceId != null + ? headerWorkspaceId : SkillService.DEFAULT_WORKSPACE_ID; + long owner = skill.getWorkspaceId() != null + ? skill.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID; + if (owner != requested) { + throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403, + "Skill " + skill.getId() + " does not belong to the current workspace"); + } + } + @Operation(summary = "获取已启用技能列表") @GetMapping("/enabled") - public R> listEnabled() { + @RequireWorkspaceRole("member") + public R> listEnabled( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { // Mirror the merging the paginated /skills endpoint does so the agent // edit picker (which calls this endpoint) sees MCP- and ACP-derived // virtual skills alongside the persisted ones. The shadow base must // include all real skill names — including disabled ones — so a // disabled real skill correctly suppresses its same-named virtual // twin, matching /skills and /counts. - List result = new ArrayList<>(skillService.listEnabledSkills()); - Set realNames = realSkillNames(); + // The bridges surface disabled MCP/ACP servers too (so the Skills + // page can show a toggled-off card); this endpoint is enabled-only, + // so the virtual rows are filtered to enabled before merging. + List result = new ArrayList<>(skillService.listEnabledSkills(workspaceId)); + Set realNames = realSkillNames(workspaceId); try { - result.addAll(filterShadowedVirtualSkills( - mcpSkillBridge.listMcpDerivedSkillEntities(), realNames)); + result.addAll(enabledOnly(filterShadowedVirtualSkills( + mcpSkillBridge.listMcpDerivedSkillEntities(), realNames))); } catch (Exception e) { // Bridge failure must not 500 the picker — same defensive stance as /counts. } try { - result.addAll(filterShadowedVirtualSkills( - acpSkillBridge.listAcpDerivedSkillEntities(), realNames)); + result.addAll(enabledOnly(filterShadowedVirtualSkills( + acpSkillBridge.listAcpDerivedSkillEntities(), realNames))); } catch (Exception e) { // Bridge failure must not 500 the picker — same defensive stance as /counts. } @@ -337,19 +409,25 @@ public class SkillController { @Operation(summary = "按类型获取技能列表") @GetMapping("/type/{skillType}") - public R> listByType(@PathVariable String skillType) { - return R.ok(skillService.listSkillsByType(skillType)); + @RequireWorkspaceRole("member") + public R> listByType(@PathVariable String skillType, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(skillService.listSkillsByType(skillType, workspaceId)); } @Operation(summary = "获取已启用技能摘要(按类型分组)") @GetMapping("/summary") - public R>> summary() { - return R.ok(skillService.getEnabledSkillSummary()); + @RequireWorkspaceRole("member") + public R>> summary( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(skillService.getEnabledSkillSummary(workspaceId)); } @Operation(summary = "获取技能详情") @GetMapping("/{id}") - public R get(@PathVariable Long id) { + @RequireWorkspaceRole("member") + public R get(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { // RFC-090 §3.2 — virtual MCP-derived skills synthesize a row // on demand from the live MCP server entity. if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) { @@ -365,19 +443,31 @@ public class SkillController { SkillEntity ent = acpSkillBridge.findEntityById(id); return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id); } - return R.ok(skillService.getSkill(id)); + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + return R.ok(skill); } @Operation(summary = "创建技能") @PostMapping - public R create(@RequestBody SkillEntity skill) { + @RequireWorkspaceRole("admin") + public R create( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestBody SkillEntity skill) { + // Always stamp the owning workspace from the request context — never + // trust a workspaceId in the request body. + skill.setWorkspaceId(workspaceId != null + ? workspaceId : SkillService.DEFAULT_WORKSPACE_ID); return R.ok(skillService.createSkill(skill)); } @Operation(summary = "更新技能") @PutMapping("/{id}") - public R update(@PathVariable Long id, @RequestBody SkillEntity skill) { + @RequireWorkspaceRole("admin") + public R update(@PathVariable Long id, @RequestBody SkillEntity skill, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); skill.setId(id); return R.ok(skillService.updateSkill(skill)); } @@ -393,21 +483,35 @@ public class SkillController { */ @Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)") @DeleteMapping("/{id}") - public R delete(@PathVariable Long id) { + @RequireWorkspaceRole("admin") + public R delete(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); skillService.hardDeleteSkill(id); return R.ok(); } @Operation(summary = "启用/禁用技能") @PutMapping("/{id}/toggle") - public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { + @RequireWorkspaceRole("admin") + public R toggle(@PathVariable Long id, @RequestParam boolean enabled, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + // A virtual MCP skill mirrors an MCP server — toggling it enables / + // disables that server, keeping the Skills page and Settings ▸ MCP + // Connections in sync. ACP virtual skills have no such mapping and + // stay read-only via rejectVirtualSkillMutation below. + if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) { + return R.ok(mcpSkillBridge.toggleVirtualSkill(id, enabled)); + } rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); return R.ok(skillService.toggleSkill(id, enabled)); } @Operation(summary = "预览技能 Prompt 增强效果(调试用,与 Agent 真实运行时一致)") @GetMapping("/prompt-preview") + @RequireWorkspaceRole("admin") public R> promptPreview() { String prompt = skillRuntimeService.buildSkillPromptEnhancement(); return R.ok(Map.of( @@ -421,6 +525,7 @@ public class SkillController { @Operation(summary = "获取 active skills 运行时视图") @GetMapping("/runtime/active") + @RequireWorkspaceRole("admin") public R> getActiveSkills() { List skills = skillRuntimeService.getActiveSkills(); return R.ok(Map.of("count", skills.size(), "skills", skills)); @@ -428,12 +533,14 @@ public class SkillController { @Operation(summary = "获取所有技能的运行时解析状态(管理页面使用)") @GetMapping("/runtime/status") + @RequireWorkspaceRole("admin") public R> getRuntimeStatus() { return R.ok(skillRuntimeService.resolveAllSkillsStatus()); } @Operation(summary = "刷新 active skills 缓存,resync=true 时同步内置技能到 workspace") @PostMapping("/runtime/refresh") + @RequireWorkspaceRole("admin") public R> refreshRuntime( @RequestParam(defaultValue = "false") boolean resync) { List resynced = List.of(); @@ -459,6 +566,7 @@ public class SkillController { */ @Operation(summary = "Pre-flight requirement statuses for a skill (RFC-090)") @GetMapping("/{id}/requirements") + @RequireWorkspaceRole("member") public R> requirements(@PathVariable Long id) { ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() .filter(r -> r != null && id.equals(r.getId())) @@ -524,6 +632,7 @@ public class SkillController { */ @Operation(summary = "List agents that can use this skill (RFC-090 §14.2)") @GetMapping("/{id}/employees") + @RequireWorkspaceRole("member") public R>> employees(@PathVariable Long id) { // Explicit bindings: agent_skill rows pointing to this skill. List explicitBindings = agentSkillBindingMapper.selectList( @@ -590,6 +699,7 @@ public class SkillController { */ @Operation(summary = "Read per-skill LESSONS.md (RFC-090 §11.4)") @GetMapping("/{id}/lessons") + @RequireWorkspaceRole("member") public R> getLessons(@PathVariable Long id) { ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() .filter(r -> r != null && id.equals(r.getId())) @@ -620,6 +730,7 @@ public class SkillController { @Operation(summary = "Clear all lessons for a skill (RFC-090 §11.4)") @PostMapping("/{id}/lessons/clear") + @RequireWorkspaceRole("admin") public R> clearLessons(@PathVariable Long id) { ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream() .filter(r -> r != null && id.equals(r.getId())) @@ -634,14 +745,17 @@ public class SkillController { @Operation(summary = "从对话历史合成 Skill(RFC-023)") @PostMapping("/synthesize-from-conversation") - public R> synthesizeFromConversation(@RequestBody Map body) { + @RequireWorkspaceRole("admin") + public R> synthesizeFromConversation(@RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { String conversationId = (String) body.get("conversationId"); Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; if (conversationId == null || conversationId.isBlank()) { return R.fail("conversationId is required"); } - SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId); + SkillSynthesisService.SynthesisResult result = synthesisService.synthesize( + conversationId, agentId, workspaceId); if (result.blocked()) { return R.ok(Map.of( @@ -666,8 +780,11 @@ public class SkillController { @Operation(summary = "将 skill 导出到工作区目录") @PostMapping("/{id}/export-workspace") - public R> exportToWorkspace(@PathVariable Long id) { + @RequireWorkspaceRole("admin") + public R> exportToWorkspace(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent()); if (path == null) { return R.ok(Map.of("success", false, "message", "Failed to export workspace")); @@ -677,8 +794,136 @@ public class SkillController { @Operation(summary = "获取 skill 工作区信息") @GetMapping("/{id}/workspace") - public R> getWorkspaceInfo(@PathVariable Long id) { + @RequireWorkspaceRole("admin") + public R> getWorkspaceInfo(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); return R.ok(workspaceManager.getWorkspaceInfo(skill.getName())); } + + // ==================== Skill lifecycle & curator ==================== + + @Operation(summary = "钉住/取消钉住技能(钉住的技能不会被自动归档)") + @PostMapping("/{id}/pin") + @RequireWorkspaceRole("admin") + public R pin(@PathVariable Long id, + @RequestBody(required = false) PinRequest body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); + boolean pinned = body != null && Boolean.TRUE.equals(body.pinned()); + return R.ok(skillLifecycleService.setPinned(id, pinned)); + } + + @Operation(summary = "手动归档技能") + @PostMapping("/{id}/archive") + @RequireWorkspaceRole("admin") + public R archive(@PathVariable Long id, + @RequestParam(defaultValue = "false") boolean force, + @RequestBody(required = false) ArchiveRequest body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + if (Boolean.TRUE.equals(skill.getBuiltin())) { + throw new MateClawException("err.skill.builtin_not_archivable", 400, + "Cannot archive builtin skill: " + skill.getName()); + } + String state = skill.getLifecycleState() == null ? "active" : skill.getLifecycleState(); + if ("archived".equals(state)) { + throw new MateClawException("err.skill.already_archived", 409, + "Skill already archived: " + skill.getName()); + } + // Bound skills are not silently archived: require an explicit + // second-pass confirmation (force=true) so the admin sees which + // agents lose the capability. + if (!force) { + List bound = + agentBindingService.enabledAgentsBoundToSkill(id); + if (!bound.isEmpty()) { + throw new ConfirmRequiredException("BOUND_SKILL_CONFIRM_REQUIRED", + "Skill is explicitly bound to " + bound.size() + + " agent(s); pass force=true to confirm", bound); + } + } + String reason = body != null && body.reason() != null ? body.reason() : "manual:admin"; + skillLifecycleService.applyManual(skill, LifecycleTransition.TO_ARCHIVED, + LocalDateTime.now(), reason); + return R.ok(skillService.getSkill(id)); + } + + @Operation(summary = "恢复已归档的技能") + @PostMapping("/{id}/restore") + @RequireWorkspaceRole("admin") + public R restore(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); + return R.ok(skillLifecycleService.restore(id)); + } + + @Operation(summary = "立即运行一次 curator 预览(dry-run)") + @PostMapping("/curator/dry-run") + @RequireWorkspaceRole("admin") + public R curatorDryRun() { + return R.ok(skillCuratorJob.dryRunNow()); + } + + @Operation(summary = "激活/取消激活 curator(真正归档 vs 仅预览)") + @PostMapping("/curator/activate") + @RequireWorkspaceRole("admin") + public R> curatorActivate( + @RequestParam(defaultValue = "true") boolean activate) { + skillCuratorJob.activate(activate); + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "暂停 curator 定时扫描") + @PostMapping("/curator/pause") + @RequireWorkspaceRole("admin") + public R> curatorPause() { + skillCuratorJob.setPaused(true); + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "恢复 curator 定时扫描") + @PostMapping("/curator/resume") + @RequireWorkspaceRole("admin") + public R> curatorResume() { + skillCuratorJob.setPaused(false); + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "curator 控制面状态") + @GetMapping("/curator/status") + @RequireWorkspaceRole("member") + public R> curatorStatus() { + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "列出最近的 curator 运行报告") + @GetMapping("/curator/reports") + @RequireWorkspaceRole("member") + public R> curatorReports() { + return R.ok(skillCuratorReportStore.listRunIds(20)); + } + + @Operation(summary = "读取某次 curator 运行报告") + @GetMapping("/curator/reports/{runId}") + @RequireWorkspaceRole("member") + public R curatorReport(@PathVariable String runId) { + Object report = skillCuratorReportStore.readRun(runId); + if (report == null) { + throw new MateClawException("err.skill.curator_report_not_found", 404, + "Curator report not found: " + runId); + } + return R.ok(report); + } + + /** Body of {@code POST /skills/{id}/pin}. */ + public record PinRequest(Boolean pinned) {} + + /** Optional body of {@code POST /skills/{id}/archive}. */ + public record ArchiveRequest(String reason) {} } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java index 91dea8a2..ac81767c 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import vip.mate.common.result.R; import vip.mate.skill.installer.SkillInstaller; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.skill.installer.ZipSkillFetcher; import vip.mate.skill.installer.model.*; import vip.mate.skill.runtime.SkillFrontmatterParser; @@ -34,6 +35,7 @@ public class SkillInstallController { @Operation(summary = "搜索 ClawHub 市场") @GetMapping("/hub/search") + @RequireWorkspaceRole("admin") public R> searchHub( @RequestParam String q, @RequestParam(defaultValue = "20") int limit) { @@ -42,15 +44,21 @@ public class SkillInstallController { @Operation(summary = "开始异步安装 skill") @PostMapping("/start") - public R startInstall(@RequestBody InstallRequest request) { + @RequireWorkspaceRole("admin") + public R startInstall(@RequestBody InstallRequest request, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { if (request.getBundleUrl() == null || request.getBundleUrl().isBlank()) { return R.fail("bundleUrl is required"); } + // Stamp the owning workspace from the request context — never trust + // a workspaceId smuggled in the JSON body. + request.setWorkspaceId(workspaceId); return R.ok(skillInstaller.startInstall(request)); } @Operation(summary = "查询安装任务状态") @GetMapping("/status/{taskId}") + @RequireWorkspaceRole("admin") public R getStatus(@PathVariable String taskId) { InstallTask task = skillInstaller.getTaskStatus(taskId); if (task == null) { @@ -61,6 +69,7 @@ public class SkillInstallController { @Operation(summary = "取消安装任务") @PostMapping("/cancel/{taskId}") + @RequireWorkspaceRole("admin") public R cancel(@PathVariable String taskId) { skillInstaller.cancelTask(taskId); return R.ok(); @@ -68,11 +77,13 @@ public class SkillInstallController { @Operation(summary = "上传 ZIP 安装 skill") @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RequireWorkspaceRole("admin") public R> uploadZip( @RequestPart("file") MultipartFile zipFile, @RequestParam(defaultValue = "true") Boolean enable, @RequestParam(defaultValue = "false") Boolean overwrite, - @RequestParam(required = false) String targetName) { + @RequestParam(required = false) String targetName, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { // 校验文件类型 String filename = zipFile.getOriginalFilename(); if (filename == null || !filename.toLowerCase().endsWith(".zip")) { @@ -80,7 +91,8 @@ public class SkillInstallController { } try { SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser); - Map result = skillInstaller.installFromBundle(bundle, enable, overwrite, targetName); + Map result = skillInstaller.installFromBundle( + bundle, enable, overwrite, targetName, workspaceId); return R.ok(result); } catch (IllegalArgumentException e) { return R.fail(400, e.getMessage()); @@ -91,8 +103,10 @@ public class SkillInstallController { @Operation(summary = "卸载 skill") @DeleteMapping("/{skillName}") - public R> uninstall(@PathVariable String skillName) { - skillInstaller.uninstall(skillName); + @RequireWorkspaceRole("admin") + public R> uninstall(@PathVariable String skillName, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + skillInstaller.uninstall(skillName, workspaceId); return R.ok(Map.of("message", "Skill '" + skillName + "' uninstalled")); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/event/SkillRemovedEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/event/SkillRemovedEvent.java new file mode 100644 index 00000000..70868637 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/event/SkillRemovedEvent.java @@ -0,0 +1,17 @@ +package vip.mate.skill.event; + +/** + * Fires after a skill row has been removed from {@code mate_skill}, whether + * through the user-facing uninstall path or the admin hard-delete path. + * + *

    Downstream listeners use this to scrub records that reference the + * deleted skill — most importantly the agent-skill binding rows in + * {@code mate_agent_skill}, which would otherwise leave orphan bindings the + * UI can't unset (the binding count stays > 0 and the picker can no longer + * render the row to uncheck it). + * + * @param skillId DB id of the removed skill row + * @param skillName slug identifier the row carried, useful for log lines + */ +public record SkillRemovedEvent(Long skillId, String skillName) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java index dd956087..dceb01b6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java @@ -103,7 +103,7 @@ public class BuiltinSkillSeedService implements ApplicationRunner { // from the previous successful run AND the DB still holds the same // number of builtin rows, nothing on disk changed since last seed // and we can skip the parse / select / update loop entirely. - // Hermes-style trick: stat-only check, no content read. + // The check is stat-only — no content read. Map currentManifest = buildResourceManifest(resources); SeedSnapshot snapshot = loadSnapshot(); if (snapshot != null diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java index 8e0e4cf1..a3dee481 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java @@ -81,7 +81,7 @@ public class SkillInstaller { * admin-only physical removal, call * {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}. */ - public void uninstall(String skillName) { + public void uninstall(String skillName, Long workspaceId) { List skills = skillService.listSkills(); SkillEntity target = skills.stream() .filter(s -> s.getName().equals(skillName)) @@ -89,6 +89,18 @@ public class SkillInstaller { .orElse(null); if (target != null) { + // A workspace may only uninstall the skills it owns. Builtin + // skills are global and rejected by uninstallSkill itself. + if (!Boolean.TRUE.equals(target.getBuiltin())) { + long requested = workspaceId != null + ? workspaceId : SkillService.DEFAULT_WORKSPACE_ID; + long owner = target.getWorkspaceId() != null + ? target.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID; + if (owner != requested) { + throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403, + "Skill '" + skillName + "' does not belong to the current workspace"); + } + } skillService.uninstallSkill(target.getId()); } log.info("Uninstalled skill: {}", skillName); @@ -159,7 +171,7 @@ public class SkillInstaller { // 5. Register/update the skill row first so we have an id for the file rows. SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, - Boolean.TRUE.equals(request.getEnable())); + Boolean.TRUE.equals(request.getEnable()), request.getWorkspaceId()); if (task.isCancelRequested()) { task.markCancelled(); @@ -199,7 +211,8 @@ public class SkillInstaller { * * @return 安装结果 Map(skillId, name, version, filesCount) */ - public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) { + public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, + String targetName, Long workspaceId) { String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name(); if (skillName == null || skillName.isBlank()) { throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle"); @@ -216,7 +229,7 @@ public class SkillInstaller { workspaceManager.initWorkspace(skillName, bundle.content(), exists); // Register/update skill row first so we have an id to anchor the file rows. - SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable); + SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable, workspaceId); // DB-canonical, FS-cache. Empty-bundle guard on both sides. persistBundleFiles(skillEntity, bundle, false, "zip"); @@ -241,8 +254,13 @@ public class SkillInstaller { /** * Insert or update the {@code mate_skill} row from a bundle. Returns the * persisted entity so callers have its id for downstream file writes. + * + *

    {@code workspaceId} is stamped only on the insert path — an + * existing skill keeps its current owning workspace so a re-install + * never silently migrates a skill between workspaces. */ - private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, boolean enable) { + private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, + boolean enable, Long workspaceId) { SkillEntity skillEntity; if (exists) { skillEntity = skillService.listSkills().stream() @@ -267,6 +285,7 @@ public class SkillInstaller { skillEntity.setSkillContent(bundle.content()); skillEntity.setConfigJson(buildConfigJson(bundle)); skillEntity.setEnabled(enable); + skillEntity.setWorkspaceId(workspaceId); skillService.createSkill(skillEntity); } return skillEntity; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java index 0f4ac2ac..ef483df5 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java @@ -5,8 +5,11 @@ import org.springframework.web.multipart.MultipartFile; import vip.mate.skill.installer.model.SkillBundle; import vip.mate.skill.runtime.SkillFrontmatterParser; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; @@ -135,12 +138,55 @@ public class ZipSkillFetcher { * instead of being silently dropped. */ public static ExtractedSkill extract(InputStream zipStream) throws IOException { + return extract(zipStream.readAllBytes()); + } + + /** Fallback charset for archives authored on Chinese Windows (entry names / content in GBK). */ + private static final Charset GBK = Charset.isSupported("GBK") ? Charset.forName("GBK") : null; + + /** + * Decompress raw ZIP bytes, trying UTF-8 first and falling back to GBK when + * an entry name fails to decode as UTF-8 — the common failure mode for zips + * packaged on Chinese Windows, where filenames are GBK and UTF-8 decoding + * throws a {@link CharacterCodingException}. Buffering the bytes (rather than + * a one-shot stream) is what makes the retry possible. + */ + public static ExtractedSkill extract(byte[] zipBytes) throws IOException { + try { + return extract(zipBytes, StandardCharsets.UTF_8); + } catch (IOException | RuntimeException e) { + if (GBK != null && isCharsetError(e)) { + log.warn("[ZipSkillFetcher] UTF-8 entry decode failed, retrying with GBK (Windows-authored archive?)"); + return extract(zipBytes, GBK); + } + throw e; + } + } + + /** True if {@code t} (or any cause) is a charset-decode failure, vs a genuine "no SKILL.md" error. */ + private static boolean isCharsetError(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + if (c instanceof CharacterCodingException) { + return true; + } + String m = c.getMessage(); + if (m != null && m.toLowerCase().contains("malformed")) { + return true; + } + if (c.getCause() == c) { + break; + } + } + return false; + } + + private static ExtractedSkill extract(byte[] zipBytes, Charset charset) throws IOException { List raws = new ArrayList<>(); String skillMdContent = null; String skillMdPrefix = ""; long totalSize = 0; - try (ZipInputStream zis = new ZipInputStream(zipStream, StandardCharsets.UTF_8)) { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { if (entry.isDirectory()) { @@ -150,6 +196,13 @@ public class ZipSkillFetcher { String entryName = entry.getName(); + // Skip macOS archive cruft so it doesn't surface as "ignored" noise. + if (entryName.startsWith("__MACOSX/") || entryName.equals(".DS_Store") + || entryName.endsWith("/.DS_Store")) { + zis.closeEntry(); + continue; + } + // Zip Slip guard: normalize and reject absolute / traversal entries. Path entryPath = Path.of(entryName).normalize(); if (entryPath.isAbsolute() || entryName.contains("..")) { @@ -176,7 +229,7 @@ public class ZipSkillFetcher { throw new IOException("Total extracted size exceeds 50MB limit"); } - String content = new String(bytes, StandardCharsets.UTF_8); + String content = new String(bytes, charset); String normalizedName = entryPath.toString().replace('\\', '/'); String fileName = entryPath.getFileName().toString(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java index 47143c17..f7ba22d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java @@ -33,4 +33,11 @@ public class InstallRequest { * an intentionally empty bundle. */ private Boolean forcePrune = false; + + /** + * Owning workspace for the installed skill. Stamped by the controller + * from the {@code X-Workspace-Id} header — never trusted from a raw + * client body. {@code null} falls back to the default workspace. + */ + private Long workspaceId; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java new file mode 100644 index 00000000..5e2cbc3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java @@ -0,0 +1,17 @@ +package vip.mate.skill.lifecycle; + +import java.util.List; + +/** + * A skill that satisfies the curator's idle time window but is kept out of + * the candidate set because it is explicitly bound to one or more enabled + * agents. Surfaced in the run report so an admin can see what was held back. + * + * @author MateClaw Team + */ +public record BlockedByBindingRow( + Long skillId, + String name, + List agentIds, + long daysIdle) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java new file mode 100644 index 00000000..e6dbc55a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java @@ -0,0 +1,35 @@ +package vip.mate.skill.lifecycle; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.List; + +/** + * Thrown by manual-archive when the requested action would impact resources + * the caller has not explicitly opted in to touching (a skill that is still + * explicitly bound to one or more enabled agents). + * + *

    The caller resolves the conflict by retrying with {@code force=true}. + * {@link ResponseStatus} maps this to HTTP 409 so clients can branch on the + * status code rather than parsing the body. + * + * @author MateClaw Team + */ +@Getter +@ResponseStatus(HttpStatus.CONFLICT) +public class ConfirmRequiredException extends RuntimeException { + + private final String code; + private final List boundAgents; + + public ConfirmRequiredException(String code, String message, List boundAgents) { + super(message); + this.code = code; + this.boundAgents = boundAgents == null ? List.of() : List.copyOf(boundAgents); + } + + /** Minimal agent identity surfaced to the client so it can render a confirm dialog. */ + public record AgentRow(Long id, String name) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java new file mode 100644 index 00000000..fff2e821 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java @@ -0,0 +1,49 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.audit.service.AuditEventService; + +import java.util.Map; + +/** + * Surfaces a completed lifecycle sweep through two decoupled channels: a + * durable {@code mate_audit_event} row, and a Spring application event that + * a notification subsystem may listen for. Neither channel couples the + * curator to any subsystem that may not be present. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CuratorRunNotifier { + + private final AuditEventService auditEventService; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + public void onRunComplete(SkillCuratorReport report) { + // (1) Durable audit trail — always recorded. + try { + String detail = objectMapper.writeValueAsString(Map.of( + "marked_stale", report.markedStale(), + "archived", report.archived(), + "reactivated", report.reactivated(), + "dryRun", report.isDryRun(), + "reportPath", String.valueOf(report.getPath()))); + auditEventService.record("CURATOR_RUN", "SKILL", report.getRunId(), null, detail); + } catch (Exception e) { + log.debug("Failed to record curator run audit event: {}", e.getMessage()); + } + + // (2) Application event — no listener is required; if none exists + // the event is simply discarded. + eventPublisher.publishEvent(new SkillCuratorRunCompletedEvent( + report.getRunId(), report.markedStale(), report.archived(), + report.reactivated(), report.isDryRun(), report.getPath(), report.getRunAt())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java new file mode 100644 index 00000000..2aec43ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java @@ -0,0 +1,17 @@ +package vip.mate.skill.lifecycle; + +/** + * The transition a skill should undergo on a lifecycle sweep. + * + * @author MateClaw Team + */ +public enum LifecycleTransition { + /** No change needed. */ + NONE, + /** active -> stale (idle past the stale threshold). */ + TO_STALE, + /** stale -> archived (idle past the archive threshold). */ + TO_ARCHIVED, + /** stale -> active (activity observed again). */ + REACTIVATE +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java new file mode 100644 index 00000000..28620ae0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java @@ -0,0 +1,289 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Component; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.system.service.SystemSettingService; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Daily sweep that ages idle, agent-created skills through the lifecycle + * state machine. Three gates guard the sweep: the config-level + * {@code enabled} switch, an operational {@code paused} kill switch, and a + * first-run throttle that keeps the pre-activation dry-run from flooding the + * report directory. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillCuratorJob { + + /** Admin flipped the curator from preview-only to applying transitions. */ + static final String FIRST_RUN_KEY = "skill.curator.firstRunCompleted"; + /** Runtime kill switch — pauses the scheduled sweep without a redeploy. */ + static final String PAUSED_KEY = "skill.curator.paused"; + /** ISO-8601 timestamp of the last auto dry-run, for throttling. */ + static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt"; + /** ISO-8601 timestamp of the first sweep observation after install. */ + static final String LAST_OBSERVED_KEY = "skill.curator.lastObservedAt"; + /** ISO-8601 timestamp of the last sweep that produced a report. */ + static final String LAST_RUN_KEY = "skill.curator.lastRunAt"; + + /** Minimum hours between auto dry-runs while the curator is not activated. */ + private static final long DRY_RUN_THROTTLE_HOURS = 23; + + private final SkillLifecycleService lifecycleService; + private final SkillMapper skillMapper; + private final SkillCuratorReportStore reportStore; + private final SkillLifecycleProperties properties; + private final SystemSettingService systemSettingService; + private final AgentBindingService agentBindingService; + private final SkillWorkspaceManager workspaceManager; + private final CuratorRunNotifier notifier; + + @Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}") + @SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") + public void run() { + // Gate 1: config-level enable. + if (!properties.isEnabled() || "OFF".equals(properties.getScope())) { + return; + } + // Gate 2: operational pause. + if (systemSettingService.getBool(PAUSED_KEY, false)) { + log.debug("Curator paused via {} — skipping this tick", PAUSED_KEY); + return; + } + + LocalDateTime now = LocalDateTime.now(); + boolean activated = systemSettingService.getBool(FIRST_RUN_KEY, false); + + // Gate 3: first-run throttle. Before activation the sweep is + // informational; bound it to once per ~day so the report directory + // doesn't fill with identical previews. + if (!activated) { + LocalDateTime lastObserved = parseTs(systemSettingService.getString(LAST_OBSERVED_KEY, null)); + LocalDateTime lastDry = parseTs(systemSettingService.getString(LAST_DRY_RUN_KEY, null)); + if (lastObserved == null) { + systemSettingService.saveString(LAST_OBSERVED_KEY, now.toString(), + "Skill curator first observed timestamp"); + log.info("Curator first observation — deferring; preview on demand via /curator/dry-run"); + return; + } + Duration sinceLastDry = lastDry == null + ? Duration.between(lastObserved, now) + : Duration.between(lastDry, now); + if (sinceLastDry.toHours() < DRY_RUN_THROTTLE_HOURS) { + log.debug("Curator dry-run throttled ({}h since last)", sinceLastDry.toHours()); + return; + } + } + + boolean dryRun = !activated; + SkillCuratorReport report = sweep(now, dryRun); + + if (dryRun) { + systemSettingService.saveString(LAST_DRY_RUN_KEY, now.toString(), + "Skill curator last dry-run timestamp"); + } + systemSettingService.saveString(LAST_RUN_KEY, now.toString(), + "Skill curator last run timestamp"); + notifier.onRunComplete(report); + } + + /** + * Run a dry-run sweep immediately, bypassing the first-run throttle and + * the scheduler lock — for the admin "preview now" action. + */ + public SkillCuratorReport dryRunNow() { + SkillCuratorReport report = sweep(LocalDateTime.now(), true); + notifier.onRunComplete(report); + return report; + } + + /** Flip the activation flag (preview-only ⇄ applying). */ + public void activate(boolean activate) { + systemSettingService.saveBool(FIRST_RUN_KEY, activate, "Skill curator activated"); + } + + /** Set the runtime pause flag. */ + public void setPaused(boolean paused) { + systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused"); + } + + /** Aggregated control-panel state for the admin UI. */ + public Map status() { + Map config = new LinkedHashMap<>(); + config.put("enabled", properties.isEnabled()); + config.put("scope", properties.getScope()); + config.put("staleAfterDays", properties.getStaleAfterDays()); + config.put("archiveAfterDays", properties.getArchiveAfterDays()); + config.put("cron", properties.getCron()); + + Map control = new LinkedHashMap<>(); + control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false)); + control.put("paused", systemSettingService.getBool(PAUSED_KEY, false)); + control.put("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null)); + control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null)); + control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null)); + control.put("nextScheduledRun", nextScheduledRun()); + + Map counts = new LinkedHashMap<>(); + counts.put("active", countState("active")); + counts.put("stale", countState("stale")); + counts.put("archived", countState("archived")); + counts.put("pinned", skillMapper.selectCount( + new LambdaQueryWrapper().eq(SkillEntity::getPinned, true))); + // Count only archival-relevant skills held back by a binding — same + // set the run report's blockedByBindings array shows, so the status + // count and the report stay consistent (builtin / mcp / acp / pinned + // skills are exempt regardless of bindings and are not counted here). + counts.put("blockedByBindings", + agentBindingService.blockedByBindingCandidates(LocalDateTime.now()).size()); + + Map out = new LinkedHashMap<>(); + out.put("config", config); + out.put("control", control); + out.put("counts", counts); + String latest = reportStore.latestRunId(); + out.put("lastReport", latest == null ? null : Map.of( + "id", latest, + "url", "/api/v1/skills/curator/reports/" + latest)); + return out; + } + + // ==================== Internals ==================== + + private SkillCuratorReport sweep(LocalDateTime now, boolean dryRun) { + SkillCuratorReport.Builder report = SkillCuratorReport.builder() + .runAt(now) + .dryRun(dryRun) + .config(properties.getStaleAfterDays(), properties.getArchiveAfterDays(), + properties.getScope()); + + reconcileOrphans(now, report, dryRun); + + List candidates = loadCandidates(); + int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0; + int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0; + for (SkillEntity skill : candidates) { + LifecycleTransition t = lifecycleService.planTransition(skill, now); + report.add(skill, t); + if (t == LifecycleTransition.TO_STALE) { + plannedStale++; + } else if (t == LifecycleTransition.TO_ARCHIVED) { + plannedArchived++; + } else if (t == LifecycleTransition.REACTIVATE) { + plannedReactivate++; + } + if (dryRun) { + continue; + } + boolean applied = lifecycleService.apply(skill, t, now); + if (applied) { + if (t == LifecycleTransition.TO_STALE) { + appliedStale++; + } else if (t == LifecycleTransition.TO_ARCHIVED) { + appliedArchived++; + } else if (t == LifecycleTransition.REACTIVATE) { + appliedReactivate++; + } + } + } + + report.scanned(candidates.size()) + .plannedCounts(plannedStale, plannedArchived, plannedReactivate) + .appliedCounts(appliedStale, appliedArchived, appliedReactivate) + .blockedByBindings(agentBindingService.blockedByBindingCandidates(now)); + + return reportStore.write(report.build()); + } + + /** + * Candidate skills for the state machine: not builtin, not pinned, not a + * builtin/mcp/acp type, not bound to any enabled agent, and — under the + * default {@code AGENT_CREATED} scope — created by an agent. + */ + private List loadCandidates() { + Set bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents(); + + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(SkillEntity::getBuiltin, false) + .eq(SkillEntity::getPinned, false) + .notIn(SkillEntity::getSkillType, List.of("builtin", "mcp", "acp")); + if (!bindingProtected.isEmpty()) { + w.notIn(SkillEntity::getId, bindingProtected); + } + if ("AGENT_CREATED".equals(properties.getScope())) { + w.isNotNull(SkillEntity::getSourceConversationId); + } + return skillMapper.selectList(w); + } + + /** + * Heal the unambiguous divergence class: a row marked {@code archived} + * whose convention workspace is back in place (an admin moved a directory + * or a re-install ran). The reverse class — workspace moved but the DB + * write failed — is handled inline by the archive compensation path. + */ + private void reconcileOrphans(LocalDateTime now, SkillCuratorReport.Builder report, boolean dryRun) { + List archived = skillMapper.selectList(new LambdaQueryWrapper() + .eq(SkillEntity::getLifecycleState, "archived")); + for (SkillEntity skill : archived) { + if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName())) { + continue; + } + report.reconciliation("skill '" + skill.getName() + "' (id=" + skill.getId() + + ") archived in DB but workspace present — reactivating"); + if (!dryRun) { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getLifecycleState, "active") + .set(SkillEntity::getEnabled, true) + .set(SkillEntity::getArchivedAt, null) + .set(SkillEntity::getLastActivityAt, now)); + } + } + } + + private long countState(String state) { + return skillMapper.selectCount(new LambdaQueryWrapper() + .eq(SkillEntity::getLifecycleState, state)); + } + + private String nextScheduledRun() { + try { + LocalDateTime next = CronExpression.parse(properties.getCron()).next(LocalDateTime.now()); + return next != null ? next.toString() : null; + } catch (Exception e) { + return null; + } + } + + private static LocalDateTime parseTs(String s) { + if (s == null || s.isBlank()) { + return null; + } + try { + return LocalDateTime.parse(s); + } catch (Exception e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java new file mode 100644 index 00000000..a2cb8b79 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java @@ -0,0 +1,173 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import vip.mate.skill.model.SkillEntity; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Structured result of one lifecycle sweep — serialized to {@code run.json} + * and rendered to {@code REPORT.md}. Built incrementally during the sweep + * via {@link Builder}. + * + *

    {@code planned} counts reflect what {@code planTransition} decided and + * are populated in both dry-run and applied modes. {@code applied} counts + * reflect what actually committed and stay zero for a dry-run. + * + * @author MateClaw Team + */ +@Getter +public class SkillCuratorReport { + + private static final DateTimeFormatter RUN_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private final String runId; + private final LocalDateTime runAt; + private final boolean dryRun; + private final Config config; + private final int scanned; + private final Counts planned; + private final Counts applied; + private final List transitions; + private final List blockedByBindings; + private final List reconciliations; + + /** Set by the report store after the run directory is written. */ + @JsonIgnore + private Path path; + + private SkillCuratorReport(Builder b) { + this.runAt = b.runAt != null ? b.runAt : LocalDateTime.now(); + this.runId = this.runAt.format(RUN_ID); + this.dryRun = b.dryRun; + this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope); + this.scanned = b.scanned; + this.planned = new Counts(b.plannedStale, b.plannedArchived, b.plannedReactivated); + this.applied = new Counts(b.appliedStale, b.appliedArchived, b.appliedReactivated); + this.transitions = List.copyOf(b.transitions); + this.blockedByBindings = List.copyOf(b.blockedByBindings); + this.reconciliations = List.copyOf(b.reconciliations); + } + + public void setPath(Path path) { + this.path = path; + } + + /** Applied count of skills marked stale (0 for a dry-run). */ + public int markedStale() { + return applied.stale(); + } + + /** Applied count of skills archived (0 for a dry-run). */ + public int archived() { + return applied.archived(); + } + + /** Applied count of skills reactivated (0 for a dry-run). */ + public int reactivated() { + return applied.reactivated(); + } + + public record Config(int staleAfterDays, int archiveAfterDays, String scope) {} + + public record Counts(int stale, int archived, int reactivated) {} + + public record TransitionRow(Long skillId, String name, String from, String to, long daysIdle) {} + + public static Builder builder() { + return new Builder(); + } + + /** Incremental builder used by the sweep. */ + public static final class Builder { + private LocalDateTime runAt; + private boolean dryRun; + private int staleAfterDays; + private int archiveAfterDays; + private String scope; + private int scanned; + private int plannedStale, plannedArchived, plannedReactivated; + private int appliedStale, appliedArchived, appliedReactivated; + private final List transitions = new ArrayList<>(); + private List blockedByBindings = new ArrayList<>(); + private final List reconciliations = new ArrayList<>(); + + public Builder runAt(LocalDateTime runAt) { + this.runAt = runAt; + return this; + } + + public Builder dryRun(boolean dryRun) { + this.dryRun = dryRun; + return this; + } + + public Builder config(int staleAfterDays, int archiveAfterDays, String scope) { + this.staleAfterDays = staleAfterDays; + this.archiveAfterDays = archiveAfterDays; + this.scope = scope; + return this; + } + + public Builder scanned(int scanned) { + this.scanned = scanned; + return this; + } + + /** Record a non-NONE transition for a skill in the {@code transitions} list. */ + public Builder add(SkillEntity skill, LifecycleTransition t) { + if (t == null || t == LifecycleTransition.NONE) { + return this; + } + LocalDateTime anchor = skill.getLastActivityAt() != null + ? skill.getLastActivityAt() : skill.getCreateTime(); + long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays(); + String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + String to = switch (t) { + case TO_STALE -> "stale"; + case TO_ARCHIVED -> "archived"; + case REACTIVATE -> "active"; + case NONE -> from; + }; + transitions.add(new TransitionRow(skill.getId(), skill.getName(), from, to, days)); + return this; + } + + public Builder plannedCounts(int stale, int archived, int reactivated) { + this.plannedStale = stale; + this.plannedArchived = archived; + this.plannedReactivated = reactivated; + return this; + } + + public Builder appliedCounts(int stale, int archived, int reactivated) { + this.appliedStale = stale; + this.appliedArchived = archived; + this.appliedReactivated = reactivated; + return this; + } + + public Builder blockedByBindings(List rows) { + this.blockedByBindings = rows != null ? rows : new ArrayList<>(); + return this; + } + + public Builder reconciliation(String message) { + if (message != null && !message.isBlank()) { + this.reconciliations.add(message); + } + return this; + } + + public SkillCuratorReport build() { + return new SkillCuratorReport(this); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java new file mode 100644 index 00000000..1843d2c3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java @@ -0,0 +1,204 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.workspace.SkillWorkspaceManager; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Persists lifecycle sweep reports to {@code {workspace-root}/.curator/}. + * Each run gets a {@code {runId}/} directory holding {@code run.json} (the + * structured record) and {@code REPORT.md} (a human-readable render); a + * {@code latest} symlink points at the newest run. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillCuratorReportStore { + + /** Number of run directories kept on disk; older ones are pruned. */ + private static final int KEEP_RUNS = 50; + + /** Run ids are {@code yyyyMMdd-HHmmss} — validated before any path resolve. */ + private static final Pattern RUN_ID = Pattern.compile("\\d{8}-\\d{6}"); + + private final SkillWorkspaceManager workspaceManager; + private final ObjectMapper objectMapper; + + private Path curatorRoot() { + return workspaceManager.getWorkspaceRoot().resolve(".curator"); + } + + /** + * Write the report's run directory and update the {@code latest} + * symlink. The report's {@code path} is populated on success. + */ + public SkillCuratorReport write(SkillCuratorReport report) { + Path runDir = curatorRoot().resolve(report.getRunId()); + try { + Files.createDirectories(runDir); + objectMapper.writerWithDefaultPrettyPrinter() + .writeValue(runDir.resolve("run.json").toFile(), report); + Files.writeString(runDir.resolve("REPORT.md"), renderMarkdown(report)); + report.setPath(runDir); + updateLatest(runDir); + pruneOld(); + } catch (IOException e) { + log.warn("Failed to write curator report {}: {}", report.getRunId(), e.getMessage()); + } + return report; + } + + /** Most recent run ids, newest first, capped at {@code limit}. */ + public List listRunIds(int limit) { + Path root = curatorRoot(); + if (!Files.isDirectory(root)) { + return List.of(); + } + try (var stream = Files.list(root)) { + return stream + .filter(Files::isDirectory) + .map(p -> p.getFileName().toString()) + .filter(n -> RUN_ID.matcher(n).matches()) + .sorted(Comparator.reverseOrder()) + .limit(limit > 0 ? limit : 20) + .toList(); + } catch (IOException e) { + log.warn("Failed to list curator reports: {}", e.getMessage()); + return List.of(); + } + } + + /** Newest run id, or {@code null} when no run has been recorded yet. */ + public String latestRunId() { + List ids = listRunIds(1); + return ids.isEmpty() ? null : ids.get(0); + } + + /** + * Parsed {@code run.json} for a run, or {@code null} when the run is + * unknown. The {@code runId} is validated against the timestamp pattern + * before being resolved as a path component. + */ + public Object readRun(String runId) { + if (runId == null || !RUN_ID.matcher(runId).matches()) { + return null; + } + Path runJson = curatorRoot().resolve(runId).resolve("run.json"); + if (!Files.isRegularFile(runJson)) { + return null; + } + try { + return objectMapper.readValue(runJson.toFile(), Object.class); + } catch (IOException e) { + log.warn("Failed to read curator report {}: {}", runId, e.getMessage()); + return null; + } + } + + private void updateLatest(Path runDir) { + Path latest = curatorRoot().resolve("latest"); + try { + Files.deleteIfExists(latest); + Files.createSymbolicLink(latest, runDir.getFileName()); + } catch (IOException | UnsupportedOperationException e) { + // Symlinks may be unsupported (Windows without privilege) — the + // latest run is still discoverable via listRunIds(). + log.debug("Curator 'latest' symlink not updated: {}", e.getMessage()); + } + } + + private void pruneOld() { + List ids = listRunIds(Integer.MAX_VALUE); + if (ids.size() <= KEEP_RUNS) { + return; + } + for (String old : ids.subList(KEEP_RUNS, ids.size())) { + deleteRecursively(curatorRoot().resolve(old)); + } + } + + private void deleteRecursively(Path dir) { + if (!Files.exists(dir)) { + return; + } + try (var stream = Files.walk(dir)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + /* best-effort prune */ + } + }); + } catch (IOException e) { + log.debug("Failed to prune curator report {}: {}", dir, e.getMessage()); + } + } + + private String renderMarkdown(SkillCuratorReport r) { + StringBuilder sb = new StringBuilder(); + sb.append("# Skill Curator Run ").append(r.getRunId()).append("\n\n"); + sb.append("- Run at: ").append(r.getRunAt()).append('\n'); + sb.append("- Mode: ").append(r.isDryRun() ? "dry-run (preview)" : "applied").append('\n'); + sb.append("- Scope: ").append(r.getConfig().scope()) + .append(" (stale ≥ ").append(r.getConfig().staleAfterDays()) + .append("d, archive ≥ ").append(r.getConfig().archiveAfterDays()).append("d)\n"); + sb.append("- Scanned: ").append(r.getScanned()).append(" candidate(s)\n\n"); + + sb.append("## Planned\n\n"); + sb.append("| stale | archived | reactivated |\n|---|---|---|\n"); + sb.append("| ").append(r.getPlanned().stale()) + .append(" | ").append(r.getPlanned().archived()) + .append(" | ").append(r.getPlanned().reactivated()).append(" |\n\n"); + + sb.append("## Applied\n\n"); + sb.append("| stale | archived | reactivated |\n|---|---|---|\n"); + sb.append("| ").append(r.getApplied().stale()) + .append(" | ").append(r.getApplied().archived()) + .append(" | ").append(r.getApplied().reactivated()).append(" |\n\n"); + + if (!r.getTransitions().isEmpty()) { + sb.append("## Transitions\n\n"); + sb.append("| skill | from | to | days idle |\n|---|---|---|---|\n"); + for (SkillCuratorReport.TransitionRow t : r.getTransitions()) { + sb.append("| ").append(t.name()).append(" (").append(t.skillId()).append(')') + .append(" | ").append(t.from()) + .append(" | ").append(t.to()) + .append(" | ").append(t.daysIdle()).append(" |\n"); + } + sb.append('\n'); + } + + if (!r.getBlockedByBindings().isEmpty()) { + sb.append("## Blocked by agent bindings\n\n"); + sb.append("These skills satisfy the idle window but are kept because an " + + "enabled agent explicitly binds them.\n\n"); + sb.append("| skill | bound agents | days idle |\n|---|---|---|\n"); + for (BlockedByBindingRow b : r.getBlockedByBindings()) { + sb.append("| ").append(b.name()).append(" (").append(b.skillId()).append(')') + .append(" | ").append(b.agentIds()) + .append(" | ").append(b.daysIdle()).append(" |\n"); + } + sb.append('\n'); + } + + if (!r.getReconciliations().isEmpty()) { + sb.append("## Reconciliations\n\n"); + for (String line : r.getReconciliations()) { + sb.append("- ").append(line).append('\n'); + } + sb.append('\n'); + } + return sb.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java new file mode 100644 index 00000000..1c593403 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java @@ -0,0 +1,24 @@ +package vip.mate.skill.lifecycle; + +import java.nio.file.Path; +import java.time.LocalDateTime; + +/** + * Published after every lifecycle sweep completes. Carries the applied + * counts (zero for a dry-run) so a downstream notification listener can + * surface the run without re-reading the report file. + * + *

    This event has no compile-time dependency on any notification + * subsystem: if nothing listens, it is simply a no-op. + * + * @author MateClaw Team + */ +public record SkillCuratorRunCompletedEvent( + String runId, + int markedStale, + int archived, + int reactivated, + boolean dryRun, + Path reportPath, + LocalDateTime runAt) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java new file mode 100644 index 00000000..4c386593 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java @@ -0,0 +1,14 @@ +package vip.mate.skill.lifecycle; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Auto-configuration for the skill lifecycle curator. + * + * @author MateClaw Team + */ +@Configuration +@EnableConfigurationProperties(SkillLifecycleProperties.class) +public class SkillLifecycleAutoConfiguration { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java new file mode 100644 index 00000000..9ed64593 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java @@ -0,0 +1,45 @@ +package vip.mate.skill.lifecycle; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * Configuration for the skill lifecycle curator — the daily job that moves + * idle, agent-created skills through {@code active -> stale -> archived}. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.skill.curator") +public class SkillLifecycleProperties { + + /** Master switch. When {@code false} the daily sweep never runs. */ + private boolean enabled = true; + + /** Cron expression for the daily sweep. Defaults to 02:00 every day. */ + private String cron = "0 0 2 * * *"; + + /** Days of inactivity after which an active skill becomes {@code stale}. */ + private int staleAfterDays = 30; + + /** Days of inactivity after which a stale skill becomes {@code archived}. */ + private int archiveAfterDays = 90; + + /** + * Which skills the curator considers: + *

      + *
    • {@code AGENT_CREATED} — only skills with a source conversation + * (created by an agent); the most conservative default.
    • + *
    • {@code ALL_DYNAMIC} — also includes manually-created dynamic + * skills.
    • + *
    • {@code OFF} — disables the sweep regardless of {@link #enabled}.
    • + *
    + */ + private String scope = "AGENT_CREATED"; + + /** Skills whose name starts with any of these prefixes are never touched. */ + private List protectPrefixes = new ArrayList<>(List.of("sys-", "ops-")); +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java new file mode 100644 index 00000000..be149025 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java @@ -0,0 +1,329 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.SkillWorkspaceProperties; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * State machine primitives for the skill lifecycle curator. Holds every + * mutation a skill can undergo as it ages out: {@code active -> stale -> + * archived}, plus the reverse {@code restore} and the activity bump that + * keeps an in-use skill anchored to the present. + * + *

    All writes use {@link LambdaUpdateWrapper} whitelists rather than + * {@code updateById(entity)} so {@code FieldStrategy.ALWAYS} columns are + * never wiped by a partially-populated entity. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class SkillLifecycleService { + + private final SkillMapper skillMapper; + private final SkillWorkspaceManager workspaceManager; + private final SkillWorkspaceProperties workspaceProperties; + private final SkillRuntimeService runtimeService; + private final AuditEventService auditEventService; + private final ObjectMapper objectMapper; + private final SkillLifecycleProperties properties; + + /** + * {@code @Lazy} on {@code runtimeService} breaks the construction cycle + * {@code SkillService -> SkillLifecycleService -> SkillRuntimeService -> + * SkillService}. + */ + @Autowired + public SkillLifecycleService(SkillMapper skillMapper, + SkillWorkspaceManager workspaceManager, + SkillWorkspaceProperties workspaceProperties, + @Lazy SkillRuntimeService runtimeService, + AuditEventService auditEventService, + ObjectMapper objectMapper, + SkillLifecycleProperties properties) { + this.skillMapper = skillMapper; + this.workspaceManager = workspaceManager; + this.workspaceProperties = workspaceProperties; + this.runtimeService = runtimeService; + this.auditEventService = auditEventService; + this.objectMapper = objectMapper; + this.properties = properties; + } + + // ==================== Pure decision functions ==================== + + /** Activity anchor: last recorded activity, falling back to creation time. */ + public LocalDateTime anchor(SkillEntity skill) { + if (skill.getLastActivityAt() != null) { + return skill.getLastActivityAt(); + } + return skill.getCreateTime(); + } + + /** Skills the curator must never touch (filtered out before the state machine). */ + public boolean isExempt(SkillEntity skill) { + if (Boolean.TRUE.equals(skill.getBuiltin())) { + return true; + } + if (Boolean.TRUE.equals(skill.getPinned())) { + return true; + } + String type = skill.getSkillType(); + if (type == null || List.of("builtin", "mcp", "acp").contains(type)) { + return true; + } + String name = skill.getName(); + if (name != null) { + for (String prefix : properties.getProtectPrefixes()) { + if (prefix != null && !prefix.isBlank() && name.startsWith(prefix)) { + return true; + } + } + } + return false; + } + + /** + * Decide the transition for a skill at time {@code now}. Pure function: + * no side effects, no I/O — driven entirely by the entity's anchor and + * lifecycle state against the configured day thresholds. + */ + public LifecycleTransition planTransition(SkillEntity skill, LocalDateTime now) { + if (isExempt(skill)) { + return LifecycleTransition.NONE; + } + LocalDateTime anchor = anchor(skill); + if (anchor == null) { + return LifecycleTransition.NONE; + } + long days = Duration.between(anchor, now).toDays(); + String state = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + if (days >= properties.getArchiveAfterDays()) { + return "archived".equals(state) ? LifecycleTransition.NONE : LifecycleTransition.TO_ARCHIVED; + } + if (days >= properties.getStaleAfterDays()) { + return ("stale".equals(state) || "archived".equals(state)) + ? LifecycleTransition.NONE : LifecycleTransition.TO_STALE; + } + return "stale".equals(state) ? LifecycleTransition.REACTIVATE : LifecycleTransition.NONE; + } + + // ==================== Mutations ==================== + + /** + * Apply a planned transition. Returns {@code true} when the transition + * actually committed — an archive that fails at the workspace move or + * the DB write returns {@code false} so the caller can report + * {@code applied < planned}. + */ + public boolean apply(SkillEntity skill, LifecycleTransition t, LocalDateTime now) { + return applyManual(skill, t, now, defaultReason(t)); + } + + /** + * Same as {@link #apply} but with an explicit audit reason — used by the + * admin-triggered manual archive so the audit trail records intent. + */ + public boolean applyManual(SkillEntity skill, LifecycleTransition t, LocalDateTime now, String reason) { + return switch (t) { + case TO_STALE -> mark(skill, "stale"); + case TO_ARCHIVED -> archive(skill, now, reason); + case REACTIVATE -> mark(skill, "active"); + case NONE -> false; + }; + } + + /** + * Restore an archived skill: move its workspace back (when one was + * archived), flip the row to {@code active}, and refresh the runtime + * cache. DB-only skills with no archived workspace are a legitimate path + * — they restore on the DB write alone as long as {@code skill_content} + * still holds the body. + */ + public SkillEntity restore(Long id) { + SkillEntity skill = skillMapper.selectById(id); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id); + } + if (!"archived".equals(skill.getLifecycleState())) { + throw new MateClawException("err.skill.not_archived", 409, + "Skill is not archived: " + skill.getName()); + } + + SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName()); + switch (fs) { + case MOVED -> { /* normal path */ } + case MISSING -> { + if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) { + throw new MateClawException("err.skill.unrecoverable", 409, + "Skill has no workspace archive and no skill content — cannot restore"); + } + log.warn("Restoring DB-only skill '{}' (no workspace archive)", skill.getName()); + } + case FAILED -> throw new MateClawException("err.skill.restore_failed", 500, + "Workspace archive exists but move-back failed; check disk / permissions"); + } + + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, id) + .set(SkillEntity::getEnabled, true) + .set(SkillEntity::getLifecycleState, "active") + .set(SkillEntity::getArchivedAt, null) + .set(SkillEntity::getLastActivityAt, LocalDateTime.now())); + + runtimeService.refreshActiveSkills(); + recordAudit("RESTORE", skill, Map.of("fs", fs.name(), "to", "active")); + return skillMapper.selectById(id); + } + + /** + * Pin or unpin a skill. A pinned skill is permanently exempt from the + * automatic state machine until unpinned. + */ + public SkillEntity setPinned(Long id, boolean pinned) { + SkillEntity skill = skillMapper.selectById(id); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id); + } + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, id) + .set(SkillEntity::getPinned, pinned)); + recordAudit(pinned ? "PIN" : "UNPIN", skill, Map.of("pinned", pinned)); + return skillMapper.selectById(id); + } + + /** + * Push the activity anchor of a skill to now and pull it back to + * {@code active} if it had drifted to {@code stale}. Best-effort: a + * write failure is logged, never thrown — losing one bump only delays + * the curator by a day. Archived skills are left untouched (recovering + * an archived skill must go through {@link #restore}). + */ + public void bumpActivity(Long skillId) { + if (skillId == null) { + return; + } + try { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skillId) + .and(w -> w.isNull(SkillEntity::getLifecycleState) + .or().ne(SkillEntity::getLifecycleState, "archived")) + .set(SkillEntity::getLastActivityAt, LocalDateTime.now()) + .set(SkillEntity::getLifecycleState, "active")); + } catch (Exception e) { + log.debug("Failed to bump activity for skill {}: {}", skillId, e.getMessage()); + } + } + + // ==================== Internals ==================== + + private boolean mark(SkillEntity skill, String toState) { + String prevState = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + if (prevState.equals(toState)) { + return false; + } + int rows; + try { + rows = skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getLifecycleState, toState)); + } catch (Exception e) { + log.warn("Skill '{}' lifecycle mark to {} failed: {}", skill.getName(), toState, e.getMessage()); + return false; + } + if (rows == 0) { + return false; + } + recordAudit("LIFECYCLE", skill, Map.of("from", prevState, "to", toState)); + return true; + } + + /** + * Archive a skill: move its workspace to {@code .archived/}, then flip + * the row. The filesystem move runs before the DB write so a DB failure + * can be compensated by moving the workspace back. Returns {@code false} + * (no commit) when the workspace move fails or the DB write touches no + * rows — the next sweep retries. + */ + private boolean archive(SkillEntity skill, LocalDateTime now, String reason) { + // Step 1: workspace move. MISSING is commit-safe (DB-only skill); + // FAILED defers the whole transition. + SkillWorkspaceManager.ArchiveResult fsResult = SkillWorkspaceManager.ArchiveResult.MISSING; + if ("archive".equals(workspaceProperties.getDeletePolicy())) { + fsResult = workspaceManager.archiveWorkspace(skill.getName()); + } + if (fsResult == SkillWorkspaceManager.ArchiveResult.FAILED) { + log.warn("Skill '{}' workspace archive failed; deferring DB transition", skill.getName()); + return false; + } + + // Step 2: DB flip — guarded by affected-row count + compensation. + String prevState = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + int rows = 0; + try { + rows = skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getEnabled, false) + .set(SkillEntity::getLifecycleState, "archived") + .set(SkillEntity::getArchivedAt, now)); + } catch (Exception e) { + log.error("Skill '{}' DB archive write failed; attempting compensation", skill.getName(), e); + } + if (rows == 0) { + log.warn("Skill '{}' DB archive update touched 0 rows; compensating workspace", skill.getName()); + if (fsResult == SkillWorkspaceManager.ArchiveResult.MOVED) { + workspaceManager.restoreWorkspace(skill.getName()); + } + return false; + } + + // Mirror the uninstall path: deregister wrapper tools AND refresh the + // active-skill cache so an in-flight prompt build stops seeing the row. + runtimeService.deregisterSkillWrappers(skill.getId()); + runtimeService.refreshActiveSkills(); + + recordAudit("ARCHIVE", skill, Map.of( + "reason", reason, + "anchor", String.valueOf(anchor(skill)), + "from", prevState, + "to", "archived", + "fs", fsResult.name())); + return true; + } + + private String defaultReason(LifecycleTransition t) { + return switch (t) { + case TO_STALE -> "idle>=" + properties.getStaleAfterDays() + "d"; + case TO_ARCHIVED -> "idle>=" + properties.getArchiveAfterDays() + "d"; + case REACTIVATE -> "activity-observed"; + case NONE -> ""; + }; + } + + private void recordAudit(String action, SkillEntity skill, Map detail) { + String json; + try { + json = objectMapper.writeValueAsString(detail); + } catch (Exception e) { + json = String.valueOf(detail); + } + auditEventService.record(action, "SKILL", + String.valueOf(skill.getId()), skill.getName(), json); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java index ece5db00..eb79ea27 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java @@ -91,6 +91,18 @@ public class SkillManifest { /** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */ private AcpBinding acp; + // ==================== type=code script entrypoints ==================== + + /** + * Declared script entrypoints from the {@code scripts} frontmatter + * block. Each entry is exposed to the model as a typed wrapper tool — + * the model fills schema-described fields and the runtime serializes + * them into process arguments, so a script consuming a JSON payload + * never depends on the model hand-crafting a JSON string. + */ + @Builder.Default + private List scripts = List.of(); + // ==================== Forward-compat catch-all ==================== /** Unknown frontmatter keys are stashed here so a future field @@ -198,6 +210,45 @@ public class SkillManifest { private Long resolvedEndpointId; } + /** + * One script entrypoint declared under the {@code scripts} block. The + * resolver turns each into a typed wrapper tool named + * {@code skill__}. + */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class ScriptDef { + /** Stable id; forms the suffix of the generated wrapper tool name. */ + private String id; + /** Human-readable label for the entrypoint. */ + private String label; + /** Script path relative to the skill directory (e.g. {@code scripts/run.py}). */ + private String path; + /** What the entrypoint does — surfaced as the wrapper tool description. */ + private String description; + /** + * Literal arguments prepended before the typed arguments. Lets one + * dispatcher script back several entrypoints — e.g. a fixed method + * name as {@code argv[1]} with the typed JSON payload as {@code argv[2]}. + */ + @Builder.Default + private List fixedArgs = List.of(); + /** + * Raw JSON Schema object describing the entrypoint's parameters, + * forwarded verbatim as the wrapper tool's input schema. + */ + @Builder.Default + private Map parameters = Map.of(); + /** + * How typed arguments reach the script process: + * {@code json} (default) forwards one compact JSON argument; + * {@code flags} forwards each property as a {@code --key value} pair. + */ + @Builder.Default + private String argStyle = "json"; + } + @Data @Builder @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java index ce401207..c0defeaf 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java @@ -40,6 +40,7 @@ public class SkillManifestParser { "dashboard", "self-evolution", "self_evolution", "knowledge", "acp", + "scripts", // legacy / housekeeping fields that aren't manifest-relevant "metadata" ); @@ -102,6 +103,7 @@ public class SkillManifestParser { .selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution"))) .knowledge(parseKnowledge(fm.get("knowledge"))) .acp(parseAcp(fm.get("acp"))) + .scripts(parseScripts(fm.get("scripts"))) .extras(extractUnknown(fm)); return b.build(); @@ -261,6 +263,37 @@ public class SkillManifestParser { .build(); } + // ==================== scripts ==================== + + /** + * Parse the {@code scripts} block — a list of script entrypoint maps. + * The per-entry {@code parameters} map is carried through as a raw + * JSON Schema object; nested maps / lists from the YAML parse stay + * intact so the wrapper factory can serialize them verbatim. + */ + @SuppressWarnings("unchecked") + private List parseScripts(Object rawScripts) { + if (!(rawScripts instanceof List list)) return List.of(); + List out = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) continue; + Map m = (Map) map; + Map parameters = m.get("parameters") instanceof Map p + ? toStringObjectMap((Map) p) : Map.of(); + out.add(SkillManifest.ScriptDef.builder() + .id(string(m, "id")) + .label(string(m, "label")) + .path(string(m, "path")) + .description(string(m, "description")) + .fixedArgs(stringList(coalesce(m, "fixed_args", "fixedArgs"))) + .parameters(parameters) + .argStyle(stringOrDefault(m, "arg_style", + stringOrDefault(m, "argStyle", "json"))) + .build()); + } + return out; + } + @SuppressWarnings("unchecked") private SkillManifest.AcpBinding parseAcp(Object raw) { if (!(raw instanceof Map map)) return null; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java index 17766a9d..5e383232 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java @@ -44,11 +44,27 @@ import java.util.Map; * links back to the MCP page). * * - *

    ID namespace: virtual skill ids use a high sentinel - * {@link #VIRTUAL_ID_BASE} + mcpServerId so they can never collide - * with real {@code mate_skill.id} values (Snowflake longs are bounded - * well below this base). Negative numbers were considered but several - * existing endpoints {@code abs()} the id for path constraints. + *

    ID namespace: virtual ids encode a 2-bit type tag in the top two + * bits of a {@code long}, leaving 62 bits to carry the underlying + * mcpServerId: + *

    + *   bit 63 (sign) | bit 62 | bits 0..61
    + *   --------------+--------+--------------------------------
    + *         0       |   0    |  real persisted skill (Snowflake)
    + *         1       |   0    |  virtual MCP-derived skill
    + *         1       |   1    |  virtual ACP-derived skill
    + * 
    + * + *

    The earlier {@code 9e18 + serverId} addition scheme broke once + * Snowflake-issued mcpServerIds crossed ~{@code 2e18} (the sum then + * overflowed signed long, wrapping to a negative number that no longer + * satisfied {@code id >= 9e18} — every detail / lookup of a freshly + * created MCP server 500'd with "技能不存在"). The bit-tagged layout + * has no arithmetic and survives any 62-bit server id. + * + *

    The constants are arranged so that {@code BASE + smallId} still + * equals {@code BASE | smallId} for any {@code smallId < 2^62}, so + * test fixtures that build virtual ids by addition keep working. */ @Slf4j @Service @@ -56,53 +72,85 @@ import java.util.Map; public class McpSkillBridge { /** - * High sentinel for virtual id space. Snowflake ids fit in 63 bits - * but in practice never approach this magnitude, so anything - * {@code >= VIRTUAL_ID_BASE} is unambiguously a bridged MCP skill. + * Type tag for the MCP virtual id space: bit 63 set, bit 62 clear. + * Equal to {@link Long#MIN_VALUE}; named for the historical + * "base sentinel" idiom callers still use. */ - public static final long VIRTUAL_ID_BASE = 9_000_000_000_000_000_000L; + public static final long VIRTUAL_ID_BASE = Long.MIN_VALUE; // 0x8000000000000000L + + /** Selects the top-two type-tag bits. */ + private static final long TAG_MASK = 0xC000000000000000L; + /** Selects the bottom 62 bits that carry the original server id. */ + private static final long ID_MASK = 0x3FFFFFFFFFFFFFFFL; private final McpServerService mcpServerService; private final McpClientManager mcpClientManager; private final ObjectMapper objectMapper; /** - * @return true iff the given id falls inside the virtual MCP skill - * range. Cheap O(1) check, callers use it to route lookups - * between the real DB and this bridge. + * @return true iff the given id carries the MCP virtual-skill type + * tag (bit 63 set, bit 62 clear). Cheap O(1) bit-mask check. */ public static boolean isVirtualMcpSkillId(Long id) { - return id != null && id >= VIRTUAL_ID_BASE; + return id != null && (id & TAG_MASK) == VIRTUAL_ID_BASE; } /** Inverse mapping: extract the original MCP server id. */ public static Long extractMcpServerId(Long virtualId) { if (!isVirtualMcpSkillId(virtualId)) return null; - return virtualId - VIRTUAL_ID_BASE; + return virtualId & ID_MASK; } public static long virtualIdFor(McpServerEntity server) { - return VIRTUAL_ID_BASE + server.getId(); + long sid = server.getId(); + if ((sid & TAG_MASK) != 0L) { + throw new IllegalStateException( + "MCP server id 0x" + Long.toHexString(sid) + + " uses the top two bits — would collide with the virtual id type tag"); + } + return VIRTUAL_ID_BASE | sid; } /** - * Snapshot every enabled MCP server as a virtual {@link SkillEntity}. - * Used by the Skills list endpoint; rows are non-persistent and - * regenerated on each call. + * Snapshot every MCP server as a virtual {@link SkillEntity}. Used by + * the Skills list endpoint; rows are non-persistent and regenerated on + * each call. Disabled servers are included so a skill the user toggled + * off still shows on the Skills page (as a disabled card) and can be + * toggled back on — {@code enabled} mirrors the server's flag. */ public List listMcpDerivedSkillEntities() { - return listEnabledServers().stream().map(this::serverToEntity).toList(); + return listAllServers().stream().map(this::serverToEntity).toList(); } /** - * Snapshot every enabled MCP server as a virtual {@link ResolvedSkill} - * with synthesized manifest, ready to be merged into the runtime - * status feed. Status reflects connection health: OK → READY default - * feature; ERROR / disconnected → SETUP_NEEDED with a diagnostic - * missing-dependency entry. + * Snapshot every MCP server as a virtual {@link ResolvedSkill} with + * synthesized manifest, ready to be merged into the runtime status + * feed. Status reflects connection health: OK → READY default feature; + * ERROR / disconnected → SETUP_NEEDED with a diagnostic missing-dependency + * entry. Disabled servers are included for the admin status view; the + * active-skill gate ({@code SkillRuntimeService.passesActiveGate}) keeps + * them out of the agent runtime. */ public List listMcpDerivedResolvedSkills() { - return listEnabledServers().stream().map(this::serverToResolved).toList(); + return listAllServers().stream().map(this::serverToResolved).toList(); + } + + /** + * Enable or disable the MCP server behind a virtual MCP skill. + * + *

    A virtual MCP skill has no {@code mate_skill} row — its enabled + * state is the underlying MCP server's {@code enabled} flag. Toggling + * the skill therefore toggles the server, which also connects or + * disconnects it. Returns the rebuilt virtual {@link SkillEntity} + * reflecting the new state. + */ + public SkillEntity toggleVirtualSkill(Long virtualId, boolean enabled) { + Long serverId = extractMcpServerId(virtualId); + if (serverId == null) { + throw new IllegalArgumentException("Not a virtual MCP skill id: " + virtualId); + } + McpServerEntity updated = mcpServerService.toggle(serverId, enabled); + return serverToEntity(updated); } /** @@ -121,19 +169,20 @@ public class McpSkillBridge { } } - private List listEnabledServers() { + private List listAllServers() { try { - return mcpServerService.listEnabled(); + return mcpServerService.listAll(); } catch (Exception e) { - log.warn("MCP bridge could not list enabled servers: {}", e.getMessage()); + log.warn("MCP bridge could not list servers: {}", e.getMessage()); return List.of(); } } private SkillEntity serverToEntity(McpServerEntity server) { + List tools = readToolDescriptors(server); SkillEntity s = new SkillEntity(); s.setId(virtualIdFor(server)); - s.setName(slugify(server.getName())); + s.setName(slugForServer(server)); s.setNameEn(displayName(server)); s.setNameZh(displayName(server)); s.setDescription(buildDescription(server)); @@ -145,13 +194,15 @@ public class McpSkillBridge { s.setBuiltin(false); s.setTags("mcp"); s.setSecurityScanStatus("PASSED"); // MCP servers don't go through SkillSecurityService + s.setSkillContent(buildSkillContent(server, tools)); s.setConfigJson(buildConfigJson(server)); - s.setManifestJson(serializeManifest(buildManifestFrom(server, readToolRawNames(server)))); + s.setManifestJson(serializeManifest(buildManifestFrom(server, toRawNames(tools)))); return s; } private ResolvedSkill serverToResolved(McpServerEntity server) { - List rawNames = readToolRawNames(server); + List tools = readToolDescriptors(server); + List rawNames = toRawNames(tools); Map toolDisplayNames = new LinkedHashMap<>(); for (String raw : rawNames) { String prefixed = McpToolNameResolver.prefixedName(server.getId(), raw); @@ -175,9 +226,9 @@ public class McpSkillBridge { return ResolvedSkill.builder() .id(virtualIdFor(server)) - .name(slugify(server.getName())) + .name(slugForServer(server)) .description(buildDescription(server)) - .content("") // no SKILL.md + .content(buildSkillContent(server, tools)) .source("mcp") .skillDir(null) .configuredSkillDir(null) @@ -242,9 +293,10 @@ public class McpSkillBridge { .description("MCP server '" + server.getName() + "' must be connected. Configure in Settings ▸ MCP Connections.") .build(); + String slug = slugForServer(server); return SkillManifest.builder() - .id(slugify(server.getName())) - .name(slugify(server.getName())) + .id(slug) + .name(slug) .description(buildDescription(server)) .icon(iconFor(server)) .version("1.0.0") @@ -266,24 +318,27 @@ public class McpSkillBridge { } /** - * Resolve the raw tool name list for a server with cache-first / live-fallback - * semantics. Returns an empty list (never null) so the manifest builder - * stays simple. + * Resolve the tool list for a server with cache-first / live-fallback + * semantics. Each entry carries the raw name and (when available) the + * upstream description. Returns an empty list (never null) so callers + * stay simple. */ - private List readToolRawNames(McpServerEntity server) { - List fromCache = parseCachedToolNames(server.getToolsCacheJson()); + private List readToolDescriptors(McpServerEntity server) { + List fromCache = parseCachedToolDescriptors(server.getToolsCacheJson()); if (!fromCache.isEmpty()) { return fromCache; } try { List discovered = mcpClientManager.getServerTools(server.getId()); - List names = new ArrayList<>(discovered.size()); + List out = new ArrayList<>(discovered.size()); for (McpSchema.Tool t : discovered) { if (t == null) continue; String n = t.name(); - if (n != null && !n.isBlank()) names.add(n); + if (n != null && !n.isBlank()) { + out.add(new McpToolDescriptor(n, t.description())); + } } - return names; + return out; } catch (Exception e) { log.debug("MCP bridge manifest build: getServerTools({}) failed: {}", server.getId(), e.getMessage()); @@ -293,22 +348,25 @@ public class McpSkillBridge { /** * Parse the {@code tools_cache_json} column written by - * {@code McpServerService} after each successful connect. Returns an - * empty list if the column is null/blank/malformed — the bridge is - * required to keep working when the cache hasn't been populated yet - * (e.g. first-ever connect just succeeded a moment ago). + * {@code McpServerService} after each successful connect — an array of + * {@code {name, description, inputSchema}} entries. Returns an empty + * list if the column is null/blank/malformed — the bridge is required + * to keep working when the cache hasn't been populated yet (e.g. + * first-ever connect just succeeded a moment ago). */ - private List parseCachedToolNames(String json) { + private List parseCachedToolDescriptors(String json) { if (json == null || json.isBlank()) { return List.of(); } try { cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json); - List out = new ArrayList<>(arr.size()); + List out = new ArrayList<>(arr.size()); for (Object obj : arr) { if (!(obj instanceof cn.hutool.json.JSONObject jo)) continue; String name = jo.getStr("name"); - if (name != null && !name.isBlank()) out.add(name); + if (name != null && !name.isBlank()) { + out.add(new McpToolDescriptor(name, jo.getStr("description"))); + } } return out; } catch (Exception e) { @@ -317,11 +375,106 @@ public class McpSkillBridge { } } + private static List toRawNames(List tools) { + List names = new ArrayList<>(tools.size()); + for (McpToolDescriptor t : tools) { + names.add(t.name()); + } + return names; + } + + /** + * Synthesize a SKILL.md body for an MCP-derived virtual skill. + * + *

    Persisted and uploaded skills ship a hand-written SKILL.md that the + * agent serves on demand through {@code readSkillFile}; it tells the + * model what the skill is for and how to drive it. An MCP-derived skill + * has no such file — the upstream server only exposes a tool list — so + * without a synthesized body {@code readSkillFile} returns "content not + * available" and the model has nothing beyond the one-line description + * to reason about. + * + *

    This builds an equivalent body from the live tool snapshot: a + * one-line summary, the tool catalog with per-tool descriptions, and a + * short usage note. Regenerated on every list call, so it tracks the + * upstream tool set with no persistence step. + */ + private String buildSkillContent(McpServerEntity server, List tools) { + String displayName = displayName(server); + StringBuilder md = new StringBuilder(); + md.append("# ").append(displayName).append("\n\n"); + md.append(buildDescription(server)).append("\n\n"); + md.append("This capability is provided by the MCP server **").append(displayName).append("**"); + String transport = nullSafe(server.getTransport()); + if (!transport.isBlank()) { + md.append(" (").append(transport).append(" transport)"); + } + md.append(". Its tools are available to you as ordinary function calls — ") + .append("invoke them directly by name; no shell or scripts are involved.\n\n"); + + md.append("## Available Tools\n\n"); + if (tools.isEmpty()) { + md.append("The tool list is not available yet. The MCP server may be ") + .append("disconnected or still starting up — check its status in ") + .append("Settings ▸ MCP Connections.\n"); + return md.toString(); + } + md.append("This server exposes ").append(tools.size()) + .append(tools.size() == 1 ? " tool:\n\n" : " tools:\n\n"); + for (McpToolDescriptor t : tools) { + md.append("- **").append(t.name()).append("**"); + String desc = oneLine(t.description()); + if (!desc.isBlank()) { + md.append(" — ").append(desc); + } + md.append("\n"); + } + md.append("\n## Usage Notes\n\n"); + md.append("- These tools appear in your tool list under `mcp_`-prefixed names; ") + .append("pick whichever one matches the user's request.\n"); + md.append("- If a call fails with a connection error, the MCP server is likely ") + .append("disconnected — it can be reconnected in Settings ▸ MCP Connections.\n"); + return md.toString(); + } + + /** Collapse whitespace and clamp a tool description to a prompt-friendly length. */ + private static String oneLine(String s) { + if (s == null) { + return ""; + } + String collapsed = s.replaceAll("\\s+", " ").trim(); + return collapsed.length() > 200 ? collapsed.substring(0, 200) + "…" : collapsed; + } + + /** Minimal MCP tool projection: just what the manifest and SKILL.md body need. */ + private record McpToolDescriptor(String name, String description) {} + private String slugify(String raw) { if (raw == null) return ""; return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-"); } + /** + * Stable slug for an MCP server. Falls back to {@code mcp-{id}} when + * the source name has no ASCII letter/digit (e.g. pure CJK), because + * the naive slugify would otherwise return a run of dashes — making + * two differently-named all-CJK servers collide on the same display + * key and breaking name-based skill lookup. + */ + private String slugForServer(McpServerEntity server) { + String slug = slugify(server.getName()); + return hasAsciiAlphaNumeric(slug) ? slug : "mcp-" + server.getId(); + } + + private static boolean hasAsciiAlphaNumeric(String s) { + if (s == null || s.isEmpty()) return false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) return true; + } + return false; + } + private String displayName(McpServerEntity server) { return server.getName() != null ? server.getName() : "mcp-" + server.getId(); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index 44c29730..9ab88d18 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -130,6 +130,26 @@ public class SkillEntity { /** RFC-042 §2.3 — wall-clock time of the last scan write-back. */ private LocalDateTime securityScanTime; + /** + * Lifecycle state for the time-window archival state machine: + * {@code active} / {@code stale} / {@code archived}. Defaults to + * {@code active} via the column DEFAULT. + */ + private String lifecycleState; + + /** User-pinned skill — exempt from automatic archival. */ + private Boolean pinned; + + /** + * Activity anchor, cached from {@code mate_skill_usage_stat.last_loaded_at} + * so the daily lifecycle sweep is a single indexed select instead of a + * join. {@code null} falls back to {@code createTime} as the anchor. + */ + private LocalDateTime lastActivityAt; + + /** Wall-clock time the skill entered the archived state. */ + private LocalDateTime archivedAt; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java new file mode 100644 index 00000000..fa590407 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java @@ -0,0 +1,262 @@ +package vip.mate.skill.runtime; + +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; +import vip.mate.skill.knowledge.SkillScopedToolCallback; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.secret.SkillSecretService; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Wrapper tool factory for skill script entrypoints declared in the + * {@code scripts} manifest block. + * + *

    A directory-backed skill ships executable scripts under {@code scripts/}. + * The generic {@code runSkillScript} tool can run any of them, but it forces + * the model to hand-assemble the argument list — brittle whenever a script + * consumes a structured JSON payload. This factory turns each declared + * entrypoint into its own typed tool: the model fills schema-described + * fields, and the runtime serializes them into process arguments. The model + * never crafts a JSON string by hand. + * + *

    One wrapper per entrypoint, named {@code skill__}. + * Each wrapper closes over the resolved skill directory and id, so a call + * always targets the declaring skill's own script and decrypted secrets and + * cannot be redirected elsewhere. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ScriptSkillWrapperToolFactory { + + private final SkillScriptExecutionService executionService; + private final SkillFileAccessPolicy accessPolicy; + private final SkillSecretService skillSecretService; + private final ObjectMapper objectMapper; + + /** + * Build one wrapper callback per declared script entrypoint. Returns an + * empty list when the skill declares no entrypoints or has no directory + * (a database-only skill cannot expose runnable scripts). + */ + public List buildWrappers(ResolvedSkill resolved, SkillManifest manifest) { + if (resolved == null || manifest == null + || manifest.getScripts() == null || manifest.getScripts().isEmpty() + || resolved.getSkillDir() == null) { + return List.of(); + } + String skillSlug = sanitize(manifest.getName()); + if (skillSlug.isBlank()) { + return List.of(); + } + Path skillDir = resolved.getSkillDir(); + Long skillId = resolved.getId(); + + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (SkillManifest.ScriptDef def : manifest.getScripts()) { + if (!isUsable(def)) { + continue; + } + String name = "skill_" + skillSlug + "_" + sanitize(def.getId()); + if (!seen.add(name)) { + log.warn("Skill '{}' script entrypoint id '{}' collides on tool name '{}'; skipping duplicate", + manifest.getName(), def.getId(), name); + continue; + } + out.add(new SkillScopedToolCallback( + name, + buildDescription(manifest, def), + buildInputSchema(def), + input -> invoke(skillDir, skillId, def, input))); + } + return out; + } + + /** + * Names the wrappers this manifest would produce, without building them. + * Used by the resolver to merge entrypoint names into + * {@code manifest.allowedTools} so {@code getEffectiveAllowedTools()} + * surfaces them like any other declared tool. + */ + public List wrapperNames(SkillManifest manifest) { + if (manifest == null || manifest.getName() == null + || manifest.getScripts() == null || manifest.getScripts().isEmpty()) { + return List.of(); + } + String skillSlug = sanitize(manifest.getName()); + if (skillSlug.isBlank()) { + return List.of(); + } + List names = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (SkillManifest.ScriptDef def : manifest.getScripts()) { + if (!isUsable(def)) { + continue; + } + String name = "skill_" + skillSlug + "_" + sanitize(def.getId()); + if (seen.add(name)) { + names.add(name); + } + } + return names; + } + + /** An entrypoint is usable only when it has both an id and a script path. */ + private static boolean isUsable(SkillManifest.ScriptDef def) { + return def != null + && def.getId() != null && !def.getId().isBlank() + && def.getPath() != null && !def.getPath().isBlank(); + } + + // ==================== invocation ==================== + + private String invoke(Path skillDir, Long skillId, SkillManifest.ScriptDef def, String input) { + try { + JsonNode args = (input == null || input.isBlank()) + ? objectMapper.createObjectNode() + : objectMapper.readTree(input); + + // Path traversal is blocked here — only scripts under the + // skill's own scripts/ directory can be reached. + Path scriptPath = accessPolicy.validateScriptPath(skillDir, def.getPath()); + if (scriptPath == null) { + return errorJson("invalid or unsafe script path: " + def.getPath()); + } + + List argv = buildArgv(def.getFixedArgs(), def.getArgStyle(), args); + + // Inject this skill's stored secrets as subprocess env vars, + // mirroring the generic runSkillScript path. + Map envVars = skillId != null + ? skillSecretService.getDecrypted(skillId) + : Map.of(); + + SkillScriptExecutionService.ScriptResult result = + executionService.execute(scriptPath, argv, envVars); + return JSONUtil.createObj() + .set("exitCode", result.getExitCode()) + .set("stdout", result.getStdout()) + .set("stderr", result.getStderr()) + .toString(); + } catch (Exception e) { + log.warn("script wrapper for entrypoint '{}' failed: {}", def.getId(), e.getMessage()); + return errorJson(e.getMessage() == null ? "script invocation failed" : e.getMessage()); + } + } + + /** + * Translate the typed argument object into a process argument list. + * + *

    {@code fixedArgs} are emitted first, verbatim — they let one + * dispatcher script back several entrypoints (e.g. a fixed method name + * as {@code argv[1]}). The typed arguments follow, shaped by + * {@code argStyle}: + * + *

      + *
    • {@code json} (default) — append the whole object as one compact + * JSON argument, the shape a script reading its last argv with a + * JSON parser expects.
    • + *
    • {@code flags} — append each property as {@code --key value}; + * a {@code true} boolean becomes a bare {@code --key}, while a + * {@code false} or null property is dropped.
    • + *
    + * + *

    Package-private and static for direct unit testing. + * + * @return the argument list, or {@code null} when it would be empty + */ + static List buildArgv(List fixedArgs, String argStyle, JsonNode args) { + List out = new ArrayList<>(); + if (fixedArgs != null) { + for (String fixed : fixedArgs) { + if (fixed != null) { + out.add(fixed); + } + } + } + if ("flags".equalsIgnoreCase(argStyle)) { + if (args != null && args.isObject()) { + Iterator> fields = args.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode value = field.getValue(); + if (value == null || value.isNull()) { + continue; + } + if (value.isBoolean()) { + if (value.asBoolean()) { + out.add("--" + field.getKey()); + } + continue; + } + out.add("--" + field.getKey()); + out.add(value.isValueNode() ? value.asText() : value.toString()); + } + } + } else { + // Default: json — append one compact JSON argument, unless the + // object is empty / absent (an entrypoint with no typed input). + if (args != null && !args.isNull() && !args.isMissingNode() + && !(args.isObject() && args.isEmpty())) { + out.add(args.toString()); + } + } + return out.isEmpty() ? null : out; + } + + // ==================== helpers ==================== + + private String buildDescription(SkillManifest manifest, SkillManifest.ScriptDef def) { + String base; + if (def.getDescription() != null && !def.getDescription().isBlank()) { + base = def.getDescription().trim(); + } else if (def.getLabel() != null && !def.getLabel().isBlank()) { + base = def.getLabel().trim(); + } else { + base = "Run the '" + def.getId() + "' script"; + } + return base + " (skill: " + manifest.getName() + "). " + + "Fill the described fields; the arguments are forwarded to the script for you."; + } + + private String buildInputSchema(SkillManifest.ScriptDef def) { + Map params = def.getParameters(); + if (params == null || params.isEmpty()) { + return "{\"type\":\"object\",\"properties\":{}}"; + } + try { + return objectMapper.writeValueAsString(params); + } catch (Exception e) { + log.warn("script entrypoint '{}' has an unserializable parameter schema: {}", + def.getId(), e.getMessage()); + return "{\"type\":\"object\",\"properties\":{}}"; + } + } + + /** Tool-name slug rule shared with the knowledge / acp wrapper factories. */ + private static String sanitize(String raw) { + if (raw == null) { + return ""; + } + return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); + } + + private static String errorJson(String message) { + return JSONUtil.createObj().set("error", message).toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java new file mode 100644 index 00000000..1a587fed --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillCatalogRenderer.java @@ -0,0 +1,28 @@ +package vip.mate.skill.runtime; + +import java.util.Set; + +/** + * Renders the agent-scoped skill catalog segment at runtime so its ordering can + * react to skills loaded during the current graph run. + *

    + * Built once per agent (capturing the agent's bound skills, effective tool + * allowlist, model window and workspace), then invoked each turn by the + * reasoning / step-execution nodes with the set of skills already loaded this + * run. Loaded skills are pinned to the top of the catalog so a multi-iteration + * loop stops re-loading something it already pulled into message history. + */ +@FunctionalInterface +public interface SkillCatalogRenderer { + + /** + * Render the {@code ## Skills} catalog segment. + * + * @param loadedThisRun skill names loaded via {@code load_skill} so far in + * this run; pinned to the top of the catalog. Never + * {@code null} — pass an empty set when nothing loaded. + * @return the catalog markdown, or an empty string when the agent has no + * visible skills. + */ + String render(Set loadedThisRun); +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java index d8e850a2..8fa19c5e 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java @@ -7,7 +7,7 @@ import java.nio.file.Path; /** * 技能文件访问策略 - * 确保只能访问 skillDir 内的 references/ 和 scripts/ 文件 + * 确保只能访问 skillDir 内的 references/、scripts/ 和 templates/ 文件 */ @Slf4j @Component @@ -17,7 +17,7 @@ public class SkillFileAccessPolicy { * 验证文件路径是否安全 * * @param skillDir 技能根目录 - * @param relativePath 相对路径(必须以 references/ 或 scripts/ 开头) + * @param relativePath 相对路径(必须以 references/、scripts/ 或 templates/ 开头) * @return 归一化后的绝对路径,如果不安全则返回 null */ public Path validateAndResolve(Path skillDir, String relativePath) { @@ -28,8 +28,10 @@ public class SkillFileAccessPolicy { // 归一化路径分隔符 String normalized = relativePath.replace("\\", "/"); - // 必须以 references/ 或 scripts/ 开头 - if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) { + // 必须以 references/、scripts/ 或 templates/ 开头 + if (!normalized.startsWith("references/") + && !normalized.startsWith("scripts/") + && !normalized.startsWith("templates/")) { log.warn("Invalid path prefix: {}", relativePath); return null; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index d6ee4115..25903b0b 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -72,6 +72,12 @@ public class SkillPackageResolver { * Spring lifecycle. */ private final AcpSkillWrapperToolFactory acpWrapperFactory; + /** + * Script-entrypoint wrapper factory for skills that declare a + * {@code scripts} manifest block. {@code @Lazy} to match the sibling + * wrapper factories and stay clear of bean construction-order loops. + */ + private final ScriptSkillWrapperToolFactory scriptWrapperFactory; /** * {@code @Lazy} on ToolRegistry — same lazy-resolution loop as * {@code SkillDependencyChecker}; without this we'd reach for the @@ -109,6 +115,7 @@ public class SkillPackageResolver { SkillMapper skillMapper, @Lazy WikiSkillWrapperToolFactory wikiWrapperFactory, @Lazy AcpSkillWrapperToolFactory acpWrapperFactory, + @Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory, @Lazy ToolRegistry toolRegistry) { this.frontmatterParser = frontmatterParser; this.manifestParser = manifestParser; @@ -120,6 +127,7 @@ public class SkillPackageResolver { this.skillMapper = skillMapper; this.wikiWrapperFactory = wikiWrapperFactory; this.acpWrapperFactory = acpWrapperFactory; + this.scriptWrapperFactory = scriptWrapperFactory; this.toolRegistry = toolRegistry; } @@ -335,6 +343,7 @@ public class SkillPackageResolver { .enabled(Boolean.TRUE.equals(entity.getEnabled())) .icon(entity.getIcon()) .builtin(Boolean.TRUE.equals(entity.getBuiltin())) + .workspaceId(entity.getWorkspaceId()) .createTime(entity.getCreateTime()) .build(); } @@ -376,6 +385,7 @@ public class SkillPackageResolver { .enabled(Boolean.TRUE.equals(entity.getEnabled())) .icon(entity.getIcon()) .builtin(Boolean.TRUE.equals(entity.getBuiltin())) + .workspaceId(entity.getWorkspaceId()) .createTime(entity.getCreateTime()) .build(); } @@ -531,6 +541,11 @@ public class SkillPackageResolver { // registeredWrappers map so deregistration covers both. applyAcpWrappers(resolved, manifest); + // Skills that declare a scripts[] block get one typed wrapper + // tool per entrypoint, so a script consuming structured input + // is driven by schema fields instead of a hand-built JSON arg. + applyScriptWrappers(resolved, manifest); + // Build requirement lookup for feature checks. Map reqByKey = new LinkedHashMap<>(); for (SkillManifest.RequirementDef r : manifest.getRequires()) { @@ -806,6 +821,58 @@ public class SkillPackageResolver { manifest.setAllowedTools(mergedAllowed); } + /** + * Register typed wrapper tools for a skill's declared script entrypoints + * (the {@code scripts} manifest block). Parallel structure to + * {@link #applyKnowledgeWrappers} / {@link #applyAcpWrappers}. + * + *

    Skipped for {@code knowledge} / {@code acp} skills: those types own + * the wrapper slot, and this method must never deregister wrappers a + * sibling branch just registered. Also skipped when the skill declares + * no entrypoints, is disabled, or has no directory. + */ + private void applyScriptWrappers(ResolvedSkill resolved, SkillManifest manifest) { + String type = manifest.getType(); + if ("knowledge".equalsIgnoreCase(type) || "acp".equalsIgnoreCase(type)) { + return; + } + boolean hasScripts = manifest.getScripts() != null && !manifest.getScripts().isEmpty(); + if (!hasScripts || !resolved.isEnabled() || resolved.getSkillDir() == null) { + return; + } + // Fresh build so a re-resolve diff-updates the registry cleanly. + // applyKnowledgeWrappers already cleared any prior set for a + // non-knowledge skill; this is a no-op safety net. + deregisterSkillWrappers(resolved.getId()); + + java.util.List wrappers = scriptWrapperFactory.buildWrappers(resolved, manifest); + if (wrappers.isEmpty()) { + return; + } + java.util.Set registered = new java.util.LinkedHashSet<>(); + Long entityId = resolved.getId(); + for (ToolCallback cb : wrappers) { + String name = cb.getToolDefinition().name(); + registered.add(name); + toolRegistry.registerPluginTool(cb, () -> + entityId != null && resolved.isEnabled()); + } + if (entityId != null) { + registeredWrappers.put(entityId, registered); + } + + // Append wrapper names to allowedTools so getEffectiveAllowedTools + // surfaces them like any other manifest-declared tool. + java.util.List mergedAllowed = new java.util.ArrayList<>( + manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools()); + for (String wrapperName : scriptWrapperFactory.wrapperNames(manifest)) { + if (!mergedAllowed.contains(wrapperName)) { + mergedAllowed.add(wrapperName); + } + } + manifest.setAllowedTools(mergedAllowed); + } + // ==================== 阶段 4:综合判定 ==================== private void resolveRuntimeAvailability(ResolvedSkill resolved) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 008d4553..edfa3eca 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -63,6 +63,16 @@ public class SkillRuntimeService { private final AcpSkillBridge acpSkillBridge; private final SkillUsageService usageService; + /** + * Mirrors {@code mateclaw.skill.disclosure.load-skill-tool.enabled}. When + * false the catalog guidance points at {@code readSkillFile} instead of + * {@code load_skill} (which is also unregistered upstream). Field-initialised + * to true so non-Spring unit construction keeps the default behavior. + */ + @org.springframework.beans.factory.annotation.Value( + "${mateclaw.skill.disclosure.load-skill-tool.enabled:true}") + private boolean loadSkillToolEnabled = true; + @Autowired public SkillRuntimeService(SkillService skillService, SkillPackageResolver packageResolver, @@ -345,13 +355,48 @@ public class SkillRuntimeService { public String buildSkillPromptEnhancement(Set boundSkillIds, Set effectiveToolNames, Integer maxInputTokens) { - return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null); + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null); } public String buildSkillPromptEnhancement(Set boundSkillIds, Set effectiveToolNames, Integer maxInputTokens, Long agentId) { + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, agentId, null); + } + + /** + * 构建技能目录提示片段(支持按 Agent 工作区隔离)。 + * + * @param agentWorkspaceId 调用 Agent 的工作区 ID。非 null 时,目录只保留 + * 内置技能(全局)与该工作区拥有的技能;其他工作区 + * 的技能不会注入 prompt。null 表示不做工作区过滤 + * (调试预览等全局场景)。 + */ + public String buildSkillPromptEnhancement(Set boundSkillIds, + Set effectiveToolNames, + Integer maxInputTokens, + Long agentId, + Long agentWorkspaceId) { + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, + agentId, agentWorkspaceId, Set.of()); + } + + /** + * Build the skill catalog prompt segment, pinning skills loaded this run to + * the top so a multi-iteration loop stops re-loading the same skill. + * + * @param loadedThisRunNames names of skills loaded via {@code load_skill} + * during the current graph run; sorted to the top + * of the catalog ahead of the usage-history + * signals. Never {@code null}. + */ + public String buildSkillPromptEnhancement(Set boundSkillIds, + Set effectiveToolNames, + Integer maxInputTokens, + Long agentId, + Long agentWorkspaceId, + Set loadedThisRunNames) { List activeSkills; if (boundSkillIds != null) { // Per-agent filter: pick the agent's bound subset from the @@ -381,6 +426,16 @@ public class SkillRuntimeService { activeSkills = activeSkills.stream() .filter(s -> matchesCurrentPlatform(s, currentOs)) .collect(java.util.stream.Collectors.toList()); + // Workspace filter — a workspace-B agent must not see workspace-A's + // skills in its catalog. Builtin skills are global; virtual MCP + // skills carry no workspace (null) and stay globally visible. Only + // applied when the caller supplies the agent's workspace; the debug + // preview passes null to keep its global view. + if (agentWorkspaceId != null) { + activeSkills = activeSkills.stream() + .filter(s -> matchesWorkspace(s, agentWorkspaceId)) + .collect(java.util.stream.Collectors.toList()); + } if (activeSkills.isEmpty()) { return ""; } @@ -401,14 +456,10 @@ public class SkillRuntimeService { // hide new skills behind 40+ existing ones, and the LLM tells the // user "no such skill" minutes after they uploaded it. java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now().minus(NEW_SKILL_BOOST_WINDOW); - List sorted = SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED) - .stream() - .sorted(java.util.Comparator - .comparingInt((ResolvedSkill s) -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1) - .thenComparingInt(s -> recentNames.contains(s.getName()) ? 0 : 1) - .thenComparingInt(s -> frequentNames.contains(s.getName()) ? 0 : 1) - .thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED))) - .toList(); + Set loadedNames = loadedThisRunNames == null ? Set.of() : loadedThisRunNames; + List sorted = applyCatalogSignals( + SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED), + loadedNames, recentNames, frequentNames, recencyCutoff); List pinned = sorted.stream() .filter(s -> s.getId() != null && boundIds.contains(s.getId())) .toList(); @@ -422,15 +473,29 @@ public class SkillRuntimeService { StringBuilder sb = new StringBuilder(); sb.append("\n\n## Skills\n"); sb.append("This is a compact catalog. If a listed skill matches the task, "); - sb.append("first call `readSkillFile(skillName=, filePath=\"SKILL.md\")` and follow its instructions. "); + if (loadSkillToolEnabled) { + sb.append("first call `load_skill(skillName=)` to pull its SKILL.md into the conversation, "); + sb.append("then follow its instructions. Once loaded, the skill stays available in the conversation — "); + sb.append("do not load it again. "); + } else { + sb.append("first call `readSkillFile(skillName=, filePath=\"SKILL.md\")` to read its instructions, "); + sb.append("then follow them. "); + } sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog "); sb.append("(it accepts `keyword=` and `limit=` up to 50 — use them to search by topic "); sb.append("when the default page is truncated). "); sb.append("If the user names a specific skill that isn't in this table, "); - sb.append("call `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly — "); + if (loadSkillToolEnabled) { + sb.append("call `load_skill(skillName=\"\")` directly — "); + } else { + sb.append("call `readSkillFile(skillName=\"\", filePath=\"SKILL.md\")` directly — "); + } sb.append("the catalog above is intentionally compact and doesn't list every active skill. "); sb.append("Skills are documentation packages — calling a skill name as a tool will fail. "); - sb.append("Skills with a `scripts/` directory expose `runSkillScript`; SKILL.md will name the script when needed.\n\n"); + sb.append("To read a skill's reference or script files, use "); + sb.append("`readSkillFile(skillName=, filePath=\"references/...\")`. "); + sb.append("Skills with a `scripts/` directory expose `runSkillScript`; "); + sb.append("SKILL.md will name the script when needed.\n\n"); sb.append("| Skill | Status | Description |\n"); sb.append("|-------|--------|-------------|\n"); for (ResolvedSkill skill : selected) { @@ -464,6 +529,33 @@ public class SkillRuntimeService { return sb.toString(); } + /** + * Apply the catalog ranking signals on top of the RECOMMENDED base order. + * Priority, highest first: loaded this run, freshly installed, recently + * loaded (DB history), frequently loaded (DB history), then the RECOMMENDED + * comparator as the stable tiebreak. + *

    + * Package-private and static so it can be unit-tested without standing up + * the full service. + */ + static List applyCatalogSignals(List recommended, + Set loadedThisRunNames, + Set recentNames, + Set frequentNames, + java.time.LocalDateTime recencyCutoff) { + Set loaded = loadedThisRunNames == null ? Set.of() : loadedThisRunNames; + Set recent = recentNames == null ? Set.of() : recentNames; + Set frequent = frequentNames == null ? Set.of() : frequentNames; + return recommended.stream() + .sorted(java.util.Comparator + .comparingInt((ResolvedSkill s) -> loaded.contains(s.getName()) ? 0 : 1) + .thenComparingInt(s -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1) + .thenComparingInt(s -> recent.contains(s.getName()) ? 0 : 1) + .thenComparingInt(s -> frequent.contains(s.getName()) ? 0 : 1) + .thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED))) + .toList(); + } + private static boolean isVisibleWithTools(ResolvedSkill skill, Set effectiveToolNames) { if (effectiveToolNames == null) return true; Set tools = skill.getEffectiveAllowedTools(); @@ -576,4 +668,17 @@ public class SkillRuntimeService { } return false; } + + /** + * True when the skill is visible to an agent in {@code agentWorkspaceId}. + * Builtin skills are global, virtual MCP-derived skills carry no + * workspace ({@code null}) and are likewise global; every other skill is + * visible only inside its owning workspace. + */ + static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) { + if (skill.isBuiltin()) return true; + Long skillWs = skill.getWorkspaceId(); + if (skillWs == null) return true; + return skillWs == agentWorkspaceId; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java index b6fc9793..94922ac6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillSecurityService.java @@ -121,7 +121,87 @@ public class SkillSecurityService { "(?i)import\\s+(os|subprocess|shutil)", "Python system module import", "Importing os/subprocess/shutil enables system-level operations", - "Ensure system operations are necessary and scoped appropriately") + "Ensure system operations are necessary and scoped appropriately"), + + // ===== Python:不可信反序列化 / 沙箱逃逸 ===== + rule("PICKLE_DESERIALIZE", "DESERIALIZATION", SkillValidationResult.Severity.HIGH, + "(?i)\\b(c?pickle|dill)\\.loads?\\s*\\(", + "Untrusted deserialization (pickle)", + "pickle/dill load executes arbitrary code embedded in the payload", + "Use json or a vetted serializer; never unpickle untrusted data"), + rule("MARSHAL_LOADS", "DESERIALIZATION", SkillValidationResult.Severity.HIGH, + "(?i)\\bmarshal\\.loads?\\s*\\(", + "Untrusted deserialization (marshal)", + "marshal can execute crafted bytecode", + "Avoid marshal for external data"), + // MEDIUM (warn, not block): yaml.load is only unsafe WITHOUT SafeLoader, + // and the line-by-line scan can't see a SafeLoader argument that wraps + // onto the next line — so blocking here would false-positive legit + // multi-line safe calls. Surface it for review instead of blocking. + rule("YAML_UNSAFE_LOAD", "DESERIALIZATION", SkillValidationResult.Severity.MEDIUM, + "(?i)\\byaml\\.load\\s*\\((?![^)]*(?i:safe))", + "Possibly unsafe yaml.load", + "yaml.load without SafeLoader can instantiate arbitrary Python objects", + "Use yaml.safe_load or Loader=yaml.SafeLoader"), + rule("PY_SANDBOX_ESCAPE", "SANDBOX_ESCAPE", SkillValidationResult.Severity.HIGH, + "(__subclasses__|__mro__|__builtins__|__globals__)", + "Python sandbox-escape primitive", + "Introspection attributes commonly used to break out of restricted execution", + "Remove reflection into builtins / class hierarchies"), + rule("PY_CTYPES", "CODE_EXECUTION", SkillValidationResult.Severity.HIGH, + "(?i)\\bctypes\\.(cdll|windll)\\b", + "Native library loading via ctypes", + "ctypes can load and call arbitrary native code", + "Avoid ctypes; use safe Python APIs"), + rule("PY_DYNAMIC_IMPORT", "CODE_EXECUTION", SkillValidationResult.Severity.LOW, + "(?i)(\\b__import__\\s*\\(|\\bimportlib\\.import_module\\s*\\()", + "Dynamic module import", + "Dynamic imports can load attacker-controlled modules", + "Import modules statically by name where possible"), + + // ===== Node.js:危险 API ===== + rule("NODE_CHILD_PROCESS", "CODE_EXECUTION", SkillValidationResult.Severity.MEDIUM, + "(?i)(require\\s*\\(\\s*['\"]child_process['\"]\\s*\\)|child_process\\.(exec|execSync|spawn|spawnSync|fork))", + "Node child_process execution", + "child_process can run arbitrary system commands", + "Confirm subprocess use is necessary and arguments are not attacker-controlled"), + rule("NODE_NEW_FUNCTION", "CODE_EXECUTION", SkillValidationResult.Severity.HIGH, + "(?i)\\bnew\\s+Function\\s*\\(", + "Dynamic code execution (new Function)", + "new Function compiles strings into executable code, like eval", + "Use structured logic instead of constructing functions from strings"), + rule("NODE_VM_MODULE", "CODE_EXECUTION", SkillValidationResult.Severity.MEDIUM, + "(?i)require\\s*\\(\\s*['\"](vm|vm2)['\"]\\s*\\)", + "Node vm/vm2 module", + "vm/vm2 are frequently used (and escaped) for sandboxed eval", + "Avoid the vm module for untrusted code"), + rule("PROTOTYPE_POLLUTION", "SANDBOX_ESCAPE", SkillValidationResult.Severity.MEDIUM, + "(\\[\\s*['\"]__proto__['\"]\\s*\\]|\\.__proto__\\s*=)", + "Prototype pollution pattern", + "Writing __proto__ can corrupt object prototypes platform-wide", + "Validate keys before dynamic property assignment"), + + // ===== 混淆 / 资源耗尽 / 持久化 / 凭据读取 ===== + rule("BASE64_PIPE_EXEC", "OBFUSCATION", SkillValidationResult.Severity.HIGH, + "(?i)base64\\s+(-d|--decode)\\b[^\\n|]*\\|\\s*(sh|bash|zsh|python|perl|node)\\b", + "Obfuscated execution (base64 decode piped to interpreter)", + "Decoding then piping to a shell hides the real command from review", + "Ship the command in clear text"), + rule("FORK_BOMB", "RESOURCE_EXHAUSTION", SkillValidationResult.Severity.CRITICAL, + ":\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:\\s*&\\s*\\}\\s*;\\s*:", + "Fork bomb", + "Self-replicating process that exhausts system resources", + "Remove the fork bomb"), + rule("PERSISTENCE", "PERSISTENCE", SkillValidationResult.Severity.MEDIUM, + "(?i)(crontab\\s+-|/etc/cron|authorized_keys|/etc/rc\\.local|/etc/profile\\.d/|>>\\s*~?/?\\.?(bashrc|zshrc|profile))", + "Persistence mechanism", + "Modifies startup files / cron / SSH keys to persist across sessions", + "Skills should not install persistence hooks"), + rule("SECRET_FILE_READ", "DATA_EXFILTRATION", SkillValidationResult.Severity.HIGH, + "(?i)(/etc/shadow|\\.ssh/id_(rsa|ed25519|ecdsa)|\\.aws/credentials|\\.kube/config)", + "Sensitive credential file access", + "References well-known secret files (private keys, cloud credentials)", + "Skills must not read system or user credential files") ); /** diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java index 6df78e20..eda94028 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java @@ -73,6 +73,14 @@ public class ResolvedSkill { @Builder.Default private boolean builtin = false; + /** + * Owning workspace, copied from {@code mate_skill.workspace_id}. Builtin + * skills are global, so for them this is informational only. {@code null} + * for virtual MCP-derived skills (MCP servers carry no workspace) — the + * runtime treats a null workspace as globally visible. + */ + private Long workspaceId; + /** * Skill row create timestamp, copied from {@code mate_skill.create_time}. * Used by the prompt-catalog ranker to surface freshly installed skills diff --git a/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java b/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java index 3d4761d3..9335f643 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/secret/SkillSecretController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -36,12 +37,14 @@ public class SkillSecretController { @Operation(summary = "List secret keys + masked previews for a skill") @GetMapping + @RequireWorkspaceRole("admin") public R> list(@PathVariable Long skillId) { return R.ok(skillSecretService.listSummaries(skillId)); } @Operation(summary = "Upsert a secret value (empty value deletes it)") @PostMapping + @RequireWorkspaceRole("admin") public R put(@PathVariable Long skillId, @RequestBody Map body) { skillSecretService.put(skillId, body.get("key"), body.get("value")); return R.ok(); @@ -49,6 +52,7 @@ public class SkillSecretController { @Operation(summary = "Delete a single secret by key") @DeleteMapping("/{key}") + @RequireWorkspaceRole("admin") public R remove(@PathVariable Long skillId, @PathVariable String key) { skillSecretService.remove(skillId, key); return R.ok(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index f20b8612..69f17d40 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -5,8 +5,11 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import vip.mate.exception.MateClawException; +import vip.mate.skill.event.SkillRemovedEvent; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillFileMapper; import vip.mate.skill.repository.SkillMapper; @@ -47,6 +50,15 @@ public class SkillService { private final SkillWorkspaceManager workspaceManager; private final SkillWorkspaceProperties workspaceProperties; private final SkillSecretService skillSecretService; + /** + * Fires {@link SkillRemovedEvent} on both uninstall and hard-delete so + * the agent-binding listener (and any future subscriber) can scrub + * dependent rows — without this, {@code mate_agent_skill} keeps orphan + * rows that the UI can no longer clear from the picker. + */ + private final ApplicationEventPublisher eventPublisher; + /** Stamps the activity anchor on create / update / enable so the curator sees fresh skills as active. */ + private final SkillLifecycleService lifecycleService; private vip.mate.skill.runtime.SkillRuntimeService runtimeService; /** @@ -58,6 +70,25 @@ public class SkillService { // ==================== CRUD ==================== + /** Default workspace id used when no {@code X-Workspace-Id} is supplied. */ + public static final long DEFAULT_WORKSPACE_ID = 1L; + + static long normalizeWorkspaceId(Long workspaceId) { + return workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + } + + /** + * Restrict a query to skills visible inside {@code workspaceId}: builtin + * skills are global (shared across every workspace), every other skill is + * owned by exactly one workspace. Applied as a nested {@code AND (builtin + * OR workspace_id = ?)} group so it composes with other filters. + */ + private static void applyWorkspaceScope(LambdaQueryWrapper wrapper, Long workspaceId) { + long wsId = normalizeWorkspaceId(workspaceId); + wrapper.and(w -> w.eq(SkillEntity::getBuiltin, true) + .or().eq(SkillEntity::getWorkspaceId, wsId)); + } + /** * 获取所有技能列表(管理页面使用) * 排序:内置优先,然后按创建时间倒序 @@ -68,6 +99,18 @@ public class SkillService { .orderByDesc(SkillEntity::getCreateTime)); } + /** + * Workspace-scoped variant of {@link #listSkills()} — returns builtin + * skills plus the skills owned by {@code workspaceId}. + */ + public List listSkills(Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByDesc(SkillEntity::getBuiltin) + .orderByDesc(SkillEntity::getCreateTime); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectList(wrapper); + } + /** * Paginated skill listing for the SkillMarket admin UI. * @@ -79,33 +122,24 @@ public class SkillService { * security_scan_status}: {@code "FAILED"} surfaces blocked skills so the * admin can inspect findings and rescan, {@code "PASSED"} shows scanned * clean rows, {@code null} / empty means no scan filter. + * + *

    {@code workspaceId} scopes the result to one workspace's catalog: + * builtin skills are always included (they're global), every other skill + * only when it belongs to {@code workspaceId}. A {@code null} workspace + * falls back to the default workspace. */ - public IPage pageSkills(int page, int size, String keyword, - String skillType, Boolean enabled, - String scanStatus) { - return pageSkills(page, size, keyword, skillType, enabled, scanStatus, - null, null, null, Set.of()); - } - - public IPage pageSkills(int page, int size, String keyword, - String skillType, Boolean enabled, - String scanStatus, - String sort, - String source, - String runtime) { - return pageSkills(page, size, keyword, skillType, enabled, scanStatus, - sort, source, runtime, Set.of()); - } - public IPage pageSkills(int page, int size, String keyword, String skillType, Boolean enabled, String scanStatus, String sort, String source, String runtime, - Set pinnedSkillIds) { + Set pinnedSkillIds, + Long workspaceId, + String lifecycleState) { Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + applyWorkspaceScope(wrapper, workspaceId); if (keyword != null && !keyword.isBlank()) { String kw = keyword.trim(); @@ -127,6 +161,13 @@ public class SkillService { if (scanStatus != null && !scanStatus.isBlank()) { wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase()); } + if (lifecycleState != null && !lifecycleState.isBlank()) { + wrapper.eq(SkillEntity::getLifecycleState, lifecycleState.trim().toLowerCase()); + } else { + // Default catalog view hides archived skills — they have their own tab. + wrapper.and(w -> w.isNull(SkillEntity::getLifecycleState) + .or().ne(SkillEntity::getLifecycleState, "archived")); + } SkillCatalogSort catalogSort = SkillCatalogSort.parse(sort); if (runtime != null && !runtime.isBlank() && !"all".equalsIgnoreCase(runtime) @@ -182,14 +223,19 @@ public class SkillService { /** * Aggregate skill counts per {@code skill_type}, plus an {@code all} * rollup. Feeds the SkillMarket tab badges without pulling every row. + * Scoped to {@code workspaceId}: builtin skills count for every + * workspace, all other skills only for their owning workspace. */ - public Map countByType() { + public Map countByType(Long workspaceId) { Map result = new LinkedHashMap<>(); - result.put("all", skillMapper.selectCount(null)); + LambdaQueryWrapper allWrapper = new LambdaQueryWrapper<>(); + applyWorkspaceScope(allWrapper, workspaceId); + result.put("all", skillMapper.selectCount(allWrapper)); for (String type : List.of("builtin", "mcp", "dynamic")) { - result.put(type, skillMapper.selectCount( - new LambdaQueryWrapper() - .eq(SkillEntity::getSkillType, type))); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getSkillType, type); + applyWorkspaceScope(wrapper, workspaceId); + result.put(type, skillMapper.selectCount(wrapper)); } return result; } @@ -208,6 +254,20 @@ public class SkillService { .orderByAsc(SkillEntity::getName)); } + /** + * Workspace-scoped variant of {@link #listEnabledSkills()} — builtin + * skills plus the enabled skills owned by {@code workspaceId}. + */ + public List listEnabledSkills(Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getEnabled, true) + .and(w -> w.isNull(SkillEntity::getSecurityScanStatus) + .or().eq(SkillEntity::getSecurityScanStatus, "PASSED")) + .orderByAsc(SkillEntity::getName); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectList(wrapper); + } + /** * 按名称查找技能(RFC-023:SkillManageTool 重名检查用) */ @@ -226,6 +286,17 @@ public class SkillService { .orderByDesc(SkillEntity::getCreateTime)); } + /** + * Workspace-scoped variant of {@link #listSkillsByType(String)}. + */ + public List listSkillsByType(String skillType, Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getSkillType, skillType) + .orderByDesc(SkillEntity::getCreateTime); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectList(wrapper); + } + /** * 获取技能详情 */ @@ -259,6 +330,14 @@ public class SkillService { if (skill.getEnabled() == null) { skill.setEnabled(true); } + // Every skill belongs to a workspace. Callers that carry an + // X-Workspace-Id header set this explicitly; the no-arg create + // path falls back to the default workspace instead of relying on + // the column DEFAULT, so the value is always populated on the + // returned entity. + if (skill.getWorkspaceId() == null) { + skill.setWorkspaceId(DEFAULT_WORKSPACE_ID); + } // 前端只识别 builtin/mcp/dynamic,用户新建默认为 dynamic if (skill.getSkillType() == null || skill.getSkillType().isBlank()) { skill.setSkillType("dynamic"); @@ -271,6 +350,10 @@ public class SkillService { skillMapper.insert(skill); log.info("Created skill: {} (type={})", skill.getName(), skill.getSkillType()); + // Stamp the activity anchor so a freshly-created skill is anchored to + // now rather than ageing from create_time alone. + lifecycleService.bumpActivity(skill.getId()); + // 自动初始化工作区目录 if (workspaceProperties.isAutoInit() && !hasExplicitSkillDir(skill)) { workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent()); @@ -381,6 +464,9 @@ public class SkillService { skillMapper.updateById(existing); log.info("Updated skill: {}", existing.getName()); + // A manual edit counts as activity — keep the skill anchored to now. + lifecycleService.bumpActivity(existing.getId()); + // 若 skillContent 变更且约定工作区存在,同步 SKILL.md syncSkillContentToWorkspace(existing); @@ -414,6 +500,10 @@ public class SkillService { skillMapper.deleteById(id); // logical delete (deleted=1) log.info("Uninstalled skill (logical delete + archive): {}", skill.getName()); + // Notify listeners (e.g. agent-binding cleanup) so dependent rows + // referencing this skill_id don't outlive the row itself. + eventPublisher.publishEvent(new SkillRemovedEvent(id, skill.getName())); + if ("archive".equals(workspaceProperties.getDeletePolicy())) { workspaceManager.archiveWorkspace(skill.getName()); } @@ -448,6 +538,10 @@ public class SkillService { } log.info("Hard-deleted skill (physical delete + purge): {}", skill.getName()); + // Same notification as the uninstall path — agent-binding cleanup + // applies regardless of which delete flavor the admin chose. + eventPublisher.publishEvent(new SkillRemovedEvent(id, skill.getName())); + // RFC-091 settings bridge — purge any per-skill secrets so a // future skill reusing this id doesn't inherit stale credentials. try { @@ -492,6 +586,11 @@ public class SkillService { skillMapper.updateById(skill); log.info("Skill {} {}", skill.getName(), enabled ? "enabled" : "disabled"); + // Re-enabling a skill is an explicit "I use this again" signal. + if (enabled) { + lifecycleService.bumpActivity(id); + } + // RFC-090 review #3 — when disabling, explicitly tear down any // registered wrapper tools (knowledge / acp). Without this the // wrappers stay advertised because the availability supplier @@ -544,7 +643,7 @@ public class SkillService { return ""; } - // --- 第零层:Skill 自治引导(RFC-023,对标 hermes-agent prompt_builder.py:164-171) --- + // --- 第零层:Skill 自治引导 --- StringBuilder catalog = new StringBuilder(); catalog.append("\n\n## Skill Management\n\n"); catalog.append("After completing a complex task (5+ tool calls), fixing a tricky error, "); @@ -653,6 +752,17 @@ public class SkillService { )); } + /** + * Workspace-scoped variant of {@link #getEnabledSkillSummary()}. + */ + public Map> getEnabledSkillSummary(Long workspaceId) { + return listEnabledSkills(workspaceId).stream() + .collect(Collectors.groupingBy( + SkillEntity::getSkillType, + Collectors.mapping(SkillEntity::getName, Collectors.toList()) + )); + } + // ==================== Workspace 集成辅助方法 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java index f337ef39..61fc5f6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java @@ -56,9 +56,10 @@ public class SkillSynthesisService { * * @param conversationId 源对话 ID * @param agentId Agent ID(用于记录来源) + * @param workspaceId 目标工作区 ID(决定新 Skill 的归属) * @return 合成结果(包含 skillId、name、status) */ - public SynthesisResult synthesize(String conversationId, Long agentId) { + public SynthesisResult synthesize(String conversationId, Long agentId, Long workspaceId) { // 1. 读取对话历史 List messages = messageMapper.selectList( new LambdaQueryWrapper() @@ -121,6 +122,7 @@ public class SkillSynthesisService { skill.setVersion(extractFrontmatterValue(skillMd, "version")); skill.setSourceConversationId(conversationId); skill.setSecurityScanStatus(scanStatus); + skill.setWorkspaceId(workspaceId); skillService.createSkill(skill); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java index 5ed11ac0..90e8ad15 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.skill.model.SkillEntity; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -33,12 +34,14 @@ public class SkillTemplateController { @Operation(summary = "List skill templates (RFC-091)") @GetMapping + @RequireWorkspaceRole("member") public R> list() { return R.ok(registry.all()); } @Operation(summary = "Get a single skill template") @GetMapping("/{id}") + @RequireWorkspaceRole("member") public R get(@PathVariable String id) { SkillTemplate t = registry.find(id); if (t == null) return R.fail("Template not found: " + id); @@ -47,9 +50,11 @@ public class SkillTemplateController { @Operation(summary = "Instantiate a template into a skill") @PostMapping("/{id}/instantiate") + @RequireWorkspaceRole("admin") public R instantiate( @PathVariable String id, - @RequestBody Map values) { - return R.ok(templateService.instantiate(id, values)); + @RequestBody Map values, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(templateService.instantiate(id, values, workspaceId)); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java index b350daf9..6d261a64 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java @@ -62,11 +62,12 @@ public class SkillTemplateService { * Instantiate the template by id, substituting fields, and create * the skill. Returns the created {@link SkillEntity}. * - * @param templateId id from the registry (e.g. {@code tcm-qa}) - * @param values user-supplied field values; missing required - * fields throw a translatable exception + * @param templateId id from the registry (e.g. {@code tcm-qa}) + * @param values user-supplied field values; missing required + * fields throw a translatable exception + * @param workspaceId owning workspace for the created skill */ - public SkillEntity instantiate(String templateId, Map values) { + public SkillEntity instantiate(String templateId, Map values, Long workspaceId) { SkillTemplate template = registry.find(templateId); if (template == null) { throw new MateClawException("err.skill_template.not_found", @@ -97,6 +98,7 @@ public class SkillTemplateService { entity.setAuthor("skill-template-wizard"); entity.setSkillContent(skillMd); entity.setEnabled(true); + entity.setWorkspaceId(workspaceId); SkillEntity created = skillService.createSkill(entity); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java index cea08e4c..27438823 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.repository.SkillUsageStatMapper; import vip.mate.skill.runtime.model.ResolvedSkill; @@ -18,6 +19,8 @@ import java.util.stream.Collectors; public class SkillUsageService { private final SkillUsageStatMapper mapper; + /** Bubbles the load event up to {@code mate_skill.last_activity_at} for the lifecycle curator. */ + private final SkillLifecycleService lifecycleService; public void recordLoaded(ResolvedSkill skill, Long agentId, String conversationId, String filePath, int tokenEstimate) { @@ -51,6 +54,9 @@ public class SkillUsageService { row.setLastTokenEstimate(tokenEstimate); mapper.updateById(row); } + // Mirror the activity anchor onto mate_skill so the lifecycle + // curator's daily scan stays a single indexed select. + lifecycleService.bumpActivity(skill.getId()); } catch (Exception e) { log.debug("Failed to record skill usage for {}: {}", skill.getName(), e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index 16414841..6f3aa1ab 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -160,12 +160,45 @@ public class SkillWorkspaceManager { } /** - * 归档 workspace 到 {root}/.archived/{name}-{timestamp}/ + * Tri-state outcome of {@link #archiveWorkspace}. {@code MISSING} is a + * commit-safe no-op — the runtime accepts skills that live only in + * {@code mate_skill.skill_content} with no convention workspace — while + * {@code FAILED} is a real error callers requiring atomicity must honor. */ - public void archiveWorkspace(String skillName) { + public enum ArchiveResult { + /** Workspace directory existed and was moved to {@code .archived/}. */ + MOVED, + /** No convention workspace directory — commit-safe no-op. */ + MISSING, + /** Workspace existed but the move failed (IOException). */ + FAILED + } + + /** Symmetric tri-state outcome of {@link #restoreWorkspace}. */ + public enum RestoreResult { + /** Archive directory existed and was moved back into place. */ + MOVED, + /** No archive directory found — DB-only skill or nothing to restore. */ + MISSING, + /** Archive directory existed but the move-back failed (IOException). */ + FAILED + } + + /** + * Move {@code {root}/{name}/} to {@code {root}/.archived/{name}-{ts}/}. + * + *

    Returns {@link ArchiveResult#MISSING} when the workspace doesn't + * exist — callers may treat this as a successful no-op since the runtime + * accepts skills that live only in {@code mate_skill.skill_content}. + * Returns {@link ArchiveResult#FAILED} on IOException; callers requiring + * atomicity must refuse to commit derived state. Returns + * {@link ArchiveResult#MOVED} on success, having already published + * {@link SkillWorkspaceEvent.Type#ARCHIVED}. + */ + public ArchiveResult archiveWorkspace(String skillName) { Path workspaceDir = resolveConventionPath(skillName); if (!Files.exists(workspaceDir)) { - return; + return ArchiveResult.MISSING; } try { @@ -178,8 +211,69 @@ public class SkillWorkspaceManager { Files.move(workspaceDir, archiveDir, StandardCopyOption.ATOMIC_MOVE); log.info("Archived skill workspace: {} → {}", workspaceDir, archiveDir); eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.ARCHIVED, archiveDir)); + return ArchiveResult.MOVED; } catch (IOException e) { log.warn("Failed to archive workspace for skill '{}': {}", skillName, e.getMessage()); + return ArchiveResult.FAILED; + } + } + + /** + * Move the most recent {@code .archived/{name}-{ts}/} directory back to + * the convention path. Symmetric to {@link #archiveWorkspace}. + * + *

    Returns {@link RestoreResult#MISSING} when there is no archive + * directory to restore (a DB-only skill, or the convention path is + * already populated) — callers treat this as a no-op. Returns + * {@link RestoreResult#FAILED} when an archive directory exists but the + * move-back fails. + */ + public RestoreResult restoreWorkspace(String skillName) { + Path target = resolveConventionPath(skillName); + if (Files.exists(target)) { + log.warn("restoreWorkspace skipped: target {} already exists", target); + return RestoreResult.MISSING; + } + Path archiveRoot = getWorkspaceRoot().resolve(".archived"); + if (!Files.exists(archiveRoot)) { + return RestoreResult.MISSING; + } + + Optional newest = listArchivedFor(archiveRoot, sanitizeName(skillName)); + if (newest.isEmpty()) { + return RestoreResult.MISSING; + } + + try { + Files.move(newest.get(), target, StandardCopyOption.ATOMIC_MOVE); + log.info("Restored skill workspace: {} → {}", newest.get(), target); + eventPublisher.publishEvent(new SkillWorkspaceEvent( + skillName, SkillWorkspaceEvent.Type.CREATED, target)); + return RestoreResult.MOVED; + } catch (IOException e) { + log.warn("Failed to restore workspace for skill '{}': {}", skillName, e.getMessage()); + return RestoreResult.FAILED; + } + } + + /** + * Most recent archive directory for {@code sanitizedName}. Archive names + * are {@code {sanitizedName}-{yyyyMMdd-HHmmss}}; the timestamp suffix is + * matched exactly so a name like {@code foo} never picks up an archive of + * {@code foo-bar}. Lexical order on the fixed-width timestamp equals + * chronological order. + */ + private Optional listArchivedFor(Path archiveRoot, String sanitizedName) { + java.util.regex.Pattern suffix = + java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(sanitizedName) + "-\\d{8}-\\d{6}"); + try (var stream = Files.list(archiveRoot)) { + return stream + .filter(Files::isDirectory) + .filter(p -> suffix.matcher(p.getFileName().toString()).matches()) + .max(Comparator.comparing(p -> p.getFileName().toString())); + } catch (IOException e) { + log.warn("Failed to list archive directory {}: {}", archiveRoot, e.getMessage()); + return Optional.empty(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java index 741f5b8d..35a5a452 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java +++ b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java @@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @Tag(name = "系统设置") @RestController @@ -19,12 +21,14 @@ public class SystemSettingController { @Operation(summary = "获取系统设置") @GetMapping + @RequireWorkspaceRole("admin") public R getSettings() { return R.ok(systemSettingService.getSettings()); } @Operation(summary = "保存系统设置") @PutMapping + @RequireWorkspaceRole("admin") public R saveSettings(@RequestBody SystemSettingsDTO dto) { return R.ok(systemSettingService.saveSettings(dto)); } @@ -32,12 +36,15 @@ public class SystemSettingController { @Operation(summary = "获取当前语言") @GetMapping("/language") public R getLanguage() { + // Stays anonymous via SecurityConfig (first-paint i18n). return R.ok(systemSettingService.getLanguage()); } @Operation(summary = "更新当前语言") @PutMapping("/language") + @RequireGlobalAdmin public R saveLanguage(@RequestBody LanguageRequest request) { + // System-wide setting; only the global admin may change it. return R.ok(systemSettingService.saveLanguage(request.getLanguage())); } @@ -53,6 +60,7 @@ public class SystemSettingController { */ @Operation(summary = "更新多模态 sidecar 配置") @PutMapping("/sidecar") + @RequireWorkspaceRole("admin") public R saveSidecar(@RequestBody SidecarRequest request) { return R.ok(systemSettingService.updateSidecarSettings( request.getDefaultVisionModelId(), diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java index 5f634c47..56882f8e 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java +++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java @@ -16,6 +16,7 @@ import vip.mate.common.result.R; import vip.mate.system.featureflag.repository.FeatureFlagMapper; import java.util.List; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; /** * Admin endpoints for runtime feature-flag toggling. @@ -37,6 +38,7 @@ public class FeatureFlagController { /** Lists every flag currently registered, including disabled and whitelisted ones. */ @GetMapping + @RequireWorkspaceRole("admin") public R> list() { return R.ok(mapper.selectList(null)); } @@ -46,6 +48,7 @@ public class FeatureFlagController { * body are touched; unspecified fields preserve their current values. */ @PutMapping("/{flagKey}") + @RequireWorkspaceRole("admin") public R update(@PathVariable @NotBlank String flagKey, @RequestBody UpdateRequest req) { FeatureFlagEntity flag = mapper.selectOne( diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 1a22ca6a..8384afa8 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -409,6 +409,32 @@ public class SystemSettingService { return Boolean.parseBoolean(getValue(STATEGRAPH_ENABLED_KEY, "false")); } + /** + * Read a boolean setting. Returns {@code defaultValue} when the key is + * absent or stored as a non-boolean string. + */ + public boolean getBool(String key, boolean defaultValue) { + return Boolean.parseBoolean(getValue(key, String.valueOf(defaultValue))); + } + + /** Persist a boolean setting. */ + public void saveBool(String key, boolean value, String description) { + saveValue(key, String.valueOf(value), description); + } + + /** + * Read a raw string setting. Returns {@code defaultValue} (which may be + * {@code null}) when the key is absent. + */ + public String getString(String key, String defaultValue) { + return getValue(key, defaultValue); + } + + /** Persist a raw string setting. */ + public void saveString(String key, String value, String description) { + saveValue(key, value, description); + } + private String getValue(String key, String defaultValue) { SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper() .eq(SystemSettingEntity::getSettingKey, key) diff --git a/mateclaw-server/src/main/java/vip/mate/task/AsyncTaskService.java b/mateclaw-server/src/main/java/vip/mate/task/AsyncTaskService.java index 54647533..60e383fe 100644 --- a/mateclaw-server/src/main/java/vip/mate/task/AsyncTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/task/AsyncTaskService.java @@ -133,6 +133,130 @@ public class AsyncTaskService implements ApplicationRunner { return entity; } + // ==================== One-shot Callable submission ==================== + + /** + * Submit a one-shot {@link Callable} that runs on this service's + * {@code pollExecutor} and persists its outcome through the standard + * {@code mate_async_task} lifecycle. + *

    + * This is the local-work counterpart to {@link #startPolling}: instead of + * polling an external provider, the worker runs {@code work.call()} in + * the shared executor, writes the return value to {@code resultJson} (or + * the exception message to {@code errorMessage}), and registers itself in + * the same {@code activePolls} / {@code pollTaskToConv} bookkeeping so + * {@link #onConversationDeleted} can cancel both kinds uniformly. + *

    + * Race closure: the worker is scheduled at 0 ms but blocks on an internal + * {@code CountDownLatch} until the calling thread has finished + * registering both bookkeeping entries. Without this, the executor could + * dequeue the worker, run its {@code finally} cleanup, and return — all + * before {@code activePolls.put} runs on the calling thread — leaving a + * ghost entry no later event drains. + *

    + * Cancellation: while {@code work.call()} runs the worker has no + * cooperative cancel signal beyond {@link #isConversationCanceled}; it + * checks once before invoking the body and once after, so a parent + * conversation deleted mid-run never lands as {@code succeeded}. + * {@link #onConversationDeleted} additionally writes {@code failed} + * synchronously for any non-terminal {@code agent_delegate} task so the + * DB row never lingers in {@code running} after parent deletion. + * + * @param taskType Discriminator written to {@code task_type} + * (e.g. {@code "agent_delegate"}). Listeners + * and the conversation-deleted DB write-back gate + * on this value. + * @param conversationId Parent conversation ID. Written to + * {@code conversation_id} so deleting the parent + * conversation cascade-cancels the worker. Any + * child / detached identifiers belong in + * {@code requestJson}, not here. + * @param messageId Optional parent message ID. + * @param requestJson Caller-serialized request payload. + * @param createdBy Audit attribution; counts toward + * {@code MAX_ACTIVE_TASKS_PER_USER}. + * @param work Body whose return value is persisted as + * {@code resultJson}. A thrown exception lands as + * {@code status=failed} with the exception + * message recorded. + * @return the created task entity (status = pending at return time). + */ + public AsyncTaskEntity submitOneShot(String taskType, String conversationId, + Long messageId, String requestJson, + String createdBy, Callable work) { + AsyncTaskEntity entity = createTask(taskType, conversationId, messageId, + "internal", null, requestJson, createdBy); + final String taskId = entity.getTaskId(); + updateStatus(taskId, "running", 0, null, null); + + // schedule(0)-vs-put race closure: see method javadoc. + CountDownLatch enrolled = new CountDownLatch(1); + ScheduledFuture future = pollExecutor.schedule(() -> { + try { + try { + enrolled.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + updateStatus(taskId, "failed", null, null, "worker interrupted before start"); + return; + } + // Pre-call cancel check: parent conversation may have been + // deleted between submitOneShot returning and the worker + // being dequeued. + if (isConversationCanceled(conversationId)) { + updateStatus(taskId, "failed", null, null, "conversation deleted before start"); + return; + } + String result; + try { + result = work.call(); + } catch (Exception e) { + log.warn("[AsyncTask] One-shot task {} failed: {}", taskId, e.getMessage()); + updateStatus(taskId, "failed", null, null, e.getMessage()); + return; + } + // Post-call cancel check: parent may have been deleted while + // work was running; avoid resurrecting a succeeded row for a + // conversation whose DB cascade already wiped it. + if (isConversationCanceled(conversationId)) { + updateStatus(taskId, "failed", null, null, "conversation deleted during execution"); + } else { + updateStatus(taskId, "succeeded", 100, result, null); + } + } finally { + activePolls.remove(taskId); + pollTaskToConv.remove(taskId); + // Generic terminal event so SSE listeners (parent + // conversation of an async delegation, UI badges, …) can + // react without polling the DB. Re-fetch so the event + // payload reflects the row we just wrote. Wrapped in a + // try-catch because a broadcast failure on a stale + // conversation stream must not mask the task outcome. + try { + AsyncTaskEntity finalEntity = findEntityByTaskId(taskId); + if (finalEntity != null) { + boolean success = "succeeded".equals(finalEntity.getStatus()); + broadcastTaskEventWithData(finalEntity, "async_task_completed", + success, java.util.Map.of(), + success ? null : finalEntity.getErrorMessage()); + } + } catch (Exception broadcastErr) { + log.debug("[AsyncTask] Completion broadcast failed for task {}: {}", + taskId, broadcastErr.getMessage()); + } + } + }, 0, TimeUnit.MILLISECONDS); + + activePolls.put(taskId, future); + if (conversationId != null) { + pollTaskToConv.put(taskId, conversationId); + } + enrolled.countDown(); + log.info("[AsyncTask] Submitted one-shot task {} (type={}, conv={})", + taskId, taskType, conversationId); + return entity; + } + // ==================== 轮询管理 ==================== /** @@ -145,7 +269,7 @@ public class AsyncTaskService implements ApplicationRunner { public void startPolling(String taskId, Function statusChecker, BiConsumer onComplete) { - AsyncTaskEntity task = findByTaskId(taskId); + AsyncTaskEntity task = findEntityByTaskId(taskId); if (task == null) { log.warn("[AsyncTask] Cannot start polling: task {} not found", taskId); return; @@ -190,7 +314,7 @@ public class AsyncTaskService implements ApplicationRunner { updateStatus(taskId, "failed", null, null, result.errorMessage()); } // 刷新任务实体 - AsyncTaskEntity freshTask = findByTaskId(taskId); + AsyncTaskEntity freshTask = findEntityByTaskId(taskId); onComplete.accept(freshTask, result); } } catch (Exception e) { @@ -251,7 +375,22 @@ public class AsyncTaskService implements ApplicationRunner { int cancelled = 0; for (Map.Entry entry : pollTaskToConv.entrySet()) { if (convId.equals(entry.getValue())) { - cancelPolling(entry.getKey()); + String taskId = entry.getKey(); + cancelPolling(taskId); + // One-shot tasks (taskType "agent_delegate") have no separate + // poll loop to observe the cancel and write the terminal row: + // cancelPolling only nukes the Future. Without this explicit + // write-back the DB row stays "running" forever. Polling + // tasks (video / image / ...) keep their original behavior — + // their own poll completion or startup-recovery path is what + // writes the terminal status. + AsyncTaskEntity t = findEntityByTaskId(taskId); + if (t != null + && "agent_delegate".equals(t.getTaskType()) + && !"succeeded".equals(t.getStatus()) + && !"failed".equals(t.getStatus())) { + updateStatus(taskId, "failed", null, null, "conversation deleted"); + } cancelled++; } } @@ -290,7 +429,7 @@ public class AsyncTaskService implements ApplicationRunner { // ==================== 查询 ==================== public AsyncTaskInfo getTaskInfo(String taskId) { - AsyncTaskEntity entity = findByTaskId(taskId); + AsyncTaskEntity entity = findEntityByTaskId(taskId); if (entity == null) { return null; } @@ -307,7 +446,12 @@ public class AsyncTaskService implements ApplicationRunner { return entities.stream().map(this::toInfo).toList(); } - private AsyncTaskEntity findByTaskId(String taskId) { + /** Returns the persisted task entity by its public {@code taskId}, or + * {@code null} if no row matches. Promoted from private to public so the + * conversation-deleted listener (and overrides in tests) can resolve a + * task's current state without going through the read-model + * {@link #getTaskInfo}. */ + public AsyncTaskEntity findEntityByTaskId(String taskId) { return asyncTaskMapper.selectOne( new LambdaQueryWrapper() .eq(AsyncTaskEntity::getTaskId, taskId) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index 8a736fe1..67532885 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -86,16 +86,28 @@ public class ToolRegistry { * which {@link AgentToolSet} relies on (built-in tools first, MCP tools second). */ private LinkedHashMap getEnabledToolBeansByName() { + return getToolBeansByName(true); + } + + /** + * Iterate Spring beans once, returning a {@code beanName → bean} map of + * every @Tool bean. When {@code enabledOnly} is true, DB rows with + * {@code enabled=false} are excluded; when false, disabled rows are kept for + * admin metadata use cases where the UI still needs to resolve aliases. + */ + private LinkedHashMap getToolBeansByName(boolean enabledOnly) { // 1. 从数据库获取明确禁用的 beanName 黑名单 // 逻辑:只有 DB 中存在记录且 enabled=false 的才跳过 // DB 中没有记录的 bean 默认启用(向后兼容 + 新工具自动可用) - Set disabledBeanNames = toolMapper.selectList( - new LambdaQueryWrapper() - .eq(ToolEntity::getEnabled, false) - .isNotNull(ToolEntity::getBeanName) - ).stream() - .map(ToolEntity::getBeanName) - .collect(Collectors.toSet()); + Set disabledBeanNames = enabledOnly + ? toolMapper.selectList( + new LambdaQueryWrapper() + .eq(ToolEntity::getEnabled, false) + .isNotNull(ToolEntity::getBeanName) + ).stream() + .map(ToolEntity::getBeanName) + .collect(Collectors.toSet()) + : Set.of(); LinkedHashMap enabled = new LinkedHashMap<>(); @@ -120,10 +132,29 @@ public class ToolRegistry { } } - log.info("Total enabled tools: {}", enabled.size()); + log.info("Total {} tools: {}", enabledOnly ? "enabled" : "registered", enabled.size()); return enabled; } + /** + * Build an alias index for every registered {@code @Tool} bean, including + * rows disabled in DB. This is for admin display only; runtime tool + * exposure must continue to use {@link #getEnabledToolSet()}. + */ + public AgentToolSet getAllToolBeanSetForAdmin() { + LinkedHashMap beansByName = getToolBeansByName(false); + List toolBeans = new ArrayList<>(beansByName.values()); + IdentityHashMap nameByBean = new IdentityHashMap<>(); + for (Map.Entry e : beansByName.entrySet()) { + nameByBean.put(e.getValue(), e.getKey()); + } + List callbacks = new ArrayList<>(); + for (Object bean : toolBeans) { + Collections.addAll(callbacks, ToolCallbacks.from(bean)); + } + return AgentToolSet.fromCallbacks(toolBeans, callbacks, nameByBean::get); + } + /** * 获取统一的 AgentToolSet(包含 @Tool Bean + ToolCallbackProvider) *

    @@ -152,9 +183,12 @@ public class ToolRegistry { for (ToolCallback cb : cbs) { String toolName = cb.getToolDefinition().name(); String descKey = "tool." + toolName + ".desc"; - String localizedDesc = i18nService.msg(descKey); - // 如果 key 被解析(不等于 key 本身),使用本地化描述 - if (!localizedDesc.equals(descKey)) { + // The i18n description is an optional override: tools without a + // bundle entry (e.g. wiki tools) keep the description declared on + // their @Tool annotation. Use msgOptional so an absent key is not + // logged as a "missing key" — that is expected, not a fault. + String localizedDesc = i18nService.msgOptional(descKey); + if (localizedDesc != null) { localizedCallbacks.add(new LocaleAwareToolCallback(cb, localizedDesc)); } else { localizedCallbacks.add(cb); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java index d8c57a90..25f017e4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java @@ -26,6 +26,7 @@ import java.nio.file.Paths; import java.util.Base64; import java.util.List; import java.util.concurrent.*; +import java.util.regex.Pattern; /** * 浏览器自动化工具 @@ -98,7 +99,7 @@ public class BrowserUseTool { - screenshot: Take a screenshot. Optional path to save file; returns base64 if no path. - click: Click an element. Requires selector (CSS selector). - type: Type text into an element. Requires selector and text. - - eval: Execute JavaScript on the page. Requires code parameter. + - eval: Execute JavaScript on the page. Requires code parameter. Top-level await is supported; use `return` to surface a value. - connect_cdp: Connect to an existing Chrome via CDP. Requires url (e.g. "http://localhost:9222"). - list_cdp_targets: Scan local ports (9000-10000) for CDP endpoints. Optional cdpPort for single port. - navigate_back: Go back in browser history. @@ -109,7 +110,7 @@ public class BrowserUseTool { @ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url, @ToolParam(description = "CSS selector for target element (for click/type)", required = false) String selector, @ToolParam(description = "Text to type (for action=type)", required = false) String text, - @ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code, + @ToolParam(description = "JavaScript code to execute (for action=eval). Top-level await is allowed; add `return` to return a value when the snippet uses await.", required = false) String code, @ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path, @ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed, @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort, @@ -642,6 +643,19 @@ public class BrowserUseTool { return JSONUtil.toJsonPrettyStr(result); } + /** Detects the {@code await} keyword as a whole word to decide whether eval code needs an async wrapper. */ + private static final Pattern TOP_LEVEL_AWAIT = Pattern.compile("\\bawait\\b"); + + /** + * Playwright raises this exact message when a bare-expression eval contains a + * top-level {@code return}. Such snippets are safe to retry inside an async + * IIFE, where {@code return} surfaces the value. + */ + private static boolean isIllegalReturn(PlaywrightException ex) { + String m = ex.getMessage(); + return m != null && m.contains("Illegal return statement"); + } + private String doEval(String sessionKey, String code) { if (code == null || code.isBlank()) { return error("code is required for action=eval"); @@ -655,7 +669,29 @@ public class BrowserUseTool { session.touch(); Page page = session.page; - Object evalResult = page.evaluate(code); + // Playwright evaluates the supplied string as a plain expression, which + // rejects both top-level `await` and top-level `return` ("SyntaxError: + // Illegal return statement"). Snippets that use `await` are wrapped up + // front in an async IIFE (valid for `await` and `return` alike). + // A top-level `return` only fails at eval time, so we retry once wrapped + // rather than pre-wrapping on a naive `return` match — that would mangle + // bare expressions containing a nested return (e.g. arr.map(x => { + // return x; })) into an IIFE with no top-level return, yielding undefined. + String script = TOP_LEVEL_AWAIT.matcher(code).find() + ? "(async () => {" + code + "})()" + : code; + Object evalResult; + try { + evalResult = page.evaluate(script); + } catch (PlaywrightException ex) { + if (script.equals(code) && isIllegalReturn(ex)) { + log.debug("[BrowserUse] eval had a top-level return; retrying wrapped in async IIFE"); + script = "(async () => {" + code + "})()"; + evalResult = page.evaluate(script); + } else { + throw ex; + } + } String resultStr = evalResult != null ? evalResult.toString() : "null"; if (resultStr.length() > 10_000) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index 4fcdd294..06921bf1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -2,6 +2,7 @@ package vip.mate.tool.builtin; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,8 +19,11 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.audit.service.AuditEventService; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.task.AsyncTaskService; +import vip.mate.task.model.AsyncTaskEntity; import vip.mate.workspace.conversation.ConversationService; +import java.time.Duration; import java.util.*; import java.util.concurrent.*; import java.util.stream.Collectors; @@ -114,7 +118,19 @@ public class DelegateAgentTool { // long-term memory surface. "remember", "remember_structured", - "forget_structured" + "forget_structured", + // RFC 48 — goal ownership is bound to the parent conversation. + // A child mutating the parent's goal would let sub-agents + // declare the parent's goal "completed" or replace its budget. + "setGoal", + "addGoalCriterion", + "completeGoal", + "getGoalStatus", + // Employee authoring spawns persistent agents; a delegated child + // doing so risks recursive team creation and privilege creep, so + // it stays with the parent (same stance as delegate* recursion + // guards above). The read-only capability catalog is fine to keep. + "create_employee" ); /** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */ @@ -128,6 +144,27 @@ public class DelegateAgentTool { private final ObjectMapper objectMapper; private final SubagentRegistry subagentRegistry; private final AuditEventService auditEventService; + private final AsyncTaskService asyncTaskService; + + /** Max characters of the task description persisted in {@code request_json}. + * Anything longer is truncated — full task is still inside the running + * child's conversation context. */ + private static final int ASYNC_TASK_REQUEST_MAX_CHARS = 8000; + + /** Max label length carried inside {@code request_json} and surfaced on + * spawn-event payloads. Picked to fit a short UI badge without wrapping. */ + private static final int ASYNC_LABEL_MAX_CHARS = 32; + + /** Default {@code block=true} wait when caller omits {@code timeoutSeconds}. */ + private static final int TASK_OUTPUT_DEFAULT_TIMEOUT_S = 30; + + /** Upper bound on {@code block=true} wait. Picked to be longer than the + * typical ReAct turn latency yet short enough that the parent agent + * doesn't burn its own LLM budget blocked on a stalled child. */ + private static final int TASK_OUTPUT_MAX_TIMEOUT_S = 120; + + /** Polling interval inside {@code block=true} wait. */ + private static final long TASK_OUTPUT_POLL_INTERVAL_MS = 500L; /** * Operator-supplied deny-list extension. Configured via @@ -200,11 +237,19 @@ public class DelegateAgentTool { } String parentConversationId = resolveParentConversationId(); + // Root (human-facing) conversation at the top of the delegation tree. + // At depth 0 the immediate parent IS the root; deeper layers carry it + // forward via DelegationContext so events reach the stream the user sees. + String rootConversationId = DelegationContext.rootConversationId(); + if (rootConversationId == null) rootConversationId = parentConversationId; + String parentSubagentId = DelegationContext.currentSubagentId(); + int childDepth = depth + 1; - // Spawn-pause: when the operator paused this conversation's tree - // (via /api/v1/subagents/spawn-pause), short-circuit before creating - // child state so no conversation rows / relays / registry entries leak. - if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) { + // Spawn-pause: short-circuit before creating child state when either the + // immediate parent or the root tree is paused, so no conversation rows / + // relays / registry entries leak. + if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) + || (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) { return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"; } @@ -226,24 +271,28 @@ public class DelegateAgentTool { log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}", depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId); - // Broadcast delegation_start + register event relay to parent session - boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId); - if (hasParent) { - streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of( - "childConversationId", childConversationId, - "childAgentName", target.getName(), - "task", truncate(task, 200))); - } - Runnable stopRelay = hasParent ? registerBatchedRelay(childConversationId, parentConversationId, target.getName()) : null; - - // Register the live sub-agent so the operator UI / heartbeat watchdog - // can observe it. Disposable is null in the synchronous single-task - // path because the executor blocks on AgentService#chat directly — - // there is no Flux subscription to dispose. Interrupts in this path - // are best-effort (status flip; no underlying cancel). + // Register the live sub-agent first so its stable id rides on every + // event. Disposable is null in the synchronous single-task path because + // the executor blocks on AgentService#chat directly — there is no Flux + // subscription to dispose. Interrupts here are best-effort (status flip). String subagentId = parentConversationId != null ? subagentRegistry.register(parentConversationId, childConversationId, - target.getId(), task, null) + target.getId(), task, null, parentSubagentId, childDepth, rootConversationId) + : null; + + // Broadcast to the ROOT conversation (not the immediate parent) so a + // grandchild's progress reaches the stream the user is watching. Every + // event carries subagentId/parentSubagentId/depth for tree rebuild. + boolean hasRoot = rootConversationId != null && streamTracker.isRunning(rootConversationId); + if (hasRoot) { + Map startEvent = delegationPayload(subagentId, parentSubagentId, childDepth, + childConversationId, target.getName()); + startEvent.put("task", truncate(task, 200)); + streamTracker.broadcastObject(rootConversationId, "delegation_start", startEvent); + } + Runnable stopRelay = hasRoot + ? registerBatchedRelay(childConversationId, rootConversationId, target.getName(), + subagentId, parentSubagentId, childDepth) : null; // Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent @@ -252,7 +301,8 @@ public class DelegateAgentTool { ChatOrigin parentOrigin = ChatOrigin.from(ctx); ChildResult result; try { - result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, parentOrigin); + result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, + parentOrigin, rootConversationId, subagentId, childDepth); } finally { // Cleanup relay + registry regardless of how the child returned // (success / exception / interruption) so we never leak entries. @@ -266,8 +316,9 @@ public class DelegateAgentTool { subagentRegistry.unregister(subagentId); } } - if (hasParent) { - broadcastEnd(parentConversationId, childConversationId, target.getName(), result); + if (hasRoot) { + broadcastEnd(rootConversationId, childConversationId, target.getName(), result, + subagentId, parentSubagentId, childDepth); } return result.toToolResponse(target.getName()); @@ -308,15 +359,21 @@ public class DelegateAgentTool { } String parentConversationId = resolveParentConversationId(); + String rootConversationId = DelegationContext.rootConversationId(); + if (rootConversationId == null) rootConversationId = parentConversationId; + final String rootConvFinal = rootConversationId; + final String parentSubagentId = DelegationContext.currentSubagentId(); + final int childDepth = depth + 1; // Spawn-pause: short-circuit before allocating any per-child state so // we don't leak conversation rows / relays / registry entries when an - // operator paused this conversation's tree. - if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) { + // operator paused this conversation's tree (immediate parent or root). + if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) + || (rootConvFinal != null && subagentRegistry.isSpawnPaused(rootConvFinal))) { return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"; } - boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId); + boolean hasRoot = rootConvFinal != null && streamTracker.isRunning(rootConvFinal); // 2. Main thread: validate agents, create child conversations, register relays record PreparedChild(int index, AgentEntity agent, String task, String childConvId, @@ -341,12 +398,13 @@ public class DelegateAgentTool { } String childConvId = createChildConv(agent, parentConversationId); - Runnable stopRelay = hasParent - ? registerBatchedRelay(childConvId, parentConversationId, agent.getName()) - : null; String subagentId = parentConversationId != null ? subagentRegistry.register(parentConversationId, childConvId, - agent.getId(), task, null) + agent.getId(), task, null, parentSubagentId, childDepth, rootConvFinal) + : null; + Runnable stopRelay = hasRoot + ? registerBatchedRelay(childConvId, rootConvFinal, agent.getName(), + subagentId, parentSubagentId, childDepth) : null; prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId)); } @@ -357,14 +415,15 @@ public class DelegateAgentTool { log.info("Parallel delegation: {} tasks, parentConv={}", prepared.size(), parentConversationId); - // 3. Broadcast delegation_start (parallel mode) - if (hasParent) { - List> childrenInfo = prepared.stream().map(p -> Map.of( - "childConversationId", p.childConvId, - "childAgentName", p.agent.getName(), - "task", truncate(p.task, 100) - )).toList(); - streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of( + // 3. Broadcast delegation_start (parallel mode) to the root conversation + if (hasRoot) { + List> childrenInfo = prepared.stream().map(p -> { + Map m = delegationPayload(p.subagentId, parentSubagentId, childDepth, + p.childConvId, p.agent.getName()); + m.put("task", truncate(p.task, 100)); + return m; + }).toList(); + streamTracker.broadcastObject(rootConvFinal, "delegation_start", Map.of( "parallel", true, "children", childrenInfo)); } @@ -380,7 +439,8 @@ public class DelegateAgentTool { ChatOrigin parentOriginParallel = ChatOrigin.from(ctx); for (PreparedChild p : prepared) { CompletableFuture future = CompletableFuture.supplyAsync( - () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, parentOriginParallel), + () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, + parentOriginParallel, rootConvFinal, p.subagentId, childDepth), DELEGATION_EXECUTOR); // Broadcast per-child completion as soon as each child finishes @@ -389,18 +449,16 @@ public class DelegateAgentTool { // because the timeout result is already handled in the collection loop below and // emitting here first would race-replace the correct "timeout" error before delegation_end // has a chance to patch remaining running segments. - if (hasParent) { - final String parentConvIdFinal = parentConversationId; + if (hasRoot) { future.whenComplete((result, ex) -> { if (ex instanceof java.util.concurrent.CancellationException) return; - if (!streamTracker.isRunning(parentConvIdFinal)) return; + if (!streamTracker.isRunning(rootConvFinal)) return; ChildResult r = (result != null) ? result : ChildResult.ofError(p.index, p.agent.getName(), ex != null ? ex.getMessage() : "Unknown error"); - Map payload = new java.util.LinkedHashMap<>(); + Map payload = delegationPayload(p.subagentId, parentSubagentId, childDepth, + p.childConvId, r.agentName); payload.put("taskIndex", r.taskIndex); - payload.put("childConversationId", p.childConvId); - payload.put("childAgentName", r.agentName); payload.put("success", r.success); payload.put("outcome", r.outcome); payload.put("rawLength", r.rawLength); @@ -410,7 +468,7 @@ public class DelegateAgentTool { payload.put("resultPreview", r.success ? truncate(r.result, 400) : (r.error != null ? r.error : "error")); - streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", payload); + streamTracker.broadcastObject(rootConvFinal, "delegation_child_complete", payload); }); } @@ -475,7 +533,7 @@ public class DelegateAgentTool { } // 7. Broadcast delegation_end with per-child structured summary - if (hasParent) { + if (hasRoot) { List> childResults = results.stream().map(r -> { Map m = new java.util.LinkedHashMap<>(); m.put("taskIndex", r.taskIndex); @@ -486,15 +544,18 @@ public class DelegateAgentTool { m.put("trimmedLength", r.trimmedLength); m.put("blank", r.isBlank()); m.put("durationMs", r.durationMs); - // childConversationId for stable frontend segment lookup + // childConversationId + subagentId for stable frontend tree lookup prepared.stream() .filter(p -> p.index == r.taskIndex) .findFirst() - .ifPresent(p -> m.put("childConversationId", p.childConvId)); + .ifPresent(p -> { + m.put("childConversationId", p.childConvId); + if (p.subagentId != null) m.put("subagentId", p.subagentId); + }); if (!r.success && r.error != null) m.put("error", r.error); return m; }).toList(); - streamTracker.broadcastObject(parentConversationId, "delegation_end", Map.of( + streamTracker.broadcastObject(rootConvFinal, "delegation_end", Map.of( "parallel", true, "totalDurationMs", totalDurationMs, "success", results.stream().allMatch(r -> r.success), @@ -565,6 +626,300 @@ public class DelegateAgentTool { return truncate(sb.toString(), MAX_RESULT_LENGTH * 2); // 并行结果允许更长 } + // ==================== Async (detached) delegation ==================== + + @Tool(description = """ + Delegate a task to another agent asynchronously and return a task_id immediately. \ + Parent continues reasoning while child runs in background. \ + Use task_output(task_id) in a later turn to retrieve the result. \ + Best for long-running sub-tasks (research, file processing) where the parent has \ + other work to do in parallel. For quick tasks where you need the answer immediately, \ + use delegateToAgent instead.""") + public String delegateAsync( + @ToolParam(description = "Target Agent name (exact match)") String agentName, + @ToolParam(description = "Task description with complete context information") String task, + @ToolParam(description = "Optional short label (≤ 32 chars) for human tracking on the UI badge", + required = false) String label, + @Nullable ToolContext ctx) { + + if (agentName == null || agentName.isBlank()) { + return errorJson("agentName 不能为空"); + } + if (task == null || task.isBlank()) { + return errorJson("task 不能为空"); + } + String safeLabel = label == null ? "" : + (label.length() > ASYNC_LABEL_MAX_CHARS ? label.substring(0, ASYNC_LABEL_MAX_CHARS) : label); + + int depth = DelegationContext.currentDepth(); + if (depth >= MAX_DELEGATION_DEPTH) { + return errorJson("Delegation depth exceeded (max " + MAX_DELEGATION_DEPTH + ")"); + } + + AgentEntity target = findAgent(agentName); + if (target == null) { + return errorJson("Agent not found: " + agentName); + } + + String parentConversationId = resolveParentConversationId(); + if (parentConversationId == null || parentConversationId.isBlank()) { + return errorJson("delegateAsync requires a parent conversation context"); + } + String rootConversationId = DelegationContext.rootConversationId(); + if (rootConversationId == null) rootConversationId = parentConversationId; + String parentSubagentId = DelegationContext.currentSubagentId(); + int childDepth = depth + 1; + if (subagentRegistry.isSpawnPaused(parentConversationId) + || subagentRegistry.isSpawnPaused(rootConversationId)) { + return errorJson("Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"); + } + + // Capture origin / user on the calling thread — the Callable runs on + // AsyncTaskService.pollExecutor, where the ToolContext ThreadLocal is + // not visible. The child's identity (agentId) is swapped in below; + // channel / workspace / requester all propagate via the closure. + ChatOrigin parentOrigin = ChatOrigin.from(ctx); + String currentUser = parentOrigin != null && parentOrigin.requesterId() != null + && !parentOrigin.requesterId().isBlank() + ? parentOrigin.requesterId() + : "system"; + + String childConversationId = createChildConv(target, parentConversationId); + + // Register first so the subagentId + tree identity can be persisted into + // the task payload; the registry is process-local, but the request_json + // is the durable record that task_output authorizes against. + String subagentId = subagentRegistry.register(parentConversationId, childConversationId, + target.getId(), task, null, parentSubagentId, childDepth, rootConversationId); + final String rootConvAsync = rootConversationId; + + String requestJson; + try { + Map payload = new LinkedHashMap<>(); + payload.put("parentConversationId", parentConversationId); + payload.put("rootConversationId", rootConversationId); + payload.put("childConversationId", childConversationId); + payload.put("childAgentId", target.getId()); + payload.put("subagentId", subagentId); + if (parentSubagentId != null) payload.put("parentSubagentId", parentSubagentId); + payload.put("depth", childDepth); + payload.put("task", truncate(task, ASYNC_TASK_REQUEST_MAX_CHARS)); + payload.put("label", safeLabel); + requestJson = objectMapper.writeValueAsString(payload); + } catch (Exception e) { + subagentRegistry.unregister(subagentId); + return errorJson("Failed to serialize task payload: " + e.getMessage()); + } + + AsyncTaskEntity entity; + try { + entity = asyncTaskService.submitOneShot( + "agent_delegate", + parentConversationId, + null, + requestJson, + currentUser, + () -> { + try { + ChildResult childResult = runSingleChild(0, target, task, + parentConversationId, childConversationId, parentOrigin, + rootConvAsync, subagentId, childDepth); + return childResult.toToolResponse(target.getName()); + } finally { + subagentRegistry.get(subagentId).ifPresent(rec -> { + if ("running".equals(rec.status().get())) { + rec.status().set("completed"); + } + }); + subagentRegistry.unregister(subagentId); + } + }); + } catch (IllegalStateException e) { + // Per-user concurrency cap hit inside AsyncTaskService#createTask. + // Roll back the registry entry so it doesn't dangle. + subagentRegistry.unregister(subagentId); + return errorJson(e.getMessage()); + } catch (Exception e) { + subagentRegistry.unregister(subagentId); + log.error("delegateAsync submit failed: target={}, err={}", target.getName(), e.getMessage()); + return errorJson("Failed to spawn async task: " + e.getMessage()); + } + + log.info("Async delegation spawned: taskId={}, target={}({}), childConv={}, parentConv={}", + entity.getTaskId(), target.getName(), target.getId(), + childConversationId, parentConversationId); + + if (streamTracker.isRunning(rootConvAsync)) { + Map spawnEvent = delegationPayload(subagentId, parentSubagentId, childDepth, + childConversationId, target.getName()); + spawnEvent.put("taskId", entity.getTaskId()); + spawnEvent.put("label", safeLabel); + spawnEvent.put("task", truncate(task, 200)); + streamTracker.broadcastObject(rootConvAsync, "delegation_async_spawned", spawnEvent); + } + + Map result = new LinkedHashMap<>(); + result.put("task_id", entity.getTaskId()); + result.put("child_conversation_id", childConversationId); + result.put("agent_name", target.getName()); + result.put("status", "running"); + result.put("hint", "Call task_output(task_id) in a later turn to retrieve the result."); + if (!safeLabel.isEmpty()) { + result.put("label", safeLabel); + } + try { + return objectMapper.writeValueAsString(result); + } catch (Exception e) { + return errorJson("Failed to serialize response: " + e.getMessage()); + } + } + + @Tool(description = """ + Retrieve the result of a previously spawned async sub-agent task. \ + Returns the final reply when completed, or a status indicator if still running. \ + Set block=true to wait up to timeout seconds for completion.""") + public String taskOutput( + @ToolParam(description = "task_id returned by delegateAsync") String taskId, + @ToolParam(description = "Whether to block until done or timeout. Default false.", + required = false) Boolean block, + @ToolParam(description = "Max seconds to wait when block=true. Default 30, max 120.", + required = false) Integer timeoutSeconds, + @Nullable ToolContext ctx) { + + if (taskId == null || taskId.isBlank()) { + return errorJson("taskId 不能为空"); + } + String trimmedTaskId = taskId.trim(); + + AsyncTaskEntity entity = asyncTaskService.findEntityByTaskId(trimmedTaskId); + if (entity == null) { + return errorJson("Task not found: " + trimmedTaskId); + } + if (!"agent_delegate".equals(entity.getTaskType())) { + return errorJson("Task is not a delegate task: " + trimmedTaskId); + } + + // Attribution gate — registry is live-only, so the persistent + // request_json + created_by columns are the only authoritative + // sources. Both must match the calling context; otherwise this is a + // cross-user or cross-conversation lookup and must be denied even + // for an already-succeeded task (otherwise a stranger can read the + // result by guessing taskIds). + // + // Caveat on the user gate: when ChatOrigin.requesterId is empty, + // delegateAsync stamps the task with the literal sentinel "system" + // (mirrors the existing channel/cron-originated flow). All callers + // that share that sentinel — e.g. two cron jobs in the same + // workspace — therefore satisfy the user gate against each other. + // The conversation gate above still narrows it to "the same parent + // conversation as the spawn", which keeps the blast radius bounded; + // a follow-up that surfaces a stable per-channel / per-cron caller + // identity into ChatOrigin.requesterId would close this gap. + String taskParentConv; + String taskRootConv; + try { + JsonNode req = entity.getRequestJson() == null + ? null + : objectMapper.readTree(entity.getRequestJson()); + taskParentConv = req == null ? "" : req.path("parentConversationId").asText(""); + taskRootConv = req == null ? "" : req.path("rootConversationId").asText(""); + } catch (Exception e) { + return errorJson("Failed to parse task payload: " + e.getMessage()); + } + String currentParentConv = resolveParentConversationId(); + ChatOrigin origin = ChatOrigin.from(ctx); + String currentUser = origin != null ? origin.requesterId() : null; + + // Authorize the caller against EITHER the immediate spawn conversation or + // the root of its delegation tree. The latter lets a root agent poll a + // task that one of its (sub)children spawned: the child stamped its own + // conversation as parentConversationId, but rootConversationId points + // back at the user-facing conversation the root agent runs in. + boolean convOk = currentParentConv != null + && ((!taskParentConv.isEmpty() && taskParentConv.equals(currentParentConv)) + || (!taskRootConv.isEmpty() && taskRootConv.equals(currentParentConv))); + if (!convOk) { + return errorJson("Forbidden: task does not belong to current conversation"); + } + if (entity.getCreatedBy() == null || currentUser == null + || currentUser.isBlank() + || !entity.getCreatedBy().equals(currentUser)) { + return errorJson("Forbidden: task does not belong to current user"); + } + + String status = entity.getStatus(); + boolean isTerminal = "succeeded".equals(status) || "failed".equals(status); + if (Boolean.TRUE.equals(block) && !isTerminal) { + int waitSec = Math.min(TASK_OUTPUT_MAX_TIMEOUT_S, + Math.max(1, Optional.ofNullable(timeoutSeconds).orElse(TASK_OUTPUT_DEFAULT_TIMEOUT_S))); + long deadline = System.currentTimeMillis() + waitSec * 1000L; + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(TASK_OUTPUT_POLL_INTERVAL_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + AsyncTaskEntity refreshed = asyncTaskService.findEntityByTaskId(trimmedTaskId); + if (refreshed == null) break; + entity = refreshed; + status = entity.getStatus(); + if ("succeeded".equals(status) || "failed".equals(status)) break; + } + } + + if (streamTracker.isRunning(currentParentConv)) { + streamTracker.broadcastObject(currentParentConv, "delegation_async_polled", Map.of( + "taskId", trimmedTaskId, + "status", status)); + } + + Map result = new LinkedHashMap<>(); + result.put("task_id", trimmedTaskId); + result.put("status", status); + switch (status == null ? "" : status) { + case "pending", "running" -> { + result.put("progress", entity.getProgress()); + result.put("hint", "Try again later or call task_output with block=true."); + } + case "succeeded" -> { + result.put("result", entity.getResultJson()); + result.put("duration_ms", durationMs(entity)); + } + case "failed" -> { + result.put("error", entity.getErrorMessage()); + result.put("duration_ms", durationMs(entity)); + } + default -> result.put("error", "Unknown status: " + status); + } + try { + return objectMapper.writeValueAsString(result); + } catch (Exception e) { + return errorJson("Failed to serialize response: " + e.getMessage()); + } + } + + /** Build a one-line JSON error envelope for tool returns. Kept distinct + * from {@link #truncate} / plain-text errors used by sync delegate paths + * so the model sees a consistent shape for async results. */ + private String errorJson(String message) { + try { + return objectMapper.writeValueAsString(Map.of( + "error", true, + "message", message != null ? message : "")); + } catch (Exception e) { + // Fallback — never throw from an error helper. + return "{\"error\":true,\"message\":\"" + (message == null ? "" : message.replace("\"", "\\\"")) + "\"}"; + } + } + + /** Walltime estimate using the create/update timestamps written by + * {@code AsyncTaskService}. Returns 0 when either timestamp is missing. */ + private static long durationMs(AsyncTaskEntity entity) { + if (entity == null || entity.getCreateTime() == null || entity.getUpdateTime() == null) return 0L; + return Duration.between(entity.getCreateTime(), entity.getUpdateTime()).toMillis(); + } + // ==================== Child agent execution (shared by single and parallel paths) ==================== /** @@ -577,8 +932,19 @@ public class DelegateAgentTool { */ private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, String parentConversationId, String childConversationId, - ChatOrigin parentOrigin) { - DelegationContext.enter(parentConversationId, deniedToolsForChild()); + ChatOrigin parentOrigin, + String rootConversationId, String subagentId, int childDepth) { + boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId); + if (relayChildEvents) { + streamTracker.register(childConversationId); + streamTracker.incrementFlux(childConversationId); + } + // Carry root conversation + this child's subagentId into the context so + // a grandchild broadcasts to the root stream and tags this as its parent. + // Pass the real tree depth so the gate survives the executor-thread hop: + // async/parallel children run with an empty ThreadLocal stack. + DelegationContext.enter(parentConversationId, deniedToolsForChild(), + rootConversationId, subagentId, childDepth); try { long startTime = System.currentTimeMillis(); // RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId @@ -597,6 +963,9 @@ public class DelegateAgentTool { taskIndex, target.getName(), e.getMessage()); return ChildResult.ofError(taskIndex, target.getName(), e.getMessage()); } finally { + if (relayChildEvents) { + streamTracker.complete(childConversationId); + } DelegationContext.exit(); } } @@ -797,76 +1166,110 @@ public class DelegateAgentTool { return childConvId; } - private Runnable registerRelay(String childConvId, String parentConvId, String childAgentName) { - return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> { - if ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) { - try { - // Parse jsonData into a plain Object so the frontend receives a proper - // JSON object under "data", not a string containing serialized JSON. - // If parsing fails (e.g. plain text payload), fall back to the raw string. - Object parsedData; - try { - parsedData = objectMapper.readValue(jsonData, Object.class); - } catch (Exception ignored) { - parsedData = jsonData; - } - streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of( - "childConversationId", childConvId, - "childAgentName", childAgentName, - "originalEvent", eventName, - "data", parsedData)); - } catch (Exception e) { - log.debug("Relay error: {}", e.getMessage()); - } - } - }); + /** Child event types that are relayed to the root for the nested delegation timeline. */ + private static final Set RELAYED_CHILD_EVENTS = Set.of( + "tool_call_started", "tool_call_completed", "phase", + "plan_created", "plan_step_started", "plan_step_completed"); + + /** Tree identity attached to every relayed delegation event. */ + private record RelayIdentity(String childConvId, String childAgentName, + String subagentId, String parentSubagentId, int depth) {} + + /** + * Builds a delegation event payload carrying tree identity. A null + * {@code parentSubagentId} (first-level child) is omitted rather than + * inserted, since downstream consumers treat absence as "top of tree". + */ + private Map delegationPayload(String subagentId, String parentSubagentId, int depth, + String childConvId, String childAgentName) { + Map m = new LinkedHashMap<>(); + if (subagentId != null) m.put("subagentId", subagentId); + if (parentSubagentId != null) m.put("parentSubagentId", parentSubagentId); + m.put("depth", depth); + m.put("childConversationId", childConvId); + m.put("childAgentName", childAgentName); + return m; } /** - * Registers a batched relay so a chatty child does not flood the parent - * transcript with one tool-call event per LLM step. The streaming layer - * batches {@code tool_call_started} / {@code tool_call_completed} into - * envelopes (5 events / 500 ms) and flushes immediately on lifecycle - * events ({@code subagent_*}, {@code error}, {@code phase}, etc.). + * Registers a batched relay so a chatty child does not flood the transcript + * with one tool-call event per LLM step. The streaming layer batches + * {@code tool_call_started} / {@code tool_call_completed} into envelopes + * (5 events / 500 ms) and flushes immediately on lifecycle events + * ({@code subagent_*}, {@code error}, {@code phase}, etc.). * - *

    The wrapper keeps the on-the-wire shape identical to - * {@link #registerRelay} so frontend consumers do not need to change - * — both batched envelopes and pass-through events surface as - * {@code delegation_progress} on the parent. + *

    Both batched envelopes and pass-through events surface as + * {@code delegation_progress} on the {@code rootConvId} stream (the + * human-facing conversation), tagged with subagentId/parentSubagentId/depth + * so the frontend can rebuild the multi-level spawn tree. */ - private Runnable registerBatchedRelay(String childConvId, String parentConvId, String childAgentName) { - return streamTracker.addBatchedEventRelay(childConvId, parentConvId, 5, 500L, + private Runnable registerBatchedRelay(String childConvId, String rootConvId, String childAgentName, + String subagentId, String parentSubagentId, int depth) { + RelayIdentity id = new RelayIdentity(childConvId, childAgentName, subagentId, parentSubagentId, depth); + return streamTracker.addBatchedEventRelay(childConvId, rootConvId, 5, 500L, (eventName, jsonData) -> { - if ("tool_call_started".equals(eventName) - || "tool_call_completed".equals(eventName) - || "phase".equals(eventName)) { - try { - Object parsedData; - try { - parsedData = objectMapper.readValue(jsonData, Object.class); - } catch (Exception ignored) { - parsedData = jsonData; - } - streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of( - "childConversationId", childConvId, - "childAgentName", childAgentName, - "originalEvent", eventName, - "data", parsedData)); - } catch (Exception e) { - log.debug("Batched relay error: {}", e.getMessage()); - } + // (1) pass-through events arrive directly (plan/phase/error); + // (2) batched tool-calls arrive as a "delegation_batch" + // envelope. Unpack both into delegation_progress events so the + // frontend only handles a single event shape. + if ("delegation_batch".equals(eventName)) { + relayBatchEnvelope(jsonData, rootConvId, id); + } else if (RELAYED_CHILD_EVENTS.contains(eventName)) { + relayChildEvent(eventName, jsonData, rootConvId, id); } }); } - private void broadcastEnd(String parentConvId, String childConvId, String agentName, ChildResult result) { - streamTracker.broadcastObject(parentConvId, "delegation_end", Map.of( - "childConversationId", childConvId, - "childAgentName", agentName, - "success", result.success, - "durationMs", result.durationMs, - "resultPreview", result.success ? truncate(result.result, 200) : (result.error != null ? result.error : "") - )); + /** Forward one child event to the root as a delegation_progress envelope. */ + private void relayChildEvent(String eventName, String jsonData, String rootConvId, RelayIdentity id) { + try { + Object parsedData; + try { + parsedData = objectMapper.readValue(jsonData, Object.class); + } catch (Exception ignored) { + parsedData = jsonData; + } + Map ev = delegationPayload(id.subagentId(), id.parentSubagentId(), id.depth(), + id.childConvId(), id.childAgentName()); + ev.put("originalEvent", eventName); + ev.put("data", parsedData); + streamTracker.broadcastObject(rootConvId, "delegation_progress", ev); + } catch (Exception e) { + log.debug("Child event relay error: {}", e.getMessage()); + } + } + + /** Unpack a delegation_batch envelope and replay each entry as delegation_progress. */ + @SuppressWarnings("unchecked") + private void relayBatchEnvelope(String envelopeJson, String rootConvId, RelayIdentity id) { + try { + Map envelope = objectMapper.readValue(envelopeJson, Map.class); + Object eventsObj = envelope.get("events"); + if (!(eventsObj instanceof List events)) return; + for (Object entryObj : events) { + if (!(entryObj instanceof Map entry)) continue; + Object name = entry.get("event"); + Object payload = entry.get("data"); + if (name == null) continue; + if (!RELAYED_CHILD_EVENTS.contains(name.toString())) continue; + String payloadJson = payload == null + ? "{}" + : (payload instanceof String s ? s : objectMapper.writeValueAsString(payload)); + relayChildEvent(name.toString(), payloadJson, rootConvId, id); + } + } catch (Exception e) { + log.debug("Batch envelope relay error: {}", e.getMessage()); + } + } + + private void broadcastEnd(String rootConvId, String childConvId, String agentName, ChildResult result, + String subagentId, String parentSubagentId, int depth) { + Map ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName); + ev.put("success", result.success); + ev.put("durationMs", result.durationMs); + ev.put("resultPreview", + result.success ? truncate(result.result, 200) : (result.error != null ? result.error : "")); + streamTracker.broadcastObject(rootConvId, "delegation_end", ev); } private String resolveParentConversationId() { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index c0b81b10..85481626 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -17,16 +17,39 @@ public final class DelegationContext { /** * Snapshot of one delegation layer's state. + * + *

    {@code rootConversationId} is the human-facing conversation at the top + * of the delegation tree — every layer carries it unchanged so that a + * grandchild's progress events can be broadcast to the same stream the user + * is watching, rather than to its immediate (machine-only) parent. + * {@code currentSubagentId} is the id of the subagent running THIS layer; a + * deeper child reads it as its own {@code parentSubagentId} to reconstruct + * the spawn tree. */ - private record Frame(String parentConversationId, Set childDeniedTools) {} + private record Frame(String parentConversationId, Set childDeniedTools, + String rootConversationId, String currentSubagentId, int depth) {} private static final ThreadLocal> STACK = ThreadLocal.withInitial(ArrayDeque::new); private DelegationContext() {} - /** Current delegation depth (0 = top-level call, not inside any delegation) */ + /** + * Current delegation depth (0 = top-level call, not inside any delegation). + *

    Read from the TOP frame's recorded depth, NOT the thread-local stack + * size: async / parallel children run on fresh executor threads where the + * stack starts empty, so a size-based depth would reset to 1 at every async + * hop and let a child bypass {@code MAX_DELEGATION_DEPTH}. The real tree + * depth is carried in via {@link #enter(String, Set, String, String, int)}. + */ public static int currentDepth() { - return STACK.get().size(); + Frame top = STACK.get().peek(); + return top != null ? top.depth : 0; + } + + /** Depth for the next layer when the caller doesn't pass one explicitly. */ + private static int nextDepth() { + Frame top = STACK.get().peek(); + return (top != null ? top.depth : 0) + 1; } /** Parent conversation ID for event relay (from the current frame) */ @@ -41,14 +64,49 @@ public final class DelegationContext { return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of(); } + /** Root (human-facing) conversation ID for the whole tree, or null at top level. */ + public static String rootConversationId() { + Frame top = STACK.get().peek(); + return top != null ? top.rootConversationId : null; + } + + /** Subagent id of the layer currently executing, or null at top level. */ + public static String currentSubagentId() { + Frame top = STACK.get().peek(); + return top != null ? top.currentSubagentId : null; + } + /** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */ public static void enter(String parentConversationId, Set deniedTools) { - STACK.get().push(new Frame(parentConversationId, deniedTools)); + enter(parentConversationId, deniedTools, null, null, nextDepth()); + } + + /** + * Enter the next delegation layer carrying the full tree identity so deeper + * children can broadcast to the root conversation and tag their parent. + * Depth is inferred from the current frame; use the explicit-depth overload + * from executor threads where the stack starts empty. + */ + public static void enter(String parentConversationId, Set deniedTools, + String rootConversationId, String currentSubagentId) { + enter(parentConversationId, deniedTools, rootConversationId, currentSubagentId, nextDepth()); + } + + /** + * Enter the next delegation layer with an EXPLICIT tree depth. Async / + * parallel children run on fresh executor threads with an empty stack, so + * they must pass the real {@code childDepth} computed on the dispatching + * thread — otherwise depth-based recursion limits reset at every hop. + */ + public static void enter(String parentConversationId, Set deniedTools, + String rootConversationId, String currentSubagentId, int depth) { + STACK.get().push(new Frame(parentConversationId, deniedTools, + rootConversationId, currentSubagentId, depth)); } /** Enter the next delegation layer (backward-compatible overload) */ public static void enter() { - enter(null, null); + enter(null, null, null, null, nextDepth()); } /** Exit the current delegation layer, restoring the previous layer's context */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java index 32e07c99..b3fc54e2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java @@ -20,7 +20,7 @@ import java.util.zip.ZipInputStream; /** * Document text extraction tool. - * Supports PDF, DOCX, XLSX, PPTX with format-specific fallback chains. + * Supports PDF, DOCX, XLSX, PPTX, HTML with format-specific fallback chains. * * Strategy by format: * - PDF: pdftotext -> pdfplumber/pypdf -> pdfbox -> OCR (scanned) -> Tika @@ -28,6 +28,8 @@ import java.util.zip.ZipInputStream; * - XLSX/PPTX: Tika directly (POI-based; correctly resolves the shared-strings * indirection table and walks SmartArt / chart / grouped-shape * text that a naive ZIP+XML scan misses). + * - HTML: jsoup parse -> drop script/style/nav/footer noise -> keep + * heading hierarchy as Markdown ATX lines. */ @Slf4j @Component @@ -46,11 +48,13 @@ public class DocumentExtractTool { - Word (.docx, .doc) - Excel (.xlsx, .xls) - 提取为文本表格 - PowerPoint (.pptx, .ppt) + - HTML (.html, .htm) - jsoup 清洗后提取正文 提取策略(按格式分链): - PDF: pdftotext → pdfplumber/pypdf → pdfbox → OCR(扫描版) → Tika - DOCX: textutil / pandoc / libreoffice → ZIP-XML → Tika - XLSX/PPTX: 直接走 Tika(基于 POI,正确解析 sharedStrings 表与 SmartArt / 图表文本) + - HTML: jsoup 解析 → 去除 script/style/nav/footer 等噪音 → 保留标题层级 - 返回详细的提取过程和元数据 参数 options 可包含: @@ -134,6 +138,8 @@ public class DocumentExtractTool { content = extractXlsx(path, options, attempts); } else if (mimeType.contains("presentationml") || mimeType.contains("powerpoint")) { content = extractPptx(path, options, attempts); + } else if (mimeType.contains("html")) { + content = extractHtml(path, attempts); } else { return errorResult(filePath, "不支持的文档类型: " + mimeType, attempts); } @@ -869,6 +875,53 @@ public class DocumentExtractTool { return count; } + // ==================== HTML 提取 ==================== + + /** + * Extract readable text from an HTML file with jsoup. + *

    + * Drops structural noise (script / style / nav / header / footer / aside / + * form / iframe), then walks the surviving elements emitting headings as + * Markdown ATX lines ({@code # }, {@code ## } …) so the wiki preprocessor + * can still detect the document's heading hierarchy. The charset is + * auto-detected from the BOM / {@code } declaration. + */ + private ExtractedContent extractHtml(Path path, List attempts) throws Exception { + long t = System.currentTimeMillis(); + org.jsoup.nodes.Document doc; + try { + // charsetName = null lets jsoup sniff the encoding from BOM / meta tag. + doc = org.jsoup.Jsoup.parse(path.toFile(), null); + } catch (IOException e) { + attempts.add("jsoup: 读取失败 - " + e.getMessage()); + throw new Exception("HTML 文件读取失败: " + e.getMessage()); + } + + doc.select("script, style, noscript, nav, header, footer, aside, form, iframe").remove(); + + StringBuilder sb = new StringBuilder(); + org.jsoup.nodes.Element root = doc.body() != null ? doc.body() : doc; + for (org.jsoup.nodes.Element el : root.getAllElements()) { + String text = el.ownText(); + if (text.isBlank()) continue; + String tag = el.tagName(); + if (tag.length() == 2 && tag.charAt(0) == 'h' && tag.charAt(1) >= '1' && tag.charAt(1) <= '6') { + int level = tag.charAt(1) - '0'; + sb.append('\n').append("#".repeat(level)).append(' ').append(text.trim()).append('\n'); + } else { + sb.append(text.trim()).append('\n'); + } + } + + String out = sb.toString().strip(); + if (out.isBlank()) { + attempts.add("jsoup: 解析成功但无可读文本 (" + (System.currentTimeMillis() - t) + "ms)"); + throw new Exception("HTML 提取无文本(页面可能仅含脚本 / 样式)"); + } + attempts.add("jsoup: 成功 (" + (System.currentTimeMillis() - t) + "ms)"); + return new ExtractedContent(out, "jsoup", 0); + } + // ==================== 工具方法 ==================== /** @@ -938,6 +991,7 @@ public class DocumentExtractTool { if (fileName.endsWith(".xls")) return "application/vnd.ms-excel"; if (fileName.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; if (fileName.endsWith(".ppt")) return "application/vnd.ms-powerpoint"; + if (fileName.endsWith(".html") || fileName.endsWith(".htm")) return "text/html"; return "application/octet-stream"; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java index ca1fbe45..8eed7643 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java @@ -20,8 +20,8 @@ import java.nio.file.Paths; *

    * 安全说明: *

      - *
    • 编辑操作经过 ToolGuard 审批(DefaultToolGuard 对 file_edit 工具默认返回 NEEDS_APPROVAL)
    • - *
    • 每次编辑需要用户确认
    • + *
    • 编辑操作会经过 ToolGuard 安全检查;命中安全规则时会要求用户审批
    • + *
    • 路径边界由 {@code WorkspacePathGuard} 处理
    • *
    * * @author MateClaw Team @@ -36,7 +36,8 @@ public class EditFileTool { @vip.mate.tool.ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path") @Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. " + "Returns structured JSON with filePath, replacements count. " - + "Requires user approval. Replaces first occurrence by default; set replaceAll=true for all.") + + "May require user approval when security rules flag the edit. " + + "Replaces first occurrence by default; set replaceAll=true for all.") public String edit_file( @ToolParam(description = "Absolute or relative file path") String filePath, @ToolParam(description = "Original text to find (exact match)") String oldText, diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EnableExtensionTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EnableExtensionTool.java new file mode 100644 index 00000000..f1b3ad6a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EnableExtensionTool.java @@ -0,0 +1,78 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.disclosure.DisclosureTier; +import vip.mate.tool.disclosure.ToolDisclosureService; + +import java.util.Set; + +/** + * Activates an extension-tier tool for the rest of the conversation. + *

    + * The model calls this after spotting a tool in the {@code ## Extension Tools} + * catalog. The tool only validates and returns a confirmation message — the + * activation is recorded into graph state by the action node (tools cannot + * mutate {@code OverAllState} directly), so the enabled tool's schema becomes + * visible on the next reasoning turn of the same ReAct loop. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EnableExtensionTool { + + private final ToolRegistry toolRegistry; + private final ToolDisclosureService toolDisclosureService; + private final AgentBindingService agentBindingService; + + @Tool(name = "enable_tool", description = """ + Activate an extension tool for the rest of this conversation. + Use this when the Extension Tools catalog lists a tool you need to call. + + Parameters: + - toolName: The tool's function name exactly as shown in the catalog. + + After enabling, issue the real tool call in your NEXT response — it + becomes callable from then on. Only enable a tool the task actually needs. + """) + public String enableTool( + @ToolParam(description = "Extension tool function name from the catalog") + String toolName, + + @Nullable ToolContext ctx + ) { + if (toolName == null || toolName.isBlank()) { + return "Error: toolName is required. See the Extension Tools catalog for valid names."; + } + // Validate against THIS agent's effective tool set, not the global registry — + // otherwise a tool that exists globally but isn't bound to the agent would be + // reported active yet never appear (the reasoning-node split only activates + // tools in the agent's own set). + AgentToolSet agentSet = toolRegistry.getEnabledToolSet(); + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId != null) { + Set effective = agentBindingService.getEffectiveToolNames(agentId); + agentSet = agentSet.withAllowedToolsOnly(effective); // null = no restriction + } + ToolCallback callback = agentSet.callbackByName().get(toolName); + if (callback == null) { + return "Error: Tool '" + toolName + "' is not available to this agent. " + + "Use the exact function name from the Extension Tools catalog."; + } + if (toolDisclosureService.resolveTier(callback) != DisclosureTier.EXTENSION) { + return "Tool '" + toolName + "' is already directly callable — just call it, no need to enable."; + } + log.info("enable_tool: activating extension tool '{}' for agent {}", toolName, agentId); + return "Tool '" + toolName + "' is now active. Issue the call in your next response."; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java new file mode 100644 index 00000000..9b61bdcd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -0,0 +1,250 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.service.GoalService; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Built-in tools that let an agent create and manipulate its own + * persistent goal. The agent should reach for these when the user states + * an objective that spans multiple turns — the runtime then tracks + * progress across the entire conversation. + * + *

    All four tool names are added to + * {@code DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS} so a child agent + * cannot mutate the parent conversation's goal. Goal ownership is bound + * to the parent conversation, period. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GoalManagementTool { + + private final GoalService goalService; + private final GoalProperties properties; + private final ObjectMapper objectMapper; + private final ChatStreamTracker streamTracker; + + @Tool(description = """ + Set a persistent goal for the current conversation. The agent will \ + self-evaluate progress after every reply and surface what is still \ + missing. Use ONLY when the user states an objective that genuinely \ + spans multiple turns (e.g. 'deploy this to production', 'rewrite \ + this module to use async I/O'). Single-question Q&A does not need \ + a goal.""") + public String setGoal( + @ToolParam(description = "Short title under 80 chars; shown in UI hover.") String title, + @ToolParam(description = "Full description of what success looks like.", + required = false) String description, + @ToolParam(description = "Exit criteria the evaluator scores against (e.g. 'tests pass + deployed').", + required = false) String exitCriteria, + @ToolParam(description = "Max evaluation turns before exhaustion. Default 20.", + required = false) Integer turnBudget, + @ToolParam(description = "If true, the agent may auto-followup when progress is incomplete. Default false.", + required = false) Boolean autoFollowup, + @Nullable ToolContext ctx) { + + if (!properties.isEnabled()) { + return errorJson("Goal subsystem is disabled on this server"); + } + if (title == null || title.isBlank()) { + return errorJson("title is required"); + } + + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin == null || origin.conversationId() == null || origin.conversationId().isBlank()) { + return errorJson("setGoal requires a bound conversation context"); + } + if (origin.agentId() == null) { + return errorJson("setGoal requires an agent context"); + } + + GoalCreateRequest req = new GoalCreateRequest(); + req.setConversationId(origin.conversationId()); + req.setAgentId(origin.agentId()); + req.setWorkspaceId(origin.workspaceId() != null ? origin.workspaceId() : 1L); + req.setTitle(title.trim()); + req.setDescription(description != null ? description : title.trim()); + req.setExitCriteria(exitCriteria); + if (turnBudget != null) req.setTurnBudget(turnBudget); + if (autoFollowup != null) req.setAutoFollowupEnabled(autoFollowup); + + String username = origin.requesterId() != null && !origin.requesterId().isBlank() + ? origin.requesterId() : "system"; + try { + GoalEntity created = goalService.create(req, username); + broadcastGoalEvent(created.getConversationId(), "goal_created", created); + return successJson(Map.of( + "goalId", String.valueOf(created.getId()), + "status", created.getStatus().getValue(), + "turnBudget", created.getTurnBudget(), + "llmCallBudget", created.getLlmCallBudget(), + "autoFollowup", created.getAutoFollowupEnabled())); + } catch (MateClawException e) { + return errorJson(e.getMessage()); + } + } + + @Tool(description = """ + Append a sub-criterion to the active goal without restarting it. \ + Use when the user adds a new requirement mid-task (e.g. 'also make \ + sure it works on Safari'). No-op if no active goal is bound.""") + public String addGoalCriterion( + @ToolParam(description = "Single new criterion sentence.") String criterion, + @Nullable ToolContext ctx) { + + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + if (criterion == null || criterion.isBlank()) { + return errorJson("criterion must not be empty"); + } + GoalEntity goal = resolveActive(ctx); + if (goal == null) { + return errorJson("No active goal on this conversation"); + } + String username = resolveUsername(ctx); + try { + GoalEntity updated = goalService.appendCriterion(goal.getId(), criterion.trim(), username); + broadcastGoalEvent(updated.getConversationId(), "goal_updated", updated); + return successJson(Map.of( + "goalId", String.valueOf(updated.getId()), + "exitCriteria", updated.getExitCriteria() == null ? "" : updated.getExitCriteria())); + } catch (MateClawException e) { + return errorJson(e.getMessage()); + } + } + + @Tool(description = """ + Explicitly mark the active goal as completed. Use ONLY when all \ + exit criteria are satisfied (e.g. tests passed, feature deployed, \ + user confirmed). The runtime evaluator will also mark goals \ + completed automatically when score >= 0.95 — prefer that path.""") + public String completeGoal(@Nullable ToolContext ctx) { + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + GoalEntity goal = resolveActive(ctx); + if (goal == null) { + return errorJson("No active goal on this conversation"); + } + // Synthesize a completion-style evaluation result for the audit trail. + GoalEvaluationResult synthetic = new GoalEvaluationResult( + 1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED, + true, "manual", 0, 0L); + try { + GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic); + // Broadcast a goal_completed event with the same shape as the + // GoalEvaluationNode auto-completed path, so the frontend + // handler doesn't need to branch on which path completed it. + if (streamTracker != null && completed.getConversationId() != null) { + streamTracker.broadcastObject(completed.getConversationId(), "goal_completed", Map.of( + "goalId", String.valueOf(completed.getId()), + "score", synthetic.score())); + } + return successJson(Map.of( + "goalId", String.valueOf(completed.getId()), + "status", completed.getStatus().getValue())); + } catch (MateClawException e) { + return errorJson(e.getMessage()); + } + } + + @Tool(description = """ + Get the active goal's current status, progress score, and the most \ + recent gap text. Useful when the user asks 'how are we doing?' or \ + before deciding the next sub-step.""") + public String getGoalStatus(@Nullable ToolContext ctx) { + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + GoalEntity goal = resolveActive(ctx); + if (goal == null) { + return successJson(Map.of("active", false)); + } + Map out = new LinkedHashMap<>(); + out.put("active", true); + out.put("goalId", String.valueOf(goal.getId())); + out.put("title", goal.getTitle()); + out.put("status", goal.getStatus().getValue()); + out.put("turnsUsed", goal.getTurnsUsed()); + out.put("turnBudget", goal.getTurnBudget()); + out.put("agentLlmCallsUsed", goal.getAgentLlmCallsUsed()); + out.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed()); + out.put("totalLlmCallsUsed", goal.totalLlmCallsUsed()); + out.put("llmCallBudget", goal.getLlmCallBudget()); + out.put("completionScore", goal.getCompletionScore()); + out.put("progressSummary", goal.getProgressSummary()); + out.put("autoFollowupEnabled", goal.getAutoFollowupEnabled()); + return successJson(out); + } + + // ==================== Internals ==================== + + private GoalEntity resolveActive(ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin == null || origin.conversationId() == null) return null; + return goalService.findActiveByConversation(origin.conversationId()); + } + + private String resolveUsername(ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin != null && origin.requesterId() != null && !origin.requesterId().isBlank()) { + return origin.requesterId(); + } + return "system"; + } + + private String successJson(Map payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + return "{\"ok\":true}"; + } + } + + /** + * Broadcast a goal-namespaced SSE event so the frontend store can + * refresh its active-goal cache without waiting for the user to + * reload. Best-effort: a missing stream (e.g. cron-origin tool call + * with no SSE subscriber) is not an error path. + */ + private void broadcastGoalEvent(String conversationId, String eventName, GoalEntity goal) { + if (streamTracker == null || conversationId == null || conversationId.isBlank()) { + return; + } + // Send the full goal payload so the store can hydrate without an + // extra GET round-trip. Long IDs are stringified at the wire by + // ToStringSerializer; the rest of the payload is plain JSON. + try { + streamTracker.broadcastObject(conversationId, eventName, Map.of( + "goalId", String.valueOf(goal.getId()), + "conversationId", conversationId, + "goal", goal)); + } catch (Exception e) { + log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage()); + } + } + + private String errorJson(String message) { + try { + return objectMapper.writeValueAsString(Map.of( + "error", true, + "message", message != null ? message : "")); + } catch (JsonProcessingException e) { + return "{\"error\":true}"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java new file mode 100644 index 00000000..41a1b8e4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java @@ -0,0 +1,95 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.progress.ProgressLedger; +import vip.mate.agent.progress.ProgressLedgerService; +import vip.mate.agent.progress.ProgressStatus; + +/** + * Tool exposed to the LLM for maintaining the conversation-scoped progress + * ledger. The runtime renders the ledger into the system prompt before every + * reasoning step, so the model can rely on this tool as the durable record + * of "what I have done and what remains" across context-window trims. + * + *

    Why a single mutating tool rather than separate + * {@code progress_mark_done} / {@code progress_block} / etc. methods: the + * model already volunteers the desired status as a string. Splitting into + * per-status methods would multiply the tool schema for no gain and forces + * a re-classification when statuses evolve. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ProgressLedgerTool { + + private final ProgressLedgerService service; + + @Tool(description = "Record or update a single step in the current conversation's progress " + + "ledger. Use this to track multi-step tasks (research workflows, document drafting " + + "split by section, etc.) — the runtime injects a rendered snapshot of the ledger " + + "into your context before every reasoning step so you never lose track of what is " + + "already done after a context trim. Call once per step transition: " + + "register pending entries up front when you decompose a task, mark in_progress " + + "before starting each one, then done as soon as it lands. Re-using the same stepKey " + + "overwrites the entry in place (no duplicates).") + public String progress_update( + @ToolParam(description = "Stable identifier for this step (e.g. 'model_gpt55', " + + "'section_intro', 'step_pptx'). Reuse exactly to update an existing entry.") + String stepKey, + @ToolParam(description = "Human-readable label shown in the snapshot (e.g. " + + "'GPT-5.5 调研'). Pass empty to keep the existing label when updating.", + required = false) + String label, + @ToolParam(description = "One of: pending, in_progress, done, blocked.") + String status, + @ToolParam(description = "Optional 1-line note (why it's blocked, what was produced, " + + "next sub-step). Capped at ~120 chars when rendered into the snapshot.", + required = false) + String note, + @Nullable ToolContext ctx) { + + String conversationId = ToolExecutionContext.conversationId(ctx); + if (conversationId == null || conversationId.isBlank()) { + // Happens only on test paths that bypass the executor wiring; + // give a structured error so the LLM doesn't loop on it. + return "Error: no conversation context bound to this call. progress_update is only " + + "usable from inside an active agent run."; + } + if (stepKey == null || stepKey.isBlank()) { + return "Error: stepKey is required."; + } + ProgressStatus parsed = ProgressStatus.parse(status); + if (parsed == null) { + return "Error: status must be one of pending, in_progress, done, blocked. Got: " + status; + } + try { + ProgressLedger updated = service.upsert(conversationId, stepKey, label, parsed, note); + // Return the freshly-rendered snapshot in the tool result so the + // model immediately sees its own update reflected in the + // canonical view it will be reading next iteration. Without this + // positive-feedback loop the model treats progress_update as a + // fire-and-forget side effect and stops calling it after the + // first few transitions (observed: round-4 dropped to 3 calls in + // 27 minutes of work). The snapshot is also what the runtime + // injects pre-LLM-call, so echoing it here keeps the two views + // identical. + StringBuilder out = new StringBuilder(256); + out.append("✓ Recorded ").append(stepKey).append(" → ").append(parsed.wireValue()) + .append(" (").append(updated.size()).append(" entries total).\n\n"); + String snapshot = updated.renderSnapshot(); + if (snapshot != null) { + out.append(snapshot); + } + return out.toString(); + } catch (Exception e) { + log.warn("progress_update failed for conv={} key={}: {}", conversationId, stepKey, e.getMessage()); + return "Error: failed to persist progress entry — " + e.getMessage(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java index 6c582826..dc81781c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java @@ -49,12 +49,15 @@ public class ReadFileTool { @Tool(description = """ Read the contents of a file. Supports line-range reading (1-based). \ Returns structured JSON with filePath, totalLines, readLines, content. \ - Auto-truncates large files with continuation hints. \ - Text files only; use extract_document_text for PDF/Office documents.""") + Auto-truncates large files with continuation hints: when the result has \ + truncated=true, continue with the returned nextStartLine (and \ + nextStartColumn when present, to resume reading the rest of a very long \ + line). Text files only; use extract_document_text for PDF/Office documents.""") public String read_file( @ToolParam(description = "Absolute or relative file path") String filePath, @ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine, @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine, + @ToolParam(description = "Start character position within startLine (1-based, inclusive). Used to resume reading the rest of a very long line; pass the nextStartColumn from a previous truncated result. Omit to start at the beginning of the line", required = false) Integer startColumn, // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. @Nullable ToolContext ctx) { @@ -130,35 +133,109 @@ public class ReadFileTool { // 提取指定范围的行(转为 0-based) List selectedLines = allLines.subList(start - 1, end); - // 截断控制 + // Character offset into the FIRST selected line, used to resume reading + // the tail of a very long line across calls. 1-based on the wire, 0-based + // here. Only applies to the first line of the selection. + int firstLineOffset = (startColumn != null && startColumn > 1) ? startColumn - 1 : 0; + + // Truncation control. Each output line carries a "%6d\t" prefix and a + // trailing newline, so the budget available for a line's own text is the + // remaining budget minus that overhead. StringBuilder sb = new StringBuilder(); int linesRead = 0; boolean truncated = false; - int maxLines = Math.min(selectedLines.size(), DEFAULT_MAX_LINES); + boolean lineTruncated = false; + int truncatedLineNum = 0; + // Where a subsequent read_file call should resume. nextLine is 1-based; + // nextColumn is a 1-based char offset (1 = start of the line). + int nextLine = -1; + int nextColumn = 1; for (int i = 0; i < selectedLines.size(); i++) { - String line = selectedLines.get(i); int lineNum = start + i; + String fullLine = selectedLines.get(i); + // The offset only applies to the first line of the selection. + int offset = (i == 0) ? Math.min(firstLineOffset, fullLine.length()) : 0; + String line = offset > 0 ? fullLine.substring(offset) : fullLine; - String numberedLine = String.format("%6d\t%s\n", lineNum, line); - if (sb.length() + numberedLine.length() > MAX_OUTPUT_BYTES || linesRead >= DEFAULT_MAX_LINES) { + if (linesRead >= DEFAULT_MAX_LINES) { + // Hit the line-count cap; resume at this line from the same offset. truncated = true; + nextLine = lineNum; + nextColumn = offset + 1; break; } - sb.append(numberedLine); + + String prefix = String.format("%6d\t", lineNum); + int lineCost = prefix.length() + line.length() + 1; // +1 for '\n' + + if (sb.length() + lineCost <= MAX_OUTPUT_BYTES) { + sb.append(prefix).append(line).append('\n'); + linesRead++; + continue; + } + + // This line does not fit in the remaining budget. + boolean fitsAlone = prefix.length() + line.length() + 1 <= MAX_OUTPUT_BYTES; + if (fitsAlone || linesRead > 0) { + // EITHER the line would fit in a fresh budget (normal truncation + // at a clean line boundary), OR we have already emitted lines and + // defer this oversized line to the next call. Either way, resume + // at this line; for non-first lines offset is 0 so column is 1. + truncated = true; + nextLine = lineNum; + nextColumn = offset + 1; + break; + } + + // linesRead == 0 AND the line is larger than the whole budget even on + // its own. Returning empty content here would yield readLines=0 with a + // continuation hint that never advances — the infinite retry loop from + // the original bug. Emit as much of this line as fits (a window), + // flagged truncated, and advance by exactly the chars consumed so the + // caller can page through the rest of the line with nextStartColumn. + String marker = i18n.msg("tool.read_file.line_truncated_marker"); + int windowBudget = MAX_OUTPUT_BYTES - prefix.length() - marker.length() - 1; + String window = safeTruncate(line, Math.max(0, windowBudget)); + sb.append(prefix).append(window).append(marker).append('\n'); linesRead++; + truncated = true; + lineTruncated = true; + truncatedLineNum = lineNum; + int consumed = offset + window.length(); + if (consumed < fullLine.length()) { + nextLine = lineNum; // more of this line remains + nextColumn = consumed + 1; + } else { + nextLine = lineNum + 1; // line exactly consumed; move on + nextColumn = 1; + } + break; } result.set("startLine", start); + result.set("startColumn", firstLineOffset + 1); result.set("endLine", start + linesRead - 1); result.set("readLines", linesRead); result.set("content", sb.toString()); if (truncated) { - int nextStart = start + linesRead; result.set("truncated", true); - result.set("message", "输出已截断(最多 " + DEFAULT_MAX_LINES + " 行 / " + (MAX_OUTPUT_BYTES / 1024) - + "KB)。使用 startLine=" + nextStart + " 继续读取。"); + result.set("nextStartLine", nextLine); + int kb = MAX_OUTPUT_BYTES / 1024; + if (lineTruncated && nextColumn > 1) { + // A long line was windowed and more of it remains. Surface the + // column so the caller can resume reading the same line's tail. + result.set("lineTruncated", true); + result.set("nextStartColumn", nextColumn); + result.set("message", i18n.msg("tool.read_file.line_truncated", + truncatedLineNum, kb, nextLine, nextColumn, nextLine + 1)); + } else { + if (lineTruncated) { + result.set("lineTruncated", true); + } + result.set("message", i18n.msg("tool.read_file.truncated", DEFAULT_MAX_LINES, kb, nextLine)); + } } else { result.set("truncated", false); } @@ -196,6 +273,22 @@ public class ReadFileTool { return sb.toString(); } + /** + * Truncate a string to at most {@code maxChars} characters without splitting + * a UTF-16 surrogate pair. If the cut would land between a high and low + * surrogate, drop the dangling high surrogate so the result stays valid. + */ + private static String safeTruncate(String s, int maxChars) { + if (s.length() <= maxChars) { + return s; + } + int end = maxChars; + if (end > 0 && Character.isHighSurrogate(s.charAt(end - 1))) { + end--; + } + return s.substring(0, end); + } + /** * 以 UTF-8 读取文件全部行,对非 UTF-8 文件做容错处理 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java new file mode 100644 index 00000000..65e8411a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java @@ -0,0 +1,163 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.tool.document.GeneratedFileCache; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * Built-in tool: send an existing server file to the user. + *

    + * Reads the file at the given path and stores it in {@link GeneratedFileCache} + * to mint a short-lived download link, which channel adapters (Feishu, DingTalk, + * Telegram, etc.) auto-detect and deliver as a native attachment. + *

    + * Unlike {@link ReadFileTool}, this handles any file type (including binary + * files): the goal is to deliver the file as an attachment rather than read + * its text content. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@lombok.RequiredArgsConstructor +public class SendFileTool { + + private final GeneratedFileCache cache; + private final vip.mate.i18n.I18nService i18n; + + private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + + private static final Map EXTENSION_MIME = Map.ofEntries( + Map.entry(".pdf", "application/pdf"), + Map.entry(".doc", "application/msword"), + Map.entry(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + Map.entry(".xls", "application/vnd.ms-excel"), + Map.entry(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + Map.entry(".ppt", "application/vnd.ms-powerpoint"), + Map.entry(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + Map.entry(".txt", "text/plain"), + Map.entry(".csv", "text/csv"), + Map.entry(".json", "application/json"), + Map.entry(".xml", "application/xml"), + Map.entry(".html", "text/html"), + Map.entry(".htm", "text/html"), + Map.entry(".md", "text/markdown"), + Map.entry(".png", "image/png"), + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".gif", "image/gif"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".webp", "image/webp"), + Map.entry(".mp3", "audio/mpeg"), + Map.entry(".wav", "audio/wav"), + Map.entry(".ogg", "audio/ogg"), + Map.entry(".mp4", "video/mp4"), + Map.entry(".avi", "video/x-msvideo"), + Map.entry(".mov", "video/quicktime"), + Map.entry(".zip", "application/zip"), + Map.entry(".tar", "application/x-tar"), + Map.entry(".gz", "application/gzip"), + Map.entry(".yaml", "text/yaml"), + Map.entry(".yml", "text/yaml"), + Map.entry(".log", "text/plain") + ); + + @Tool(description = """ + Send an existing file from the server to the user as an attachment. \ + The file is uploaded to the IM channel as a native attachment (not a text link). \ + Works for any file type: documents (PDF, DOCX, XLSX, PPTX), images, \ + audio, video, archives, etc. Use this instead of read_file when you \ + need to send a binary file to the user.""") + public String send_file( + @ToolParam(description = "Absolute or relative file path on the server") String filePath, + @ToolParam(description = "Display name for the file (e.g. 'report.pdf'). Omit to use the original filename", required = false) String fileName, + @Nullable ToolContext ctx) { + + try { + Path path; + try { + path = WorkspacePathGuard.validatePath(filePath, ctx); + } catch (IllegalArgumentException e) { + // Sandbox rejected the literal path. Try chat-upload fallback. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, e.getMessage()); + } + path = attachment; + } + + if (!Files.exists(path)) { + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path)); + } + path = attachment; + } + if (Files.isDirectory(path)) { + return errorResult(filePath, i18n.msg("tool.read_file.error.is_directory", path)); + } + if (!Files.isReadable(path)) { + return errorResult(filePath, i18n.msg("tool.read_file.error.not_readable", path)); + } + + long fileSize = Files.size(path); + if (fileSize > MAX_FILE_SIZE) { + return errorResult(filePath, i18n.msg("tool.send_file.error.too_large", + fileSize / 1024 / 1024, MAX_FILE_SIZE / 1024 / 1024)); + } + + byte[] bytes = Files.readAllBytes(path); + String displayName = (fileName != null && !fileName.isBlank()) ? fileName : path.getFileName().toString(); + String mimeType = resolveMimeType(displayName); + + String url = stash(bytes, displayName, mimeType); + + log.info("[SendFile] Sending {} ({}, {} bytes) via generated file cache", + displayName, mimeType, fileSize); + + // Return in the same format as GeneratedFileLink so the channel + // adapter's GeneratedFileScrubber detects the URL and sends the + // file as a native attachment. The LLM MUST echo the URL in its + // reply for the scrubber to pick it up. + return i18n.msg("tool.send_file.success", displayName, url); + + } catch (Exception e) { + log.error("[SendFile] Failed to send file: {}", e.getMessage(), e); + return errorResult(filePath, i18n.msg("tool.send_file.error.failed", e.getMessage())); + } + } + + private String stash(byte[] bytes, String displayName, String mimeType) { + String id = cache.put(bytes, displayName, mimeType); + return "/api/v1/files/generated/" + id; + } + + private String resolveMimeType(String fileName) { + String lower = fileName.toLowerCase(); + for (Map.Entry entry : EXTENSION_MIME.entrySet()) { + if (lower.endsWith(entry.getKey())) { + return entry.getValue(); + } + } + return "application/octet-stream"; + } + + private String errorResult(String filePath, String message) { + JSONObject result = new JSONObject(); + result.set("filePath", filePath); + result.set("error", true); + result.set("message", message); + return JSONUtil.toJsonPrettyStr(result); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index c2f14b36..b4b73e8b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -40,18 +40,19 @@ public class SkillFileTool { private final SkillUsageService usageService; @Tool(description = """ - Read a file from a skill's directory (SKILL.md, references/, or scripts/). + Read a file from a skill's directory (SKILL.md, references/, scripts/, or templates/). Use this when you need to access skill documentation or reference files. Parameters: - skillName: Name of the skill (e.g., "channel_message") - - filePath: Relative path within skill directory, must start with "references/" or "scripts/" - (e.g., "references/config.md", "scripts/helper.py") + - filePath: Relative path within skill directory, must start with "references/", "scripts/", + or "templates/" (e.g., "references/config.md", "scripts/helper.py", + "templates/template.html") To read SKILL.md itself, use "SKILL.md" as filePath Returns: File content as string, or error message if file not found or access denied. - Security: Only files under references/ and scripts/ can be accessed. Path traversal is blocked. + Security: Only files under references/, scripts/, and templates/ can be accessed. Path traversal is blocked. """) public String readSkillFile( @JsonProperty(required = true) @@ -59,7 +60,7 @@ public class SkillFileTool { String skillName, @JsonProperty(required = true) - @JsonPropertyDescription("Relative file path (e.g., 'references/doc.md' or 'scripts/run.py')") + @JsonPropertyDescription("Relative file path (e.g., 'references/doc.md', 'scripts/run.py', or 'templates/template.html')") String filePath, @JsonProperty(required = false) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java new file mode 100644 index 00000000..7574903e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java @@ -0,0 +1,74 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; + +/** + * Explicit skill-load entry point. + *

    + * Pulls a skill package's SKILL.md (or a named sub-file) into the conversation + * as a tool observation. Naming it {@code load_skill} — rather than reusing the + * lower-level {@code readSkillFile} — gives the model a clear "load this skill" + * verb that matches the catalog guidance, and the call is detected by the + * action node to pin the skill at the top of the runtime catalog so the model + * does not reload it on later iterations. + *

    + * The full content is returned as a normal tool result; it flows into message + * history via the standard tool-response path and never mutates the system + * prompt, so the prompt-cache prefix stays stable. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillLoadTool { + + private final SkillRuntimeService runtimeService; + private final SkillFileTool skillFileTool; + + @Tool(name = "load_skill", description = """ + Load a skill package's SKILL.md into the conversation. + Call this when a skill in the catalog matches the task. + + Parameters: + - skillName: Skill name exactly as shown in the catalog. + - filePath: Optional sub-file inside the skill (e.g. "references/api.md"). + Omit to load SKILL.md. + + The full content is returned as a tool observation; later turns see it in + message history, so do NOT load the same skill again once it is loaded. + Skills are documentation packages — calling a skill name directly as a + tool will fail; load it first, then follow its instructions. + """) + public String loadSkill( + @ToolParam(description = "Skill name as shown in the catalog") + String skillName, + + @ToolParam(description = "Optional sub-file path inside the skill (e.g. references/api.md)", + required = false) + String filePath, + + @Nullable ToolContext ctx + ) { + if (skillName == null || skillName.isBlank()) { + return "Error: skillName is required. Call listAvailableSkills() to see loadable skills."; + } + ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + if (skill == null) { + log.info("load_skill: skill '{}' not found or not enabled", skillName); + return "Error: Skill '" + skillName + "' not found or not enabled. " + + "Call listAvailableSkills(keyword=\"" + skillName + "\") to find the correct name."; + } + String path = (filePath == null || filePath.isBlank()) ? "SKILL.md" : filePath; + log.info("load_skill: loading skill='{}', path='{}'", skillName, path); + // Delegate to the shared reader: it resolves the skill, paginates large + // sub-files, and records usage. SKILL.md is returned in full by default. + return skillFileTool.readSkillFile(skillName, path, null, null, ctx); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index c0444551..d590ddbd 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -4,9 +4,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillSecurityService; import vip.mate.skill.runtime.SkillValidationResult; import vip.mate.skill.service.SkillService; @@ -17,8 +21,8 @@ import java.util.regex.Pattern; /** * RFC-023: Agent 自治 Skill 管理工具 *

    - * 对标 hermes-agent 的 skill_manager_tool.py,让 Agent 在对话中自主创建、编辑、 - * 修补和删除 Skill。每次写入前强制安全扫描,失败则拒绝并返回原因。 + * 让 Agent 在对话中自主创建、编辑、修补和删除 Skill。 + * 每次写入前强制安全扫描,失败则拒绝并返回原因。 *

    * 系统 prompt 引导 Agent 使用此工具: *

    @@ -37,6 +41,7 @@ public class SkillManageTool { private final SkillService skillService; private final SkillSecurityService securityService; private final SkillWorkspaceManager workspaceManager; + private final SkillRuntimeService runtimeService; /** Skill 名称格式:小写字母/数字/连字符/下划线/点,首字符必须是字母或数字 */ private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9._-]{0,63}$"); @@ -45,9 +50,25 @@ public class SkillManageTool { @vip.mate.tool.ConcurrencyUnsafe("create/edit/patch/delete on the shared skill registry; concurrent ops on the same skill name race") @Tool(description = """ - Manage reusable skills: create, edit, patch, or delete skill procedures (SKILL.md format). + Manage the canonical SKILL.md content for a reusable skill: create, edit, patch, + or delete the skill itself (its body, version, description, frontmatter). + + USE THIS TOOL when the user (or you) wants to change WHAT a skill is: + - Create a new skill from scratch + - Rewrite an existing skill's body or steps + - Bump the version field in YAML frontmatter + - Fix a typo, outdated command, or wrong instruction inside SKILL.md + - Delete a skill + + DO NOT use this tool to record a tip, observation, or lesson learned while USING + a skill — that belongs in record_lesson (per-skill LESSONS.md) or remember + (cross-skill memory). Lessons are notes ABOUT a skill; this tool rewrites the + skill itself. + + Quick rule of thumb: + - "Update / fix / rewrite / change version of skill X" → skill_manage + - "Remember that X works better when..." / "Note: ..." → record_lesson or remember - Use this tool to save successful approaches, workflows, and solutions as reusable skills. When to create a skill: - After completing a complex task (5+ tool calls) - After fixing a tricky error with a non-obvious solution @@ -59,8 +80,8 @@ public class SkillManageTool { Actions: - create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body) - - edit: Replace entire skill content (for major rewrites) - - patch: Find-and-replace a specific section (for small fixes) + - edit: Replace entire skill content (for major rewrites; preferred when changing version + body together) + - patch: Find-and-replace a specific section (for small targeted fixes) - delete: Remove a skill SKILL.md format example: @@ -102,7 +123,12 @@ public class SkillManageTool { @JsonProperty @JsonPropertyDescription("For patch action: the new text to replace with") - String newText + String newText, + + // RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden + // from the LLM by JsonSchemaGenerator. Used to stamp the new + // skill with the agent's owning workspace. + @Nullable ToolContext toolContext ) { if (action == null || action.isBlank()) { return "Error: action is required (create | edit | patch | delete)"; @@ -117,8 +143,10 @@ public class SkillManageTool { + "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)"; } + Long workspaceId = ChatOrigin.from(toolContext).workspaceId(); + return switch (action.strip().toLowerCase()) { - case "create" -> doCreate(normalizedName, content); + case "create" -> doCreate(normalizedName, content, workspaceId); case "edit" -> doEdit(normalizedName, content); case "patch" -> doPatch(normalizedName, oldText, newText); case "delete" -> doDelete(normalizedName); @@ -128,7 +156,7 @@ public class SkillManageTool { // ==================== Create ==================== - private String doCreate(String name, String content) { + private String doCreate(String name, String content, Long workspaceId) { if (content == null || content.isBlank()) { return "Error: content is required for create action. Provide full SKILL.md content."; } @@ -157,6 +185,7 @@ public class SkillManageTool { skill.setBuiltin(false); skill.setVersion(extractVersion(content)); skill.setSecurityScanStatus("PASSED"); + skill.setWorkspaceId(workspaceId); skillService.createSkill(skill); @@ -211,6 +240,8 @@ public class SkillManageTool { log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); } + rescanQuietly(existing); + log.info("[SkillManage] Agent edited skill: name={}, contentLen={}", name, content.length()); return "Skill '" + name + "' updated successfully (security scan: PASSED)."; } catch (Exception e) { @@ -280,6 +311,7 @@ public class SkillManageTool { try { existing.setSkillContent(patchedContent); existing.setDescription(extractDescription(patchedContent)); + existing.setVersion(extractVersion(patchedContent)); existing.setSecurityScanStatus("PASSED"); skillService.updateSkill(existing); @@ -289,6 +321,8 @@ public class SkillManageTool { log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); } + rescanQuietly(existing); + log.info("[SkillManage] Agent patched skill: name={}", name); return "Skill '" + name + "' patched successfully (security scan: PASSED)."; } catch (Exception e) { @@ -357,6 +391,22 @@ public class SkillManageTool { } } + /** + * Synchronously re-run the resolver pipeline for the modified skill so + * the active-skills cache and any manifest-projected columns are + * coherent before this tool call returns. Without this, callers race + * the debounced 500ms workspace-event refresh and may observe stale + * state (e.g. the skill detail page showing the previous version). + */ + private void rescanQuietly(SkillEntity skill) { + if (skill == null || runtimeService == null) return; + try { + runtimeService.rescanSingle(skill); + } catch (Exception e) { + log.warn("[SkillManage] Post-write rescan failed for '{}': {}", skill.getName(), e.getMessage()); + } + } + /** 从 YAML frontmatter 提取 description */ private String extractDescription(String content) { String fm = extractFrontmatterValue(content, "description"); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java index 378dc165..1f9bb38b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java @@ -2,6 +2,9 @@ package vip.mate.tool.builtin; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; @@ -13,6 +16,7 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.secret.SkillSecretService; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -30,6 +34,7 @@ public class SkillScriptTool { private final SkillFileAccessPolicy accessPolicy; private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; + private final ObjectMapper objectMapper; @vip.mate.tool.ConcurrencyUnsafe("script execution can have arbitrary side effects on the host process and filesystem") @Tool(description = """ @@ -39,10 +44,12 @@ public class SkillScriptTool { Parameters: - skillName: Name of the skill - scriptPath: Relative path to script under scripts/ directory (e.g., "scripts/run.py") - - args: Optional list of script arguments. Each element is passed as a separate - CLI argument exactly as written — no shell interpretation, no splitting. - For a JSON payload, wrap it as a single-element list, e.g. - ["{\\"date\\":\\"2026-05-12\\",\\"topic\\":\\"meeting\\"}"]. + - args: Optional script arguments, given as ONE JSON-encoded string: + * a JSON array for multiple positional arguments, e.g. ["--verbose","input.txt"]; + * a JSON object when the script expects a single JSON payload — it is + forwarded as one argument, e.g. {"date":"2026-05-19","topic":"meeting"}; + * any other plain text is forwarded verbatim as a single argument. + Pass the JSON object directly — do not wrap it in an array or escape it. Returns: JSON with exitCode, stdout, stderr @@ -59,8 +66,8 @@ public class SkillScriptTool { String scriptPath, @JsonProperty(required = false) - @JsonPropertyDescription("Optional list of script arguments. Each element is passed as one CLI arg verbatim. Wrap a JSON payload as a single-element list.") - List args + @JsonPropertyDescription("Optional script arguments as ONE JSON-encoded string: a JSON array for multiple positional args, a JSON object for a single JSON payload, or plain text for one literal argument.") + String args ) { log.info("Executing skill script: skill={}, script={}, args={}", skillName, scriptPath, args); @@ -81,11 +88,13 @@ public class SkillScriptTool { return formatError("Invalid or unsafe script path: " + scriptPath); } - // Pass args straight through. No splitting — arbitrary delimiters - // (notably commas inside JSON payloads) used to shatter a single - // logical argument into multiple positional args, which broke any - // skill expecting a JSON-encoded payload. - List argList = (args == null || args.isEmpty()) ? null : args; + // Normalize the JSON-encoded args into a positional argument list. + // Taking one JSON string (rather than a raw array) keeps the model + // out of nested-array-of-escaped-JSON territory — the failure mode + // where a JSON payload arrived shattered across array elements or + // type-mismatched, and the receiving script then rejected it as + // malformed JSON. + List argList = normalizeArgs(args); // RFC-091 settings bridge — pull this skill's stored secrets // (e.g. AIRTABLE_API_KEY) and inject them as env vars for the @@ -106,6 +115,66 @@ public class SkillScriptTool { } } + /** + * Decode the JSON-encoded {@code args} string into a positional argument + * list for the subprocess. + * + *
      + *
    • A JSON array becomes one CLI argument per element. Non-string + * elements are re-serialized to compact JSON, so an object the + * model wrapped in a single-element array still reaches the script + * as a JSON payload.
    • + *
    • A JSON object is forwarded as a single argument — its compact + * JSON text — which is what a script reading {@code json.loads(argv[1])} + * expects.
    • + *
    • Anything else (a bare date, topic, number, or malformed JSON) is + * forwarded verbatim as one literal argument. A bare scalar is never + * JSON-decoded: that would mangle e.g. {@code 2026-05-19} into + * {@code 2026}.
    • + *
    + * + *

    Package-private for direct unit testing of the decode rules. + * + * @param args the raw {@code args} tool parameter, may be {@code null} + * @return the positional argument list, or {@code null} when empty + */ + List normalizeArgs(String args) { + if (args == null) { + return null; + } + String trimmed = args.trim(); + if (trimmed.isEmpty()) { + return null; + } + // Only decode when the text clearly intends JSON structure. The + // lead-char gate keeps a plain argument that merely looks numeric + // (a date, a version string) from being parsed and truncated. + char lead = trimmed.charAt(0); + if (lead == '[' || lead == '{') { + try { + JsonNode node = objectMapper.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(trimmed); + if (node != null && node.isArray()) { + List out = new ArrayList<>(node.size()); + for (JsonNode el : node) { + out.add(el.isTextual() ? el.asText() : el.toString()); + } + return out.isEmpty() ? null : out; + } + if (node != null && node.isObject()) { + return List.of(node.toString()); + } + } catch (Exception e) { + // Looked like JSON but didn't parse — forward it unchanged so + // the script reports its own input error rather than us + // silently reshaping a malformed payload. + log.debug("runSkillScript: args not valid JSON, forwarding verbatim: {}", e.getMessage()); + } + } + return List.of(trimmed); + } + private String formatResult(SkillScriptExecutionService.ScriptResult result) { return String.format( "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}", diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index 4083b2f4..cfe453c4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -9,12 +9,16 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.memory.service.MemoryRecallTracker; +import vip.mate.workspace.document.MemorySearchHit; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; import java.nio.charset.StandardCharsets; import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** * 基于数据库工作区文件的长期记忆工具。 @@ -197,6 +201,88 @@ public class WorkspaceMemoryTool { return JSONUtil.toJsonPrettyStr(result); } + @Tool(description = """ + Search agent's workspace memory files (MEMORY.md, PROFILE.md, AGENTS.md, memory/*.md) \ + by keyword. Use this BEFORE read_workspace_memory_file when looking for a fact across \ + many memory entries. Returns ranked hits with filename, line number, and snippet \ + (matched terms wrapped in [[...]]).""") + public String search_workspace_memory( + @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "关键词或短语,2-64 字符") String query, + @ToolParam(description = "搜索范围:all(全部)/ memory(MEMORY.md 与 memory/)/ profile / persona,默认 all", + required = false) String scope, + @ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit) { + + if (agentId == null) { + return error("agentId 不能为空"); + } + if (query == null || query.isBlank()) { + return error("query 不能为空"); + } + String trimmed = query.trim(); + if (trimmed.length() < 2) { + return error("query 至少 2 个字符"); + } + if (trimmed.length() > 64) { + return error("query 不能超过 64 个字符"); + } + + int effectiveLimit = limit == null ? 10 : Math.min(Math.max(limit, 1), 30); + Set prefixes = resolveScope(scope); + + List hits = workspaceFileService.searchSnippets( + agentId, trimmed, prefixes, effectiveLimit); + + // Treat each unique file in the results as an active retrieval signal — + // boosts that file's weight in the dream-consolidation ranker the same + // way an explicit read_workspace_memory_file call would. + Set retrieved = new HashSet<>(); + for (MemorySearchHit hit : hits) { + if (retrieved.add(hit.filename())) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, hit.filename()); + if (file != null && file.getContent() != null) { + memoryRecallTracker.trackActiveRetrieval(agentId, hit.filename(), file.getContent()); + } + } + } + + JSONArray hitsJson = new JSONArray(); + for (MemorySearchHit hit : hits) { + JSONObject h = new JSONObject(); + h.set("filename", hit.filename()); + h.set("lineNumber", hit.lineNumber()); + h.set("snippet", hit.snippet()); + h.set("score", hit.score()); + hitsJson.add(h); + } + JSONObject result = new JSONObject(); + result.set("agentId", agentId); + result.set("query", trimmed); + result.set("scope", scope == null || scope.isBlank() ? "all" : scope); + result.set("totalHits", hits.size()); + result.set("hits", hitsJson); + if (!hits.isEmpty()) { + result.set("hint", "Use read_workspace_memory_file to get full context of any hit."); + } + return JSONUtil.toJsonPrettyStr(result); + } + + /** Map the {@code scope} tool argument to a filename-prefix whitelist. + * {@code "all"} (or null/blank) targets every memory-class file rather + * than every workspace file the agent has, so a search doesn't surface + * unrelated docs the user happens to store in the same workspace. */ + private static Set resolveScope(String scope) { + if (scope == null || scope.isBlank() || "all".equalsIgnoreCase(scope.trim())) { + return new LinkedHashSet<>(List.of("memory/", "MEMORY.md", "PROFILE.md", "AGENTS.md")); + } + return switch (scope.trim().toLowerCase()) { + case "memory" -> new LinkedHashSet<>(List.of("memory/", "MEMORY.md")); + case "profile" -> new LinkedHashSet<>(List.of("PROFILE.md")); + case "persona" -> new LinkedHashSet<>(List.of("AGENTS.md")); + default -> new LinkedHashSet<>(List.of("memory/", "MEMORY.md", "PROFILE.md", "AGENTS.md")); + }; + } + private String validate(Long agentId, String filename) { if (agentId == null) { return "agentId 不能为空"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java index 352cda98..8bd6ce5b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java @@ -20,8 +20,8 @@ import java.nio.file.Paths; *

    * 安全说明: *

      - *
    • 写入操作经过 ToolGuard 审批(DefaultToolGuard 对 file_write 工具默认返回 NEEDS_APPROVAL)
    • - *
    • 覆写已有文件前需要用户确认
    • + *
    • 写入操作会经过 ToolGuard 安全检查;命中安全规则时会要求用户审批
    • + *
    • 路径边界由 {@code WorkspacePathGuard} 处理
    • *
    * * @author MateClaw Team @@ -36,7 +36,7 @@ public class WriteFileTool { @vip.mate.tool.ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths") @Tool(description = "Write content to a file. Overwrites if exists, creates if not (auto-creates parent directories). " + "Returns structured JSON with filePath, bytesWritten. " - + "Requires user approval.") + + "May require user approval when security rules flag the write.") public String write_file( @ToolParam(description = "Absolute or relative file path") String filePath, @ToolParam(description = "Content to write to the file") String content) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java b/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java index e2074d3f..b7c15ab4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java @@ -5,12 +5,16 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.tool.disclosure.DisclosureTier; +import vip.mate.tool.disclosure.ToolDisclosureService; import vip.mate.tool.model.AvailableToolDTO; import vip.mate.tool.model.ToolEntity; import vip.mate.tool.service.AvailableToolService; import vip.mate.tool.service.ToolService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; +import java.util.Map; /** * 工具管理接口 @@ -25,39 +29,46 @@ public class ToolController { private final ToolService toolService; private final AvailableToolService availableToolService; + private final ToolDisclosureService toolDisclosureService; @Operation(summary = "获取工具列表") @GetMapping + @RequireWorkspaceRole("member") public R> list() { return R.ok(toolService.listTools()); } @Operation(summary = "获取已启用工具列表") @GetMapping("/enabled") + @RequireWorkspaceRole("member") public R> listEnabled() { return R.ok(toolService.listEnabledTools()); } @Operation(summary = "获取员工可绑定的全部原子工具(含 MCP)") @GetMapping("/available") + @RequireWorkspaceRole("member") public R> listAvailable() { return R.ok(availableToolService.listAvailable()); } @Operation(summary = "获取工具详情") @GetMapping("/{id}") + @RequireWorkspaceRole("admin") public R get(@PathVariable Long id) { return R.ok(toolService.getTool(id)); } @Operation(summary = "创建工具(MCP)") @PostMapping + @RequireWorkspaceRole("admin") public R create(@RequestBody ToolEntity tool) { return R.ok(toolService.createTool(tool)); } @Operation(summary = "更新工具") @PutMapping("/{id}") + @RequireWorkspaceRole("admin") public R update(@PathVariable Long id, @RequestBody ToolEntity tool) { tool.setId(id); return R.ok(toolService.updateTool(tool)); @@ -65,6 +76,7 @@ public class ToolController { @Operation(summary = "删除工具") @DeleteMapping("/{id}") + @RequireWorkspaceRole("admin") public R delete(@PathVariable Long id) { toolService.deleteTool(id); return R.ok(); @@ -72,7 +84,29 @@ public class ToolController { @Operation(summary = "启用/禁用工具") @PutMapping("/{id}/toggle") + @RequireWorkspaceRole("admin") public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { return R.ok(toolService.toggleTool(id, enabled)); } + + @Operation(summary = "设置工具披露分级(core / extension)") + @PutMapping("/{id}/disclosure-tier") + @RequireWorkspaceRole("admin") + public R setDisclosureTier(@PathVariable Long id, @RequestBody Map body) { + String tier = body == null ? null : body.get("tier"); + if (!DisclosureTier.isValidToken(tier)) { + return R.fail(400, "tier must be 'core' or 'extension'"); + } + ToolEntity tool = toolService.getTool(id); + String type = tool.getToolType(); + // Only builtin / channel atomic tools are tiered on the row itself; MCP / + // ACP / skill tools are tiered at their owning source. + if (!"builtin".equals(type) && !"channel".equals(type)) { + return R.fail(409, "This tool's tier is decided by its owning server/endpoint/skill. " + + "Modify it there instead."); + } + ToolEntity updated = toolService.setDisclosureTier(id, tier); + toolDisclosureService.invalidate(); + return R.ok(updated); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java new file mode 100644 index 00000000..81120b40 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java @@ -0,0 +1,262 @@ +package vip.mate.tool.disclosure; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.service.McpServerService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.service.AvailableToolService; +import vip.mate.tool.service.ToolService; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Default tier resolver. Tier data is read from {@code mate_tool} and + * {@code mate_mcp_server} and cached in a short-lived snapshot so the per-turn + * {@link #split} does not hit the DB on every reasoning step. The PATCH + * endpoints call {@link #invalidate()} after changing a tier. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultToolDisclosureService implements ToolDisclosureService { + + private static final long CACHE_TTL_MS = 30_000L; + + /** + * Meta-tools that must always stay core: hiding them would make progressive + * disclosure unrecoverable (the model could never call {@code enable_tool} + * to surface anything, nor {@code load_skill} to read a skill). + */ + private static final Set ALWAYS_CORE = Set.of("enable_tool", "load_skill"); + + /** + * Code-level extension defaults for builtin tools that may not yet have a + * {@code mate_tool} row when first resolved. A persisted + * {@code disclosure_tier} always overrides these. + */ + private static final Set BUILTIN_EXTENSION_DEFAULTS = Set.of( + "image_generate", "music_generate", "video_generate", "model3d_generate", "browser_use"); + + private final ToolService toolService; + private final McpServerService mcpServerService; + private final AvailableToolService availableToolService; + private final ToolRegistry toolRegistry; + + @Value("${mateclaw.tools.disclosure.mode:progressive}") + private String disclosureMode; + + private volatile Snapshot snapshot; + + private boolean legacyMode() { + return "legacy".equalsIgnoreCase(disclosureMode); + } + + @Override + public DisclosureTier resolveTier(ToolCallback callback) { + if (callback == null || callback.getToolDefinition() == null) { + return DisclosureTier.CORE; + } + return resolveTierByName(callback.getToolDefinition().name()); + } + + @Override + public DisclosureTier resolveTierByName(String toolName) { + if (toolName == null || toolName.isBlank() || legacyMode()) { + return DisclosureTier.CORE; + } + if (ALWAYS_CORE.contains(toolName)) { + return DisclosureTier.CORE; + } + Snapshot snap = snapshot(); + DisclosureTier dbTier = snap.builtinTierByName.get(toolName); + if (dbTier != null) { + return dbTier; + } + if (BUILTIN_EXTENSION_DEFAULTS.contains(toolName)) { + return DisclosureTier.EXTENSION; + } + Long serverId = snap.mcpToolToServerId.get(toolName); + if (serverId != null) { + return snap.serverTierById.getOrDefault(serverId, DisclosureTier.CORE); + } + // Unknown source (ACP / dynamic-skill / plugin) — keep visible. + return DisclosureTier.CORE; + } + + @Override + public ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions) { + List all = baseSet == null ? List.of() : baseSet.callbacks(); + if (legacyMode()) { + return new ToolDisclosureSplit(all, List.of()); + } + Set enabled = enabledExtensions == null ? Set.of() : enabledExtensions; + List active = new ArrayList<>(all.size()); + List extensionCatalog = new ArrayList<>(); + for (ToolCallback cb : all) { + if (resolveTier(cb) == DisclosureTier.EXTENSION) { + extensionCatalog.add(cb); + if (enabled.contains(cb.getToolDefinition().name())) { + active.add(cb); + } + } else { + active.add(cb); + } + } + return new ToolDisclosureSplit(active, extensionCatalog); + } + + @Override + public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens) { + if (legacyMode() || baseSet == null) { + return ""; + } + List extension = split(baseSet, Set.of()).extensionCatalog(); + if (extension.isEmpty()) { + return ""; + } + int limit = catalogEntryLimit(maxInputTokens); + Snapshot snap = snapshot(); + + StringBuilder sb = new StringBuilder(); + sb.append("\n\n## Extension Tools\n"); + sb.append("These tools are not directly callable yet. To use one, first call "); + sb.append("`enable_tool(toolName=\"\")`, then issue the real tool call in your next response. "); + sb.append("Activation lasts for the rest of this conversation. Only enable a tool when the task needs it.\n\n"); + sb.append("| Tool | Source | Description |\n"); + sb.append("|------|--------|-------------|\n"); + int shown = 0; + for (ToolCallback cb : extension) { + if (shown >= limit) break; + String name = cb.getToolDefinition().name(); + sb.append("| `").append(name).append("` | ") + .append(sourceLabel(name, snap)).append(" | "); + String desc = cb.getToolDefinition().description(); + if (desc != null && !desc.isBlank()) { + String d = desc.length() > 80 ? desc.substring(0, 80) + "..." : desc; + sb.append(d.replace("|", "\\|").replace("\n", " ")); + } + sb.append(" |\n"); + shown++; + } + if (extension.size() > shown) { + sb.append("\nShowing ").append(shown).append(" of ").append(extension.size()) + .append(" extension tools.\n"); + } + return sb.toString(); + } + + @Override + public void invalidate() { + this.snapshot = null; + } + + private String sourceLabel(String toolName, Snapshot snap) { + Long serverId = snap.mcpToolToServerId.get(toolName); + if (serverId != null) { + String serverName = snap.serverNameById.get(serverId); + return serverName != null && !serverName.isBlank() ? "mcp:" + serverName : "mcp"; + } + return "builtin"; + } + + private static int catalogEntryLimit(Integer maxInputTokens) { + if (maxInputTokens == null || maxInputTokens <= 0) return 20; + if (maxInputTokens < 8_000) return 12; + if (maxInputTokens < 32_000) return 25; + return 40; + } + + private Snapshot snapshot() { + Snapshot snap = this.snapshot; + if (snap != null && (System.currentTimeMillis() - snap.builtAtMillis) < CACHE_TTL_MS) { + return snap; + } + Snapshot rebuilt = buildSnapshot(); + this.snapshot = rebuilt; + return rebuilt; + } + + private Snapshot buildSnapshot() { + // resolveTier() queries by the runtime function name (cb.getToolDefinition().name()), + // but mate_tool stores the Java class name (e.g. "ImageGenerateTool") and bean name + // (e.g. "imageGenerateTool"). Bridge both onto the function name(s) via the global + // tool set's alias index so a persisted tier actually reaches the runtime split. + Map builtinTierByName = new LinkedHashMap<>(); + AgentToolSet globalSet = null; + try { + globalSet = toolRegistry.getEnabledToolSet(); + } catch (Exception e) { + log.warn("ToolDisclosureService: global tool set unavailable, tier name bridge disabled: {}", + e.getMessage()); + } + try { + for (ToolEntity t : toolService.listTools()) { + if (t.getName() == null || t.getDisclosureTier() == null || t.getDisclosureTier().isBlank()) { + continue; + } + DisclosureTier tier = DisclosureTier.fromToken(t.getDisclosureTier()); + // Key by the raw stored name too — harmless, and covers rows that already + // store a function name. + builtinTierByName.put(t.getName(), tier); + if (globalSet != null) { + Set aliases = new LinkedHashSet<>(); + aliases.add(t.getName()); + if (t.getBeanName() != null && !t.getBeanName().isBlank()) { + aliases.add(t.getBeanName()); + } + for (String functionName : globalSet.functionNamesFor(aliases)) { + builtinTierByName.put(functionName, tier); + } + } + } + } catch (Exception e) { + log.warn("ToolDisclosureService: failed to read mate_tool tiers, defaulting builtin tools to core: {}", + e.getMessage()); + } + + Map mcpToolToServerId = new LinkedHashMap<>(); + try { + for (AvailableToolDTO d : availableToolService.listAvailable()) { + if ("mcp".equals(d.getSource()) && d.getName() != null && d.getProviderId() != null) { + mcpToolToServerId.put(d.getName(), d.getProviderId()); + } + } + } catch (Exception e) { + log.warn("ToolDisclosureService: failed to map MCP tools to servers: {}", e.getMessage()); + } + + Map serverTierById = new LinkedHashMap<>(); + Map serverNameById = new LinkedHashMap<>(); + try { + for (McpServerEntity s : mcpServerService.listAll()) { + serverTierById.put(s.getId(), DisclosureTier.fromToken(s.getDisclosureTier())); + serverNameById.put(s.getId(), s.getName()); + } + } catch (Exception e) { + log.warn("ToolDisclosureService: failed to read MCP server tiers, defaulting to core: {}", + e.getMessage()); + } + + return new Snapshot(builtinTierByName, mcpToolToServerId, serverTierById, serverNameById, + System.currentTimeMillis()); + } + + private record Snapshot(Map builtinTierByName, + Map mcpToolToServerId, + Map serverTierById, + Map serverNameById, + long builtAtMillis) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DisclosureTier.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DisclosureTier.java new file mode 100644 index 00000000..c90613bd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DisclosureTier.java @@ -0,0 +1,36 @@ +package vip.mate.tool.disclosure; + +/** + * Progressive tool disclosure tier. + *
      + *
    • {@link #CORE} — always advertised to the LLM.
    • + *
    • {@link #EXTENSION} — hidden behind the extension-tools catalog until + * the model calls {@code enable_tool}, which activates it for the rest of + * the conversation.
    • + *
    + */ +public enum DisclosureTier { + CORE, + EXTENSION; + + /** Token stored in DB columns / accepted by the PATCH endpoints. */ + public String token() { + return name().toLowerCase(); + } + + /** + * Parse a stored tier token. Anything other than a case-insensitive + * {@code "extension"} maps to {@link #CORE} — including {@code null} / blank, + * so a row whose column was never set is treated as core. + */ + public static DisclosureTier fromToken(String token) { + return token != null && "extension".equalsIgnoreCase(token.trim()) + ? EXTENSION : CORE; + } + + /** True for the two valid tokens, used to validate PATCH input. */ + public static boolean isValidToken(String token) { + return token != null + && ("core".equalsIgnoreCase(token.trim()) || "extension".equalsIgnoreCase(token.trim())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java new file mode 100644 index 00000000..9ad06d83 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java @@ -0,0 +1,51 @@ +package vip.mate.tool.disclosure; + +import org.springframework.ai.tool.ToolCallback; +import vip.mate.agent.AgentToolSet; + +import java.util.List; +import java.util.Set; + +/** + * Splits an agent's tool set into the subset advertised to the LLM up front + * ({@code core} + already-enabled extensions) and the {@code extension} catalog + * that stays behind {@code enable_tool} until the model activates it. + * + *

    Tier is resolved per source: builtin / channel atomic tools from + * {@code mate_tool.disclosure_tier}, MCP tools from their owning + * {@code mate_mcp_server.disclosure_tier}. Tools that cannot be classified + * (ACP / dynamic-skill wrapped, plugin tools) default to {@code core} so the + * feature never hides a tool it does not understand. + */ +public interface ToolDisclosureService { + + /** Resolve the tier of a runtime tool callback. */ + DisclosureTier resolveTier(ToolCallback callback); + + /** Resolve the tier of a tool by its function name. */ + DisclosureTier resolveTierByName(String toolName); + + /** + * Split {@code baseSet} into active callbacks (core ∪ enabled extensions) + * and the full extension catalog (every extension tool, enabled or not). + */ + ToolDisclosureSplit split(AgentToolSet baseSet, Set enabledExtensions); + + /** + * Render the {@code ## Extension Tools} system-prompt segment for the + * agent's extension tools, or an empty string when there are none / when + * disclosure is disabled. + */ + String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens); + + /** Drop the cached tier snapshot so the next resolve re-reads the DB. */ + void invalidate(); + + /** + * Result of {@link #split}: {@code activeCallbacks} go to the LLM now; + * {@code extensionCatalog} is every extension tool (enabled or not), used to + * render the prompt catalog. + */ + record ToolDisclosureSplit(List activeCallbacks, List extensionCatalog) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java index acd1e3d0..f0420a4d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -39,8 +39,11 @@ public class GeneratedFileController { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType(entry.mimeType())); // RFC 5987 filename* lets non-ASCII names round-trip in browsers. + String disposition = entry.mimeType() != null && entry.mimeType().startsWith("image/") + ? "inline" + : "attachment"; headers.add(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + sanitizeAscii(entry.filename()) + disposition + "; filename=\"" + sanitizeAscii(entry.filename()) + "\"; filename*=UTF-8''" + encodedName); headers.setContentLength(entry.bytes().length); return ResponseEntity.ok().headers(headers).body(entry.bytes()); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java index 06540941..a3eb8902 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java @@ -24,8 +24,8 @@ public final class GeneratedFileLink { GeneratedFileCache cache, String typeLabel) { String url = stash(bytes, displayName, mimeType, cache); return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n" - + "重要:回答用户时**必须**使用上述相对路径 `" + url + "`," - + "**不要**添加任何 https://、http:// 域名前缀,前端会自动拼接当前主机。"; + + "重要:回答用户时**必须**使用上述 markdown 链接格式 [" + displayName + "](" + url + ")," + + "保持相对路径原样,**不要**用反引号包裹路径,也**不要**添加任何 https://、http:// 域名前缀。"; } /** @@ -45,9 +45,10 @@ public final class GeneratedFileLink { ? typeLabel + " generated from " + sourceFileCount + " files" : typeLabel + " generated"; return prefix + ": [" + displayName + "](" + url + ") (link valid for 10 minutes).\n" - + "IMPORTANT: when replying to the user you **must** use the relative path `" - + url + "` verbatim. Do **not** prepend any https://, http:// or domain — " - + "the frontend will resolve the current host automatically."; + + "IMPORTANT: when replying to the user you **must** keep the markdown link form [" + + displayName + "](" + url + ") above. Keep the relative path verbatim — do **not** " + + "wrap it in backticks and do **not** prepend any https://, http:// or domain " + + "(the frontend resolves the current host automatically)."; } private static String stash(byte[] bytes, String displayName, String mimeType, diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java index 7f9cbae0..4aa578cc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/pdf/FlyingSaucerPdfBackend.java @@ -1,6 +1,6 @@ package vip.mate.tool.document.pdf; -import com.lowagie.text.pdf.BaseFont; +import org.openpdf.text.pdf.BaseFont; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.commonmark.ext.autolink.AutolinkExtension; @@ -118,7 +118,7 @@ public class FlyingSaucerPdfBackend implements PdfBackend { // the output PDF — without IDENTITY_H glyph indexing, Chinese characters // render as blanks even when the font file is found. // - // OpenPDF 2.0.5 has a known weakness with Apple-style .ttc font + // OpenPDF has a known weakness with Apple-style .ttc font // collections (PingFang.ttc, STHeiti.ttc, Songti.ttc on macOS): the // load succeeds but the cmap is empty, charExists returns false even // for ASCII, and the rendered PDF is a blank page. We probe the font diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java index 7e279fa9..60f17469 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java @@ -17,7 +17,9 @@ import vip.mate.tool.guard.service.ToolGuardConfigService; import vip.mate.tool.guard.service.ToolGuardRuleService; import java.util.HashMap; +import java.util.List; import java.util.Map; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; /** * 安全管理接口 @@ -42,18 +44,21 @@ public class SecurityController { @Operation(summary = "获取 Guard 配置") @GetMapping("/guard/config") + @RequireWorkspaceRole("admin") public R getGuardConfig() { return R.ok(configService.getConfig()); } @Operation(summary = "更新 Guard 配置") @PutMapping("/guard/config") + @RequireWorkspaceRole("admin") public R updateGuardConfig(@RequestBody ToolGuardConfigEntity config) { return R.ok(configService.updateConfig(config)); } @Operation(summary = "获取 File Guard 配置") @GetMapping("/guard/config/file-guard") + @RequireWorkspaceRole("admin") public R> getFileGuardConfig() { ToolGuardConfigEntity config = configService.getConfig(); Map result = new HashMap<>(); @@ -64,6 +69,7 @@ public class SecurityController { @Operation(summary = "更新 File Guard 配置") @PutMapping("/guard/config/file-guard") + @RequireWorkspaceRole("admin") public R updateFileGuardConfig(@RequestBody ToolGuardConfigEntity config) { ToolGuardConfigEntity update = new ToolGuardConfigEntity(); update.setFileGuardEnabled(config.getFileGuardEnabled()); @@ -75,6 +81,7 @@ public class SecurityController { @Operation(summary = "规则列表") @GetMapping("/guard/rules") + @RequireWorkspaceRole("admin") public R> listRules( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "50") int size, @@ -87,6 +94,7 @@ public class SecurityController { @Operation(summary = "内置规则列表") @GetMapping("/guard/rules/builtin") + @RequireWorkspaceRole("admin") public R> listBuiltinRules( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "50") int size) { @@ -95,6 +103,7 @@ public class SecurityController { @Operation(summary = "新增自定义规则") @PostMapping("/guard/rules") + @RequireWorkspaceRole("admin") public R createRule(@RequestBody ToolGuardRuleEntity rule) { try { return R.ok(ruleService.createRule(rule)); @@ -109,6 +118,7 @@ public class SecurityController { @Operation(summary = "更新规则") @PutMapping("/guard/rules/{ruleId}") + @RequireWorkspaceRole("admin") public R updateRule( @PathVariable String ruleId, @RequestBody ToolGuardRuleEntity rule) { @@ -121,6 +131,7 @@ public class SecurityController { @Operation(summary = "启用/禁用规则") @PutMapping("/guard/rules/{ruleId}/toggle") + @RequireWorkspaceRole("admin") public R toggleRule( @PathVariable String ruleId, @RequestParam boolean enabled) { @@ -134,6 +145,7 @@ public class SecurityController { @Operation(summary = "删除自定义规则") @DeleteMapping("/guard/rules/{ruleId}") + @RequireWorkspaceRole("admin") public R deleteRule(@PathVariable String ruleId) { try { ruleService.deleteRule(ruleId); @@ -145,6 +157,7 @@ public class SecurityController { @Operation(summary = "按主键 ID 删除自定义规则(兜底,rule_id 异常时使用)") @DeleteMapping("/guard/rules/by-id/{id}") + @RequireWorkspaceRole("admin") public R deleteRuleByPk(@PathVariable Long id) { try { ruleService.deleteRuleByPk(id); @@ -154,10 +167,41 @@ public class SecurityController { } } + @Operation(summary = "导出全部规则为 JSON") + @GetMapping("/guard/rules/export") + @RequireWorkspaceRole("admin") + public R> exportRules() { + return R.ok(ruleService.exportRules()); + } + + @Operation(summary = "从 JSON 批量导入规则(upsert 语义)") + @PostMapping("/guard/rules/import") + @RequireWorkspaceRole("admin") + public R> importRules(@RequestBody Map body) { + try { + Object rulesNode = body == null ? null : body.get("rules"); + if (!(rulesNode instanceof List raw)) { + return R.fail("Body must contain a 'rules' array"); + } + com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper(); + List incoming = new java.util.ArrayList<>(); + for (Object item : raw) { + ToolGuardRuleEntity rule = om.convertValue(item, ToolGuardRuleEntity.class); + incoming.add(rule); + } + return R.ok(ruleService.importRules(incoming)); + } catch (IllegalArgumentException e) { + return R.fail(e.getMessage()); + } catch (Exception e) { + return R.fail("Import failed: " + e.getMessage()); + } + } + // ==================== Audit ==================== @Operation(summary = "审计日志") @GetMapping("/audit/logs") + @RequireWorkspaceRole("admin") public R> listAuditLogs( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @@ -169,6 +213,7 @@ public class SecurityController { @Operation(summary = "审计统计") @GetMapping("/audit/stats") + @RequireWorkspaceRole("admin") public R> getAuditStats() { return R.ok(auditService.getStats()); } @@ -177,12 +222,15 @@ public class SecurityController { @Operation(summary = "审批记录(管理视角)") @GetMapping("/approvals") + @RequireWorkspaceRole("admin") public R listApprovals( - @RequestParam(required = false) String conversationId) { + @RequestParam(required = false) String conversationId, + @RequestParam(required = false, defaultValue = "0") int limit) { if (conversationId != null && !conversationId.isBlank()) { return R.ok(approvalWorkflowService.getPendingByConversation(conversationId)); } - // 返回空列表(后续可扩展为全量审批记录查询) - return R.ok(java.util.List.of()); + // Global view — reads from mate_tool_approval directly so the result + // survives in-memory map drift after a restart/recovery cycle. + return R.ok(approvalWorkflowService.listPendingFromDb(limit)); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolGuardRuleRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolGuardRuleRegistry.java index 00de1410..9ca3a412 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolGuardRuleRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolGuardRuleRegistry.java @@ -66,6 +66,19 @@ public class ToolGuardRuleRegistry implements ApplicationRunner { .collect(Collectors.toList()); } + /** + * 按 category 取所有已启用规则(不限工具)。 + * 用于 alwaysRun 类的横切 Guardian(凭据扫描、PII 扫描等)。 + */ + public List getRulesByCategory(String category) { + if (category == null || category.isEmpty()) { + return List.of(); + } + return allRules.stream() + .filter(r -> category.equals(r.getCategory())) + .collect(Collectors.toList()); + } + /** * 获取所有已启用规则 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolPolicyResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolPolicyResolver.java index 8b3b36af..7c60f349 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolPolicyResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/engine/ToolPolicyResolver.java @@ -5,7 +5,6 @@ import org.springframework.stereotype.Component; import vip.mate.tool.guard.model.*; import java.util.List; -import java.util.Set; /** * 策略解析器 @@ -15,44 +14,66 @@ import java.util.Set; *
  • Guardian 只负责发现风险事实
  • *
  • PolicyResolver 负责把事实映射为执行策略
  • * - * 采用 findings-driven approval 策略,不按工具类型默认审批。 + * 聚合规则(取最严格者): + *
      + *
    1. 遍历每条 finding,得到该 finding 的目标 decision:
    2. + *
        + *
      • 若 finding 显式带 decision(来自 DB 规则),用之;
      • + *
      • 否则按 severity 回退:CRITICAL→BLOCK,HIGH/MEDIUM→NEEDS_APPROVAL,LOW/INFO→ALLOW。
      • + *
      + *
    3. 取所有 finding 中最严格的:BLOCK > NEEDS_APPROVAL > ALLOW。
    4. + *
    */ @Slf4j @Component public class ToolPolicyResolver { - /** - * 根据 findings 和上下文产出最终裁决 - *

    - * 策略(findings-driven approval): - *

      - *
    • 无 findings → ALLOW(普通命令直接执行)
    • - *
    • CRITICAL → BLOCK(极端危险直接阻断)
    • - *
    • HIGH → NEEDS_APPROVAL(高风险需审批)
    • - *
    • MEDIUM → NEEDS_APPROVAL(中风险需审批)
    • - *
    - */ public GuardDecision resolve(List findings, ToolInvocationContext context) { - // 无 findings → 直接允许(不再按工具类型默认审批) if (findings == null || findings.isEmpty()) { return GuardDecision.ALLOW; } - GuardSeverity maxSeverity = findings.stream() - .map(GuardFinding::severity) - .reduce(GuardSeverity.INFO, GuardSeverity::max); + GuardDecision aggregate = GuardDecision.ALLOW; + for (GuardFinding f : findings) { + GuardDecision perFinding = resolveSingle(f); + aggregate = stricter(aggregate, perFinding); + if (aggregate == GuardDecision.BLOCK) { + return aggregate; + } + } + return aggregate; + } - // CRITICAL → 直接 BLOCK - if (maxSeverity.isAtLeast(GuardSeverity.CRITICAL)) { + /** + * 单条 finding 的目标 decision:显式优先,否则按 severity 默认映射 + */ + private GuardDecision resolveSingle(GuardFinding finding) { + if (finding.decision() != null) { + return finding.decision(); + } + GuardSeverity sev = finding.severity(); + if (sev == null) { + return GuardDecision.ALLOW; + } + if (sev.isAtLeast(GuardSeverity.CRITICAL)) { return GuardDecision.BLOCK; } - - // HIGH / MEDIUM → 需要审批 - if (maxSeverity.isAtLeast(GuardSeverity.MEDIUM)) { + if (sev.isAtLeast(GuardSeverity.MEDIUM)) { return GuardDecision.NEEDS_APPROVAL; } + return GuardDecision.ALLOW; + } - // LOW / INFO → 允许 + /** + * 取两个 decision 的严格上界:BLOCK > NEEDS_APPROVAL > ALLOW + */ + private GuardDecision stricter(GuardDecision a, GuardDecision b) { + if (a == GuardDecision.BLOCK || b == GuardDecision.BLOCK) { + return GuardDecision.BLOCK; + } + if (a == GuardDecision.NEEDS_APPROVAL || b == GuardDecision.NEEDS_APPROVAL) { + return GuardDecision.NEEDS_APPROVAL; + } return GuardDecision.ALLOW; } @@ -61,14 +82,12 @@ public class ToolPolicyResolver { */ public String buildSummary(List findings, GuardDecision decision) { if (findings == null || findings.isEmpty()) { - // 无 findings 时不应该有 NEEDS_APPROVAL 或 BLOCK return null; } StringBuilder sb = new StringBuilder(); sb.append("检测到 ").append(findings.size()).append(" 项安全风险"); - // 列出最高风险的发现 findings.stream() .filter(f -> f.severity() != null && f.severity().isAtLeast(GuardSeverity.MEDIUM)) .limit(3) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/CredentialExposureGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/CredentialExposureGuardian.java index a76cc389..3add1c34 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/CredentialExposureGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/CredentialExposureGuardian.java @@ -2,6 +2,7 @@ package vip.mate.tool.guard.guardian; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; import vip.mate.tool.guard.model.*; import java.util.ArrayList; @@ -14,8 +15,13 @@ import java.util.regex.Pattern; /** * 凭据泄露守卫 *

    - * 检测工具参数中可能包含的敏感凭据信息。 - * alwaysRun=true,不受 guarded tools 范围限制。 + * 检测工具参数中可能包含的敏感凭据信息。alwaysRun=true,不受 guarded tools 范围限制。 + *

    + * 规则优先级: + *

      + *
    1. 优先读取 DB 中 category=CREDENTIAL_EXPOSURE 的已启用规则(受 UI 开关控制);
    2. + *
    3. DB 无任何凭据规则时回退到内置硬编码列表(保证未初始化部署也能工作)。
    4. + *
    */ @Slf4j @Component @@ -23,31 +29,43 @@ public class CredentialExposureGuardian implements ToolGuardGuardian { private static final Map COMPILED = new ConcurrentHashMap<>(); - private record CredentialRule(String ruleId, String pattern, String title, String description) {} + private record CredentialRule(String ruleId, String pattern, String title, + String description, GuardDecision decision) {} - private static final List RULES = List.of( + private static final List BUILTIN_FALLBACK = List.of( new CredentialRule("CRED_PASSWORD_ASSIGN", "(password|secret|api[_-]?key|token)\\s*=\\s*['\"]?\\S{8,}", "凭据信息暴露", - "检测到可能的密码/密钥/Token 赋值"), + "检测到可能的密码/密钥/Token 赋值", + GuardDecision.NEEDS_APPROVAL), new CredentialRule("CRED_AWS_KEY", "AKIA[0-9A-Z]{16}", "AWS Access Key 泄露", - "检测到 AWS Access Key ID 模式"), + "检测到 AWS Access Key ID 模式", + GuardDecision.NEEDS_APPROVAL), new CredentialRule("CRED_PRIVATE_KEY", "-----BEGIN\\s+(RSA\\s+)?PRIVATE\\s+KEY-----", "私钥泄露", - "检测到 PEM 格式私钥"), + "检测到 PEM 格式私钥", + GuardDecision.BLOCK), new CredentialRule("CRED_JWT_TOKEN", "eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+", "JWT Token 泄露", - "检测到 JWT Token 格式的字符串"), + "检测到 JWT Token 格式的字符串", + GuardDecision.NEEDS_APPROVAL), new CredentialRule("CRED_GITHUB_TOKEN", "gh[pousr]_[A-Za-z0-9_]{36,}", "GitHub Token 泄露", - "检测到 GitHub Personal Access Token") + "检测到 GitHub Personal Access Token", + GuardDecision.NEEDS_APPROVAL) ); + private final ToolGuardRuleRegistry ruleRegistry; + + public CredentialExposureGuardian(ToolGuardRuleRegistry ruleRegistry) { + this.ruleRegistry = ruleRegistry; + } + @Override public boolean supports(ToolInvocationContext context) { return true; @@ -69,7 +87,45 @@ public class CredentialExposureGuardian implements ToolGuardGuardian { if (raw == null || raw.isEmpty()) return List.of(); List findings = new ArrayList<>(); - for (CredentialRule rule : RULES) { + + // 1) 优先使用 DB 规则(受 UI 启用/禁用开关控制) + List dbRules = ruleRegistry.getRulesByCategory( + GuardCategory.CREDENTIAL_EXPOSURE.name()); + if (!dbRules.isEmpty()) { + for (ToolGuardRuleEntity rule : dbRules) { + if (rule.getPattern() == null || rule.getPattern().isBlank()) continue; + Pattern p = ruleRegistry.getCompiledPattern(rule.getPattern()); + Matcher matcher = p.matcher(raw); + if (!matcher.find()) continue; + + // 排除模式(白名单) + if (rule.getExcludePattern() != null && !rule.getExcludePattern().isBlank()) { + Pattern exclude = ruleRegistry.getCompiledExcludePattern(rule.getExcludePattern()); + if (exclude.matcher(raw).find()) continue; + } + + String snippet = extractSnippet(raw, matcher.start(), 30); + GuardSeverity severity = parseSeverity(rule.getSeverity()); + GuardDecision decision = parseDecision(rule.getDecision()); + findings.add(new GuardFinding( + rule.getRuleId(), + severity, + GuardCategory.CREDENTIAL_EXPOSURE, + rule.getName(), + rule.getDescription(), + rule.getRemediation(), + context.toolName(), + rule.getParamName(), + rule.getPattern(), + maskCredential(snippet), + decision + )); + } + return findings; + } + + // 2) DB 未初始化 → 回退内置规则 + for (CredentialRule rule : BUILTIN_FALLBACK) { Pattern p = COMPILED.computeIfAbsent(rule.pattern, r -> Pattern.compile(r, Pattern.CASE_INSENSITIVE)); Matcher matcher = p.matcher(raw); @@ -85,13 +141,32 @@ public class CredentialExposureGuardian implements ToolGuardGuardian { context.toolName(), null, rule.pattern, - maskCredential(snippet) + maskCredential(snippet), + rule.decision )); } } return findings; } + private GuardSeverity parseSeverity(String raw) { + if (raw == null || raw.isBlank()) return GuardSeverity.HIGH; + try { + return GuardSeverity.valueOf(raw); + } catch (IllegalArgumentException e) { + return GuardSeverity.HIGH; + } + } + + private GuardDecision parseDecision(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return GuardDecision.valueOf(raw); + } catch (IllegalArgumentException e) { + return null; + } + } + private String extractSnippet(String input, int matchStart, int contextLen) { int start = Math.max(0, matchStart - contextLen / 2); int end = Math.min(input.length(), matchStart + contextLen / 2); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java new file mode 100644 index 00000000..c9e83f3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java @@ -0,0 +1,109 @@ +package vip.mate.tool.guard.guardian; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Generic guardian — applies any DB-stored {@link ToolGuardRuleEntity} + * to the matching tool invocation, regardless of tool type. Previously + * the only consumer of DB rules was {@link ShellCommandGuardian}, which + * gated on a hard-coded list of 3 shell tool names; that left every + * other tool (including channel-native ones from + * {@code ChannelToolProvider}) with no path from a {@code + * mate_tool_guard_rule} row to a Guard finding. This class fixes that + * gap. + * + *

    Disjointness contract: {@link ShellCommandGuardian} now + * defers to this class when a shell tool has DB rules — see its + * {@code supports()} gate. So a single invocation is evaluated by + * exactly one of the two, never both. Pattern matching is identical + * between them, preserving the existing test corpus. + */ +@Slf4j +@Component +public class DbRuleGuardian implements ToolGuardGuardian { + + private final ToolGuardRuleRegistry ruleRegistry; + + public DbRuleGuardian(ToolGuardRuleRegistry ruleRegistry) { + this.ruleRegistry = ruleRegistry; + } + + @Override + public boolean supports(ToolInvocationContext context) { + return context != null && context.toolName() != null + && !ruleRegistry.getRulesForTool(context.toolName()).isEmpty(); + } + + /** + * Priority just below {@link ShellCommandGuardian}'s 200 so when + * the engine sorts guardians, both will fire in a stable order + * before any built-in-rule fallbacks. They're disjoint via + * {@code supports()} so the order is cosmetic. + */ + @Override + public int priority() { + return 199; + } + + @Override + public List evaluate(ToolInvocationContext context) { + String combined = buildMatchInput(context); + if (combined == null || combined.isEmpty()) return List.of(); + + List findings = new ArrayList<>(); + for (ToolGuardRuleEntity rule : ruleRegistry.getRulesForTool(context.toolName())) { + Pattern pattern = ruleRegistry.getCompiledPattern(rule.getPattern()); + Matcher matcher = pattern.matcher(combined); + if (!matcher.find()) continue; + + if (rule.getExcludePattern() != null && !rule.getExcludePattern().isBlank()) { + Pattern exclude = ruleRegistry.getCompiledExcludePattern(rule.getExcludePattern()); + if (exclude.matcher(combined).find()) continue; + } + String snippet = extractSnippet(combined, matcher.start(), 40); + findings.add(new GuardFinding( + rule.getRuleId(), + GuardSeverity.valueOf(rule.getSeverity()), + GuardCategory.valueOf(rule.getCategory()), + rule.getName(), + rule.getDescription(), + rule.getRemediation(), + context.toolName(), + rule.getParamName() != null ? rule.getParamName() : "args", + rule.getPattern(), + snippet, + parseDecision(rule.getDecision()) + )); + } + return findings; + } + + private static String buildMatchInput(ToolInvocationContext context) { + String raw = context.rawArguments(); + if (raw == null || raw.isEmpty()) return null; + return (context.toolName() != null ? context.toolName() + " " : "") + raw; + } + + private static GuardDecision parseDecision(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return GuardDecision.valueOf(raw); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String extractSnippet(String input, int matchStart, int contextLen) { + int start = Math.max(0, matchStart - contextLen / 2); + int end = Math.min(input.length(), matchStart + contextLen / 2); + return input.substring(start, end); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java index 21501140..06e7b2a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java @@ -39,7 +39,15 @@ public class ShellCommandGuardian implements ToolGuardGuardian { @Override public boolean supports(ToolInvocationContext context) { - return context.toolName() != null && SHELL_TOOL_NAMES.contains(context.toolName()); + if (context.toolName() == null || !SHELL_TOOL_NAMES.contains(context.toolName())) { + return false; + } + // Mutual-exclusion gate with DbRuleGuardian: when DB rules + // exist for this shell tool, DbRuleGuardian evaluates them + // and we skip — keeping the two paths strictly disjoint. + // Empty DB rules → we own this invocation and fall through to + // the hard-coded built-in shell rules below. + return ruleRegistry.getRulesForTool(context.toolName()).isEmpty(); } @Override @@ -79,7 +87,8 @@ public class ShellCommandGuardian implements ToolGuardGuardian { context.toolName(), rule.getParamName() != null ? rule.getParamName() : "command", rule.getPattern(), - snippet + snippet, + parseDecision(rule.getDecision()) )); } } @@ -115,6 +124,15 @@ public class ShellCommandGuardian implements ToolGuardGuardian { return (context.toolName() != null ? context.toolName() + " " : "") + raw; } + private GuardDecision parseDecision(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return GuardDecision.valueOf(raw); + } catch (IllegalArgumentException e) { + return null; + } + } + private String extractSnippet(String input, int matchStart, int contextLen) { int start = Math.max(0, matchStart - contextLen / 2); int end = Math.min(input.length(), matchStart + contextLen / 2); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/GuardFinding.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/GuardFinding.java index f4c9e4b3..17ea1368 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/GuardFinding.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/GuardFinding.java @@ -7,6 +7,10 @@ import java.util.Map; *

    * 由 Guardian 评估产出,携带完整的威胁上下文信息。 * 使用不可变 record,产出后不允许被修改。 + *

    + * {@code decision} 为可选的"该规则希望的最终裁决"。Guardian 若从 DB 加载规则, + * 应把 rule.decision 透传过来;PolicyResolver 在聚合阶段会取所有 findings 中 + * 最严格的一项作为最终 decision,未设置时回退到 severity → 默认动作。 */ public record GuardFinding( String ruleId, @@ -19,6 +23,7 @@ public record GuardFinding( String paramName, String matchedPattern, String snippet, + GuardDecision decision, Map metadata ) { @@ -26,7 +31,15 @@ public record GuardFinding( String title, String description, String remediation, String toolName, String paramName, String matchedPattern, String snippet) { this(ruleId, severity, category, title, description, remediation, - toolName, paramName, matchedPattern, snippet, Map.of()); + toolName, paramName, matchedPattern, snippet, null, Map.of()); + } + + public GuardFinding(String ruleId, GuardSeverity severity, GuardCategory category, + String title, String description, String remediation, + String toolName, String paramName, String matchedPattern, String snippet, + GuardDecision decision) { + this(ruleId, severity, category, title, description, remediation, + toolName, paramName, matchedPattern, snippet, decision, Map.of()); } /** @@ -43,7 +56,8 @@ public record GuardFinding( Map.entry("toolName", toolName != null ? toolName : ""), Map.entry("paramName", paramName != null ? paramName : ""), Map.entry("matchedPattern", matchedPattern != null ? matchedPattern : ""), - Map.entry("snippet", snippet != null ? snippet : "") + Map.entry("snippet", snippet != null ? snippet : ""), + Map.entry("decision", decision != null ? decision.name() : "") ); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java index 4a488710..0a366d3b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java @@ -169,19 +169,18 @@ public class ToolGuardRuleSeedService implements ApplicationRunner { log.debug("[RuleSeed] Rule {} insert failed: {}", rule.getRuleId(), e.getMessage()); } } else if (needsUpdate(existing, rule)) { - // 已存在但内容有变化 → 更新 + // 已存在但内容字段有变化 → 同步代码侧拥有的字段(content fields)。 + // 严格保留用户侧拥有的策略字段(severity / decision / priority / enabled + // / excludePattern)—— 这些一旦用户在 UI 上调整,重启后不应被覆盖。 ruleMapper.update(null, new LambdaUpdateWrapper() .eq(ToolGuardRuleEntity::getRuleId, rule.getRuleId()) .set(ToolGuardRuleEntity::getName, rule.getName()) .set(ToolGuardRuleEntity::getDescription, rule.getDescription()) .set(ToolGuardRuleEntity::getPattern, rule.getPattern()) - .set(ToolGuardRuleEntity::getSeverity, rule.getSeverity()) .set(ToolGuardRuleEntity::getCategory, rule.getCategory()) - .set(ToolGuardRuleEntity::getDecision, rule.getDecision()) .set(ToolGuardRuleEntity::getToolName, rule.getToolName()) - .set(ToolGuardRuleEntity::getRemediation, rule.getRemediation()) - .set(ToolGuardRuleEntity::getPriority, rule.getPriority())); + .set(ToolGuardRuleEntity::getRemediation, rule.getRemediation())); updated++; } else { unchanged++; @@ -195,16 +194,21 @@ public class ToolGuardRuleSeedService implements ApplicationRunner { } /** - * 判断已有 builtin 规则是否需要更新(任一核心字段有变化即需要) + * 判断已有 builtin 规则是否需要更新。 + *

    + * 只比较"内容字段"(代码侧拥有,应当随版本升级同步): + * name / description / pattern / category / toolName / remediation。 + *

    + * 故意不比较"策略字段"(用户侧拥有,UI 可调):severity / decision / priority / enabled / excludePattern。 + * 这样用户把某条 builtin 规则的 decision 从 NEEDS_APPROVAL 改成 BLOCK、或者关闭某条规则, + * 重启不会把改动覆盖回种子初值。 */ private boolean needsUpdate(ToolGuardRuleEntity existing, ToolGuardRuleEntity expected) { - return !Objects.equals(existing.getPattern(), expected.getPattern()) - || !Objects.equals(existing.getSeverity(), expected.getSeverity()) + return !Objects.equals(existing.getName(), expected.getName()) + || !Objects.equals(existing.getDescription(), expected.getDescription()) + || !Objects.equals(existing.getPattern(), expected.getPattern()) || !Objects.equals(existing.getCategory(), expected.getCategory()) - || !Objects.equals(existing.getDecision(), expected.getDecision()) || !Objects.equals(existing.getToolName(), expected.getToolName()) - || !Objects.equals(existing.getPriority(), expected.getPriority()) - || !Objects.equals(existing.getName(), expected.getName()) || !Objects.equals(existing.getRemediation(), expected.getRemediation()); } @@ -330,6 +334,16 @@ public class ToolGuardRuleSeedService implements ApplicationRunner { GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "BLOCK", null, gf("CRED_PRIVATE_KEY"), 140)); + rules.add(rule("CRED_JWT_TOKEN", gn("CRED_JWT_TOKEN"), + "eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+", + GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "NEEDS_APPROVAL", + null, gf("CRED_JWT_TOKEN"), 140)); + + rules.add(rule("CRED_GITHUB_TOKEN", gn("CRED_GITHUB_TOKEN"), + "gh[pousr]_[A-Za-z0-9_]{36,}", + GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "NEEDS_APPROVAL", + null, gf("CRED_GITHUB_TOKEN"), 140)); + return rules; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleService.java index 66ab98b4..aa024d24 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleService.java @@ -10,6 +10,11 @@ import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; import vip.mate.tool.guard.model.ToolGuardRuleEntity; import vip.mate.tool.guard.repository.ToolGuardRuleMapper; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + /** * 工具安全规则 CRUD 服务 */ @@ -94,6 +99,11 @@ public class ToolGuardRuleService { if (existing == null) { throw new IllegalArgumentException("Rule not found: " + ruleId); } + // 内置规则只允许调整策略字段;内容字段(pattern / category / name 等)由 + // 代码侧种子管理,写进 DB 也会在下次启动被覆盖回去,提前在这里拦掉以免误用。 + if (Boolean.TRUE.equals(existing.getBuiltin())) { + return updateBuiltinPolicy(ruleId, update); + } if (update.getName() != null) { requireNonBlank(update.getName(), "Rule name"); @@ -153,6 +163,130 @@ public class ToolGuardRuleService { ruleRegistry.reload(); } + /** + * 更新内置规则时,限制只允许调整策略字段(severity / decision / priority / enabled + * / excludePattern)。内容字段(pattern / category / name 等)由代码侧种子管理, + * UI 改动也会在下次重启被覆盖回去,提前在 API 层拦截避免误用。 + */ + public ToolGuardRuleEntity updateBuiltinPolicy(String ruleId, ToolGuardRuleEntity patch) { + ToolGuardRuleEntity existing = getByRuleId(ruleId); + if (existing == null) { + throw new IllegalArgumentException("Rule not found: " + ruleId); + } + if (!Boolean.TRUE.equals(existing.getBuiltin())) { + throw new IllegalArgumentException("Rule is not builtin: " + ruleId); + } + if (patch.getSeverity() != null) existing.setSeverity(patch.getSeverity()); + if (patch.getDecision() != null) existing.setDecision(patch.getDecision()); + if (patch.getPriority() != null) existing.setPriority(patch.getPriority()); + if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled()); + if (patch.getExcludePattern() != null) existing.setExcludePattern(patch.getExcludePattern()); + ruleMapper.updateById(existing); + ruleRegistry.reload(); + return existing; + } + + /** + * 导出全部规则(含 builtin),格式可被 importRules 直接吃回去。 + * 导出时保留 ruleId 作为主键标识,省略 id / createTime / updateTime / deleted 这些 + * 部署敏感的字段;builtin 标志保留,import 时用来判断走 builtin policy 通道还是 + * 创建/覆盖 custom 规则。 + */ + public Map exportRules() { + List all = ruleMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(ToolGuardRuleEntity::getPriority)); + List> rows = new ArrayList<>(); + for (ToolGuardRuleEntity r : all) { + Map row = new LinkedHashMap<>(); + row.put("ruleId", r.getRuleId()); + row.put("name", r.getName()); + row.put("description", r.getDescription()); + row.put("toolName", r.getToolName()); + row.put("paramName", r.getParamName()); + row.put("category", r.getCategory()); + row.put("severity", r.getSeverity()); + row.put("decision", r.getDecision()); + row.put("pattern", r.getPattern()); + row.put("excludePattern", r.getExcludePattern()); + row.put("remediation", r.getRemediation()); + row.put("priority", r.getPriority()); + row.put("enabled", r.getEnabled()); + row.put("builtin", r.getBuiltin()); + rows.add(row); + } + Map envelope = new LinkedHashMap<>(); + envelope.put("schema", "mateclaw.tool-guard.rules.v1"); + envelope.put("exportedAt", java.time.OffsetDateTime.now().toString()); + envelope.put("count", rows.size()); + envelope.put("rules", rows); + return envelope; + } + + /** + * 导入规则。upsert 语义: + *

      + *
    • ruleId 已存在 + builtin → 仅同步策略字段(severity / decision / priority / enabled / excludePattern);
    • + *
    • ruleId 已存在 + custom → 全字段覆盖;
    • + *
    • ruleId 不存在 → 作为 custom 规则插入(强制 builtin=false,避免被 import 篡改内置标记)。
    • + *
    + */ + public Map importRules(List incoming) { + if (incoming == null || incoming.isEmpty()) { + throw new IllegalArgumentException("No rules to import"); + } + int inserted = 0; + int updatedBuiltin = 0; + int updatedCustom = 0; + int skipped = 0; + List errors = new ArrayList<>(); + + for (ToolGuardRuleEntity rule : incoming) { + try { + if (rule.getRuleId() == null || rule.getRuleId().isBlank()) { + skipped++; + errors.add("missing ruleId"); + continue; + } + if (rule.getPattern() == null || rule.getPattern().isBlank()) { + skipped++; + errors.add(rule.getRuleId() + ": missing pattern"); + continue; + } + String rid = rule.getRuleId().trim(); + ToolGuardRuleEntity existing = getByRuleId(rid); + if (existing == null) { + rule.setRuleId(rid); + rule.setBuiltin(false); + if (rule.getEnabled() == null) rule.setEnabled(true); + if (rule.getPriority() == null) rule.setPriority(100); + if (rule.getSeverity() == null) rule.setSeverity("HIGH"); + if (rule.getDecision() == null) rule.setDecision("NEEDS_APPROVAL"); + ruleMapper.insert(rule); + inserted++; + } else if (Boolean.TRUE.equals(existing.getBuiltin())) { + updateBuiltinPolicy(rid, rule); + updatedBuiltin++; + } else { + updateRule(rid, rule); + updatedCustom++; + } + } catch (Exception e) { + skipped++; + errors.add((rule.getRuleId() == null ? "" : rule.getRuleId()) + + ": " + e.getMessage()); + } + } + ruleRegistry.reload(); + Map summary = new LinkedHashMap<>(); + summary.put("inserted", inserted); + summary.put("updatedBuiltin", updatedBuiltin); + summary.put("updatedCustom", updatedCustom); + summary.put("skipped", skipped); + summary.put("errors", errors); + return summary; + } + /** * 按主键 ID 删除自定义规则。兜底通道:当 rule_id 因历史脏数据为空或无法走 * /guard/rules/{ruleId} 路径变量时,UI 仍可通过主键删除。 diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/GoogleImagenProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/GoogleImagenProvider.java index eef4bf1f..f5f2c42f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/GoogleImagenProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/GoogleImagenProvider.java @@ -19,12 +19,19 @@ import java.util.List; import java.util.Set; /** - * Google Imagen 图片生成 Provider — 使用 Gemini API 的图片生成能力 - *

    - * 同步模式:直接返回 Base64 图片数据。 - * 复用已有的 Google/Gemini LLM provider 的 API Key。 - *

    - * API: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent + * Google Gemini native image provider — "Nano Banana". + * + *

    Calls the Gemini {@code generateContent} endpoint with + * {@code responseModalities:[TEXT,IMAGE]} and returns the inline base64 image + * as a {@code data:} URI. Supports both text-to-image and image editing / + * image-to-image: reference images from {@link ImageGenerationRequest#getInputImages()} + * are sent as {@code inlineData} parts alongside the prompt. + * + *

    Default model is Nano Banana Pro ({@code gemini-3-pro-image-preview}); the + * original Nano Banana ({@code gemini-2.5-flash-image}) is also available. + * Reuses the {@code gemini} LLM provider's API key — no separate credential. + * + *

    API: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent * * @author MateClaw Team */ @@ -37,7 +44,10 @@ public class GoogleImagenProvider implements ImageGenerationProvider { private final ObjectMapper objectMapper; private static final String BASE_URL = "https://generativelanguage.googleapis.com"; - private static final String DEFAULT_MODEL = "gemini-2.0-flash-preview-image-generation"; + /** Nano Banana Pro — Gemini 3 Pro image generation. */ + private static final String DEFAULT_MODEL = "gemini-3-pro-image-preview"; + /** LLM provider id whose API key this image provider reuses. */ + private static final String LLM_PROVIDER_ID = "gemini"; @Override public String id() { @@ -46,7 +56,7 @@ public class GoogleImagenProvider implements ImageGenerationProvider { @Override public String label() { - return "Google Imagen"; + return "Google Gemini Image (Nano Banana)"; } @Override @@ -61,7 +71,7 @@ public class GoogleImagenProvider implements ImageGenerationProvider { @Override public Set capabilities() { - return Set.of(ImageCapability.TEXT_TO_IMAGE); + return Set.of(ImageCapability.TEXT_TO_IMAGE, ImageCapability.IMAGE_EDIT); } @Override @@ -69,17 +79,17 @@ public class GoogleImagenProvider implements ImageGenerationProvider { return ImageProviderCapabilities.builder() .modes(capabilities()) .supportedSizes(List.of("1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024")) - .aspectRatios(List.of("1:1", "3:4", "4:3", "9:16", "16:9")) - .maxCount(4) + .aspectRatios(List.of("1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9")) + .maxCount(1) .defaultModel(DEFAULT_MODEL) - .models(List.of("gemini-2.0-flash-preview-image-generation", "imagen-4.0-generate-preview", "imagen-4.0-ultra-generate-preview")) + .models(List.of("gemini-3-pro-image-preview", "gemini-2.5-flash-image")) .build(); } @Override public boolean isAvailable(SystemSettingsDTO config) { try { - return modelProviderService.isProviderConfigured("google"); + return modelProviderService.isProviderConfigured(LLM_PROVIDER_ID); } catch (Exception e) { return false; } @@ -90,21 +100,36 @@ public class GoogleImagenProvider implements ImageGenerationProvider { try { String apiKey = getApiKey(); if (apiKey == null) { - return ImageSubmitResult.failure(id(), "Google API Key 未配置"); + return ImageSubmitResult.failure(id(), "Gemini API Key 未配置"); } String model = request.getModel() != null && !request.getModel().isBlank() ? request.getModel() : DEFAULT_MODEL; - // 构建请求体 ObjectNode body = objectMapper.createObjectNode(); - // contents + // contents — one user turn holding the prompt text plus any reference images. ArrayNode contents = body.putArray("contents"); ObjectNode content = contents.addObject(); content.put("role", "user"); ArrayNode parts = content.putArray("parts"); - parts.addObject().put("text", request.getPrompt()); + + if (request.getPrompt() != null && !request.getPrompt().isBlank()) { + parts.addObject().put("text", request.getPrompt()); + } + // Reference images (image edit / image-to-image): inline as base64 parts. + List inputImages = request.getInputImages(); + boolean editing = inputImages != null && !inputImages.isEmpty(); + if (inputImages != null) { + for (ImageReference ref : inputImages) { + if (ref == null || ref.data() == null || ref.data().length == 0) { + continue; + } + ObjectNode inlineData = parts.addObject().putObject("inlineData"); + inlineData.put("mimeType", ref.mimeType() != null ? ref.mimeType() : "image/png"); + inlineData.put("data", Base64.getEncoder().encodeToString(ref.data())); + } + } // generationConfig ObjectNode genConfig = body.putObject("generationConfig"); @@ -112,43 +137,54 @@ public class GoogleImagenProvider implements ImageGenerationProvider { modalities.add("TEXT"); modalities.add("IMAGE"); - if (request.getAspectRatio() != null) { - ObjectNode imageConfig = genConfig.putObject("imageConfig"); + ObjectNode imageConfig = objectMapper.createObjectNode(); + if (request.getAspectRatio() != null && !request.getAspectRatio().isBlank()) { imageConfig.put("aspectRatio", request.getAspectRatio()); } + // Nano Banana Pro resolution tier (1K / 2K / 4K) — opt-in via extraParams. + Object imageSize = request.getExtraParams() != null + ? request.getExtraParams().get("imageSize") : null; + if (imageSize instanceof String sizeTier && !sizeTier.isBlank()) { + imageConfig.put("imageSize", sizeTier); + } + if (!imageConfig.isEmpty()) { + genConfig.set("imageConfig", imageConfig); + } String url = BASE_URL + "/v1beta/models/" + model + ":generateContent?key=" + apiKey; HttpResponse response = HttpRequest.post(url) .header("Content-Type", "application/json") .body(body.toString()) - .timeout(60_000) + .timeout(120_000) .execute(); if (response.getStatus() != 200) { String errBody = response.body(); - log.warn("[Google Imagen] Failed: HTTP {} - {}", response.getStatus(), errBody); - return ImageSubmitResult.failure(id(), "Google Imagen 失败: HTTP " + response.getStatus()); + log.warn("[Nano Banana] Failed: HTTP {} - {}", response.getStatus(), errBody); + return ImageSubmitResult.failure(id(), "Gemini 图像生成失败: HTTP " + response.getStatus()); } JsonNode result = objectMapper.readTree(response.body()); List imageUrls = extractImagesFromResponse(result); if (imageUrls.isEmpty()) { - return ImageSubmitResult.failure(id(), "Google Imagen 未返回图片"); + return ImageSubmitResult.failure(id(), "Gemini 未返回图片"); } - log.info("[Google Imagen] Generated {} images (model={})", imageUrls.size(), model); + log.info("[Nano Banana] Generated {} image(s) (model={}, editing={})", + imageUrls.size(), model, editing); return ImageSubmitResult.syncSuccess(id(), imageUrls); } catch (Exception e) { - log.error("[Google Imagen] Error: {}", e.getMessage(), e); - return ImageSubmitResult.failure(id(), "Google Imagen 异常: " + e.getMessage()); + log.error("[Nano Banana] Error: {}", e.getMessage(), e); + return ImageSubmitResult.failure(id(), "Gemini 图像生成异常: " + e.getMessage()); } } /** - * 从 Gemini 响应中提取 Base64 图片,转换为 data URI + * Extract base64 images from a Gemini generateContent response, converting + * each {@code inlineData} part to a {@code data:} URI. */ private List extractImagesFromResponse(JsonNode result) { List images = new ArrayList<>(); @@ -159,7 +195,7 @@ public class GoogleImagenProvider implements ImageGenerationProvider { JsonNode parts = candidate.path("content").path("parts"); if (parts.isArray()) { for (JsonNode part : parts) { - // 尝试 inlineData 或 inline_data + // Accept both inlineData (camelCase) and inline_data (snake_case). JsonNode inlineData = part.has("inlineData") ? part.get("inlineData") : part.path("inline_data"); if (inlineData.has("data")) { @@ -167,7 +203,6 @@ public class GoogleImagenProvider implements ImageGenerationProvider { ? inlineData.get("mimeType").asText("image/png") : inlineData.path("mime_type").asText("image/png"); String base64Data = inlineData.get("data").asText(); - // 返回 data URI 格式 images.add("data:" + mimeType + ";base64," + base64Data); } } @@ -179,7 +214,7 @@ public class GoogleImagenProvider implements ImageGenerationProvider { private String getApiKey() { try { - return modelProviderService.getProviderConfig("google").getApiKey(); + return modelProviderService.getProviderConfig(LLM_PROVIDER_ID).getApiKey(); } catch (Exception e) { return null; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java index 151f53b1..c2add30f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java @@ -23,8 +23,7 @@ import java.util.Set; * 同步模式:返回图片 URL(DALL-E 系列)或 base64 data URL(gpt-image-2 系列)。 * 复用已有的 OpenAI LLM provider 的 API Key。 * - *

    gpt-image-2 三档质量做成 3 个虚拟 model ID(参考 hermes-agent - * plugins/image_gen/openai/__init__.py 的 model catalog 设计),让 picker + *

    gpt-image-2 三档质量做成 3 个虚拟 model ID,让 picker * 能直接选 fast/balanced/high。三档底层都打到 API model {@code "gpt-image-2"}, * 区别仅在 {@code quality} 参数。 * diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java index b99693f8..ebe4a1af 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/controller/McpServerController.java @@ -11,6 +11,7 @@ import vip.mate.tool.mcp.runtime.McpClientManager.ConnectionResult; import vip.mate.tool.mcp.service.McpServerService; import java.util.List; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; /** * MCP Server 管理接口 @@ -30,21 +31,25 @@ import java.util.List; public class McpServerController { private final McpServerService mcpServerService; + private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService; @Operation(summary = "获取 MCP Server 列表") @GetMapping + @RequireWorkspaceRole("admin") public R> list() { return R.ok(mcpServerService.sanitizeList(mcpServerService.listAll())); } @Operation(summary = "获取 MCP Server 详情") @GetMapping("/{id}") + @RequireWorkspaceRole("admin") public R get(@PathVariable Long id) { return R.ok(mcpServerService.sanitize(mcpServerService.getById(id))); } @Operation(summary = "创建 MCP Server") @PostMapping + @RequireWorkspaceRole("admin") public R create(@RequestBody McpServerEntity entity) { McpServerEntity created = mcpServerService.create(entity); return R.ok(mcpServerService.sanitize(created)); @@ -52,6 +57,7 @@ public class McpServerController { @Operation(summary = "更新 MCP Server") @PutMapping("/{id}") + @RequireWorkspaceRole("admin") public R update(@PathVariable Long id, @RequestBody McpServerEntity entity) { McpServerEntity updated = mcpServerService.update(id, entity); return R.ok(mcpServerService.sanitize(updated)); @@ -59,6 +65,7 @@ public class McpServerController { @Operation(summary = "删除 MCP Server") @DeleteMapping("/{id}") + @RequireWorkspaceRole("admin") public R delete(@PathVariable Long id) { mcpServerService.delete(id); return R.ok(); @@ -66,13 +73,29 @@ public class McpServerController { @Operation(summary = "启用/禁用 MCP Server") @PutMapping("/{id}/toggle") + @RequireWorkspaceRole("admin") public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { McpServerEntity toggled = mcpServerService.toggle(id, enabled); return R.ok(mcpServerService.sanitize(toggled)); } + @Operation(summary = "设置 MCP Server 披露分级(core / extension),整组工具跟随") + @PutMapping("/{id}/disclosure-tier") + @RequireWorkspaceRole("admin") + public R setDisclosureTier(@PathVariable Long id, + @RequestBody java.util.Map body) { + String tier = body == null ? null : body.get("tier"); + if (!vip.mate.tool.disclosure.DisclosureTier.isValidToken(tier)) { + return R.fail(400, "tier must be 'core' or 'extension'"); + } + McpServerEntity updated = mcpServerService.setDisclosureTier(id, tier); + toolDisclosureService.invalidate(); + return R.ok(mcpServerService.sanitize(updated)); + } + @Operation(summary = "测试 MCP Server 连接") @PostMapping("/{id}/test") + @RequireWorkspaceRole("admin") public R test(@PathVariable Long id) { ConnectionResult result = mcpServerService.testConnectionById(id); return R.ok(result); @@ -100,12 +123,14 @@ public class McpServerController { */ @Operation(summary = "列出 MCP Server 已发现的工具") @GetMapping("/{id}/tools") + @RequireWorkspaceRole("admin") public R> listTools(@PathVariable Long id) { return R.ok(mcpServerService.listToolsByServer(id)); } @Operation(summary = "刷新所有 MCP Server 连接") @PostMapping("/refresh") + @RequireWorkspaceRole("admin") public R refresh() { mcpServerService.refreshAll(); return R.ok(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java index 2c5fbaf8..1f393d5b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java @@ -85,6 +85,16 @@ public class McpServerEntity { /** 是否系统内置 */ private Boolean builtin; + /** + * Progressive disclosure tier for the whole server's tool group: + * {@code core} (always advertised) or {@code extension} (hidden behind the + * extension-tools catalog until {@code enable_tool} activates an individual + * tool). Defaults to {@code core} so MCP tools stay directly callable; an + * admin can move a noisy server to {@code extension} to keep it out of every + * agent's tool schema until needed. + */ + private String disclosureTier; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index 30b90d86..deaad663 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -152,6 +152,19 @@ public class McpServerService { return entity; } + /** + * Set the disclosure tier ({@code core} / {@code extension}) for the whole + * server's tool group. No reconnect needed — tiering only affects how the + * tools are advertised to the LLM. + */ + public McpServerEntity setDisclosureTier(Long id, String tier) { + McpServerEntity entity = getById(id); + entity.setDisclosureTier(vip.mate.tool.disclosure.DisclosureTier.fromToken(tier).token()); + mcpServerMapper.updateById(entity); + log.info("MCP server disclosure tier set: name={}, tier={}", entity.getName(), entity.getDisclosureTier()); + return entity; + } + // ==================== Runtime Operations ==================== public ConnectionResult testConnection(McpServerEntity entity) { @@ -264,6 +277,9 @@ public class McpServerService { copy.setLastConnectedTime(entity.getLastConnectedTime()); copy.setToolCount(entity.getToolCount()); copy.setBuiltin(entity.getBuiltin()); + // Disclosure tier is not sensitive and the UI relies on it to render the + // per-server core/extension pill — dropping it made the field always null. + copy.setDisclosureTier(entity.getDisclosureTier()); copy.setCreateTime(entity.getCreateTime()); copy.setUpdateTime(entity.getUpdateTime()); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java index 5eb1dc7a..91eaa6c2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java @@ -93,4 +93,43 @@ public class AvailableToolDTO { .unavailableReason(null) .build(); } + + /** + * Channel-native tool — exposed by a + * {@link vip.mate.channel.tool.ChannelToolProvider} and registered + * by {@code ChannelToolService}. Grouped per owning channel so the + * picker shows "Channel · {channelName}" rather than mixing them + * into the generic Built-in bucket. + */ + public static AvailableToolDTO fromChannel(ToolEntity t) { + // displayName format set by ChannelToolService is "{base} ({channelName})"; + // the channel name is what we surface in the picker group label. + String channelName = extractChannelName(t.getDisplayName()); + String groupLabel = channelName.isEmpty() ? "Channel" : "Channel · " + channelName; + String groupKey = t.getChannelId() != null ? "channel:" + t.getChannelId() : "channel"; + return AvailableToolDTO.builder() + .rowId("channel#" + t.getName()) + .source("channel") + .providerId(t.getChannelId()) + .providerName(channelName) + .name(t.getName()) + .rawName(t.getName()) + .description(t.getDescription() != null ? t.getDescription() : "") + .group(groupLabel) + .groupId(groupKey) + .stale(false) + .available(true) + .unavailableReason(null) + .build(); + } + + private static String extractChannelName(String displayName) { + if (displayName == null) return ""; + int open = displayName.lastIndexOf('('); + int close = displayName.lastIndexOf(')'); + if (open > 0 && close > open) { + return displayName.substring(open + 1, close).trim(); + } + return ""; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java index 27f08e5c..73a5b1da 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; /** * 工具实体 @@ -49,6 +50,33 @@ public class ToolEntity { /** 是否系统内置 */ private Boolean builtin; + /** + * For channel-native tools ({@code tool_type="channel"}), the + * {@code mate_channel.id} that materialised this tool. {@link + * vip.mate.channel.tool.ChannelToolService} uses this column to + * delete a channel's tool rows when its config row is removed and + * to detect "config changed → rebuild tool" cases. Null for + * built-in / MCP / skill tools. + */ + private Long channelId; + + /** + * Progressive disclosure tier: {@code core} (always advertised to the LLM) + * or {@code extension} (hidden behind the extension-tools catalog until the + * model calls {@code enable_tool}). Admin override for builtin / channel + * atomic tools; sensible defaults for unset rows live in + * {@code ToolDisclosureService}. + */ + private String disclosureTier; + + /** + * Runtime {@code @Tool} function names exposed by this row's bean/class + * aliases. Not persisted; populated for admin UI so tier changes can be + * correlated with the names the model actually sees. + */ + @TableField(exist = false) + private List runtimeNames; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java index a39a68cf..d21cbd74 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java @@ -63,7 +63,14 @@ public class AvailableToolService { private void appendBuiltinTools(List out) { for (ToolEntity t : toolService.listEnabledTools()) { if (t == null || t.getName() == null || t.getName().isBlank()) continue; - out.add(AvailableToolDTO.fromBuiltin(t)); + // Dispatch by toolType so channel-native tools (registered by + // ChannelToolService) land in their own picker group rather + // than getting lumped under "Built-in". + if ("channel".equals(t.getToolType())) { + out.add(AvailableToolDTO.fromChannel(t)); + } else { + out.add(AvailableToolDTO.fromBuiltin(t)); + } } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java index c6feb452..fc52751d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/ToolService.java @@ -9,6 +9,8 @@ import vip.mate.tool.model.ToolEntity; import vip.mate.tool.repository.ToolMapper; import java.util.List; +import java.util.LinkedHashSet; +import java.util.Set; /** * 工具业务服务 @@ -24,11 +26,11 @@ public class ToolService { private final ToolRegistry toolRegistry; public List listTools() { - return toolRegistry.listToolEntities(); + return enrichRuntimeNames(toolRegistry.listToolEntities()); } public List listEnabledTools() { - return toolRegistry.listEnabledToolEntities(); + return enrichRuntimeNames(toolRegistry.listEnabledToolEntities()); } public ToolEntity getTool(Long id) { @@ -36,7 +38,7 @@ public class ToolService { if (tool == null) { throw new MateClawException("err.tool.not_found", "工具不存在: " + id); } - return tool; + return enrichRuntimeNames(tool); } public ToolEntity createTool(ToolEntity tool) { @@ -45,7 +47,7 @@ public class ToolService { tool.setEnabled(true); } toolMapper.insert(tool); - return tool; + return enrichRuntimeNames(tool); } public ToolEntity updateTool(ToolEntity tool) { @@ -53,10 +55,10 @@ public class ToolService { if (Boolean.TRUE.equals(existing.getBuiltin())) { existing.setEnabled(tool.getEnabled()); toolMapper.updateById(existing); - return existing; + return enrichRuntimeNames(existing); } toolMapper.updateById(tool); - return tool; + return enrichRuntimeNames(tool); } public void deleteTool(Long id) { @@ -71,6 +73,64 @@ public class ToolService { ToolEntity tool = getTool(id); tool.setEnabled(enabled); toolMapper.updateById(tool); + return enrichRuntimeNames(tool); + } + + /** + * Set the disclosure tier ({@code core} / {@code extension}) of a builtin or + * channel atomic tool. MCP / ACP / skill tools are tiered at their owning + * source, not here — the controller rejects those before calling this. + */ + public ToolEntity setDisclosureTier(Long id, String tier) { + ToolEntity tool = getTool(id); + tool.setDisclosureTier(vip.mate.tool.disclosure.DisclosureTier.fromToken(tier).token()); + toolMapper.updateById(tool); + return enrichRuntimeNames(tool); + } + + private List enrichRuntimeNames(List tools) { + if (tools == null || tools.isEmpty()) { + return tools; + } + vip.mate.agent.AgentToolSet set; + try { + set = toolRegistry.getAllToolBeanSetForAdmin(); + } catch (Exception e) { + log.debug("Unable to enrich tool runtime names: {}", e.getMessage()); + return tools; + } + for (ToolEntity tool : tools) { + enrichRuntimeNames(tool, set); + } + return tools; + } + + private ToolEntity enrichRuntimeNames(ToolEntity tool) { + if (tool == null) { + return null; + } + try { + enrichRuntimeNames(tool, toolRegistry.getAllToolBeanSetForAdmin()); + } catch (Exception e) { + log.debug("Unable to enrich tool runtime names for {}: {}", tool.getName(), e.getMessage()); + } return tool; } + + private static void enrichRuntimeNames(ToolEntity tool, vip.mate.agent.AgentToolSet set) { + if (tool == null || set == null) { + return; + } + Set aliases = new LinkedHashSet<>(); + if (tool.getName() != null && !tool.getName().isBlank()) { + aliases.add(tool.getName()); + } + if (tool.getBeanName() != null && !tool.getBeanName().isBlank()) { + aliases.add(tool.getBeanName()); + } + Set names = set.functionNamesFor(aliases); + if (!names.isEmpty()) { + tool.setRuntimeNames(List.copyOf(names)); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java b/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java index 89f78112..4c3c61ba 100644 --- a/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java +++ b/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java @@ -9,6 +9,7 @@ import vip.mate.trigger.ingest.TriggerEventEnvelope; import vip.mate.trigger.ingest.TriggerEventIngestService; import vip.mate.trigger.model.TriggerEntity; import vip.mate.trigger.service.TriggerService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -30,12 +31,14 @@ public class TriggerController { @Operation(summary = "List triggers in the caller's workspace.") @GetMapping + @RequireWorkspaceRole("admin") public R> list(@RequestHeader("X-Workspace-Id") long workspaceId) { return R.ok(triggerService.listByWorkspace(workspaceId)); } @Operation(summary = "Get a trigger by id, scoped to the caller's workspace.") @GetMapping("/{id}") + @RequireWorkspaceRole("admin") public R get(@PathVariable long id, @RequestHeader("X-Workspace-Id") long workspaceId) { TriggerEntity row = triggerService.get(id, workspaceId); @@ -45,6 +48,7 @@ public class TriggerController { @Operation(summary = "Create a trigger; if enabled, registers it with the scheduler.") @PostMapping + @RequireWorkspaceRole("admin") public R create(@RequestBody TriggerEntity trigger, @RequestHeader("X-Workspace-Id") long workspaceId) { // The controller forces workspace from the trusted header — the @@ -59,6 +63,7 @@ public class TriggerController { @Operation(summary = "Update a trigger; pattern_version bumps when the cron expression changes.") @PutMapping("/{id}") + @RequireWorkspaceRole("admin") public R update(@PathVariable long id, @RequestBody TriggerEntity trigger, @RequestHeader("X-Workspace-Id") long workspaceId) { @@ -71,6 +76,7 @@ public class TriggerController { @Operation(summary = "Delete a trigger and unregister its schedule.") @DeleteMapping("/{id}") + @RequireWorkspaceRole("admin") public R delete(@PathVariable long id, @RequestHeader("X-Workspace-Id") long workspaceId) { triggerService.delete(id, workspaceId); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java index 3ff9cde4..d9c43d50 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java @@ -16,6 +16,7 @@ import vip.mate.wiki.service.WikiScaffoldService; import java.util.HashMap; import java.util.Map; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; /** * RFC-051 follow-up: small set of operator-facing endpoints for things the @@ -44,6 +45,7 @@ public class WikiAdminController { @Operation(summary = "Ensure overview/log scaffold + rebuild overview stats now", description = "Idempotent. Use after manual data imports or when stats look stale.") @PostMapping("/kb/{kbId}/rebuild-overview") + @RequireWorkspaceRole("admin") public ResponseEntity> rebuildOverview(@PathVariable Long kbId) { Map body = new HashMap<>(); scaffoldService.ensureScaffold(kbId); @@ -62,6 +64,7 @@ public class WikiAdminController { description = "Picks up to BATCH_SIZE chunks with token_count IS NULL and fills them. " + "Returns the pending count after the batch so callers can poll.") @PostMapping("/backfill-tokens") + @RequireWorkspaceRole("admin") public ResponseEntity> backfillTokens() { Map body = new HashMap<>(); if (backfillJob == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 0de20d85..377e470f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -4,17 +4,16 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.audit.service.AuditEventService; import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.wiki.WikiProperties; -import vip.mate.wiki.event.WikiProcessingEvent; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; @@ -52,8 +51,8 @@ public class WikiController { private final WikiProcessingService processingService; private final WikiDirectoryScanService scanService; private final WikiProperties properties; - private final ApplicationEventPublisher eventPublisher; private final WikiProgressBus progressBus; + private final AuditEventService auditEventService; // ==================== Knowledge Base ==================== @@ -63,7 +62,7 @@ public class WikiController { public R> listKBs( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { long wsId = workspaceId != null ? workspaceId : 1L; - return R.ok(kbService.listByWorkspace(wsId)); + return R.ok(withLivePageCount(kbService.listByWorkspace(wsId))); } @RequireWorkspaceRole("viewer") @@ -73,8 +72,27 @@ public class WikiController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(id, workspaceId); WikiKnowledgeBaseEntity kb = kbService.getById(id); - if (kb == null) return R.fail("Knowledge base not found"); - return R.ok(kb); + if (kb == null) return R.fail(404, "Knowledge base not found"); + return R.ok(withLivePageCount(kb)); + } + + /** + * Overlay the live page count onto knowledge bases before returning them. + * The {@code pageCount} column is denormalized and only refreshed by the + * processing pipeline, so system-page generation (overview/log) and other + * out-of-band mutations leave it stale. Recomputing on read keeps the count + * the UI shows consistent with the page list. + */ + private List withLivePageCount(List kbs) { + kbs.forEach(this::withLivePageCount); + return kbs; + } + + private WikiKnowledgeBaseEntity withLivePageCount(WikiKnowledgeBaseEntity kb) { + if (kb != null && kb.getId() != null) { + kb.setPageCount(pageService.countByKbId(kb.getId())); + } + return kb; } @RequireWorkspaceRole("viewer") @@ -85,9 +103,9 @@ public class WikiController { long wsId = workspaceId != null ? workspaceId : 1L; // 按 agent 查询后,过滤出属于当前 workspace 的知识库 List kbs = kbService.listByAgentId(agentId); - return R.ok(kbs.stream() + return R.ok(withLivePageCount(kbs.stream() .filter(kb -> kb.getWorkspaceId() == null || kb.getWorkspaceId().equals(wsId)) - .toList()); + .collect(java.util.stream.Collectors.toList()))); } @RequireWorkspaceRole("member") @@ -131,7 +149,12 @@ public class WikiController { public R deleteKB(@PathVariable Long id, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(id, workspaceId); - kbService.delete(id); + WikiKnowledgeBaseService.CascadeDeleteResult result = kbService.delete(id); + String detail = String.format( + "{\"rawMaterialCount\":%d,\"pageCount\":%d,\"chunkCount\":%d,\"citationCount\":%d,\"processingJobCount\":%d}", + result.rawMaterialCount(), result.pageCount(), result.chunkCount(), + result.citationCount(), result.processingJobCount()); + auditEventService.record("DELETE", "WIKI_KB", String.valueOf(id), result.kbName(), detail); return R.ok(); } @@ -142,7 +165,7 @@ public class WikiController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(id, workspaceId); WikiKnowledgeBaseEntity kb = kbService.getById(id); - if (kb == null) return R.fail("Knowledge base not found"); + if (kb == null) return R.fail(404, "Knowledge base not found"); return R.ok(Map.of("content", kb.getConfigContent() != null ? kb.getConfigContent() : "")); } @@ -240,22 +263,28 @@ public class WikiController { : "txt"; // Resolve source type from extension. Image extensions route to the - // vision-in pipeline at extraction time; everything else falls through - // to the existing text / pdf / docx handling. + // vision-in pipeline at extraction time; Office / PDF / HTML extensions + // are staged on disk and extracted by DocumentExtractTool; plain-text + // formats (incl. CSV) are stored directly. Unknown extensions fall back + // to text so the upload never hard-fails. String sourceType = switch (extension) { case "pdf" -> "pdf"; case "docx", "doc" -> "docx"; - case "txt", "md" -> "text"; + case "xlsx", "xls" -> "xlsx"; + case "pptx", "ppt" -> "pptx"; + case "html", "htm" -> "html"; + case "txt", "md", "csv" -> "text"; case "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif" -> "image"; default -> "text"; }; if ("text".equals(sourceType)) { - // 文本文件直接读取内容 + // Text files can be stored directly without staging to disk. String content = new String(file.getBytes(), StandardCharsets.UTF_8); return R.ok(rawService.addText(kbId, originalName, content)); } else { - // 二进制文件保存到磁盘(转绝对路径,避免 Tomcat 临时目录解析问题) + // Binary files are staged under an absolute path so Tomcat temp + // directory resolution does not affect later processing. Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize(); Files.createDirectories(uploadDir); Path targetPath = uploadDir.resolve(System.currentTimeMillis() + "_" + originalName); @@ -274,7 +303,7 @@ public class WikiController { verifyKBWorkspace(kbId, workspaceId); WikiRawMaterialEntity raw = rawService.getById(rawId); if (raw == null || !kbId.equals(raw.getKbId())) { - return R.fail("Raw material not found in this knowledge base"); + return R.fail(404, "Raw material not found in this knowledge base"); } rawService.delete(rawId); kbService.decrementRawCount(kbId); @@ -290,9 +319,9 @@ public class WikiController { verifyKBWorkspace(kbId, workspaceId); WikiRawMaterialEntity raw = rawService.getById(rawId); if (raw == null || !kbId.equals(raw.getKbId())) { - return R.fail("Raw material not found in this knowledge base"); + return R.fail(404, "Raw material not found in this knowledge base"); } - // RFC-012 Change 5:force=true 时清空 last_processed_hash,让下一次处理必然执行完整管线 + // Force reprocessing by clearing the hash used to skip unchanged inputs. if (force) { rawService.setLastProcessedHash(rawId, null); } @@ -308,7 +337,7 @@ public class WikiController { verifyKBWorkspace(kbId, workspaceId); WikiRawMaterialEntity raw = rawService.getById(rawId); if (raw == null || !kbId.equals(raw.getKbId())) { - return R.fail("Raw material not found in this knowledge base"); + return R.fail(404, "Raw material not found in this knowledge base"); } // requestCancel is idempotent: a no-op when the row is not processing, // so repeated clicks (or a click after the run already finished) are @@ -411,7 +440,7 @@ public class WikiController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); WikiPageEntity page = pageService.getBySlug(kbId, slug); - if (page == null) return R.fail("Page not found"); + if (page == null) return R.fail(404, "Page not found"); return R.ok(page); } @@ -500,21 +529,8 @@ public class WikiController { @RequestParam(value = "force", defaultValue = "false") boolean force, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); - List targets; - if (force) { - // 强制重处理:所有非 pending 的材料重置为 pending,并清空 hash 短路 - targets = rawService.listByKbId(kbId); - for (WikiRawMaterialEntity r : targets) { - rawService.setLastProcessedHash(r.getId(), null); - rawService.reprocess(r.getId()); // reprocess 会把状态设为 pending 并发布事件 - } - return R.ok(Map.of("queued", targets.size(), "force", true)); - } - targets = rawService.listPending(kbId); - for (WikiRawMaterialEntity raw : targets) { - eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId)); - } - return R.ok(Map.of("queued", targets.size(), "force", false)); + int queued = processingService.processKB(kbId, force); + return R.ok(Map.of("queued", queued, "force", force)); } @RequireWorkspaceRole("viewer") @@ -524,7 +540,7 @@ public class WikiController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); WikiKnowledgeBaseEntity kb = kbService.getById(kbId); - if (kb == null) return R.fail("Knowledge base not found"); + if (kb == null) return R.fail(404, "Knowledge base not found"); List rawList = rawService.listByKbId(kbId); long pending = rawList.stream().filter(r -> "pending".equals(r.getProcessingStatus())).count(); @@ -602,11 +618,11 @@ public class WikiController { private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) { WikiKnowledgeBaseEntity kb = kbService.getById(kbId); if (kb == null) { - throw new MateClawException("Knowledge base not found"); + throw new MateClawException(404, "Knowledge base not found"); } long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L; if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) { - throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区"); + throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区"); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java index 484be855..d309e598 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java @@ -15,6 +15,7 @@ import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.repository.WikiPageCitationMapper; import vip.mate.wiki.service.*; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -96,12 +97,21 @@ public class WikiRelationController { long enrichedCount = pageService.listByKbIdWithContent(kbId).stream() .filter(p -> p.getContent() != null && p.getContent().contains("[[")) .count(); - // Use listByKbId (all statuses) instead of listQueued (queued-only) + // Use listByKbId (all statuses) instead of listQueued (queued-only). + // A raw material accumulates one job row per (re)processing attempt; + // only its most recent job reflects current state. Collapse to the + // latest job per raw (highest snowflake id wins) so a failed attempt + // that a later successful reprocess superseded stops being counted. var allJobs = jobMapper.listByKbId(kbId, 200); - int failedJobCount = (int) allJobs.stream() + Map latestByRaw = new HashMap<>(); + for (WikiProcessingJobEntity job : allJobs) { + latestByRaw.merge(job.getRawId(), job, + (a, b) -> a.getId() >= b.getId() ? a : b); + } + int failedJobCount = (int) latestByRaw.values().stream() .filter(j -> "failed".equals(j.getStatus())) .count(); - int runningJobCount = (int) allJobs.stream() + int runningJobCount = (int) latestByRaw.values().stream() .filter(j -> "running".equals(j.getStatus())) .count(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java index be3cb422..e4c9c7fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java @@ -60,7 +60,7 @@ public class WikiTransformationController { public R get(@PathVariable Long id, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationEntity t = transformationService.getById(id); - if (t == null) return R.fail("Transformation not found"); + if (t == null) return R.fail(404, "Transformation not found"); verifyTemplateWorkspace(t, workspaceId); return R.ok(t); } @@ -84,7 +84,7 @@ public class WikiTransformationController { @RequestBody WikiTransformationEntity body, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationEntity existing = transformationService.getById(id); - if (existing == null) return R.fail("Transformation not found"); + if (existing == null) return R.fail(404, "Transformation not found"); verifyTemplateWorkspace(existing, workspaceId); return R.ok(transformationService.update(id, body)); } @@ -114,16 +114,16 @@ public class WikiTransformationController { @RequestParam(defaultValue = "false") boolean sync, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationEntity t = transformationService.getById(id); - if (t == null) return R.fail("Transformation not found"); + if (t == null) return R.fail(404, "Transformation not found"); verifyTemplateWorkspace(t, workspaceId); Object rawIdRaw = body == null ? null : body.get("rawId"); Object pageIdRaw = body == null ? null : body.get("pageId"); if (rawIdRaw == null && pageIdRaw == null) { - return R.fail("One of rawId / pageId is required"); + return R.fail(400, "One of rawId / pageId is required"); } if (rawIdRaw != null && pageIdRaw != null) { - return R.fail("Pass only one of rawId / pageId, not both"); + return R.fail(400, "Pass only one of rawId / pageId, not both"); } if (rawIdRaw != null) { @@ -147,14 +147,14 @@ public class WikiTransformationController { @RequestParam Long kbId, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationEntity t = transformationService.getById(id); - if (t == null) return R.fail("Transformation not found"); + if (t == null) return R.fail(404, "Transformation not found"); verifyTemplateWorkspace(t, workspaceId); verifyKBWorkspace(kbId, workspaceId != null ? workspaceId : 1L); try { WikiTransformationAggregator.Result res = aggregator.aggregate(t, kbId, "manual"); if (res.pageId() == null) { - return R.fail(res.title()); // when sources are empty we put the reason in title field + return R.fail(409, res.title()); // when sources are empty we put the reason in title field } return R.ok(Map.of( "pageId", res.pageId(), @@ -164,7 +164,7 @@ public class WikiTransformationController { "charsFed", res.charsFed(), "created", res.created())); } catch (IllegalStateException | IllegalArgumentException e) { - return R.fail(e.getMessage()); + return R.fail(400, e.getMessage()); } } @@ -175,7 +175,7 @@ public class WikiTransformationController { public R getRun(@PathVariable Long runId, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationRunEntity run = transformationService.getRun(runId); - if (run == null) return R.fail("Run not found"); + if (run == null) return R.fail(404, "Run not found"); verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L); return R.ok(run); } @@ -194,7 +194,7 @@ public class WikiTransformationController { } if (transformationId != null) { WikiTransformationEntity t = transformationService.getById(transformationId); - if (t == null) return R.fail("Transformation not found"); + if (t == null) return R.fail(404, "Transformation not found"); verifyTemplateWorkspace(t, wsId); return R.ok(transformationService.listRunsByTransformation(transformationId, limit)); } @@ -202,7 +202,7 @@ public class WikiTransformationController { verifyKBWorkspace(kbId, wsId); return R.ok(transformationService.listRunsByKb(kbId, limit)); } - return R.fail("One of rawId / kbId / transformationId is required"); + return R.fail(400, "One of rawId / kbId / transformationId is required"); } @RequireWorkspaceRole("member") @@ -214,10 +214,10 @@ public class WikiTransformationController { public R cancelRun(@PathVariable Long runId, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationRunEntity run = transformationService.getRun(runId); - if (run == null) return R.fail("Run not found"); + if (run == null) return R.fail(404, "Run not found"); verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L); boolean cancelled = executor.cancelRun(runId); - if (!cancelled) return R.fail("Run is not running"); + if (!cancelled) return R.fail(409, "Run is not running"); return R.ok(); } @@ -228,17 +228,17 @@ public class WikiTransformationController { public R> saveRunAsPage(@PathVariable Long runId, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { WikiTransformationRunEntity run = transformationService.getRun(runId); - if (run == null) return R.fail("Run not found"); + if (run == null) return R.fail(404, "Run not found"); verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L); try { var page = executor.manualSaveRunAsPage(runId); - if (page == null) return R.fail("Page service unavailable"); + if (page == null) return R.fail(503, "Page service unavailable"); return R.ok(Map.of( "pageId", page.getId(), "slug", page.getSlug(), "title", page.getTitle())); } catch (IllegalStateException | IllegalArgumentException e) { - return R.fail(e.getMessage()); + return R.fail(400, e.getMessage()); } } @@ -263,14 +263,14 @@ public class WikiTransformationController { } long wsId = workspaceId != null ? workspaceId : 1L; if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) { - throw new MateClawException("err.common.wrong_workspace", "Resource does not belong to current workspace"); + throw new MateClawException("err.common.wrong_workspace", 403, "Resource does not belong to current workspace"); } } private void verifyTemplateWorkspace(WikiTransformationEntity t, Long workspaceId) { long wsId = workspaceId != null ? workspaceId : 1L; if (t.getWorkspaceId() != null && !t.getWorkspaceId().equals(wsId)) { - throw new MateClawException("err.common.wrong_workspace", "Resource does not belong to current workspace"); + throw new MateClawException("err.common.wrong_workspace", 403, "Resource does not belong to current workspace"); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java new file mode 100644 index 00000000..62792a78 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java @@ -0,0 +1,81 @@ +package vip.mate.wiki.job; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parses optional machine-readable KB config from JSON or markdown frontmatter. + */ +public final class WikiKbConfigParser { + + private WikiKbConfigParser() { + } + + public static WikiKbConfig parse(ObjectMapper objectMapper, String content) { + String trimmed = content == null ? "" : content.trim(); + if (trimmed.isEmpty()) return null; + if (trimmed.startsWith("{")) { + try { + return objectMapper.readValue(trimmed, WikiKbConfig.class); + } catch (Exception e) { + return null; + } + } + if (trimmed.startsWith("---")) { + return parseFrontmatter(trimmed); + } + return null; + } + + private static WikiKbConfig parseFrontmatter(String content) { + int end = content.indexOf("\n---", 3); + if (end < 0) return null; + + WikiKbConfig config = new WikiKbConfig(); + Map stepModels = new LinkedHashMap<>(); + String frontmatter = content.substring(3, end); + for (String line : frontmatter.split("\\R")) { + int colon = line.indexOf(':'); + if (colon <= 0) continue; + String key = line.substring(0, colon).trim(); + String value = unquote(line.substring(colon + 1).trim()); + if (value.isBlank()) continue; + + if ("ingestMode".equals(key)) { + config.setIngestMode(value); + } else if ("useStructuredRoute".equals(key)) { + config.setUseStructuredRoute(Boolean.valueOf(value)); + } else if ("wikiDefaultModelId".equals(key)) { + Long parsed = parseLong(value); + if (parsed != null) config.setWikiDefaultModelId(parsed); + } else if (key.startsWith("stepModels.")) { + Long parsed = parseLong(value); + if (parsed != null) { + stepModels.put(key.substring("stepModels.".length()), parsed); + } + } + } + if (!stepModels.isEmpty()) { + config.setStepModels(stepModels); + } + return config; + } + + private static String unquote(String value) { + if ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) { + return value.substring(1, value.length() - 1); + } + return value; + } + + private static Long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java index 05b7a8ea..85a76813 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java @@ -164,10 +164,12 @@ public class WikiProcessingJobService { } private static boolean isHardError(String errorCode) { + // Only provider-wide failures evict a provider from the shared pool. A + // MODEL_NOT_FOUND is model-scoped — it must not take the provider's other + // models offline for every other consumer of the pool. return errorCode != null && ( errorCode.equals("AUTH_ERROR") || - errorCode.equals("BILLING") || - errorCode.equals("MODEL_NOT_FOUND")); + errorCode.equals("BILLING")); } private void notifyPoolHardError(Long modelId, String errorCode) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java index ea44de14..249d425c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java @@ -7,6 +7,7 @@ import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import vip.mate.wiki.job.WikiJobStep; import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.WikiKbConfigParser; import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -30,15 +31,11 @@ public class KbConfigStepModelStrategy implements WikiStepModelStrategy { @Override public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { if (kb == null || kb.getConfigContent() == null) return null; - try { - WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); - Map stepModels = config.getStepModels(); - if (stepModels == null) return null; - String key = job.getJobType() + "." + step.name().toLowerCase(); - return stepModels.get(key); - } catch (Exception e) { - log.debug("[KbConfigStrategy] Failed to parse KB config: {}", e.getMessage()); - return null; - } + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + if (config == null) return null; + Map stepModels = config.getStepModels(); + if (stepModels == null) return null; + String key = job.getJobType() + "." + step.name().toLowerCase(); + return stepModels.get(key); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java index 24bbebfb..ae03657c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java @@ -7,6 +7,7 @@ import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import vip.mate.wiki.job.WikiJobStep; import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.WikiKbConfigParser; import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -38,12 +39,7 @@ public class KbDefaultModelStrategy implements WikiStepModelStrategy { @Override public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { if (kb == null || kb.getConfigContent() == null) return null; - try { - WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); - return config.getWikiDefaultModelId(); - } catch (Exception e) { - log.debug("[KbDefaultModelStrategy] Failed to parse KB config: {}", e.getMessage()); - return null; - } + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + return config != null ? config.getWikiDefaultModelId() : null; } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java index 010cfb30..a8a86312 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java @@ -46,11 +46,16 @@ public class WikiContentNormalizer { if (rawText == null) return ""; String type = sourceType == null ? "" : sourceType.toLowerCase(); return switch (type) { - case "url", "html" -> normalizeHtml(rawText); + // Web and HTML sources can still carry raw markup, so they go through + // normalizeHtml, which strips script/style/nav/footer noise. normalizeHtml + // also recognizes text that was already tag-stripped upstream (an extracted + // .html/.htm upload re-entering the normalizer) and leaves that text's line + // structure intact instead of re-collapsing its recovered headings. + case "url", "html", "htm" -> normalizeHtml(rawText); // PDF text from DocumentExtractTool may already contain "--- Page N ---" // markers; we keep them so the preprocessor can map char offsets to pages. case "pdf" -> collapseBlankLines(rawText); - case "docx", "pptx", "xlsx" -> collapseBlankLines(rawText); + case "docx", "doc", "pptx", "ppt", "xlsx", "xls" -> collapseBlankLines(rawText); case "markdown", "md", "text", "paste" -> collapseBlankLines(rawText); default -> collapseBlankLines(rawText); }; @@ -58,16 +63,46 @@ public class WikiContentNormalizer { /** * Strip nav/footer/script/style/aside and ad-like classes from HTML, then - * return readable text. Falls through to the raw input when the document - * is too large to parse safely or jsoup throws. + * return readable text. + *

    + * The output never carries markup: input that still contains element + * structure is parsed and stripped; input with no element structure is + * reduced to its text content. When the document cannot be parsed (too + * large, or jsoup throws) it is run through {@link #stripMarkupLossy(String)} so that + * script/style bodies and tags are dropped without a full parse — the raw + * markup is never passed through verbatim. */ private String normalizeHtml(String rawHtml) { if (rawHtml.length() > MAX_HTML_LEN) { - log.warn("[WikiContentNormalizer] HTML payload exceeds {} bytes, skipping cleanup", MAX_HTML_LEN); - return collapseBlankLines(rawHtml); + log.warn("[WikiContentNormalizer] HTML payload exceeds {} bytes, stripping markup without a full parse", MAX_HTML_LEN); + return stripMarkupLossy(rawHtml); } try { Document doc = Jsoup.parse(rawHtml); + + // Distinguish genuine HTML markup from text that was already tag-stripped + // upstream (an extracted .html/.htm upload re-entering the normalizer). + // jsoup synthesizes an // skeleton even for plain text, + // so the absence of any element children means there is no real markup. + // Plain text must skip the element walker below: that walker relies on + // Element.ownText(), which collapses newlines and would merge a heading + // line into the paragraph that follows it. + Element body = doc.body(); + Element head = doc.head(); + boolean hasMarkup = (body != null && !body.children().isEmpty()) + || (head != null && !head.children().isEmpty()); + if (!hasMarkup) { + // No element structure — text that was already tag-stripped upstream + // (an extracted .html/.htm upload). Return its whole text rather than + // the raw string: wholeText() keeps the original line breaks (so ATX + // headings stay on their own lines) but carries no tags, no attributes + // and no comment nodes. The raw string must never be returned here — + // a bare / skeleton can still hold event-handler + // attributes that would otherwise leak through verbatim. + Element textRoot = body != null ? body : doc; + return collapseBlankLines(textRoot.wholeText()); + } + // Drop structural noise. doc.select("script, style, noscript, nav, header, footer, aside, form, iframe").remove(); // Drop common ad / share / cookie banners by class hint. @@ -94,13 +129,41 @@ public class WikiContentNormalizer { } } String out = sb.toString(); - return out.isBlank() ? collapseBlankLines(rawHtml) : collapseBlankLines(out); + if (!out.isBlank()) { + return collapseBlankLines(out); + } + // The walker produced nothing — the document was pure script/style/nav + // noise. Fall back to jsoup's plain-text extraction, never the raw markup: + // returning rawHtml here would carry + + +``` + +--- + +## Testing + +### Backend tests + +```bash +cd mateclaw-server +mvn test # All tests +mvn test -Dtest=StateGraphReActAgentTest # Single class +mvn test -Dtest=StateGraphReActAgentTest#testChat # Single method +``` + +### Frontend type check and lint + +```bash +cd mateclaw-ui +pnpm build # vue-tsc type check + vite build +pnpm lint # ESLint with auto-fix +``` + +### Manual test checklist + +- [ ] Backend starts without errors +- [ ] Frontend builds without type errors (`pnpm build`) +- [ ] Login works with default credentials +- [ ] Model configured via UI +- [ ] Chat streams a response back +- [ ] New feature works as described +- [ ] No console errors +- [ ] Docs updated if user-facing behavior changed + +--- + +## Documentation changes + +If your PR changes user-facing behavior — a new feature, a renamed endpoint, a changed config key — **update the docs in the same PR**. + +The docs live in `docs/`. Pick the relevant page and update both `docs/en/` and `docs/zh/`. The Chinese and English versions are **independently written**, not translations — match tone and style with the existing page. + +```bash +cd docs +pnpm build +``` + +Build must succeed with zero errors before you open the PR. + +--- + +## Pull request process + +1. **Title** — conventional commit format +2. **Description** — what, why, how; link issues +3. **Screenshots** — for UI changes, before/after +4. **Testing** — describe how you tested +5. **Breaking changes** — note clearly at the top + +### PR template + +```markdown +## What + +Brief description of the change. + +## Why + +Why this change is needed (link issue). + +## How + +Technical approach. + +## Testing + +How this was tested. + +## Screenshots (if UI changes) + +Before / After. +``` + +--- + +## Reporting issues + +When filing a bug: + +- MateClaw version (or commit hash) +- Java version and OS +- Exact steps to reproduce +- Expected vs. actual behavior +- Relevant log output + +Good bug reports get good fixes. + +--- + +## Next + +- [Quick Start](./quickstart) — setup walkthrough +- [Introduction](./intro) — architecture overview +- [Architecture](./architecture) — StateGraph deep-dive for developers +- [Roadmap](./roadmap) — what we're working on next diff --git a/mateclaw-server/src/main/resources/docs/en/desktop.md b/mateclaw-server/src/main/resources/docs/en/desktop.md new file mode 100644 index 00000000..36565129 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/desktop.md @@ -0,0 +1,284 @@ +# Desktop App + +**Double-click. Wait thirty seconds. Log in. Use it.** + +That's the desktop app in four sentences. No Java to install. No browser to open. No docker compose file. No port to remember. MateClaw's desktop edition bundles Electron, a JRE 21 runtime, and the packaged Spring Boot server JAR into a single installer. **Your users never know Java is underneath.** + +This page is for people who want to run it, build it, or debug it. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────┐ +│ Electron Shell │ +│ ┌────────────────────────────────────┐ │ +│ │ BrowserWindow (Chromium) │ │ +│ │ ┌──────────────────────────────┐ │ │ +│ │ │ Vue 3 Frontend (dist/) │ │ │ +│ │ │ Element Plus + Tailwind │ │ │ +│ │ └────────────┬─────────────────┘ │ │ +│ └───────────────┼────────────────────┘ │ +│ │ HTTP / SSE │ +│ ┌───────────────▼────────────────────┐ │ +│ │ Spring Boot Backend (child proc) │ │ +│ │ dynamic port on 127.0.0.1 │ │ +│ │ Bundled JRE 21 + H2 file DB │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ electron-updater Auto Update │ │ +│ └────────────────────────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +Three things living inside one process tree: + +1. **Electron main process** — window, tray, IPC, backend lifecycle +2. **BrowserWindow (Chromium)** — renders the Vue 3 frontend (same code as the web version) +3. **Spring Boot backend** — spawned as a child process, listens on localhost only + +The backend picks a **free port dynamically** at startup so you don't collide with anything else on your machine. The frontend queries the main process for the actual port before the first API call. + +### Key features + +- Native window, no browser dependency +- System tray integration for background operation +- **Bundled JRE 21** — users never install Java +- **Auto update** via electron-updater (GitHub Releases) +- **Local-first data** — everything in a user directory +- **Dynamic backend port** — no port collisions +- **UI hot update** — frontend assets can be updated without repackaging the installer +- Cross-platform (macOS, Windows, Linux) + +--- + +## Supported platforms + +| Platform | Architecture | Status | +|----------|-------------|--------| +| macOS | Intel (x64) | Stable | +| macOS | Apple Silicon (ARM64) | Stable | +| Windows | x64 | Stable | +| Linux | x64 | Stable | + +--- + +## Prerequisites (for building, not for running) + +If you're **running** the app: download and install. Full stop. + +If you're **building** the app: + +| Tool | Version | Purpose | +|------|---------|---------| +| Node.js | 18+ | Frontend build + Electron | +| pnpm / npm | 8+ / 9+ | Package manager | +| Java | 21+ | Backend compilation + dev mode (production builds bundle the JRE) | +| Maven | 3.8+ | Backend build | + +--- + +## Module layout + +``` +mateclaw-desktop/ +├── electron/ +│ ├── main/index.ts # Main process — backend lifecycle, auto-update, tray +│ └── preload/index.ts # IPC bridge +├── src/ # Vue 3 renderer source +├── resources/ +│ ├── jre/ # Bundled JRE (per platform/arch) +│ └── app.jar # Packaged Spring Boot backend JAR +├── build/ # App icons +├── electron-builder.json # Packaging config +├── package.json +└── vite.config.ts +``` + +--- + +## Development mode + +```bash +cd mateclaw-desktop +pnpm install +pnpm dev +``` + +In dev mode: + +1. Vite starts the frontend dev server (HMR enabled) +2. Electron main process launches and loads the Vite dev URL +3. Main process spawns the Spring Boot JAR as a child process on a free port +4. Frontend talks to the backend via HTTP/SSE + +Frontend changes trigger HMR. Main-process changes restart Electron. + +--- + +## Production build + +```bash +cd mateclaw-desktop +pnpm build && npx electron-builder --mac # macOS +pnpm build && npx electron-builder --win # Windows +pnpm build && npx electron-builder --linux # Linux +``` + +Output lands in `release/`: + +| Platform | Artifact | Notes | +|----------|----------|-------| +| macOS | `.dmg` + `.zip` | Drag into Applications | +| Windows | `.exe` (NSIS) | Custom install dir | +| Linux | `.AppImage` | Add execute permission and run | + +### Build prerequisites — the full sequence + +```bash +# 1. Build frontend static assets +cd mateclaw-ui +pnpm install && pnpm build + +# 2. Build backend JAR (includes frontend assets in static/) +cd ../mateclaw-server +mvn clean package -DskipTests + +# 3. Copy JAR to desktop resources +cp target/mateclaw-server.jar ../mateclaw-desktop/resources/app.jar + +# 4. Download platform-specific JRE +cd ../mateclaw-desktop +bash scripts/download-jre.sh + +# 5. Build the installer +pnpm build && npx electron-builder +``` + +--- + +## Java backend lifecycle + +The Electron main process manages the Spring Boot backend through Node.js `child_process`: + +1. **Startup** — spawn the JAR using the bundled JRE, hand it a dynamic port, wait for ready +2. **Readiness check** — poll `http://127.0.0.1:{port}` until the backend responds, then load the frontend +3. **Runtime** — frontend communicates via REST + SSE +4. **Shutdown** — graceful shutdown signal (SIGTERM / taskkill), wait for exit, close the window + +If the backend crashes mid-session, the main process notices and shows an error dialog with the log tail. **No blank white window.** + +--- + +## Auto update + +electron-updater integration with GitHub Releases. + +### Flow + +1. On startup, checks GitHub Releases for a new version +2. When one is found, an in-UI notification shows version + changelog +3. On confirmation, downloads with a progress bar +4. Once downloaded, install now / install on next launch +5. App exits, replaces files, restarts + +### Configuration + +```json +{ + "publish": [ + { + "provider": "github", + "owner": "matevip", + "repo": "mateclaw" + } + ] +} +``` + +### UI hot update (no repackage) + +Frontend assets can be **hot-updated independently** — a frontend-only fix doesn't require a new installer. See `mateclaw-desktop/scripts/` and `desktop-ui-hot-update.md` for the hot-update build flow. + +--- + +## Data storage + +| OS | Path | +|-----|------| +| macOS | `~/Library/Application Support/MateClaw/data/` | +| Windows | `%APPDATA%/MateClaw/data/` | +| Linux | `~/.local/share/MateClaw/data/` | + +Logs, workspace files, skill scripts, wiki content all live alongside the database in the same user directory. Back it up before major changes. + +--- + +## `electron-builder.json` reference + +| Setting | Purpose | +|---------|---------| +| `appId` | `vip.mate.mateclaw` — system registration and code signing | +| `productName` | App name in title bar and installer | +| `publish` | Auto-update source (GitHub Releases) | +| `extraResources` | JRE and `app.jar` | +| `mac.target` | `dmg` + `zip`, `arm64` and `x64` | +| `win.target` | `nsis` installer | +| `linux.target` | `AppImage` | +| `mac.hardenedRuntime` | Required for signing + notarization | +| `nsis.oneClick` | `false` — lets users choose install directory | + +--- + +## Environment variables + +The desktop app reads env vars the same way the standalone backend does. But there's an easier way: **configure everything through the Settings page** after launch. API keys go into the encrypted `mate_model_provider` table and stay there. + +--- + +## Troubleshooting + +### Blank window + +1. Backend failed to start — check logs for crash details +2. Port conflict — dynamic port picker handles most cases, restrictive firewalls can break it +3. Bundled JRE corrupted — reinstall +4. Check logs below + +### Code signing warnings + +- **macOS** — right-click → **Open** to bypass Gatekeeper (first launch). Production: Apple Developer certificate + notarization. See `mateclaw-desktop/CODESIGNING.md`. +- **Windows** — SmartScreen warning → **More info → Run anyway**. Production: EV code signing certificate. + +### Desktop app won't start + +1. Installed app bundles JRE — you don't need Java. Dev build from source: verify `java -version` shows 21+. +2. Check logs: + - macOS: `~/Library/Logs/MateClaw/` + - Windows: `%APPDATA%/MateClaw/logs/` + - Linux: `~/.local/share/MateClaw/logs/` +3. Launch from terminal to see console output +4. Confirm backend port isn't blocked + +### WeCom auth popup + +The WeCom QR-code authorization flow **must open in an in-app popup** (not the system browser) so the `postMessage` callback works. MateClaw handles this in `setWindowOpenHandler` — `work.weixin.qq.com` domain opens as an in-app popup window. + +--- + +## Notes + +- First launch takes 10–30 seconds (database init) +- Closing the window doesn't stop the background service — use the system tray menu to fully quit +- Back up the user data directory regularly +- Bundled JRE makes the installer 80–120 MB + +--- + +## Next + +- [Quick Start](./quickstart) — fastest path through the desktop experience +- [Configuration](./config) — runtime settings +- [Admin Console](./console) — the UI inside the Electron window diff --git a/mateclaw-server/src/main/resources/docs/en/docker-deploy.md b/mateclaw-server/src/main/resources/docs/en/docker-deploy.md new file mode 100644 index 00000000..051ceae3 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/docker-deploy.md @@ -0,0 +1,310 @@ +# Docker Deployment + +The only recommended production deployment outside the desktop app. One `docker compose up -d` brings up three containers: MySQL, SearXNG, and mateclaw-server. + +This page covers **requirements, steps, verification, and common gotchas**. For the full environment variable reference, see [Configuration](./config). + +--- + +## Prerequisites + +| Item | Minimum | Recommended | Notes | +|---|---|---|---| +| Docker Engine | 24.0+ | latest stable | `docker --version` | +| Docker Compose | v2.20+ | v2.30+ | `docker compose version` (the v2 plugin, not the legacy `docker-compose`) | +| Host RAM | 4 GB | 8 GB+ | Chromium consumes 1-2 GB when the browser tool is active | +| Disk | 6 GB | 20 GB+ | ~2 GB image + MySQL data + workspace files | +| /dev/shm | default | compose sets 2 GB automatically | Chromium uses shared memory for rendering; the 64 MB default causes SIGBUS | +| Network | outbound | — | for pulling images and calling LLM APIs | + +**Not required on the host**: Java, Node, Maven, Chrome, or Python — all live inside the image. + +--- + +## The three containers + +| Service | Image | Role | Exposed port | +|---|---|---|---| +| `mysql` | `mysql:8.0` | Business data | `3306` | +| `searxng` | Built from `./docker/searxng/` | Keyless search fallback | `8088` | +| `mateclaw-server` | Built from `mateclaw-server/Dockerfile` | Spring Boot backend + embedded browser | `18080` | + +--- + +## SearXNG search service + +### Why we build a custom image + +`docker/searxng/Dockerfile` derives from upstream `searxng/searxng:latest` and **bakes our own `settings.yml` into `/etc/searxng/settings.yml`**. This isn't polish — it's mandatory: + +- **Upstream ships with only `html` output enabled**, while mateclaw calls `GET /search?q=...&format=json`. The default image responds to JSON requests with an HTML error page, `SearXNGSearchProvider` fails to parse it, returns empty results, and the UI shows "search temporarily unavailable". +- **Upstream enables the anti-bot Limiter plugin by default**, which rejects server-side calls (no JS, no cookies) with HTTP 429. + +Our `docker/searxng/settings.yml` changes three things: + +1. `search.formats: [html, json]` — enable JSON output +2. `server.limiter: false` — disable anti-bot rate limiting +3. Trim the engine list to a reliable subset (DuckDuckGo / Bing / Brave / Wikipedia / Google / Startpage), dropping the dozens of niche engines the upstream enables + +**Do not** switch this to a host bind-mount. An earlier version did, and deploys where the host directory didn't exist got an auto-created empty directory that shadowed the file — SearXNG started with no config at all. To tweak settings.yml, edit `docker/searxng/settings.yml` then: + +```sh +docker compose build searxng +docker compose up -d searxng +``` + +### Search provider fallback chain + +The backend `SearchProviderRegistry` picks a provider in this order: + +1. Whatever the user explicitly set under `Settings → Search` (the `searchProvider` setting) +2. Walk `autoDetectOrder`, **preferring paid providers whose API key is configured** (Serper order=1, Tavily order=2) +3. Fall back to keyless — SearXNG (order=50) wins over DuckDuckGo (order=100) + +On a fresh container with no API keys configured at all, **SearXNG handles every search call**. + +### Verifying the SearXNG path + +```sh +# 1. Hit the container directly +curl -s 'http://localhost:8088/search?q=test&format=json' | head -5 +# Expect: {"query": ..., "results": [...]} +# If you get HTML back, settings.yml didn't take effect. + +# 2. Hit it from inside the mateclaw-server container +docker exec mateclaw-server wget -qO- 'http://searxng:8080/search?q=test&format=json' | head -5 +# If this fails, compose networking is the problem. + +# 3. Ask an agent to search and tail backend logs +docker compose logs -f mateclaw-server | grep "搜索 provider" +# Expect: 搜索 provider 解析: searxng (source=keyless-fallback) +``` + +### Using an external SearXNG instance + +If you're already running SearXNG elsewhere, point mateclaw at it via `.env`: + +```properties +SEARXNG_BASE_URL=https://your-searxng.example.com +``` + +Then comment out the `searxng` service block in `docker-compose.yml`. Make sure **your external instance has the same JSON + Limiter settings** — otherwise you'll hit the same silent failure mode. + +--- + +## Browser automation + +### What the image actually contains + +The backend runtime stage (`mateclaw-server/Dockerfile` stage 3) is based on `mcr.microsoft.com/playwright:v1.52.0-noble` (Ubuntu Noble 24.04, glibc) and installs on top of it: + +- `openjdk-21-jre-headless` — runs the Spring Boot JAR +- `fonts-noto-cjk` — Chinese/Japanese/Korean rendering in screenshots +- `fonts-noto-color-emoji` — emoji glyphs +- `tzdata` — `Asia/Shanghai` timezone + +Microsoft's base image already ships all three browsers in `/ms-playwright/`: + +- `chromium-XXXX/chrome-linux/chrome` — the primary +- `firefox-XXXX/firefox/firefox` +- `webkit-XXXX/pw_run.sh` + +Plus every system library Chromium needs (`libnss3`, `libgbm1`, `libasound2`, `libx11-xcb1`, `libxkbcommon`, …). **No `playwright install` is required, and the Alpine-vs-musl incompatibility that blocks most Playwright deployments is sidestepped entirely.** + +The Dockerfile sets one environment variable explicitly: + +```dockerfile +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +``` + +This tells Playwright Java to use the pre-installed browsers and **not** try to download to `$HOME/.cache/ms-playwright` at runtime. + +### BrowserLauncher's 7-strategy fallback + +`vip.mate.tool.browser.BrowserLauncher` tries each strategy in order until one succeeds: + +1. `CONFIG_CDP` — if `MATECLAW_BROWSER_CDP_URL` is set, attach to that running Chrome +2. `CONFIG_PATH` — if `MATECLAW_BROWSER_CHROME_PATH` or `CHROME_PATH` env is set, use that exe +3. `CONFIG_CHANNEL` — if `MATECLAW_BROWSER_CHANNEL=chrome|msedge`, use the Playwright channel +4. `AUTO_CHANNEL` — try `chrome` then `msedge` channel (this step always wins inside the Docker image) +5. `AUTO_PATH` — scan standard install paths (`/usr/bin/google-chrome`, `chromium-browser`, `/snap/bin/chromium`, `microsoft-edge`, `brave-browser`) +6. `BUNDLED` — Playwright bundled Chromium (also guaranteed to work inside the image) +7. `EXTERNAL_CDP` — last resort: fork a system Chrome with `--remote-debugging-port=0`, parse stderr for the DevTools URL, attach via `connectOverCDP` (the openfang pattern) + +Inside the Docker image, **strategy 4 or 6 always hits** and no configuration is needed. If you need to attach to an external Chrome, use strategy 1. If you want a specific host-installed Chrome, use strategy 2. + +### `/dev/shm` must be 2 GB + +`docker-compose.yml` sets `shm_size: 2gb` for `mateclaw-server`. Docker defaults to 64 MB per container — Chromium uses shared memory for GPU compositing and page rendering, and three tabs is enough to SIGBUS the browser. Playwright surfaces this as `TargetClosedError: Target page, context or browser has been closed`. **Do not shrink this value.** + +### SSRF protection + +Before any `navigate` call, `BrowserUseTool` runs the URL through `UrlSafetyChecker`, which **hard-blocks** these hosts: + +- `localhost`, `127.0.0.1`, `::1`, `0.0.0.0` +- `169.254.169.254` (AWS / GCP / Azure IMDS), `100.100.100.200` (Alibaba Cloud IMDS), `192.0.0.192` (Azure IMDS alternative) +- All link-local / private / multicast IP ranges + +An LLM generating a malicious URL to dump cloud credentials is therefore a closed loop. If you genuinely need to scrape internal infrastructure from a specific host, either disable via `mateclaw.browser.ssrf-check-enabled` or edit the `UrlSafetyChecker` allowlist. **Think twice before doing this in production.** + +### Verifying the browser path + +```sh +# 1. Pre-flight diagnosis (doesn't actually launch a browser) +curl -s http://localhost:18080/api/v1/system/browser-health | jq . +# Expect: overall: "healthy", system.browsers found with chromium path + +# 2. Drive it from an agent +# browser_use(action="diagnose") # returns the strategy-chain trace +# browser_use(action="start") # actually launches +# browser_use(action="open", url="https://example.com") +# browser_use(action="screenshot") # returns a base64 PNG +``` + +--- + +## First deployment + +```sh +git clone https://github.com/matevip/mateclaw.git +cd mateclaw + +# 1. Fill in required values +cp .env.example .env +vi .env # see table below +``` + +**Required** (compose refuses to start without these, so you can't accidentally ship default passwords): + +| Variable | Notes | +|---|---| +| `DB_PASSWORD` | App DB password — 16+ chars, mixed case, digits, symbols | +| `DB_ROOT_PASSWORD` | MySQL root password — **must differ from the above** | + +**Strongly recommended** (not enforced, but startup logs WARN if missing): + +| Variable | Notes | +|---|---| +| `JWT_SECRET` | JWT signing key — generate with `openssl rand -base64 48` | +| `MATECLAW_CORS_ALLOWED_ORIGINS` | Production allowlist, e.g. `https://mateclaw.example.com` | + +Then bring the stack up: + +```sh +docker compose up -d --build # first build takes 3-10 minutes +docker compose logs -f mateclaw-server +``` + +First boot runs Flyway migrations (~5 s) and seeds default data (~3 s), then binds `0.0.0.0:18080`. + +Open `http://localhost:18080`, sign in as `admin / admin123`, and **change the password immediately** under `Settings → Security`. + +--- + +## Build-time performance + +### US / EU servers + +**Already optimal.** `mateclaw-server/pom.xml` lists repositories in the order `Maven Central → Google CDN → Aliyun`; Central direct is fastest over US/EU backbones. + +### China servers + +Flip to Aliyun-first. Either edit the `mvn` lines in `mateclaw-server/Dockerfile` to add `-Paliyun-first`, or (easier) expose it as a build arg: + +```dockerfile +# from +RUN mvn dependency:go-offline -q +RUN mvn package -DskipTests -q + +# to +ARG MAVEN_PROFILE= +RUN mvn dependency:go-offline -q ${MAVEN_PROFILE:+-P${MAVEN_PROFILE}} +RUN mvn package -DskipTests -q ${MAVEN_PROFILE:+-P${MAVEN_PROFILE}} +``` + +Then: + +```sh +docker compose build --build-arg MAVEN_PROFILE=aliyun-first mateclaw-server +``` + +Aliyun's public + Spring mirrors are promoted to the top of the lookup chain, keeping traffic inside China. + +--- + +## Optional overrides + +All can be set in `.env` and are read as environment variables. **Leave them empty to accept the container defaults.** + +| Variable | Default | Purpose | +|---|---|---| +| `SERPER_API_KEY` | — | Google Serper search API (paid, best quality) | +| `SEARXNG_SECRET` | built-in dev secret | Only fill when exposing port 8088 to the public internet | +| `SEARXNG_BASE_URL` | `http://searxng:8080` | Point at an external SearXNG instance | +| `MATECLAW_BROWSER_CDP_URL` | — | Attach to an external Chrome CDP sidecar | +| `MATECLAW_BROWSER_CHROME_PATH` | — | Override the bundled Chromium with a host-installed browser | +| `MATECLAW_BROWSER_CHANNEL` | — | Force a Playwright channel (`chrome`, `msedge`, ...) | + +**LLM API keys (DashScope, OpenAI, Anthropic, DeepSeek, Kimi, etc.) are not read from `.env`** — add them after startup in the UI under `Settings → Models → Add Provider`. Hot-reload supported. The container starts with **zero LLM keys configured**; just log in and add your first provider on the Models page. + +--- + +## Verification + +Run these in order after `docker compose up -d`: + +```sh +# 1. All three containers healthy +docker compose ps + +# 2. Base health check +curl -s http://localhost:18080/api/v1/system/health | jq . + +# 3. Browser tool self-diagnosis (the most common failure point on Linux hosts) +curl -s http://localhost:18080/api/v1/system/browser-health | jq . +# Expect overall: "healthy" + +# 4. SearXNG returns JSON (not an HTML error page) +curl -s 'http://localhost:8088/search?q=hello&format=json' | head -5 +``` + +If any of these fail, jump to the next section. + +--- + +## Common gotchas + +**Build stage `mvn dependency:go-offline` hangs** +US servers pulling through Aliyun is slow. The default `pom.xml` puts Maven Central first, so it should be fast. If it's still slow, the container has no outbound access — check your egress firewall. + +**`mateclaw-server` stays unhealthy at startup** +`docker compose logs mateclaw-server` and look for Flyway migration errors. Nine times out of ten, a special character in `DB_PASSWORD` got eaten by the shell — wrap the value in double quotes in `.env`. + +**Browser tool reports "Target page closed" or SIGBUS** +`shm_size: 2gb` didn't take effect. Check the actual value with `docker inspect mateclaw-server | grep ShmSize`. Upgrade Docker Engine to 24.0+ if it's still showing 64 MB. + +**Search returns "Search temporarily unavailable"** +SearXNG either isn't up or the image default settings disabled JSON output. Our own `./docker/searxng/` build patches this; if you're reusing an old named volume, reset it: `docker compose down -v searxng && docker compose up -d searxng`. + +**LLM responses show tofu boxes (□) for Chinese** +The image already installs `fonts-noto-cjk` and `fonts-noto-color-emoji`, so this isn't a server-side font issue. Check your frontend browser's locale / font settings. + +--- + +## Upgrading + +```sh +git pull +docker compose build mateclaw-server # only rebuild the backend +docker compose up -d mateclaw-server +``` + +The `mysql_data` volume persists across rebuilds. Flyway runs incremental migrations automatically and self-heals checksum changes on restart. **Version is pinned in `mateclaw-server/pom.xml` and the git tag** — prefer pinning to a tag in production, not tracking `dev`. + +--- + +## Next steps + +- [Configuration](./config) — every environment variable and runtime toggle +- [Doctor Health Check](./doctor) — the in-app diagnostics page +- [Security & Approval](./security) — pre-production hardening checklist diff --git a/mateclaw-server/src/main/resources/docs/en/doctor.md b/mateclaw-server/src/main/resources/docs/en/doctor.md new file mode 100644 index 00000000..1b91c503 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/doctor.md @@ -0,0 +1,233 @@ +# Doctor + +**The Doctor page answers one question: is this thing actually working right now?** + +MateClaw has a lot of moving parts — the backend, the database, model providers, MCP servers, IM channels, cron jobs, memory consolidation, wiki digestion. When something goes sideways, the symptom ("my agent isn't responding") usually has a specific cause ("the DashScope API key expired yesterday") buried several layers away from where you'd notice. Doctor is a single page that runs every check at once and tells you what's green, what's yellow, and what's red. + +Open it with `Settings → Doctor` or just navigate to `/doctor`. + +--- + +## What it checks + +Each check runs independently and reports one of three states: + +- **✅ OK** — everything is working as expected +- **⚠️ Warning** — working but degraded (e.g., using a fallback provider, nearing a quota, a non-critical cron job is paused) +- **❌ Error** — broken in a way you need to fix + +### Core infrastructure + +| Check | What it verifies | +|-------|-----------------| +| **Backend version** | MateClaw is running and reports its version | +| **Database connection** | The configured datasource is reachable and queries succeed | +| **Database schema** | All expected `mate_*` tables exist; migration state is clean | +| **Disk usage** | The data directory has enough free space (warns under 20%, errors under 5%) | +| **H2 console exposure** | Warns if the H2 console is enabled in production profile | +| **JWT secret strength** | Warns if the default JWT secret is still in use | + +### Models + +| Check | What it verifies | +|-------|-----------------| +| **Active model** | A default model config exists and is enabled | +| **Provider connectivity** | Each enabled provider has passed a recent connection test | +| **API key presence** | Keys are configured for every cloud provider marked enabled | +| **Ollama reachability** | If Ollama is configured, the local instance is reachable | + +### Agents & tools + +| Check | What it verifies | +|-------|-----------------| +| **Tool registry** | Built-in and MCP tools are loaded without errors | +| **Tool Guard config** | At least one Tool Guard rule exists (warns if `default-policy: allow` is used) | +| **Default agent** | The default agent exists and is enabled | +| **Agent templates** | Built-in templates are present and loadable | + +### Memory & wiki + +| Check | What it verifies | +|-------|-----------------| +| **Memory consolidation cron** | Per-agent consolidation cron jobs exist and are enabled | +| **Last consolidation run** | Warns if no consolidation has run in the past 7 days | +| **Wiki digestion queue** | No stuck `pending` or `processing` raw materials | +| **Wiki schema** | `mate_wiki_*` tables exist and are queryable | + +### Channels + +| Check | What it verifies | +|-------|-----------------| +| **Channel health monitor** | Every enabled channel reports `connected` or is actively reconnecting | +| **Per-channel status** | For each IM channel, connection state and last error | +| **Webhook URL reachability** | Warns if a webhook-mode channel has no public URL configured in production | + +### MCP + +| Check | What it verifies | +|-------|-----------------| +| **Enabled MCP servers** | Every enabled MCP server is `connected` | +| **Tool count** | Each connected server reports at least one tool | +| **Orphaned subprocesses** | No stdio subprocesses outlive their parent client | + +### Cron & async + +| Check | What it verifies | +|-------|-----------------| +| **Cron engine** | The scheduled-task executor is running | +| **Overdue jobs** | Warns if any job is more than 24 hours overdue | +| **Async task queue** | `mate_async_task` queue length is within normal bounds | + +--- + +## How checks run + +Doctor runs two ways: + +### On demand + +Click **Run All Checks** on the Doctor page. The button fires off every check in parallel; the UI streams results back as each finishes. Most checks complete in under a second; the slowest (MCP server connection tests) can take 10–30 seconds. + +### On a schedule + +Doctor also runs **automatically every 15 minutes** in the background. Results are cached in memory and persisted to `mate_doctor_check` so the page loads instantly when you open it — you're seeing the last cached state until you click **Run All Checks**. + +You can tune the schedule in `application.yml`: + +```yaml +mateclaw: + doctor: + enabled: true + schedule-minutes: 15 + cache-ttl-minutes: 10 +``` + +--- + +## Reading results + +Each check returns: + +```json +{ + "name": "DashScope Provider Connectivity", + "category": "Models", + "status": "ok", + "message": "Connection test succeeded (latency: 240ms)", + "lastChecked": "2026-04-11T14:30:22", + "details": { + "provider": "dashscope", + "baseUrl": "https://dashscope.aliyuncs.com", + "latencyMs": 240 + }, + "fixUrl": "/settings/models" +} +``` + +The UI renders: + +- **Category tabs** at the top — Infrastructure, Models, Agents, Memory, Wiki, Channels, MCP, Cron +- **Status counters** — green / yellow / red +- **Check list** — name, status, message, time since last check, "View details" expand, optional "Fix" button that navigates to the relevant settings page +- **History graph** — (for each check) a sparkline of the last 50 runs so you can see flapping checks at a glance + +--- + +## Fix buttons + +For actionable checks, the Doctor row includes a **Fix** button that navigates directly to the relevant settings page: + +- Model provider failure → `Settings → Models` +- Tool Guard `default-policy: allow` → `Settings → Security & Approval` +- H2 console in production → `Settings → System` (or show a config snippet to copy) +- JWT default secret → `Settings → System` (or show a config snippet) +- MCP server disconnected → `Tools → MCP Servers` +- Stuck wiki digestion → `Wiki → [KB] → Raw Material` + +Clicking Fix takes you to the exact page where you can address the issue. When possible, the target page is pre-filtered to highlight the failing item. + +--- + +## Doctor API + +```bash +# Run all checks (synchronous) +curl http://localhost:18088/api/v1/doctor/run \ + -H "Authorization: Bearer " + +# Get the cached check results +curl http://localhost:18088/api/v1/doctor/checks \ + -H "Authorization: Bearer " + +# Run a specific category only +curl http://localhost:18088/api/v1/doctor/run?category=models \ + -H "Authorization: Bearer " + +# Historical results +curl "http://localhost:18088/api/v1/doctor/history?check=dashscope-connectivity&limit=50" \ + -H "Authorization: Bearer " +``` + +--- + +## Using Doctor in operations + +### As a health endpoint for uptime monitoring + +Point your external uptime monitor (UptimeRobot, Pingdom, internal Prometheus) at: + +``` +GET /api/v1/doctor/checks +``` + +The endpoint returns HTTP 200 with JSON summary — aggregate pass/fail counts and per-category breakdown. Your monitor should alert when `errorCount > 0`. + +For a simpler health check, use: + +``` +GET /actuator/health +``` + +which follows Spring Boot's standard format. + +### During upgrades + +After deploying a new MateClaw version, run Doctor to verify nothing regressed: + +1. Open `/doctor` +2. Click **Run All Checks** +3. Look for any yellows or reds that weren't there before +4. Pay special attention to **Database schema** — a mismatched schema after an upgrade usually means a migration didn't run + +### When something's broken + +Doctor is the first place to look when a user reports "it's not working". Open the page, see which check is red, click **Fix**, solve the problem. If no check is red but the user still has an issue, it's probably something Doctor doesn't cover yet — file it as a [GitHub issue](https://github.com/matevip/mateclaw/issues) so we can add a check. + +--- + +## Data model + +**`mate_doctor_check`** + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `name` | Check name | +| `category` | Check category | +| `status` | `ok` / `warning` / `error` | +| `message` | Human-readable message | +| `details` | JSON blob of extra detail | +| `last_checked` | When it last ran | +| `run_duration_ms` | How long the check took | +| `workspace_id` | Scoping (nullable for global checks) | + +Historical results go into `mate_doctor_check_history` with the same columns plus a retention cleanup job. + +--- + +## Next + +- [Admin Console](./console) — the UI Doctor lives in +- [Configuration](./config) — things you might configure based on Doctor warnings +- [Security & Approval](./security) — what Doctor checks in Tool Guard +- [Contributing](./contributing) — add a new Doctor check if something's missing diff --git a/mateclaw-server/src/main/resources/docs/en/faq.md b/mateclaw-server/src/main/resources/docs/en/faq.md new file mode 100644 index 00000000..1f09792f --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/faq.md @@ -0,0 +1,418 @@ +# FAQ + +Common questions and real answers. If your question isn't here, check the relevant feature page or open a [GitHub issue](https://github.com/matevip/mateclaw/issues). + +--- + +## Installation & setup + +### What Java version do I need? + +**Java 17 or higher.** MateClaw uses features introduced in Java 17 (sealed classes, text blocks, records, pattern matching). Verify with `java -version`. + +If you're using the desktop app, **you don't need Java installed at all** — the installer bundles JRE 21. + +### Do I need a cloud API key to start? + +No. Three keyless paths: + +- **Ollama** — local GPU inference; MateClaw auto-detects it on `localhost:11434` at startup +- **ChatGPT OAuth** — if you have a ChatGPT Plus or Pro subscription, log in through the browser flow — your subscription is used directly, no API key needed +- **OpenRouter free tier** — 200+ free models, one OpenRouter key gives you access + +**You also don't need to set any API key as an environment variable to start MateClaw.** All provider configuration is done through the UI at `Settings → Models` after startup. + +### How do I get a DashScope API key? + +1. Go to the [Alibaba Cloud DashScope console](https://dashscope.console.aliyun.com/) +2. Sign up or log in +3. Create an API key +4. In MateClaw, go to `Settings → Models → DashScope` and paste it + +### The backend won't start — port 18088 is in use + +Either stop the other process or change the port: + +```bash +mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=19090" +``` + +The **desktop app picks a free port dynamically**, so you don't see this error there. + +### H2 database lock error on startup + +```bash +rm -f data/mateclaw.mv.db.lock +``` + +Or wipe the data directory to start fresh: + +```bash +rm -rf data/ +``` + +--- + +## Authentication + +### What are the default credentials? + +Username `admin`, password `admin123`. **Change it immediately in any real deployment.** + +### My JWT token keeps expiring + +MateClaw implements **sliding-window renewal** — when a token is within 25% of expiry, the server issues a new one in the `X-New-Token` response header. The frontend handles this automatically. + +If you're calling the API manually (curl, Postman), read the `X-New-Token` header and use the new value for subsequent requests. + +### How do I change the admin password? + +Through `Settings → Security` in the UI is the easiest path. Or directly in the database (BCrypt-encoded): + +```sql +UPDATE mate_user SET password = '$2a$10$...' WHERE username = 'admin'; +``` + +--- + +## Models + +### How do I configure models? + +**All through the UI.** `Settings → Models → Add Provider`. Pick a provider, paste your API key (or OAuth in for ChatGPT Plus, or skip for Ollama), save, test. Model configuration is 100% UI-driven — no `spring.ai.*` YAML blocks to edit. + +LLM API keys are not read from environment variables — setting `DASHSCOPE_API_KEY` and friends has no effect. The container starts with zero providers; sign in and add the first one in the UI. + +### How do I use GPT-4 with MateClaw? + +`Settings → Models → Add Provider`. Either paste your OpenAI API key, or use **OpenAI OAuth** if you have ChatGPT Plus/Pro — a browser window opens for you to log in. After saving, pick `gpt-4o` (or whichever model) from the model picker. + +### Ollama models are slow + +Local model performance depends on hardware: + +- Use smaller models (7B instead of 14B) on less RAM +- Ensure Ollama has GPU access (`ollama ps` should show GPU) +- Increase Ollama's memory limit if available +- `qwen2.5:7b` or `qwen3:latest` is a good speed/quality balance + +### Can I use multiple providers at once? + +Yes. Configure multiple providers and assign different model configs to different agents. Each agent can use its own model — or inherit the global default. Switch the global active model at runtime without restart. + +### How do I pick a cheap model for some agents and a reasoning model for others? + +- Set the **globally active model** to your cheap general-purpose one (e.g., `qwen-plus`, `gpt-4o-mini`) +- Per-agent override: on a reasoning-heavy agent, bind it to `o3` or `qwen-max` specifically +- The grouped model picker in chat lets you switch per-conversation too + +--- + +## Tools & search + +### How do I switch the search provider? + +`Settings → System → Search Service`. Pick from Serper, Tavily, DuckDuckGo, or SearXNG. Enable **fallback** so failures fall through the chain. Takes effect immediately. + +Keyless options (DuckDuckGo, SearXNG) let you have working web search without any API keys. + +### How do I add a custom tool? + +Write a Spring `@Component` with `@Tool`-annotated methods: + +```java +@Component +public class MyCustomTool { + + @Tool(description = "Get weather information") + public String getWeather(@ToolParam(description = "City name") String city) { + return "Sunny, 25C"; + } +} +``` + +Auto-registered on startup. See [Tools](./tools). + +**If the tool does anything dangerous, add a Tool Guard rule for it.** + +### WebSearchTool returns empty results + +Configure a search provider in `Settings → System → Search Service`. Keyless options (DuckDuckGo, SearXNG) work without API keys. + +### Tool Guard keeps blocking my tool calls + +This is **by design** — dangerous tools require approval. Three ways to loosen it: + +1. **Add a specific allow rule** for the exact pattern you need (`Settings → Security & Approval → Tool Guard Rules`). Example: `ShellExecuteTool` with arg pattern `^(ls|cat|grep|find)\s` → `allow`. +2. **Lower the default policy** in `application.yml`: + ```yaml + mateclaw: + tool: + guard: + default-policy: allow # Not recommended in production + ``` +3. **Disable Tool Guard entirely** (only for dev): + ```yaml + mateclaw: + tool: + guard: + enabled: false + ``` + +**Production-safe:** keep `default-policy: require_approval` and add targeted allow rules for specific patterns you trust. + +### How do I configure MCP servers? + +`Tools → MCP Servers` in the UI. Three transport modes: stdio, streamable_http, sse. Config changes take effect without restart. See [MCP](./mcp). + +--- + +## LLM Wiki + +### What's the difference between Wiki and Memory? + +**Wiki is deliberate. Memory is passive.** + +- **Wiki** — you drop documents in, the system digests them into structured pages, agents read those pages. You build it. You edit it. You review it. +- **Memory** — built automatically as a byproduct of conversations. Agent extracts what seems memorable, consolidates patterns nightly. + +Wiki for **source material you want to make queryable** (product specs, design docs, past decisions). Memory for **context that accumulates** (your preferences, what you're working on). + +### Why does the agent still guess things when it has a knowledge base? + +Because you haven't bound the agent to the KB. `Agents → [your agent] → Knowledge` — bind the KB there. Until then, the wiki tools don't get injected. + +### Digestion is slow + +Tune `mate.wiki.digestion-concurrency` in `application.yml`. Default is 2 — bump to 4 or 8 if your LLM quota allows. + +--- + +## Memory + +### Memory is not working + +1. **Confirm auto-extraction is enabled** — check `mate.memory.auto-summarize-enabled` in config +2. **Verify conversation meets thresholds** — `min-messages-for-summarize` (default 4), `min-user-message-length` (default 10) +3. **Check cooldown** — same agent can't trigger extraction more than once every `cooldown-minutes` (default 5) +4. **Read the logs** — `vip.mate.memory` at DEBUG level shows every attempt + +### Memory consolidation tasks aren't running + +Consolidation is driven by seed data in `mate_cron_job`, scheduled for 2 AM daily per agent. Check: + +- Is `enabled` set to `1`? +- Are seed cron jobs present? (`SELECT * FROM mate_cron_job WHERE task_type = 'memory_emergence'`) + +### I don't like what the agent remembered about me + +Edit `PROFILE.md` or `MEMORY.md` directly in the agent workspace view. Lock pages you've edited. See [Memory](./memory). + +--- + +## Approvals + +### I approved a tool call but the agent didn't resume + +1. Is `AWAITING_APPROVAL` still set? (`GET /api/v1/agents/{id}`) +2. Did the approval actually persist? (`GET /api/v1/approvals/{id}`) +3. Are there errors in the agent log around the replay attempt? +4. If replay failed, the agent should surface an error in the chat + +### I want to batch-approve future tool calls from this agent + +You want an **allow rule**, not a blanket approval. `Settings → Security & Approval → Tool Guard Rules → Add Rule`. + +### How long do pending approvals stay pending? + +Default 10 minutes, then they expire and become `rejected`. Configure with `mateclaw.tool.guard.approval-timeout-seconds`. + +--- + +## Agents + +### Agent stuck in RUNNING state + +Common causes: + +1. **Tool call timeout** — a tool is waiting for external service that's hung +2. **Max iterations exceeded** — `MAX_ITERATIONS_REACHED` handler forces a best-effort answer +3. **Awaiting approval** — Tool Guard paused execution +4. **Look at the logs**: + ```bash + mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.vip.mate.agent=DEBUG" + ``` + +### How do I tell if my agent is using the right tools? + +Expand the chat interface's **thinking panel**. You see every tool call, arguments, and result. If the agent is calling the wrong tool, tighten the system prompt. + +--- + +## Channels + +### DingTalk / Feishu webhook isn't receiving messages + +1. Server not publicly reachable +2. HTTPS required +3. Wrong verification token +4. Bot not added to group or missing permissions + +**Easier:** use **stream / long-connection / WebSocket mode** instead of webhook. DingTalk Stream, Feishu WebSocket, Telegram Long-Polling, Discord Gateway, Slack Socket mode — none need a public IP. + +### Can I use multiple channels at once? + +Yes. Each channel is independent and binds to one agent. Run a web console, DingTalk bot, and Telegram bot simultaneously, all with different agents (or the same one — your call). + +### Telegram / Discord can't reach the API (China network) + +Configure `http_proxy` in the channel config: + +```json +{ + "bot_token": "...", + "http_proxy": "http://127.0.0.1:7890" +} +``` + +--- + +## Data backup + +### How do I back up my data? + +**H2 (development / desktop):** stop, copy `./data/mateclaw.mv.db`: + +```bash +cp ./data/mateclaw.mv.db ./backup/mateclaw-$(date +%Y%m%d).mv.db +``` + +**MySQL (production):** + +```bash +mysqldump -u root -p mateclaw > mateclaw-backup-$(date +%Y%m%d).sql +``` + +**Docker:** + +```bash +docker exec mateclaw-mysql mysqldump -u root -p${MYSQL_ROOT_PASSWORD} mateclaw > backup.sql +``` + +**Desktop** data lives in the per-user directory: + +- macOS: `~/Library/Application Support/MateClaw/` +- Windows: `%APPDATA%/MateClaw/` +- Linux: `~/.local/share/MateClaw/` + +--- + +## Desktop app + +### Desktop app won't start + +The installer bundles JRE 21. Check the logs: + +- macOS: `~/Library/Logs/MateClaw/` +- Windows: `%APPDATA%/MateClaw/logs/` +- Linux: `~/.local/share/MateClaw/logs/` + +Try launching from a terminal. On Windows, right-click → Unblock. On macOS, allow the unsigned app in System Settings → Privacy. + +### How do I update the desktop app? + +**Auto-updates** via electron-updater. On startup, checks GitHub Releases and prompts you when a new version is available. Manual download also available from [Releases](https://github.com/matevip/mateclaw/releases). + +--- + +## Docker + +### Docker containers fail to start + +```bash +docker compose logs mateclaw-server +docker compose logs mateclaw-mysql +``` + +Common: + +- MySQL not ready yet +- Port conflicts (18080, 3306) +- Missing `.env` — copy from `.env.example` + +### How do I access the database in Docker? + +```bash +docker exec -it mateclaw-mysql mysql -u root -p mateclaw +``` + +--- + +## Debugging + +### How do I enable DEBUG logging? + +```yaml +logging: + level: + vip.mate: DEBUG + vip.mate.agent: DEBUG + vip.mate.agent.graph: DEBUG + org.springframework.ai: DEBUG +``` + +Or: + +```bash +mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.vip.mate=DEBUG" +``` + +### How do I access the H2 console? + +1. Visit `http://localhost:18088/h2-console` +2. JDBC URL: `jdbc:h2:file:./data/mateclaw` +3. Username: `sa` +4. Password: (empty) + +**Disable in production.** + +### How do I inspect SSE streaming events? + +Browser DevTools → Network → filter `EventStream`. Or: + +```bash +curl -N -H "Authorization: Bearer " \ + "http://localhost:18088/api/v1/chat/1/stream?conversationId=1" +``` + +--- + +## Frontend + +### Frontend shows a blank page after build + +```bash +cd mateclaw-ui +pnpm build +ls ../mateclaw-server/src/main/resources/static/ +# Should contain index.html and asset files +``` + +### Dark mode isn't persisting + +Stored in `localStorage`. Clearing browser data wipes it. + +### The UI feels sluggish + +- Turn logs back to INFO +- Check `java -Xmx` settings +- Click **Clear messages** on old conversations + +--- + +## Next + +- [Quick Start](./quickstart) — setup walkthrough +- [Configuration](./config) — full configuration reference +- [Contributing](./contributing) — how to report bugs and request features +- [GitHub Issues](https://github.com/matevip/mateclaw/issues) — when the docs don't answer your question diff --git a/mateclaw-server/src/main/resources/docs/en/goals.md b/mateclaw-server/src/main/resources/docs/en/goals.md new file mode 100644 index 00000000..73c5aa6c --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/goals.md @@ -0,0 +1,254 @@ +--- +title: Persistent Goals — lock in across turns, let the worker follow up +description: MateClaw's Goal system lets a digital worker lock a multi-turn task as a goal, self-evaluate progress, and optionally drive itself forward until done or out of budget. +head: + - - meta + - name: keywords + content: Goal,Agent,multi-turn,auto-evaluation,auto-followup,persistent,MateClaw +--- + +# Persistent Goals + +> **You used to repeat the context every turn. Now you set a goal once, the worker follows.** + +You say "deploy this blog to fly.io" in one turn, the worker answers, and stops. Next turn you have to remember to ask "is DNS set? cert signed? tests run?" — you're keeping the goal in your head, not the worker. + +Goals flip that. **You say it once, the worker locks the goal and self-checks every turn: what's still missing? Should I take the next step myself?** + +It is not a new tab or a new feature. It is a **state** the worker has. A ring appears around the assistant avatar. How filled the ring is, is how close you are to done. When done, the ring goes away. + +--- + +## What it looks like + +Not a banner. Not a dialog. Not a separate page. + +A **ring around the assistant avatar**. + +| State | Visual | Meaning | +|---|---|---| +| No goal | Plain avatar | This conversation has no goal — same as before | +| Active | Avatar + orange ring | Goal in flight, ring fills to progress | +| Evaluating | Avatar + sand breathing halo | Backend is judging this turn's answer | +| Completed | Avatar + green ring (briefly) | Goal reached; ring fades, conversation continues | +| Exhausted | Avatar + red-orange ring | Budget used up — your call to extend or let go | + +**Hover the avatar** to see the full tooltip — title + what's still missing. Don't hover, don't get bothered. That's the design. + +--- + +## Three ways to set a goal + +In increasing order of how much you have to spell out: + +### Way 1 — Let the worker decide + +State the multi-turn nature of the task plus an explicit setGoal request: + +> I want to do a complete project: translate the README to English, open a PR, address review feedback, merge. This spans many turns. **Please use setGoal to lock it in**, self-evaluate each turn, turnBudget=8, autoFollowup on. + +The worker picks up the two signals ("long task" + "setGoal requested") and creates the goal, auto-summarizing the title from context. You see a ring next to its avatar — goal is locked. + +### Way 2 — Direct tool command + +Tell the worker exactly which tool to call with which params: + +> Please call setGoal immediately, title="Deploy blog to fly.io", turnBudget=10, autoFollowup=true. Do not ask any clarifying questions. + +The "do not ask clarifying questions" clause matters — otherwise the worker's instinct is to ask "where's the code? what domain?" first. + +### Way 3 — Programmatic via the REST API + +For automation and external scripts, the endpoint is direct: + +``` +POST /api/v1/goals +{ + "conversationId": "conv-xxx", + "agentId": "1000000001", + "workspaceId": 1, + "title": "Deploy blog to fly.io", + "description": "...", + "exitCriteria": "DNS + SSL + healthcheck + tests pass", + "turnBudget": 10, + "llmCallBudget": 200, + "autoFollowupEnabled": false +} +``` + +Full surface in the [API reference](./api). + +--- + +## What a goal carries + +Four required: + +| Field | Meaning | +|---|---| +| **title** | Short label, shown on avatar hover | +| **description** | Full statement of what you want | +| **exitCriteria** | LLM-readable bar the evaluator scores against (e.g. "tests pass + deployed") | +| **budgets (turnBudget + llmCallBudget)** | Failsafes against runaway iteration | + +Optional: + +- **autoFollowupEnabled** — when on, the worker may continue itself if it judges the goal incomplete, without waiting for your next message +- **followupCooldownSeconds** — minimum delay between two consecutive auto-followups + +--- + +## How it runs + +After every turn, a backend evaluator node runs: + +1. Reads the worker's final answer + last few messages of context +2. Calls a lightweight evaluator model (point this at a cheap one) asking: completion 0–1? what's the gap? continue or done? +3. Writes the result into the `mate_agent_goal_event` timeline +4. Decides next step: complete / exhaust budget / continue / auto-followup + +**Key invariant**: evaluation runs *after* the final answer has streamed to your screen — it never blocks you seeing the reply. You see the answer appear → the ring updates a moment later. + +### Auto-followup + +When `autoFollowupEnabled=true` and this turn's evaluator decision is "continue", the backend: + +1. Writes a `followup_injected` event to the timeline +2. APPENDs a user message to the conversation: *"Continue working on the goal. Still missing: {gap}. Take the next concrete step."* +3. Re-enters the reasoning loop — the next assistant reply lands right after the first + +Feels like: the worker answers a segment → pauses a beat → **keeps going** — like a person who finished one step, thought for a second, and continued. + +--- + +## Four built-in tools (worker-callable) + +These four ship as agent-wide system tools — no binding setup needed: + +| Tool | Purpose | Prompt example | +|---|---|---| +| **setGoal** | Create a goal | "Use setGoal to lock in this task, title=..." | +| **addGoalCriterion** | Append a sub-criterion to the active goal | "Add: must support IPv6" | +| **completeGoal** | Explicitly mark done | "All items done — call completeGoal" | +| **getGoalStatus** | Inspect current state | "How are we doing?" | + +On completion (`completeGoal` or evaluator score ≥ 0.95), the worker forwards a summary to its [long-term memory](./memory) so future conversations can recall it. + +--- + +## Sub-agents cannot mutate the parent's goal + +In [multi-agent collaboration](./agents) a parent worker can delegate to a child worker. Children **don't see** the four goal tools — the goal is the parent conversation's state, the child is a stateless executor. + +> This is intentional. Children do work for the parent, but the goal stays owned by the parent. + +--- + +## When the budget runs out + +``` +turnsUsed >= turnBudget OR (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget +``` + +Either one hit → goal status flips to **exhausted**, no more evaluations, no more follow-ups, ring turns red-orange. The last turn's assistant reply still goes through. + +Your options: + +- **Raise the budget and resume** — `PATCH /api/v1/goals/{id}` to widen budgets then resume (no UI button in v1 — use the API or abandon and re-create) +- **Let it go** — call abandon; the conversation slot is freed for a new goal + +--- + +## State machine + +``` + create + ↓ + active + ↓ ↑ + paused + + active ──evaluator score≥0.95 / completeGoal──→ completed (terminal) + ↓ + active ──turns_used / llm_calls exhausted ────→ exhausted (terminal) + ↓ + active ──user abandon ────────────────────────→ abandoned (terminal) +``` + +Terminal states (completed / exhausted / abandoned) cannot revive. To keep going, create a fresh goal — intentional simplicity, avoids messy "resurrect with what budget" semantics. + +**One active goal per conversation**: at most one active row at any time. Terminal rows stay in history, don't count against the slot. Enforced at the DB layer with a generated column + unique index (H2 / MySQL), plus service-level precheck and audit — defense in depth. + +--- + +## What this system does not do + +A few deliberate non-features: + +- **No nested goals / goal trees** — one goal per conversation, no OKR stack +- **No "goal templates"** — every goal is hand-written +- **No cross-conversation goal migration** — use a [workflow](./workflow) for that +- **No completion score in the UI** — `completionScore` is an internal engineering protocol, not user vocabulary. The UI speaks via a ring; hover reveals the natural-language gap the evaluator wrote. The numeric score stays in logs and the API for debugging + +--- + +## Full event timeline (drawer view) + +Each goal has an append-only event log, newest first: + +| Event | Trigger | +|---|---| +| `created` | setGoal tool or REST POST | +| `evaluated` | every turn after evaluator runs | +| `followup_injected` | autoFollowup fired and injected a prompt | +| `completed` | evaluator concluded done, or completeGoal tool | +| `exhausted` | budget hit | +| `paused` / `resumed` / `abandoned` | user actions | +| `criterion_added` | addGoalCriterion tool | + +Pull via `GET /api/v1/goals/{id}/events`. See [API reference](./api). + +--- + +## Configuration + +`application.yml`: + +```yaml +mateclaw: + goal: + # Master switch; when off, the graph node passes through for every call. + enabled: true + # Default turn budget when the user doesn't override. + default-turn-budget: 20 + # Default combined (agent + evaluator) LLM call budget. + default-llm-call-budget: 200 + # Minimum seconds between two consecutive auto-followups. + auto-followup-cooldown-seconds: 0 + # Model used by the evaluator. Empty = same model as the chat agent. + # Recommended: a cheap model like qwen-turbo / glm-4-flash. + evaluator-model: "" + # Max recent messages included in the evaluator prompt. + evaluator-context-messages: 8 +``` + +--- + +## Database + +Two tables, all `mate_`-prefixed: + +| Table | Purpose | +|---|---| +| `mate_agent_goal` | Goal itself; status / budgets / dual LLM counters / auto-followup config | +| `mate_agent_goal_event` | Append-only event log; powers the timeline view | + +Flyway migration `V120__agent_goal.sql` (H2 + MySQL dialects). + +--- + +## One-liner + +**A goal isn't a new feature on the worker. It's a state change.** + +Before, the worker forgot the moment it answered. Goals make a worker remember one thing across many turns — what it's working on, what's still missing, when it counts as done. You say it once. The ring next to the avatar tracks the rest. diff --git a/mateclaw-server/src/main/resources/docs/en/index.md b/mateclaw-server/src/main/resources/docs/en/index.md new file mode 100644 index 00000000..cbd92c6d --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/index.md @@ -0,0 +1,44 @@ +--- +layout: home + +hero: + name: MateClaw + text: The personal AI your IT department can actually sign off on. + tagline: Other personal AI agents are built for one person. MateClaw is built for a team — multi-user workspaces, approval-gated sensitive actions, full audit trail, production-grade health monitoring. One JAR on your own machine. Zero data egress. + image: + src: /logo.png + alt: MateClaw + actions: + - theme: brand + text: Get Started → + link: /en/quickstart + - theme: alt + text: Read the Docs + link: /en/intro + - theme: alt + text: GitHub + link: https://github.com/matevip/mateclaw + +features: + - icon: 🧑‍💼 + title: Digital employees, not chatbots + details: You hire coworkers, not a chat box. Each one has a role, a goal, a backstory, a pixel-art avatar, and a color of their own — five career templates ship ready to use. ReAct + Plan-and-Execute, parallel delegation between employees. + - icon: 🧩 + title: Skills are the skeleton, not a plugin + details: One SKILL.md plus one LESSONS.md that grows with use. Eight starter templates, a five-step creation wizard, pre-flight checks before install. MCP and ACP bridges — even Claude Code and Codex show up as employees. + - icon: 📚 + title: Knowledge, shaped + details: The LLM Wiki digests raw files into structured pages with summaries and backlinks. A library you can read, not a vector store you query. The hot cache auto-injects into your employees' system prompts. + - icon: 🧬 + title: Memory that compounds + details: Session context, post-chat extraction, workspace files, and scheduled Dreaming consolidation. Tomorrow's conversation starts where today's ended. + - icon: 👀 + title: You see what every employee is doing + details: The Admin Runtime Console shows who's running, what step they're on, how many tokens they've used, with a one-click force-recycle when stuck. Streaming is staged honestly, multi-agent delegation no longer fights itself, long tasks demand evidence-grounded answers. + - icon: 🔀 + title: Business processes, not manual hand-offs + details: Workflows compose multiple employees plus system actions (approvals, channel dispatch, write-memory) into a publishable, triggerable, replayable linear DSL — seven step modes. Triggers wire system events to those flows — six pattern types cover cron, webhooks, channel messages, employee lifecycle, content match, and workflow completion. + - icon: 🌐 + title: Every surface that matters + details: Web console, desktop app with bundled JRE 21, and eight chat channels. Same brain, same memory, wherever your team works. +--- diff --git a/mateclaw-server/src/main/resources/docs/en/intro.md b/mateclaw-server/src/main/resources/docs/en/intro.md new file mode 100644 index 00000000..a4c32838 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/intro.md @@ -0,0 +1,90 @@ +--- +title: MateClaw Introduction — Self-hosted Multi-Agent AI Operating System +description: MateClaw is an open-source multi-agent AI OS built on Spring AI Alibaba. ReAct + Plan-and-Execute engines, LLM Wiki knowledge base, 4-layer memory lifecycle, MCP tool protocol, 8-channel integration. One JAR, zero data egress. +head: + - - meta + - name: keywords + content: MateClaw,multi-agent AI,self-hosted AI,AI operating system,Spring AI Alibaba,ReAct,Plan-and-Execute,MCP,LLM Wiki,memory lifecycle,Tool Guard,open source +--- + +# MateClaw — Self-hosted Multi-Agent AI Operating System + +**Your multi-agent AI. On your hardware. Under your rules.** + +MateClaw is a full AI operating system you deploy yourself. One JAR. One login. Your data never leaves the room. + +**Three things it does that other AI products can't:** + +**Proactive** — It shows up when it's needed. Push the morning briefing to Feishu at 9 AM. Alert your DingTalk when a competitor ships. **Not waiting in a browser tab.** → [Ambient AI](./ambient-ai) + +**It dreams** — While you sleep, a Dreaming pass consolidates the day's scattered conversations into a coherent understanding of you, writing it into `MEMORY.md`. The next morning **it picks up where yesterday ended** — not from scratch. → [Memory](./memory) + +**Asks before it acts** — When the agent wants to delete a file, send an email, or write to the database — Tool Guard rules **pause the turn mid-flight** and push an approval to your IM. You tap approve, the agent resumes. **Agentic, but not autonomous.** → [Security](./security) + +It lives on your desktop, in your browser, and inside the chat apps your team already uses — same brain, same memory, wherever you go. + +Bring any model. DashScope. OpenAI. Anthropic. Gemini. DeepSeek. Kimi. MiniMax. Zhipu. OpenRouter. Ollama for a local GPU. Log in to your ChatGPT Plus account via OAuth if you have one. Pick one. Add more later. + +--- + +## The problem MateClaw fights + +Most AI products stop at one layer. + +You get a chat box, but the memory resets every morning. You get a tool runtime, but no way to pause it when it's about to do something stupid. You get a knowledge base that retrieves fragments but can't tell you what it actually knows. You get a desktop app, but not the channels your team lives in. Or you get all of it — rented on someone else's cloud, with your data paying rent too. + +MateClaw fights a different fight. It's **all of it, under one roof, on hardware you control.** + +--- + +## What it actually does + +**It completes work.** Plan-and-Execute breaks complex tasks into ordered steps, executes them one at a time, and adapts mid-flight when something fails. ReAct handles the smaller loops — think, act, observe, continue. You see the plan update as the agent works. You see the tool calls. You see the thinking. You see it finish. + +**It remembers.** Session context, post-chat extraction, workspace memory files, scheduled consolidation, and a "dreaming" pass that connects yesterday's threads into today's understanding. Memory is not a feature bolted onto chat — it's how the system gets better at knowing you. + +**It shapes knowledge.** Drop a PDF. Drop a folder. Drop a thousand markdown notes. The LLM Wiki digests them into structured, linked pages with summaries and backlinks — not a vector store you query, a library you can read. Agents auto-inject page summaries and fetch full bodies on demand. + +**It holds real tools.** Built-in tools for search, file IO, time, shell, image, music, video, STT, and TTS. MCP servers for anything else. Skill packages you write in a `SKILL.md` and drop into a workspace. Everything gated by Tool Guard and optional human approval — strong hands, firm limits. + +**It shows up everywhere.** Web console, a desktop app that bundles JRE 21 so your users don't install Java, and eight chat channels: DingTalk, Feishu, WeCom, WeChat, Telegram, Discord, QQ, Slack. The same agent answers a Slack thread, a Feishu DM, and a web chat — same memory, same skills, same personality. + +--- + +## Why self-hosted changes the product + +Running MateClaw on your own hardware is not a compliance checkbox. It changes what the product **is**. + +**Your data stops paying rent.** Logs, conversations, documents, memory — none of it trains anyone else's model. None of it waits in a vendor's queue. None of it leaves your machines unless you point a channel at one. + +**You own the roadmap.** Don't like how the memory consolidator works? Change it. Need a tool your vendor won't build? Add it. MateClaw is Apache 2.0 — not source-available, not "open core", not waiting on a quarterly product review. + +**You pick the economics.** Start on DashScope. Swap to Ollama when your local GPU arrives. Put one agent on OpenAI and keep the rest cheap. Agent config and tool graphs don't care what's under the model interface. + +**Your deployment surface is real.** One JAR. One Spring Boot process. No Python runtime chain. No Node dependency hell. The desktop app bundles everything. The Docker compose file is eighteen lines. + +--- + +## What's under the hood + +- **Backend** — Spring Boot 3.5 + Spring AI Alibaba 1.1. Agent runtime built on a StateGraph with nodes for reasoning, action, observation, plan generation, and step execution. MyBatis Plus for persistence. SSE for streaming — WebFlux is explicitly excluded. +- **Frontend** — Vue 3 + TypeScript. Pinia for state, Element Plus + Tailwind for UI, full dark mode. Built into the backend JAR's `static/` so one process serves both. +- **Desktop** — Electron with bundled JRE 21 and the packaged server JAR. Launches, initializes, and your users never know Java is underneath. +- **Channels** — Each channel is a `ChannelAdapter` SPI implementation. Web streams over SSE. IM channels run on their platform's long-connection or webhook mode. +- **Storage** — H2 file DB for development, MySQL 8 for production. Flyway manages schema migrations with dialect-specific scripts for each. + +--- + +## Three ways to dive in + +You probably want one of three things. + +**Want to use it?** → [Quick Start](./quickstart) — 60 seconds to your first message on the desktop app. + +**Want to understand it?** → Read [Agents](./agents) → [LLM Wiki](./wiki) → [Memory](./memory) → [Multimodal](./multimodal). Those four pages are the product. + +**Want to build on it?** → [API Reference](./api) and [Contributing](./contributing). + +--- + +Nothing on this page is non-negotiable. If something doesn't make sense, the docs are at fault — not you. Tell us on GitHub. diff --git a/mateclaw-server/src/main/resources/docs/en/mcp.md b/mateclaw-server/src/main/resources/docs/en/mcp.md new file mode 100644 index 00000000..df8aff4a --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/mcp.md @@ -0,0 +1,433 @@ +--- +title: MCP Integration — Model Context Protocol Tool Extension +description: MateClaw acts as an MCP client, connecting to any external tool server via Model Context Protocol. JSON-RPC dynamic discovery, SSE/stdio dual transport, seamless unification with built-in tools. +head: + - - meta + - name: keywords + content: MCP,Model Context Protocol,MCP client,tool protocol,JSON-RPC,AI tool extension,Anthropic MCP +--- + +# MCP — Model Context Protocol + +**MCP is how MateClaw talks to tools someone else built.** + +Model Context Protocol is an open standard from Anthropic for connecting AI models to external tools and data. An MCP server is a process — local or remote — that advertises a set of tools over JSON-RPC. MateClaw acts as an MCP *client*: it connects, discovers tools via `tools/list`, and exposes them to your agents as if they were native. **From the agent's point of view, there's no difference between a built-in `@Tool` Spring bean and a tool coming from an MCP server.** + +This is the escape hatch. If you need a capability MateClaw doesn't ship with — filesystem access for a sandboxed directory, Tavily search, a custom internal data service, a browser automation suite — there's probably already an MCP server for it, and you can plug it in without writing a line of Java. + +--- + +## What MCP actually is + +``` +┌───────────────────────┐ ┌───────────────────────┐ +│ MateClaw │ │ MCP Server │ +│ (MCP Client) │ │ (Tool Provider) │ +│ │ JSON-RPC │ │ +│ Agent Engine ───────┼──────────────┼──► Tool A │ +│ │ │ Tool B │ +│ Tool Registry ◄──────┼──────────────┼─── Tool Discovery │ +│ │ │ (tools/list) │ +└───────────────────────┘ └───────────────────────┘ +``` + +Core concepts: + +- **MCP Client** — MateClaw, connecting to servers, discovering tools, forwarding invocations +- **MCP Server** — a third-party process declaring its available tools and executing calls +- **Tool Discovery** — the client sends `tools/list` to retrieve every tool and its parameter schema +- **Tool Invocation** — when the agent decides to call a tool, the client forwards the request to the right MCP server + +New tool capabilities become available to agents **without modifying code or restarting the service**. + +--- + +## Transport types + +Three transports for different deployment scenarios: + +### stdio (Standard I/O) + +MateClaw spawns a local child process and exchanges JSON-RPC messages via stdin/stdout. + +``` +MateClaw ── stdin ──► MCP Server subprocess + ◄─ stdout ── +``` + +**Use cases:** local Node.js/Python MCP packages (e.g., `@anthropic/mcp-filesystem`), command-line tool wrappers, development. +**Advantages:** no network configuration, works immediately, process isolation. +**Limitations:** local only. + +### streamable_http (Streamable HTTP) + +Standard HTTP POST for JSON-RPC, responses streamed back over HTTP. **Recommended for production.** + +``` +MateClaw ── HTTP POST ──► Remote MCP Server + ◄─ HTTP Stream ── +``` + +**Use cases:** cloud-deployed MCP servers, deployments behind load balancers. +**Advantages:** standard HTTP, CDN/firewall friendly, auth headers. + +### sse (Server-Sent Events) + +Earlier HTTP transport using SSE for server-to-client push. Legacy compatibility; new projects should prefer `streamable_http`. + +### Transport comparison + +| Feature | stdio | streamable_http | sse | +|---------|-------|-----------------|-----| +| Deployment | Local only | Local or remote | Local or remote | +| Network requirement | None | HTTP reachable | HTTP reachable | +| Authentication | Environment variables | HTTP Headers | HTTP Headers | +| Process management | MateClaw manages subprocess | External | External | +| Recommendation | Local tools | Remote services | Legacy compatibility | + +--- + +## Configuration via UI + +`Tools → MCP Servers → Add MCP Server`. Fill in: + +- **Name** — unique identifier (letters, numbers, `_`, `-`, `.`, spaces; 1–128 chars) +- **Description** — optional +- **Transport type** — `stdio`, `streamable_http`, or `sse` +- **Command** (stdio) — `npx`, `node`, `python`, etc. +- **Arguments** (stdio) — JSON array (e.g., `["-y", "@anthropic/mcp-filesystem", "/path"]`) +- **Working directory** (stdio) — optional +- **Environment variables** (stdio) — JSON object; supports `${ENV_VAR}` references +- **URL** (streamable_http/sse) — server endpoint +- **HTTP Headers** (streamable_http/sse) — JSON object (e.g., `{"Authorization": "Bearer token"}`) +- **Connect timeout** — default 30s +- **Read timeout** — default 30s + +Save. If enabled, MateClaw auto-attempts to connect and discover tools. + +### Testing, enabling, status + +- **Test Connection** — sends `tools/list`, returns result, latency, tool list +- **Enable/Disable toggle** — drop connection without deleting config +- **Status** — `connected` / `disconnected` / `error` with error detail + +--- + +## Configuration via REST API + +Full CRUD at `/api/v1/mcp/servers`. + +### List all + +```bash +curl -s http://localhost:18088/api/v1/mcp/servers \ + -H "Authorization: Bearer " | jq +``` + +Response includes `headersJson` and `envJson` automatically **sanitized** (`sk-****abcd`). + +### Create — stdio + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "argsJson": "[\"-y\", \"@anthropic/mcp-filesystem\", \"/home/user/workspace\"]", + "enabled": true + }' +``` + +### Create — streamable_http + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "remote-tools", + "transport": "streamable_http", + "url": "https://mcp.example.com/mcp", + "headersJson": "{\"Authorization\": \"Bearer your-api-key\"}", + "connectTimeoutSeconds": 15, + "readTimeoutSeconds": 60, + "enabled": true + }' +``` + +### Update (PATCH semantics) + +```bash +curl -X PUT http://localhost:18088/api/v1/mcp/servers/{id} \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"description": "Updated", "readTimeoutSeconds": 60}' +``` + +After update, enabled servers automatically reconnect. + +### Delete / Toggle / Test / Refresh + +```bash +curl -X DELETE http://localhost:18088/api/v1/mcp/servers/{id} \ + -H "Authorization: Bearer " + +curl -X PUT "http://localhost:18088/api/v1/mcp/servers/{id}/toggle?enabled=false" \ + -H "Authorization: Bearer " + +curl -X POST http://localhost:18088/api/v1/mcp/servers/{id}/test \ + -H "Authorization: Bearer " + +curl -X POST http://localhost:18088/api/v1/mcp/servers/refresh \ + -H "Authorization: Bearer " +``` + +**Built-in servers** (`builtin=true`) cannot be deleted. + +--- + +## Practical examples + +### Example 1 — Filesystem MCP (stdio) + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "filesystem", + "description": "Filesystem access (restricted to specified directory)", + "transport": "stdio", + "command": "npx", + "argsJson": "[\"-y\", \"@anthropic/mcp-filesystem\", \"/home/user/workspace\"]", + "enabled": true + }' +``` + +Discovered tools: `read_file`, `write_file`, `list_directory`, `search_files`, `get_file_info`. + +Security: `@anthropic/mcp-filesystem` only allows access to the specified directory and subdirectories. + +### Example 2 — Remote HTTP with auth + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "internal-data-service", + "transport": "streamable_http", + "url": "https://mcp-api.internal.example.com/mcp", + "headersJson": "{\"Authorization\": \"Bearer sk-your-api-key\", \"X-Team-Id\": \"engineering\"}", + "connectTimeoutSeconds": 10, + "readTimeoutSeconds": 120, + "enabled": true + }' +``` + +**Header values support environment variable references**: `{"Authorization": "Bearer ${MCP_API_KEY}"}` is replaced at runtime, **secrets don't land in the database**. + +### Example 3 — Tavily search (stdio + env vars) + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "tavily-search", + "transport": "stdio", + "command": "npx", + "argsJson": "[\"-y\", \"@anthropic/mcp-tavily\"]", + "envJson": "{\"TAVILY_API_KEY\": \"${TAVILY_API_KEY}\"}", + "enabled": true + }' +``` + +--- + +## How MCP tools become available to agents + +``` +Application startup + │ + ▼ +Iterate enabled MCP servers + │ + ▼ +Connect by transport → initialize → list tools → cache + │ + ▼ +Tool registry (aggregates built-in tools + MCP tools) + │ + ▼ +Agent tool set +``` + +**Key:** the agent fetches the **latest** active tool list on every invocation, so adding or removing MCP servers takes effect **without restarting**. From the agent's perspective, **MCP tools and built-in tools are identical** — no difference. + +--- + +## Per-agent tool binding + +::: tip New in 1.3.0 +Before v1.2.0, all employees could call every MCP tool by default — it was a global switch. v1.3.0 makes the binding **per-employee**, and adds dirty-state detection plus namespace collision handling. +::: + +### Three problems it solves + +**Problem 1: Tool namespace collisions.** +Two MCP servers both expose `read_file` — which one wins? v1.3.0 internally uses a **stable server-prefixed callback name** (`{serverName}__{toolName}`) and persists it to `mate_mcp_server.cached_tools`. The picker shows them as `serverA__read_file` and `serverB__read_file`; the agent's prompt maps them back to original names to save tokens and avoid LLM confusion. + +**Problem 2: MCP server / tool rename breaks bindings.** +In v1.2.0, renaming a server orphaned every employee bound to it. v1.3.0 introduces a **persistent tool cache**: every successful list-tools writes tool metadata to a `cached_tools` JSON column on `mate_mcp_server`. When validating bindings and the server is temporarily unreachable, the cache is consulted as fallback — bindings stay marked `stale` and become live again the moment the server reconnects. + +**Problem 3: Save silently accepted non-existent tool references.** +A typo'd `nonexistent-server.weird-tool` would save fine and blow up at runtime. v1.3.0 runs `AgentBindingService.validate(...)` on save: + +| Status | Meaning | Save behavior | +|---|---|---| +| `connected` | Server online, tool visible | ✅ Persist normally | +| `stale` | Server temporarily offline but in cache | ✅ Persist (marked stale) | +| `unavailable` | Server disabled | ✅ Persist (marked unavailable) | +| `orphan` | Server / tool no longer exists at all | ❌ Reject save, prompt user to clear | + +### Where to see tool status + +`Agents → pick employee → Tools` — see [Agent tool binding](./agents#tool-binding-per-agent-tool-picker). + +### Data contract + +- `mate_mcp_server.cached_tools` (new column in v1.3.0): JSON array, each element `{name, description, inputSchema, lastSeenAt}` +- `mate_agent_tool.tool_name`: stores the **prefixed callback name** `{serverName}__{toolName}` rather than the raw name, so a server rename surfaces immediately as an observable join miss +- `AgentBindingService.getEffectiveToolNames(agentId)` is the single source of truth for tool dispatch — runs every turn, ensuring the editor view and the runtime view always agree + +### Server-side rules + +- MCP servers bridged in via ACP **cannot** be edited from the MCP server list (they're owned by the ACP server's own lifecycle) +- A tool marked `unavailable` is **not listed** in the agent's system prompt — the LLM won't reach for it, but the binding row is preserved +- `returnDirect=true` tools (whose output replaces the assistant turn) go through the same ACL — they **do not bypass** binding + +--- + +## Connection management + +### Automatic connection on startup + +All `enabled=true` MCP servers connect automatically when the app starts. A single server's failure doesn't block other servers or application startup. + +### Thread safety + +The active-client map is concurrent, with an independent lock per server. + +### Connection replacement + +**"Connect new, then disconnect old"** strategy: build a new client, initialize it, swap it into the pool, close the old one. If the new client fails, the old one remains. + +### Subprocess cleanup + +For stdio servers, cleanup happens on: disable/delete, config replacement, application shutdown (`@PreDestroy`), connection failure. + +### Status monitoring + +After each connection operation, results persist: + +- `last_status` — `connected` / `disconnected` / `error` +- `last_error` — error message +- `last_connected_time` — timestamp of last success +- `tool_count` — currently discovered tools + +### Manual refresh + +`POST /api/v1/mcp/servers/refresh` drops all existing connections and reconnects every enabled server. Useful for troubleshooting. + +--- + +## Database storage — `mate_mcp_server` + +| Column | Type | Default | Purpose | +|--------|------|---------|---------| +| `id` | BIGINT | — | Primary key | +| `name` | VARCHAR(128) | — | Unique identifier | +| `description` | TEXT | NULL | Server description | +| `transport` | VARCHAR(32) | `stdio` | `stdio` / `streamable_http` / `sse` | +| `url` | VARCHAR(512) | NULL | Remote URL | +| `headers_json` | TEXT | NULL | HTTP headers JSON | +| `command` | VARCHAR(512) | NULL | Startup command | +| `args_json` | TEXT | NULL | Command arguments JSON array | +| `env_json` | TEXT | NULL | Environment variables JSON; supports `${VAR}` | +| `cwd` | VARCHAR(512) | NULL | Working directory | +| `enabled` | BOOLEAN | TRUE | On/off | +| `connect_timeout_seconds` | INT | 30 | HTTP connect timeout | +| `read_timeout_seconds` | INT | 30 | Request response timeout | +| `last_status` | VARCHAR(32) | `disconnected` | Last connection status | +| `last_error` | TEXT | NULL | Last error message | +| `last_connected_time` | DATETIME | NULL | Last successful connection | +| `tool_count` | INT | 0 | Discovered tool count | +| `builtin` | BOOLEAN | FALSE | Whether it's a built-in server | +| `create_time` / `update_time` | DATETIME | — | Timestamps | +| `deleted` | INT | 0 | Logical delete | + +### Sensitive data sanitization + +`headers_json` and `env_json` values are automatically masked in API responses. `args_json` is returned as-is. + +### Environment variable references + +- `${VAR_NAME}` — exact match and replacement +- `$VAR_NAME` — regex match + +Keeps secrets out of the database. + +--- + +## Troubleshooting + +### "Command not found" (stdio) + +1. Confirm the command is in PATH of the user running MateClaw +2. Verify: `which npx` or `npx --version` +3. Docker: confirm command is installed in the container +4. Use full path: `/usr/local/bin/npx` + +### Connection timeout + +1. HTTP/SSE: confirm URL reachable (`curl -v `) +2. Check firewall rules +3. Increase `connectTimeoutSeconds` / `readTimeoutSeconds` +4. stdio: first `npx -y` run may need to download packages + +### SSL/TLS errors + +1. Confirm remote SSL certificate is valid and not expired +2. Self-signed: add CA cert to JVM trust store +3. Confirm JDK supports required TLS version + +### Tools not showing up + +1. Check `tool_count > 0` +2. Use test connection, confirm `discoveredTools` non-empty +3. Verify MCP server implements `tools/list` +4. Check backend logs for MCP tool-discovery output + +### Tool invocation failures + +1. Check backend logs for specific errors +2. Confirm MCP server process is running (stdio) +3. Confirm remote server reachable (HTTP/SSE) +4. Check `readTimeoutSeconds` is sufficient +5. Try refresh connections + +### Orphaned subprocesses (stdio) + +Subprocesses are cleaned up on normal shutdown. If MateClaw was force-killed (`kill -9`), subprocesses may remain. `ps aux | grep mcp` and terminate. + +--- + +## Next + +- [Tools](./tools) — how MCP tools relate to built-in tools +- [Skills](./skills) — MCP-backed skills +- [Configuration](./config) — full configuration reference diff --git a/mateclaw-server/src/main/resources/docs/en/memory.md b/mateclaw-server/src/main/resources/docs/en/memory.md new file mode 100644 index 00000000..3c9ed46b --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/memory.md @@ -0,0 +1,457 @@ +--- +title: AI Memory System — 4-Layer Memory Lifecycle (Extract, Consolidate, Dream, Recall) +description: MateClaw's 4-layer memory lifecycle — in-conversation context, post-chat extraction, workspace persistence (PROFILE.md/MEMORY.md), and scheduled Dreaming consolidation. Your AI gets smarter every day. +head: + - - meta + - name: keywords + content: AI memory,memory system,Dreaming,PROFILE.md,MEMORY.md,memory lifecycle,long-term memory,memory extraction,memory consolidation +--- + +# AI Memory System + +**Memory is how the system gets better at knowing you.** + +Everything else in MateClaw is static the moment you configure it. Agents, tools, knowledge bases — they change when you change them. Memory is the one part that changes on its own, as a byproduct of actual use. That's the whole point. + +::: tip Your AI dreams about you while you sleep +That's not a marketing line. It's literal code in the `memory/dreaming/` package. + +Every night at 2 AM (default; configurable) a scheduled job runs — its name is **Dreaming**. It walks every agent's conversation trail from the day, consolidates scattered signals into a coherent understanding of you, filters out one-offs and contradictions and stale facts, promotes recurring patterns into `MEMORY.md`, and appends "what it saw, what it concluded, what it rewrote" to `DREAMS.md` — a human-readable audit trail of how memory got to where it is today. + +When you open MateClaw the next morning, it **picks up where yesterday left off** — not from zero. + +> Every other AI starts each day from scratch. MateClaw continues from where yesterday ended. +::: + +This page covers the four layers that make up memory, the files the system writes for each agent, and how agents themselves read and write those files during a conversation. + +--- + +## The four layers + +``` + ┌────────────────────────────────────────────────────────────┐ + │ 1. This turn │ + │ What you're saying, what was just said, auto-trimmed │ + │ to the model's token budget │ + │ Updated: every turn │ + └────────────────────────────────────────────────────────────┘ + │ + ▼ (after conversation completes) + ┌────────────────────────────────────────────────────────────┐ + │ 2. Post-chat extraction │ + │ Pulls the worth-keeping bits out of the conversation, │ + │ writes them into PROFILE.md / MEMORY.md / today's note │ + │ Updated: asynchronously, after each meaningful chat │ + └────────────────────────────────────────────────────────────┘ + │ + ▼ (daily at 2:00 AM, configurable) + ┌────────────────────────────────────────────────────────────┐ + │ 3. Nightly consolidation (Dreaming) │ + │ Scans recent daily notes, finds recurring patterns, │ + │ merges them into MEMORY.md, logs the run in DREAMS.md │ + │ Updated: scheduled; manual trigger available │ + └────────────────────────────────────────────────────────────┘ + │ + ▼ (next conversation picks up the latest) + ┌────────────────────────────────────────────────────────────┐ + │ 4. Workspace files as system prompt │ + │ The four markdown files are injected every turn │ + │ Updated: file changes take effect on the next turn │ + └────────────────────────────────────────────────────────────┘ +``` + +Each layer operates at a different timescale. Short-term is *this turn*. Extraction is *after each conversation*. Consolidation is *nightly*. Workspace file injection is *every turn uses whatever's current*. Together they form a loop — what you say becomes context, context becomes files, files become system prompt, system prompt becomes what the agent knows tomorrow. + +--- + +## Multi-layer memory with pluggable providers + +The memory layer is not one hard-coded implementation. It's an **interface** — the multi-layer architecture lets you stack providers: + +- The **default provider** is the workspace-file-based memory described in the rest of this page. It ships with MateClaw, and for most people it's all they'll ever need. +- **Custom providers** can be dropped in for specialized retrieval — vector-based long-term memory, graph memory, external memory services. +- **Layering** means a single agent can talk to multiple providers at once. A short-term provider returns recent context; a semantic provider returns related memories; a Wiki provider returns authoritative references. They compose at read time. + +For most agents, **default is enough** and you should ignore this section. If you're building something specialized — an agent that needs to remember thousands of facts with vector search, an agent that needs graph-structured memory — this is where you plug in. See [Architecture](./architecture). + +--- + +## The four files every agent has + +Every agent has its own workspace. Four markdown files form the backbone of long-term memory: + +``` +workspace/{agentId}/ +├── AGENTS.md # How the agent uses memory — behavior guide +├── SOUL.md # Who the agent is — core identity, personality, boundaries +├── PROFILE.md # Who you are — user profile, preferences, background +├── MEMORY.md # What matters — key decisions, project context, todos +└── memory/ + ├── 2026-04-09.md # Daily notes — what happened today, append-only + ├── 2026-04-10.md + └── 2026-04-11.md +``` + +The first four are **injected into the system prompt on every turn** (if `enabled=true`). Daily notes are not — they feed consolidation instead. + +### What each file is *for* + +- **AGENTS.md** — the agent's user manual for itself. When to write memory, what goes where, what tools are available. Seed: `enabled=true`, `sort_order=0`. +- **SOUL.md** — who the agent fundamentally is. Self-awareness, evolution guidance, privacy and boundary principles. Edit when you want to change the agent's character at a deep level. Seed: `enabled=true`, `sort_order=1`. +- **PROFILE.md** — what the agent has learned about you. Name, occupation, tech stack, communication preferences. Updated by the extractor when conversations reveal something durable. Full-replace writes. Seed: `enabled=true`, `sort_order=2`. +- **MEMORY.md** — what the agent has decided matters enough to keep. Active projects, unresolved decisions, open threads, things you asked it to remember. Updated by both the extractor and the consolidator. Seed: `enabled=true`, `sort_order=3`. + +::: tip New in 1.3.0: workflows can write memory +From v1.3.0, the [workflow](./workflow) `write_memory` step can write the run's output directly into an employee's `MEMORY.md` (or any enabled memory file) when the flow completes. Four merge strategies: `append` / `replace_section` / `upsert_kv` / `overwrite`. Memory is no longer written exclusively by the conversation extractor or the Dreaming consolidator — a business-process outcome can be persisted too. +::: + +### Daily notes + +Conversation highlights archived by date, in append mode — multiple conversations in one day concatenate into the same file. Not injected into the system prompt (`enabled=false`). They exist so the consolidator has something to scan at 2 AM. + +--- + +## Short-term: the context window + +Before every LLM call, MateClaw builds the prompt that actually gets sent: + +``` +[System Prompt] ← Always first +[Workspace file injection] ← AGENTS / SOUL / PROFILE / MEMORY +[Conversation context summary] ← Only if earlier turns got compressed +[Message 1: user] +[Message 2: assistant] +... +[Current user message] ← Always last +``` + +Workspace files are injected sorted by `sort_order`, formatted as: + +``` +--- AGENTS.md --- +(content) + +--- SOUL.md --- +(content) + +--- PROFILE.md --- +(content) + +--- MEMORY.md --- +(content) +``` + +Only files with `enabled=true` are included. + +### When context gets too big + +Three-stage defense: + +**Stage 1 — proactive compression.** When estimated total exceeds 75% of the budget (default window 128k tokens), the system calls the LLM to summarize earlier turns. The most recent 2 turns (4 messages) survive verbatim. The summary is cached for 30 minutes. + +**Stage 2 — emergency recovery.** If the LLM still returns context-too-large, the system stops calling the LLM. It discards older messages, keeps the last 2 turns, and retries once. + +**Stage 3 — hard trim.** If tokens are *still* over budget, messages drop from the front until the prompt fits. The last 2 messages are always preserved. + +> **Security design** — the summary is injected as a **user message**, not a system message. Deliberate: preventing compressed historical user input from being elevated into system-level instructions eliminates an injection vector. + +### Configuration + +```yaml +mate: + agent: + conversation: + window: + default-max-input-tokens: 128000 # Global max + compact-trigger-ratio: 0.75 # Compression trigger + preserve-recent-pairs: 2 # Turns preserved verbatim + summary-max-tokens: 300 # Compression budget +``` + +--- + +## Post-chat extraction + +After a conversation ends, the system asynchronously pulls out what's memorable and writes it to PROFILE.md, MEMORY.md, and the day's daily note. This happens off the user-response path — it never blocks the next turn. + +### What triggers it + +After a turn completes, the system handles extraction on a background thread. A few preconditions must pass before it actually runs: + +- Auto-summarize is on +- The conversation wasn't itself triggered by the consolidation cron job (avoids recursion) +- Message count meets the minimum (default 4) +- The last user message is long enough (default at least 10 chars) + +All pass — extraction begins. + +### Concurrency control + +- **Cooldown** — same agent won't extract twice within 5 minutes (default) +- **Per-agent lock** — if an extraction is already running for this agent, the new request is skipped + +### What the LLM actually does + +1. Load conversation messages +2. Read current PROFILE.md, MEMORY.md, today's daily note +3. Build a transcript: up to 30 messages, each truncated to 2000 chars +4. Call the LLM with the memory-summarize prompt templates +5. Parse the JSON response +6. Apply writes + +### LLM response schema + +| Field | Type | What it does | +|-------|------|--------------| +| `should_update` | boolean | Whether memory needs updating | +| `reason` | string | Why (for audit) | +| `daily_entry` | string | Content to append to today's daily note | +| `memory_update` | string | Full new content for MEMORY.md | +| `profile_update` | string | Full new content for PROFILE.md | + +### File write rules + +- **PROFILE.md** — full replace, only if `profile_update` is non-empty +- **MEMORY.md** — full replace, only if `memory_update` is non-empty +- **memory/YYYY-MM-DD.md** — append, created with date heading if missing + +--- + +## Consolidation and dreaming + +The third layer runs on a schedule. Its job is to watch daily notes pile up and periodically ask: *what's the pattern here, what should be promoted into core memory, what's stale and should be forgotten?* + +### What it does + +1. Lists the agent's `memory/*.md` files, takes the most recent 7 days +2. Reads those + the current MEMORY.md +3. Calls the LLM with the consolidation prompt templates +4. The LLM returns `{should_update, reason, memory_content}` +5. If `should_update` is true, MEMORY.md is fully replaced + +### Trigger methods + +- **Automatic** — every agent has a row in the system's scheduled jobs, set to run nightly at 2 AM +- **Manual** — `POST /api/v1/memory/{agentId}/emergence` + +### Why it's not recursive + +Consolidation triggers a "conversation" through the agent. Without protection, that conversation would re-trigger the post-chat extraction listener, which would trigger another conversation, ad infinitum. + +The event carries a trigger-source flag. The extraction listener sees that the conversation was started by the consolidation job and skips it. + +### DREAMS.md — the consolidation diary + +Each consolidation run appends a short entry to `workspace/{agentId}/DREAMS.md`: + +- what it looked at +- what patterns it found +- what changed in MEMORY.md +- the date + +Human-readable audit trail — open DREAMS.md and see *how* the memory got to its current state. Caps its own growth; old entries get summarized when the file exceeds a threshold. + +### Scored emergence and recall tracking + +Consolidation tracks: + +- **Which memory entries were actively recalled** in recent conversations — read patterns feed back into importance +- **Scored emergence** — candidate patterns ranked by frequency + recency + explicit recall, only high-scoring ones make it into MEMORY.md +- **Multi-gate filtering** — low-signal extractions (one-off mentions, contradictions, things the user later corrected) get filtered before becoming memory +- **Dreaming status API** — `GET /api/v1/memory/{agentId}/dreaming/status` + +### Full lifecycle (opt-in via flag) + +Memory grows from "dream nightly" to a complete turn-by-turn lifecycle. This behavior lands behind feature flags — default off in the open-source build, on in production builds. + +What it does: + +- **Every turn is bookkept** — the system takes notes at the start and end of every turn, not just at nightly consolidation +- **Fact projection** — conversations are projected into structured "fact" rows the agent can query. Trust scoring + decay built in. +- **Structured nightly report** — consolidation produces a full report; you can re-consolidate by topic on demand +- **Morning card** — the first conversation of the day surfaces yesterday's report; you Confirm / Edit / Forget each fact +- **Contradiction inbox** — when new facts conflict with old ones, you get a queue instead of silent overwrites +- **Explicit forget** — say "forget that," and it actually forgets, everywhere +- **Feedback scoring** — thumbs up/down on retrieved facts feeds back into trust +- **SOUL auto-evolution** — the agent's persona file rewrites itself from accumulated facts +- **Monthly archive** — old reports roll into a compressed monthly archive, browsable in the timeline +- **Memory Browser** — timeline, facts, contradictions, diff viewer, and a trust bar across the top + +Enable in `application.yml`: + +```yaml +mateclaw: + memory: + dream-v2: + enabled: true + fact-projection: true + contradictions: true + morning-card: true +``` + +--- + +## Agents reading and writing their own memory + +Memory isn't just something that *happens to* an agent. The agent itself can actively read and write its own files during a conversation, through a set of workspace memory tools: + +| Method | What it does | +|--------|--------------| +| `list_workspace_memory_files` | List files, optional filename prefix filter, sorted by `sort_order` | +| `read_workspace_memory_file` | Read a specific file's content | +| `write_workspace_memory_file` | Create or overwrite a file (full replace) | +| `edit_workspace_memory_file` | Find-and-replace edit (incremental, `replaceAll` supported) | + +### Keyword search over its own memory + +::: tip New in 1.4.0 +An employee can do more than read whole files — during a conversation it can **search all of its workspace memory files by keyword** and jump straight to the line. +::: + +This is an agent runtime capability: the employee supplies a keyword and the system searches across its own workspace memory files: + +- **Tokenization** — CJK is split into 2-character sliding windows, Latin text on whitespace, so both languages match +- **Per-file weighted scoring** — hits in core files like `AGENTS.md` / `MEMORY.md` / `PROFILE.md` rank above hits in the daily ledger +- **What comes back** — each hit gives a filename + line number + an 80-char context snippet (matched term highlighted) + a relevance score +- **Scan scope** — up to ~50 candidate files, sorted by score, highest first + +Use it when the employee wants to confirm "did I note this before?" or recover a specific decision spread across many days of notes — without pulling whole files into context. + +### Examples + +**List:** + +```json +// in +{"agentId": 1, "filenamePrefix": "memory/"} +// out +{"agentId": 1, "count": 3, "files": [ + {"filename": "memory/2026-04-09.md", "enabled": false, "fileSize": 512}, + ... +]} +``` + +**Read:** + +```json +// in +{"agentId": 1, "filename": "MEMORY.md"} +// out +{"agentId": 1, "filename": "MEMORY.md", "enabled": true, "content": "..."} +``` + +**Edit:** + +```json +// in +{"agentId": 1, "filename": "MEMORY.md", "oldText": "old", "newText": "new"} +// out +{"agentId": 1, "filename": "MEMORY.md", "replacements": 1} +``` + +### Safety rules + +- `.md` files only +- No absolute paths, no `..` directory traversal +- `write` is a full overwrite — read first if you care about existing content +- Newly created files have `enabled=false` by default + +--- + +## Memory snapshot export / import + +::: tip New in 1.4.0 +An employee's entire accumulated memory can be packaged into a ZIP and taken with you — for backup, migration to another deployment, or cloning a coworker who "already knows you." +::: + +A snapshot packages an employee's core memory into a single ZIP: + +- `AGENTS.md` / `MEMORY.md` / `PROFILE.md` / `SOUL.md` / `KNOWLEDGE.md` +- daily ledger files (`memory/YYYY-MM-DD.md`) +- a `manifest.json` (what's in the package, and which employee it came from) + +### Three endpoints + +| Method | Path | Role | What it does | +|--------|------|------|--------------| +| GET | `/api/v1/agents/{agentId}/workspace/memory/export` | Viewer | Export the ZIP — even read-only access can take a backup | +| POST | `.../workspace/memory/import/preview` | Member | **Dry run**: parse the ZIP, classify each file as create / update / skip, write nothing | +| POST | `.../workspace/memory/import` | Member | Apply the import, written **atomically** | + +Preview to see the diff, confirm, then import — you always know what will change before it does. + +### Safety guards + +- **Whitelist** — only the file types listed above are accepted; everything else is ignored +- **Zip-bomb guards** — ≤ 500 entries, ≤ 1 MB each (uncompressed), ≤ 16 MB total; anything over is rejected +- **UI toggle state is not serialized** — `enabled` / `sortOrder` are kept out of the snapshot; on import into a new employee the target decides them by seed rules, rather than forcing the source's toggle state + +### UI + +- The **Agent Context page right panel** has **Export / Import** buttons +- Import shows a **diff** first (what's created, overwritten, skipped) and only writes after you confirm + +--- + +## Configuration reference + +### Memory extraction & consolidation + +```yaml +mate: + memory: + # --- Automatic extraction --- + auto-summarize-enabled: true + min-messages-for-summarize: 4 + min-user-message-length: 10 + skip-cron-conversations: true + summary-max-tokens: 1000 + max-transcript-messages: 30 + + # --- Concurrency --- + cooldown-minutes: 5 + + # --- Consolidation / dreaming --- + emergence-enabled: true + emergence-day-range: 7 +``` + +Prefix: `mate.memory`. + +### Context window + +```yaml +mate: + agent: + conversation: + window: + default-max-input-tokens: 128000 + compact-trigger-ratio: 0.75 + preserve-recent-pairs: 2 + summary-max-tokens: 300 +``` + +--- + +## API endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/api/v1/memory/{agentId}/emergence` | Manually trigger consolidation | +| POST | `/api/v1/memory/{agentId}/summarize/{conversationId}` | Manually trigger extraction | +| GET | `/api/v1/memory/{agentId}/dreaming/status` | Last run, next run, latest DREAMS.md entry | + +--- + +For developers extending the memory layer, see [Architecture](./architecture). + +--- + +## Next + +- [Agents](./agents) — how agents use memory during a turn +- [LLM Wiki](./wiki) — the *deliberate* knowledge layer, contrasted with passive memory +- [Tools](./tools) — the workspace memory tool is one of many +- [Configuration](./config) — full config reference +- [Architecture](./architecture) — backend code organization, SPI extension points diff --git a/mateclaw-server/src/main/resources/docs/en/model3d.md b/mateclaw-server/src/main/resources/docs/en/model3d.md new file mode 100644 index 00000000..cd5029c8 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/model3d.md @@ -0,0 +1,225 @@ +# 3D Model Generation + +Text-to-3D and image-to-3D in one tool call. Configure the credentials once and the agent can call `model3d_generate` to produce a `.glb` model that lands directly in the chat bubble — interactive preview, drag to rotate. + +--- + +## What's in the box + +| Aspect | Detail | +|---|---| +| **Current provider** | Tencent Hunyuan 3D (`ai3d.tencentcloudapi.com`, region `ap-guangzhou`) | +| **Available models** | `HY-3D-3.1` / `HY-3D-3.0` / `HY-3D-Express` | +| **Output format** | `.glb` (binary GLTF, single file with embedded textures, rendered inline by ``) | +| **Latency** | 1–3 minutes (Pro slower, Rapid faster) | +| **Auth scheme** | TC3-HMAC-SHA256 (SecretId + SecretKey) | + +Routing is automatic based on the `model` argument: + +| Model | Backend Action | Notes | +|---|---|---| +| **HY-3D-3.1** (default) | `SubmitHunyuanTo3DProJob` | Highest quality. Supports PBR materials, multi-view input, white-model (`GenerateType=Geometry`) | +| **HY-3D-3.0** | same | Older Pro variant, shares the call site | +| **HY-3D-Express** | `SubmitHunyuanTo3DRapidJob` | Fastest. Accepts only `Prompt` or `ImageUrl` | + +--- + +## 1. Get Tencent Cloud credentials + +The Hunyuan 3D API requires traditional CAM credentials (**not** the OpenAI-style `sk-xxx` Bearer keys). You need a SecretId + SecretKey pair. + +1. Open **[Cloud Access Management → API Keys](https://console.cloud.tencent.com/cam/capi)** +2. Click **Create Key**. Tencent gives you both: + - `SecretId` (starts with `AKID`, ~36 chars) + - `SecretKey` (~32 chars) +3. **Save both** — the SecretKey is shown only once and cannot be retrieved later. + +::: tip About `sk-xxx` keys from "API Key 管理" +The Tencent console has another page called "API Key Management" that issues single `sk-` prefixed Bearer tokens. **Those are scoped to TokenHub** (`tokenhub.tencentmaas.com`) for OpenAI-compatible chat completions and **cannot be used for Hunyuan 3D**. 3D requires the SecretId + SecretKey pair from CAM above. +::: + +## 2. Activate the Hunyuan 3D service + +Open **[Hunyuan 3D Console](https://console.cloud.tencent.com/ai3d)**. The first visit asks you to accept the service agreement / activate the free tier. + +Skipping this step results in: + +``` +[Hunyuan3D] SubmitHunyuanTo3DProJob failed: ResourceInsufficient +``` + +Some variants (notably `HY-3D-3.1`) may require a separate quota application or paid plan — check the console's quota dashboard. + +## 3. Configure credentials in MateClaw + +1. Open the **Models & Credentials** page and locate the **Tencent Hunyuan 3D** card (auto-registered by the V71 migration). +2. Click **Update** / **Configure**. +3. Paste the API Key as **`SecretId:SecretKey`** (single colon, no spaces): + ``` + AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:abcdefghijklmnopqrstuvwxyz123456 + ``` +4. Leave **Base URL** at the default `https://ai3d.tencentcloudapi.com` (the system handles regional routing automatically). +5. Save. + +::: warning Single-input compromise +The provider card currently exposes a single API Key field. A future improvement will split this into separate SecretId / SecretKey inputs that auto-join with `:` on save. +::: + +## 4. Enable the 3D feature + +Go to **Settings → 3D Generation**: + +- **Enable 3D model generation**: on +- **Preferred 3D provider**: pick `Tencent Hunyuan 3D` +- **Provider fallback**: leave on (only one provider exists today, but the toggle is forward-looking) + +Click **Save System Settings**. + +## 5. Try it in chat + +Speak naturally — the agent picks `model3d_generate` automatically: + +``` +Generate a 3D model: a cute cartoon dinosaur, green, with big round eyes +``` + +``` +Quickly generate a 3D model of a red apple ← LLM selects HY-3D-Express +``` + +``` +Generate a 3D model from this image: https://example.com/foo.png ← image-to-3D +``` + +``` +Generate a white-model 3D (no textures): a mechanical gear ← Geometry mode +``` + +Expected flow: + +1. **Tool returns immediately** (milliseconds) with a `taskId=xxx`. +2. **Backend worker polls Tencent every 8 seconds** asynchronously. +3. **1–3 minutes later** the `async_task_completed` SSE event lands in the conversation. +4. **`` renders the `.glb`** inline — drag, rotate, zoom. + +--- + +## Tool parameters (`model3d_generate`) + +| Param | Type | Required | Description | +|---|---|---|---| +| `prompt` | String | Yes\* | Text description, up to 1024 UTF-8 chars | +| `imageUrl` | String | Yes\* | Reference image URL (image-to-3D mode) | +| `model` | String | No | `HY-3D-3.1` (default) / `HY-3D-3.0` / `HY-3D-Express` | +| `enableTexture` | Boolean | No | `true` (default) / `false` (white-model, Pro only) | +| `enablePbr` | Boolean | No | `true` enables PBR materials (richer rendering, Pro only, default `false`) | + +\* Either `prompt` or `imageUrl` is required (XOR — Pro doesn't accept both, except in Sketch mode which is not exposed yet). + +--- + +## Troubleshooting + +### 1. `3D 模型生成功能未启用,请在系统设置中开启` / "3D generation is not enabled" + +→ The feature toggle is off. Go to **Settings → 3D Generation** and enable it. + +### 2. `Provider api_key must be "SecretId:SecretKey" (colon-joined)` + +→ Wrong credential format. You probably saved one of: +- A single SecretId (no SecretKey appended) +- A single SecretKey +- A single `sk-xxx` token (that's a TokenHub key, not for 3D) +- Used a space instead of `:` + +Correct: `AKIDxxxx...:zzzz...` — exactly one ASCII colon, no whitespace. + +### 3. `ResourceInsufficient` (资源不足) + +→ Tencent-side business error. Possible causes: +- Hunyuan 3D service not activated yet +- Free quota exhausted +- The selected model (especially `HY-3D-3.1`) requires approval / a paid plan + +Check the [Hunyuan 3D Console](https://console.cloud.tencent.com/ai3d) for quota status. + +### 4. `invalid params, first_frame_image` + +→ Tencent couldn't fetch your `imageUrl`. The URL must be reachable from the public internet — `localhost`, internal IPs, and signed URLs that have expired won't work. Confirm: +- The URL opens directly in an incognito browser window +- File type is `jpg/png/jpeg/webp`, resolution 128–5000px per side, ≤8MB + +### 5. Task hangs for 15 minutes + +→ The worker times out at 15 min by default. Check backend logs: + +```bash +grep '\[Hunyuan3D\]\|\[Model3dGen\]' logs/mateclaw.log | tail -10 +``` + +If polling is stuck on `RUN`/`WAIT`, restart the backend (in-flight tasks get marked failed on startup). + +### 6. Generation succeeds but the bubble shows only a download link, no interactive preview + +→ Tencent returned an OBJ bundle (.zip with OBJ + textures + MTL) instead of a single GLB. Our code prefers GLB (`pickBestResultFile`), but if Tencent only returns OBJ for that request, the frontend falls back to a download link. **Defaulting to `HY-3D-3.1` usually yields a GLB.** + +--- + +## Architecture (one-pager) + +``` +[ user ] ── natural language ─▶ [ agent ] ─▶ model3d_generate + │ + (route on model field) + │ + ┌───────────────────────────────┴──────────────┐ + ▼ ▼ + SubmitHunyuanTo3DProJob SubmitHunyuanTo3DRapidJob + (HY-3D-3.1 / HY-3D-3.0) (HY-3D-Express) + │ │ + └──────────────── ai3d.tencentcloudapi.com ────┘ + │ + returns JobId (24-h URL) + │ + AsyncTaskService polls Query{Pro,Rapid}HunyuanTo3DJob every 8 s + │ + status → DONE? ─▶ ResultFile3Ds[] + │ + pick best: GLB > FBX > OBJ + │ + download to data/chat-uploads/ + │ + write mate_message (type=model3d) + │ + broadcast SSE async_task_completed + │ + ▼ + frontend useChat detects modelUrl ─▶ MessageBubble bridges virtual attachment + │ + renders the .glb +``` + +--- + +## Log markers for a successful run + +``` +[ToolExecutor] Executing tool: model3d_generate +[Hunyuan3D] SubmitHunyuanTo3DProJob submitted job: 1441791994... (model=HY-3D-3.1) +[AsyncTask] Created task d73723f14c7c4167 (providerTaskId=pro:1441791994...) +[AsyncTask] Started polling for task d73723f14c7c4167 (interval=8s, timeout=15min) +[ToolExecutor] Tool model3d_generate returned 80 chars +…1–3 minutes… +[Model3dDownloader] Downloading 3D model from https://hunyuan-prod-….cos.../...glb to data/chat-uploads/.../model_d73723f14c7c4167.glb +[Model3dDownloader] Downloaded NNNN bytes +[Model3dGen] Task d73723f14c7c4167 completed, model saved: /api/v1/chat/files/.../model_d73723f14c7c4167.glb +``` + +--- + +## See also + +- [Multimodal Overview](./multimodal.md) +- [Models & Credentials](./models.md) +- [Tools System](./tools.md) +- Design RFC: `rfcs/202605/01-generative-async-pipeline.md` diff --git a/mateclaw-server/src/main/resources/docs/en/models.md b/mateclaw-server/src/main/resources/docs/en/models.md new file mode 100644 index 00000000..2d036e52 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/models.md @@ -0,0 +1,482 @@ +# Models + +**Pick a model. Just one. Add more later.** + +MateClaw doesn't care which LLM you use. It talks to every mainstream provider through five protocol adapters, supports 15+ cloud providers and 4 local runtimes, and lets you swap models at runtime without touching agent configuration. The only opinion MateClaw has is that you should **start with one and add more when you need them** — not configure everything on day one. + +--- + +## What's supported + +### Cloud providers + +| Provider | Example models | Protocol | Notes | +|----------|---------------|----------|-------| +| **DashScope** (Alibaba) | Qwen-Max, Qwen-Plus, Qwen-Turbo, Qwen-VL, Qwen-Long | dashscope | Default out of the box | +| **DashScope (OpenAI-compatible)** | Qwen3.5-Plus, Qwen3.6-Plus, Qwen3 VL Plus, etc. (dot-versioned families) | openai | See "Two DashScope variants" below | +| **Bailian Token Plan** | Bailian token-bundle plan | dashscope | 7 seeded models; long tokens supported | +| **OpenAI** | GPT-4o, GPT-4o-mini, GPT-5.5, o1, o3, o4-mini | openai | Standard OpenAI API | +| **OpenAI OAuth (ChatGPT Plus/Pro)** | GPT-4o, o3, o4-mini via subscription | openai | Browser-based OAuth — no API key | +| **Anthropic** | Claude 4.7, Claude 4.6 Sonnet, Claude 4.5 Haiku | anthropic | Native Messages API | +| **Anthropic Claude Code OAuth** | Claude 4.7 / 4.6 via Claude Pro/Max/Team subscription | anthropic | Browser OAuth + manual-paste flow — no API key | +| **Google Gemini** _(native)_ | gemini-2.5-flash, gemini-3-pro-image-preview, gemini-2.5-flash-image | gemini | Native `generateContent` API (not OpenAI-compatible) — see "Native Gemini" below | +| **xAI / Grok** | Grok 3, Grok 4 | openai | OpenAI-compatible (base URL + API key); xAI brand icon in the UI | +| **DeepSeek** | deepseek-chat, deepseek-coder, **DeepSeek V4 flash + pro** (thinking-mode) | openai | OpenAI-compatible | +| **Kimi (Moonshot)** | moonshot-v1-8k/32k/128k | openai | OpenAI-compatible | +| **Zhipu AI** | GLM-5-Turbo, GLM-5V-Turbo, GLM-5, GLM-5.1 | openai | OpenAI-compatible | +| **MiniMax** | abab6.5, abab5.5; expanded video catalog + CN endpoint | openai | OpenAI-compatible | +| **SiliconFlow CN/INTL** | Routed inference across hosted models | openai | Two endpoints, OpenAI-compatible | +| **OpenCode** | Code-tuned routing | openai | OpenAI-compatible | +| **OpenRouter** | 200+ models with free tier | openai | Routes to any upstream with one key | +| **Any OpenAI-compatible** | Your own vLLM, etc. | openai | Custom base URL | + +### Local runtimes + +| Runtime | Example models | Protocol | Notes | +|---------|---------------|----------|-------| +| **Ollama** | Gemma 3/4, Qwen 3, Llama 3.1, DeepSeek R1, Mistral | ollama | **Auto-detected at startup** on `localhost:11434` | +| **LM Studio** | Any GGUF model | openai | OpenAI-compatible server | +| **llama.cpp** | Any GGUF model | openai | Via llama-server | +| **MLX** | Apple Silicon via mlx-lm | openai | mlx-lm's OpenAI-compatible server | + +### Protocol adapters + +Five protocols cover everything: + +| Protocol | Used by | +|----------|---------| +| **OpenAI** | OpenAI, Kimi, DeepSeek, MiniMax, Zhipu, OpenRouter, LM Studio, llama.cpp, MLX | +| **Anthropic** | Claude family | +| **DashScope** | Qwen family | +| **Gemini** | Google Gemini family | +| **Ollama** | Locally hosted models via Ollama | + +Any OpenAI-compatible service works — just point `base-url` at it. + +--- + +## Two DashScope variants + +Same `sk-` API key, **two endpoints** that ship different model families: + +| Item | DashScope | DashScope (OpenAI-compatible) | +|---|---|---| +| Endpoint | `dashscope.aliyuncs.com/api/v1` (native) | `dashscope.aliyuncs.com/compatible-mode/v1` (OpenAI-compatible) | +| Protocol | DashScope native | OpenAI standard (same shape as GPT-4 / DeepSeek / Kimi) | +| Built-in web search (`enable_search`) | ✅ Supported | ❌ Not supported | +| Models | Qwen-Max / Plus / Turbo / Long, Qwen-VL, Qwen3-Max, DeepSeek-V3.2, etc. | **Dot-versioned** new families: Qwen3.5-Plus, Qwen3.6-Plus, Qwen3 VL-Plus, etc. | + +**Why two providers**: Alibaba publishes the dot-versioned families (`qwen3.5-*` / `qwen3.6-*` / `qwen3-vl-*`) only on the OpenAI-compatible endpoint; the native protocol returns `400 InvalidParameter` for them. The two providers **share the same sk- key** — paste it once, it works for both. + +**Which to pick**: +- Want Qwen-Max / Plus / Turbo + built-in search / DeepSeek-V3.2 → **DashScope** +- Want Qwen3.5-Plus / Qwen3.6-Plus / Qwen3 vision-language → **DashScope (OpenAI-compatible)** +- **Enable both** if you want — same key, models just appear under different cards + +--- + +## Native Gemini + +::: tip New in 1.4.0 +Gemini no longer rides on an OpenAI-compatibility shim — MateClaw talks to Google's **native `generateContent` API** directly. +::: + +Plenty of products bolt Gemini on as "just another OpenAI-compatible endpoint" and then hit walls around system instructions, function calling, and inline images. MateClaw speaks Gemini's own protocol instead: + +- **Native chat builder** — maps `systemInstruction`, `functionCall` / `functionResponse` (tool-call turns), and inline image parts (multimodal input) correctly +- **Streaming SSE parsing** — parses Gemini's streaming response format chunk by chunk +- **JSON Schema sanitizing** — automatically strips JSON Schema keywords Gemini rejects, so tool definitions aren't refused +- **Startup liveness probe** — sends a lightweight request at startup to confirm the credentials and model are reachable + +Configure it under `Settings → Models → Add Provider`, pick the **Gemini** provider, paste your API key. Example models: `gemini-2.5-flash`, `gemini-3-pro-image-preview`, `gemini-2.5-flash-image`. Image generation runs through the same native path — see [Multimodal → Image generation](./multimodal#image-generation-six-providers). + +--- + +## Adding a provider + +**A fresh MateClaw install has an empty provider list. That's deliberate.** + +You don't need to see 16 providers. You need **one that works.** + +`Settings → Models → Add Provider` opens a drawer with the full catalog. Local runtimes (Ollama, LM Studio, llama.cpp, MLX — no API key required) appear first; cloud providers (DashScope, OpenAI, Anthropic, DeepSeek, etc.) follow. + +Three steps: + +1. **Find the row you want and click Enable** — the provider joins your main list +2. **Fill in the base URL** (pre-filled for known providers) **and paste your API key** — encrypted at rest, masked in UI +3. **Save → Test Connection** — the system sends a lightweight request and reports success or error + +Close the drawer and the main list shows only the providers you've enabled. **Model picker, chat page, agent editor — every place that surfaces models, surfaces only the ones you opted in.** + +::: tip Existing installs (V55 migration) +Providers already in use are **not** turned off. V55 auto-marks a provider as enabled if any of these are true: +- Has a real API key configured +- Has an OAuth token +- Has been used by a chat session in the last 30 days +- Owns the current default model + +Untouched, never-used placeholder providers go back into the drawer — flip them on the next time you need them. +::: + +--- + +## Enabling / disabling a provider + +Every provider card in the main list has an **Enable / Disable** toggle. **You must enable a provider before you can use it** — that's the core product contract from v1.1.0 onward. + +- **Disable** — the provider disappears from the model picker, chat page, and agent editor immediately. **Configuration is preserved**; flip it back on and everything is exactly where you left it. +- **If you disable the provider that owns the current default model**, the system automatically promotes a model on a still-enabled provider as the new default — no broken next-message. +- **Enable** — the provider reappears everywhere. If it has never had an API key set, you'll be prompted to configure it. + +This separates "I have a key for this provider but I'm not using it today" from "I don't have this provider." Switching providers temporarily no longer means deleting configuration. + +### ChatGPT OAuth — no API key needed + +Have a ChatGPT Plus or Pro account? MateClaw can talk to OpenAI's chat endpoint through **browser-based OAuth** — log in the way you normally would, your subscription is used directly. GPT-4o, o3, and o4-mini become available immediately. + +`Settings → Models → Add Provider → OpenAI OAuth`. A browser window opens. Token exchange happens on the backend; **credentials never leave your machine**. + +### Device authorization grant — for remote / headless deployments + +Browser-callback OAuth needs the IDP's redirect to land back on a `localhost` port that *your* browser can reach. That's fine when MateClaw runs on your laptop and breaks the moment you put it on a server, in a container, or on a host that doesn't expose a loopback socket to your client. + +For those cases, OpenAI OAuth automatically switches to **Device Authorization Grant (RFC 8628)** — the same flow ChatGPT desktop and `gh auth login` use. No callback, no port mapping. + +`Settings → Models → Add Provider → OpenAI OAuth` on a non-localhost host pops a dialog showing: + +- A short **user code** (monospace, copyable) +- A **verification URL** at `auth.openai.com/codex/device` — open it in any browser on any device +- A live **countdown** until the device code expires (default 15 min) + +Enter the user code in your browser, authorize, and the dialog closes itself the moment the backend's poll loop sees `COMPLETED`. + +**How MateClaw decides which flow to use:** + +| `mateclaw.oauth.openai.deployment-mode` | Behaviour | +|---|---| +| `auto` *(default)* | `localhost` / `127.0.0.1` / `::1` → browser callback; everything else → device code | +| `local` | Force browser callback (loopback server) | +| `device_code` | Force device code | +| `manual_paste` | Force the legacy paste-the-callback-URL flow | + +If `local` mode can't bind a loopback port (port in use, sandbox refused), it falls through to `manual_paste` automatically. + +**Backend endpoints** (`/api/v1/oauth/openai/device`): + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/start` | Begin a session — returns `deviceAuthId`, `userCode`, `verificationUrl`, `intervalSeconds`, `expiresInSeconds` | +| `POST` | `/poll` | Poll one session by `deviceAuthId` — returns `PENDING` / `COMPLETED` / `EXPIRED` | +| `POST` | `/cancel` | Drop the session (e.g. user closed the dialog) | + +The frontend respects the `intervalSeconds` OpenAI returns (typically 5 s); the server enforces a min poll interval (default 3 s) to keep load bounded. Expired sessions are swept every 5 minutes. + +Token persistence and refresh use the **same code path** as the browser-callback flow, so once the dialog closes there's no behavioural difference. + +### Anthropic Claude Code OAuth + +Same pattern, same outcome: have a Claude Pro / Max / Team subscription? Sign in with the **same OAuth flow Claude Code itself uses** — no `sk-ant-…` API key required. Claude 4.7 / 4.6 / 4.5 Haiku come online through your subscription. + +`Settings → Models → Add Provider → Anthropic Claude Code OAuth`. Two flows are supported: + +- **Browser callback** — local install, browser pops up, you click through, token lands in MateClaw +- **MANUAL_PASTE** — for remote-server deployments where the browser can't reach the backend, you complete the auth in your local browser and paste the token in + +Anti-abuse-gate compliant: Claude Code identity is injected into the system prompt, the request shape (UA / accept headers / `system` array form / `mcp_` tool-name prefixes) matches Claude Code's wire format exactly so the requests aren't rejected. + +--- + +## Model discovery + +Providers that expose a model list (OpenAI, Ollama, LM Studio, OpenRouter, etc.) support **Model Discovery** — one click and MateClaw fetches every model the provider offers. + +- `Settings → Models → [provider card] → Discover Models` +- System queries the provider's `/v1/models` endpoint +- Discovered models appear with name, context window, pricing +- Add them one by one or all at once + +For OpenRouter specifically, Model Discovery surfaces the **200+ free-tier models** — pick a free model and you have a working setup with zero cost. + +### Ollama auto-detection on startup + +No manual configuration needed. On startup: + +1. **Ping** `http://127.0.0.1:11434` +2. **Discover** — fetch pulled models via `/v1/models` +3. **Register** — add to `mate_model_config` +4. **Enable** — auto-enable matching pre-configured models +5. **Tag rewrite** — rewrites seed `:latest` tags to actual installed versions (`deepseek-r1:latest` → `deepseek-r1:7b`), no more `model not found` 404s + +If Ollama isn't running, silently skipped. + +::: tip Default behavior +- Models without tool support (`deepseek-r1`, `gemma*`, `phi3/4`, etc.) won't accidentally activate as default — they're blocklisted +- Models that are not callable on DashScope native protocol are auto-purged on startup; dot-versioned Qwen families now live on the DashScope (OpenAI-compatible) provider instead +- DashScope model discovery uses protocol-aware probing, skipping non-chat modalities +::: + +**Pre-configured Ollama models** (disabled until discovered, then auto-enabled): + +| Model | `model_name` | +|-------|-------------| +| Gemma 3 | `gemma3:latest` | +| Gemma 4 | `gemma4:latest` | +| Qwen 3 | `qwen3:latest` | +| Llama 3.1 | `llama3.1:latest` | +| DeepSeek R1 | `deepseek-r1:latest` | +| Mistral | `mistral:latest` | + +Setup: + +```bash +# Install Ollama from ollama.com, then: +ollama pull gemma3 +ollama pull qwen3 +``` + +Restart MateClaw. Auto-discovered, added, enabled. + +--- + +## Database schema + +### `mate_model_provider` + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `name` | Provider identifier | +| `display_name` | Human-readable name | +| `protocol` | `dashscope` / `openai` / `ollama` / `anthropic` / `gemini` | +| `base_url` | API base URL | +| `api_key` | Encrypted API key | +| `oauth_tokens` | OAuth tokens (ChatGPT Plus/Pro) | +| `is_local` | True for local runtimes | +| `enabled` | Provider master switch — when off, hidden from every model picker; configuration is preserved (v1.1.0+) | + +### `mate_model_config` + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `provider_id` | FK to `mate_model_provider` | +| `model_name` | Actual model identifier | +| `display_name` | Human-readable name | +| `temperature` | Default temperature (0.0 – 2.0) | +| `max_tokens` | Max output tokens | +| `top_p` | Top-p sampling | +| `group_name` | UI grouping (e.g., "Reasoning", "Fast", "Vision") | +| `enabled` | Whether the model is available | + +### Embedding models + +No `EMBEDDING_API_KEY` env vars. Embedding models are regular rows in `mate_model_config` with `model_type='embedding'`. They show up alongside chat models in `Settings → Models`. Knowledge bases pick their embedding model from a dropdown. + +::: tip New in 1.4.0 ([issue #79](https://github.com/matevip/mateclaw/issues/79)) +**Embedding models from any provider.** In the embedding section of `Settings → Models`, configure an embedding model from any provider — it **reuses that provider's API key**, so there's no separate `EMBEDDING_API_KEY`. Each knowledge base picks its embedding model from a dropdown. Keyless local proxies use a no-op placeholder key; the protocol is resolved from the provider's chat-model / protocol setting, so you never hand-enter it. +::: + +### Anthropic prompt caching + +System prompts, agent personas, tool definitions — automatically marked with `cache_control: ephemeral` on Anthropic-compatible endpoints. First request warms the cache, every follow-up gets a cache hit. The Dashboard tracks `cache_read_tokens` / `cache_write_tokens` daily. + +### Thinking depth / `reasoning_effort` + +**Which models honor this parameter**: `reasoning_effort` is only valid for the OpenAI reasoning family (`gpt-5*` / `o1*` / `o3*` / `o4*`), and only when delivered through the OpenAI or Azure-OpenAI providers. Every other provider (DeepSeek, Kimi, DashScope, Ollama, self-hosted OpenAI-compatible gateways, etc.) will either error or behave oddly if this parameter reaches them. + +**Three product contracts**: + +1. **Chat models that don't support chain-of-thought** ignore the front-end "deep thinking = high" selector entirely — this is a capability property, not a UI setting. The thinking-depth selector automatically grays out when the current model is not reasoning-capable. +2. **`generateKwargs.reasoningEffort` at provider level** only takes effect on whitelisted providers. Setting it on DeepSeek / Kimi / other OpenAI-compatible providers is silently dropped with a WARN log; the parameter is never sent. +3. **Failover** re-checks at egress time: if the primary is GPT-5 and the fallback is DeepSeek, `reasoning_effort` is stripped before hitting DeepSeek, so leaked primary options can't 400 the fallback. + +**How to enable DeepSeek thinking**: DeepSeek's thinking mode does **not** use `reasoning_effort`. + +- `deepseek-reasoner`: thinking is on by default; no config needed. +- `deepseek-chat` with thinking: follow DeepSeek's official docs and set `{"thinking": {...}}` under the provider's `generateKwargs.extra_body`. **Do not** set `reasoningEffort`. + +**Kimi K2.5 thinking**: the model activates thinking natively; don't set `reasoning_effort`. + +**Multi-round tool calls + thinking**: thinking-capable models (DeepSeek-Reasoner / GPT-5 / Kimi K2.5 / Xiaomi MiMo) correctly round-trip historical `reasoning_content` during ReAct multi-round tool calls. Cross-user-turn history is cleared at the boundary, in-turn history is preserved — matching DeepSeek's "pass back within a turn, reset across turns" contract. + +**Xiaomi MiMo thinking-mode multi-turn fix** ([issue #189](https://github.com/matevip/mateclaw/issues/189)): MiMo's `reasoning_content` is now kept correctly across turns in thinking mode, instead of being lost on subsequent turns. + +--- + +## Grouped model selector + +When your deployment has a lot of models configured, the chat model picker groups them by provider and tag. Searchable dropdown lets you filter by name, provider, or group — "all Qwen", "all reasoning models", "everything under 7B". Groups are defined in the `group_name` column. + +Became a real thing when agents could be bound to different models per task — a reasoning model for Plan-Execute, a fast cheap model for Chat, a vision model for image understanding. + +--- + +## Active model switching at runtime + +MateClaw uses a single **active model** as the global default. Agents that don't specify their own use it. + +- **UI:** `Settings → Models → [model card] → Set as Active` +- **API:** `PUT /api/v1/models/active` + +Takes effect **immediately** — no restart. Next message uses the new model. In-flight conversations unaffected. + +Per-agent override supported: bind a specific agent to a specific model config. + +::: tip New in 1.4.0 +- **Per-conversation model selection** ([issue #150](https://github.com/matevip/mateclaw/issues/150)): in the chat UI you can switch the model for **just the current conversation**, without touching the global active model or any other conversation. See [Chat & Messaging](./chat). +- **A single bad model id no longer evicts the whole provider**: when discovery / probing hits one invalid model identifier, only that model is skipped — the rest of the provider's models stay available. +::: + +--- + +## Per-model testing + +Every model card has a **Test** button. Click it, system sends a simple prompt, shows: + +- Actual response text +- Latency +- Token usage +- Any error + +Use it whenever you add a new provider or suspect a stale key. + +--- + +## Multimodal sidecar (system-wide) + +::: tip Added in 1.3.0 +Lets a text-only primary model still answer questions about uploaded images. See [issue #87](https://github.com/matevip/mateclaw/issues/87). +::: + +Entry point: **Settings → Models → Multimodal sidecar**. Two independent cards: + +| Card | Purpose | Status | +|------|---------|--------| +| **Vision sidecar model** | Captions an uploaded image once, then hands the structured description to the primary chat model | Live | +| **Video sidecar model** | Same idea for video | Reserved (config persisted but not yet wired in v1) | + +The setting stores `mate_model_config.id` rather than `model_name` — the same `model_name` can exist under multiple providers (e.g. `qwen-vl-max` lives on both DashScope and an OpenAI-Compatible custom row), so a name-keyed setting would collide. Two setting keys: + +- `default.vision_model` +- `default.video_model` + +The dropdown only lists models that **actually support the relevant modality** — filtered by `ModelCapabilityService.supports(...)` on the backend; disabled providers or models without a declared vision capability never appear. Each card has its own Save button, independent of the other. + +When does it fire? `MultimodalRouter` ([source](https://github.com/matevip/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java)) decides per turn: + +- Primary already supports vision → no routing (native multimodal path) +- Primary lacks vision + vision sidecar configured → SIDECAR strategy, captions to text +- Primary lacks vision + no sidecar → skip the attachment + tell the user to configure one + +For the end-user flow (badge, hint above the input box) see [Chat → Primary model can't see images? "Multimodal sidecar" routing](./chat#primary-model-cant-see-images-multimodal-sidecar-routing). + +--- + +## Multi-model failover + +::: tip OpenAI was down for 30 minutes. My AI didn't stop for a second. +During the last 30-minute DashScope rate-limit hiccup, our service uptime was 100%. + +Users saw their answers come through cleanly — no red error toast, no "service unavailable, please try again." **Mid-answer, mid-token**, the runtime quietly rolled to the next healthy provider. The next token after the cut landed normally. + +This isn't "automatic retry" in the engineering sense. It's **failover the user can't perceive**. +::: + +Every provider you add joins an `AvailableProviderPool` that's probed at startup and re-probed on config change. + +- **Automatic fallback** — if the primary provider returns an `AUTH_ERROR`, `BILLING`, `MODEL_NOT_FOUND`, `NETWORK`, or `5xx`, the runtime rolls forward to the next provider in the chain instead of bubbling up the error +- **Per-agent priority** — bind an agent to "OpenAI first, then Anthropic, then DashScope" via the drag-to-reorder editor in `Settings → Models` +- **Live pool state** — green / amber / red badges show each provider's health +- **4-protocol probe** — DashScope, OpenAI-compatible, Anthropic, Ollama-style +- **Manual reprobe + auto-reprobe on config change** — no restart after rotating a key +- **Egress sanitizer** — provider-specific options (e.g., `reasoning_effort` for OpenAI reasoning models) are stripped at egress when failing over to a provider that doesn't support them, so leaked options can't 400 the fallback +- **UI distinguishes 401 from session expiry** — provider auth errors and user session expiry now show different messages with different remediation + +--- + +## Configuration via API + +```bash +# List enabled providers (what the main list shows) +curl http://localhost:18088/api/v1/models \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# List the full catalog (including disabled) — what the Add Provider drawer uses +curl http://localhost:18088/api/v1/models/catalog \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Enable a provider +curl -X POST http://localhost:18088/api/v1/models/{providerId}/enable \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Disable a provider (auto-switches default model if needed) +curl -X POST http://localhost:18088/api/v1/models/{providerId}/disable \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Add a model configuration +curl -X POST http://localhost:18088/api/v1/models \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "providerId": 1, + "modelName": "qwen-plus", + "displayName": "Qwen Plus", + "temperature": 0.7, + "maxTokens": 4096, + "groupName": "Fast", + "enabled": true + }' + +# Set active model +curl -X PUT http://localhost:18088/api/v1/models/active \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"providerId": "openai", "model": "gpt-4o"}' + +# Discover models +curl -X POST http://localhost:18088/api/v1/models/{providerId}/discover \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Test connection +curl -X POST http://localhost:18088/api/v1/models/{providerId}/test-connection \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +--- + +## Everything goes through the UI + +::: tip +**Model configuration is 100% UI-driven.** There's no `spring.ai.*` YAML you need to touch. All providers, all API keys, all model configs, all switching — it all lives in `Settings → Models`, backed by the `mate_model_provider` and `mate_model_config` database tables. +::: + +The UI handles everything you'd otherwise do in YAML, plus several things YAML can't do: + +- **Add a provider** — pick a type, paste a key, save. Encrypted at rest, masked in the UI. +- **Test connection** — verify a provider before you trust it in production. +- **Discover models** — for providers that support `/v1/models`, one click pulls the whole list. +- **Per-model test** — send a test prompt and see the exact response, latency, and token usage. +- **Switch active model at runtime** — no restart, no config reload, takes effect on the next message. +- **Per-agent override** — bind a specific agent to a specific model config. + +LLM API keys are **no longer read from environment variables** — setting `DASHSCOPE_API_KEY` / `OPENAI_API_KEY` and similar has no effect. Every provider, key, and model lives in the UI. A fresh install starts with no providers configured; add your first one under `Settings → Models → Add Provider`. + +### Reference: which Qwen model to pick + +If you're on DashScope, here's the rough shape of the lineup: + +| Model | Context | Best for | +|-------|---------|----------| +| `qwen-max` | 32K | Complex reasoning, analysis | +| `qwen-plus` | 32K | General-purpose | +| `qwen-turbo` | 8K | Fast responses | +| `qwen-vl-max` | 32K | Vision + language | +| `qwen-long` | 1M | Very long documents | + +--- + +## Next + +- [Configuration](./config) — full config reference +- [Agents](./agents) — how agents use models +- [Admin Console](./console) — UI for model management diff --git a/mateclaw-server/src/main/resources/docs/en/multimodal.md b/mateclaw-server/src/main/resources/docs/en/multimodal.md new file mode 100644 index 00000000..63ec19b2 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/multimodal.md @@ -0,0 +1,203 @@ +# Multimodal + +Speech, music, images, video — all first-class in MateClaw, not tacked on. + +Most AI products treat multimodal generation as a plugin you bolt on later. MateClaw ships with it as core infrastructure: **six image providers, four video providers, three TTS backends, three STT backends, and two music providers**, all unified behind a single tool interface so agents can call any of them without knowing which vendor is underneath. + +Configure once. Use everywhere. + +--- + +## What's in the box + +### Image generation — six providers + +| Provider | Model family | Notes | +|----------|--------------|-------| +| **DashScope** | Wanxiang | Alibaba's image model, default cloud option | +| **OpenAI** | DALL-E 3 | Standard DALL-E endpoint | +| **fal.ai** | Flux | Fast Flux inference via fal.ai | +| **Google (Nano Banana)** | gemini-3-pro-image-preview, gemini-2.5-flash-image | Via the native Gemini path; **supports image editing** — see [Nano Banana](#nano-banana) below | +| **Zhipu** | CogView | Native Chinese prompt support | +| **MiniMax** | — | Sync and async both supported | + +The image-generation tool auto-picks the provider configured as default, or you can force a specific one per call. Async generation returns a job id the agent polls; when the image lands, it attaches to the **original assistant message**, not a new one. + +::: tip New in 1.3.0 +DashScope Wanxiang plugged into the **unified multimodal-generation endpoint** (`multimodal-generation/generation`) in v1.3.0, adding 14 image models — 6 of which **support image editing**. See [Image edit](#image-edit) below. +::: + +#### Image edit + +::: tip New in 1.3.0 +Image editing (image-to-image) is supported from v1.3.0. In v1.2.0 and earlier, `image_generate` was text-to-image only. +::: + +The `image_generate` tool gains two parameters: `image` and `images`: + +| Parameter | Shape | Description | +|---|---|---| +| `image` | Single reference image | String: path / `file://` / `data:image/...` / `http(s)://` / `msg::` | +| `images` | Multiple reference images (up to 5) | Array of the same forms | + +The tool normalizes all five reference forms into in-memory buffers internally before forwarding to the provider. **Five reference forms**: + +1. **Local path** — `/abs/path.png` / `~/x.png` / `./rel.png` +2. **`file://` URL** — absolute-path variant +3. **`data:image/png;base64,...`** — inline base64 / percent-encoded body +4. **`http(s)://...`** — with SSRF guard (rejects internal hosts) +5. **`msg:[:]`** — references an image attachment from a message in the same conversation. **Works for non-vision models too** — the agent doesn't need to "see" the bytes; merely having seen the messageId in conversation history is enough + +```text +User: (uploads a sunset image, messageId=12345) Replace the background with a forest. +Agent: image_generate(prompt="replace background with forest", + image="msg:12345:0", + model="qwen-image-edit") +``` + +**Models that support image editing** (DashScope Wanxiang): +- `wan2.7-image` / `wan2.7-image-pro` (**T2I + edit**) +- `qwen-image-edit` / `qwen-image-edit-plus` / `qwen-image-edit-max` (**edit-only**) + +A fuller model catalog lives in [Models](./models#two-dashscope-variants). + +#### Nano Banana + +::: tip New in 1.4.0 +Google image generation runs through **Nano Banana Pro** (`gemini-3-pro-image-preview`) via the [native Gemini path](./models#native-gemini), not an OpenAI-compatibility shim. +::: + +Because it uses the native `generateContent` endpoint, the image tool passes input images as **inline parts** straight to the model — so Nano Banana isn't just text-to-image, it **supports image editing** (image-to-image) too. It works exactly like [Image edit](#image-edit) above: pass the `image` / `images` parameter to reference one or more source images. + +- **Nano Banana Pro** — `gemini-3-pro-image-preview` (default) +- **Nano Banana** — `gemini-2.5-flash-image` (another Google image model) + +### Video generation — six providers + +- **DashScope** — Tongyi Wanxiang video +- **Runway** — Gen-2 / Gen-3 via API +- **MiniMax (Hailuo)** — text-to-video and image-to-video +- **Fal** — fast inference pipeline +- **CogVideo** — Zhipu CogVideoX +- **Kling** — Kuaishou Kling video generation + +Same async-attach model as image generation. Videos appear inline in the chat once rendering finishes — in the same bubble where the agent first said "working on it". + +### Music generation — two providers + +- **Google Lyria** — high-quality music generation +- **MiniMax** — music generation with lyrics + style prompts + +The music-generation tool takes a prompt, an optional style tag, and optional lyrics. Output is an MP3 attached to the message. + +### 3D model generation — one provider + +- **Tencent Hunyuan 3D** — `HY-3D-3.1` / `HY-3D-3.0` (Pro, supports PBR / multi-view / white-model) / `HY-3D-Express` (rapid) + +Text-to-3D and image-to-3D both work; output is a `.glb` rendered inline by `` for drag-to-rotate preview. Full setup walkthrough: **[3D Model Generation](./model3d.md)**. + +### Text-to-speech (TTS) — three providers + +- **DashScope CosyVoice** — Chinese + English, natural prosody +- **OpenAI TTS** — alloy, echo, fable, onyx, nova, shimmer +- **MiniMax T2A** — Chinese voices with emotion tags + +Click the speaker icon on any assistant message to read it aloud. The voice is whichever TTS provider is active in Settings. + +### Speech-to-text (STT) — two providers + +- **DashScope Paraformer** — Chinese-first, low latency +- **OpenAI Whisper** — the standard multilingual benchmark + +Hold the mic button in the chat input to speak. Release to transcribe. Edit the result before sending if you want to. + +--- + +## Configuration + +All multimodal providers live under `Settings → Models → [category]`. Add a provider once with its API key, then mark it as default for its category. + +```yaml +# application.yml — minimal example +mate: + image: + default-provider: dashscope + video: + default-provider: dashscope + tts: + default-provider: cosyvoice + stt: + default-provider: paraformer + music: + default-provider: dashscope +``` + +Per-agent overrides are available if you want a specific agent to always use, say, Flux for images and CosyVoice for voice. + +--- + +## How agents use it + +Every multimodal capability is exposed as a tool: + +| Tool | Signature | +|------|-----------| +| `image_generate` | `(prompt, style?, size?)` | +| `image_edit` | `(image_id, prompt)` — where the provider supports it | +| `video_generate` | `(prompt, duration?)` | +| `video_from_image` | `(image_id, prompt)` | +| `music_generate` | `(prompt, style?, lyrics?)` | +| `tts_synthesize` | `(text, voice?)` | +| `stt_transcribe` | `(audio_id, language?)` | + +Agents call them exactly like any other tool. The tool layer handles provider selection, retries, async polling, and attachment binding. + +--- + +## Async generation and message binding + +Image and video generation often takes longer than a normal agent turn. MateClaw handles this cleanly: + +1. Agent calls the generate tool. +2. Tool returns immediately with a job id and a placeholder attachment. +3. Backend polls the provider in the background. +4. When the result lands, it's attached to the **original assistant message** — not a new one. + +It works the way you'd expect: the image appears inside the same bubble where the agent first said "working on it" — not floating in a new message. + +--- + +## Where it shows up in the product + +- **Chat** — drag an image into the input for vision models; press-and-hold the mic to dictate; click the speaker on any response to read aloud; generated media appears inline. +- **Agents** — enable or disable specific multimodal tools per agent. +- **Tools page** — every provider has a test button so you can verify a key before using it in production. +- **Desktop app** — everything above, plus local filesystem access for batch operations. + +--- + +## When to use what + +- **Image** — documentation illustrations, slide graphics, concept visualization, marketing. Start with DashScope or Flux; DALL-E 3 when you need tight text rendering. +- **Video** — short-form demos, social content, product animations. Runway for quality, MiniMax for Chinese scenarios, DashScope for cloud-local. +- **Music** — background tracks, demo jingles, creative exploration. Two providers today; expect the surface to evolve. +- **TTS** — accessibility, audiobook-style reading, multilingual content. CosyVoice for Chinese, OpenAI for English variety. +- **STT** — voice-first input, meeting transcription, dictation workflows. Paraformer for Chinese, Whisper for everything else. + +--- + +## Multimodal input: primary doesn't speak it? Use a sidecar + +::: tip Added in 1.3.0 +This page is about **generation (output)**. The **input** side — uploading an image to a text-only primary model — runs through a separate "multimodal sidecar" path. See [Chat → Primary model can't see images?](./chat#primary-model-cant-see-images-multimodal-sidecar-routing) and [Models → Multimodal sidecar (system-wide)](./models#multimodal-sidecar-system-wide). +::: + +In short: configure a vision model under **Settings → Models → Multimodal sidecar**. When the primary model can't handle an uploaded image, the runtime captions it via the sidecar first and feeds the description to the primary chat. Primary stays cheap; the routing decision is fully visible in the chat UI (badge on the bubble, hint above the input box). + +--- + +## Next + +- [Chat & Messaging](./chat) — attachment input, multimodal sidecar routing, how generated media attaches to messages +- [Models](./models) — provider configuration UI, multimodal sidecar settings +- [Tools](./tools) — the tool system that hosts multimodal generation diff --git a/mateclaw-server/src/main/resources/docs/en/quickstart.md b/mateclaw-server/src/main/resources/docs/en/quickstart.md new file mode 100644 index 00000000..a2770476 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/quickstart.md @@ -0,0 +1,86 @@ +# Quick Start + +Sixty seconds to first message. **One path. Desktop app.** + +If you want Docker or local development instead, those live in [Configuration](./config) and [Contributing](./contributing). This page does one thing only — get you from zero to a working agent as fast as humanly possible. + +--- + +## 1. Download + +Grab the latest installer from [GitHub Releases](https://github.com/matevip/mateclaw/releases). + +- **Windows** — `MateClaw-Setup-x.y.z.exe` +- **macOS** — `MateClaw-x.y.z.dmg` +- **Linux** — `MateClaw-x.y.z.AppImage` + +No Java install. No Node install. No Maven. The desktop app bundles JRE 21 and the server JAR. + +## 2. Launch and log in + +Double-click. First launch takes 10 to 30 seconds while the backend boots. + +Log in. Username `admin`, password `admin123`. Change the password from `Settings → Security` the moment you're inside. Do it now. Your future self will thank you. + +## 3. Add one model + +`Settings → Models → Add Provider`. + +Pick one. Just one: + +- **DashScope** — the simplest cloud start; paste your key from Alibaba Cloud +- **OpenAI** or **Anthropic** — if you already have a key, drop it in +- **Ollama** — local GPU users; MateClaw auto-detects `localhost:11434` +- **ChatGPT OAuth** — if you have a Plus or Pro account, log in through the browser flow and use GPT-4o, o3, or o4-mini directly + +Save. The model appears in the chat screen's model picker. + +## 4. Say hello + +Click `Chat` in the left nav. Pick an agent. Pick the model you just configured. Type: + +> *Hi. What can you do right now?* + +Hit enter. Watch the tokens stream. + +If you saw an answer come back, **the system is alive and you're in the product.** Everything from here is about making it useful to you, not about making it work. + +--- + +## First useful moves + +You've got a working install. Now what? + +**Try a tool-using prompt.** Type *"Search the web for the latest Spring Boot release and summarize the breaking changes."* Watch the agent pick up a search tool, execute, observe the result, and come back with an answer. That's ReAct in action. + +**Create your first agent.** `Agents → New Agent`. Start from a template — the templates ship ready to work. Rename it, tighten the system prompt, choose which tools it can use, save. Agents are how you go from one chat window to a whole workforce. + +**Build your first knowledge base.** `Wiki → New Knowledge Base`. Drop in a PDF or point at a local folder. Wait for digestion (you'll see the progress bar on each raw material row). When it's done, bind the KB to an agent and ask a question about the content. See [LLM Wiki](./wiki) for what's happening under the hood. + +**Connect a chat channel.** `Channels` → pick Telegram, DingTalk, or any of the eight supported platforms. Paste the bot credentials. The same agent starts answering in that channel with the same memory it has on your desktop. + +Each of those has its own page in the sidebar when you're ready to go deeper. + +--- + +## Something broke? + +First run should Just Work. If it didn't: + +- **Installer won't launch** — On Windows, right-click → Properties → Unblock. On macOS, allow the unsigned app in System Settings → Privacy & Security. +- **Backend never boots** — Check `~/.mateclaw/logs/app.log` (Windows: `%USERPROFILE%\.mateclaw\logs\`). Nine times out of ten it's a port conflict on 18088. +- **Model call fails** — Wrong API key or network can't reach the provider. Go back to Settings, re-verify the key, or try a different provider. +- **UI is blank** — Hard-refresh with Ctrl/Cmd+Shift+R. Electron caches aggressively. +- **Still broken** — Open an issue on [GitHub](https://github.com/matevip/mateclaw/issues) with the tail of `app.log`. We read them. + +--- + +## Other ways to run MateClaw + +- **Docker** — `cp .env.example .env`, set the passwords, then `docker compose up -d --build`. Full prerequisites, Maven mirror selection (China vs US), browser-tool self-check, and upgrade flow live in [Docker Deployment](./docker-deploy). +- **From source** — `mvn spring-boot:run` in `mateclaw-server/` and `pnpm dev` in `mateclaw-ui/`. See [Contributing](./contributing). +- **Desktop internals** — packaging, code signing, auto-update. See [Desktop App](./desktop). + +--- + +Next: [Introduction](./intro) for the "why", or jump straight to [Agents](./agents) for the product itself. diff --git a/mateclaw-server/src/main/resources/docs/en/releases.md b/mateclaw-server/src/main/resources/docs/en/releases.md new file mode 100644 index 00000000..d29afa68 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/releases.md @@ -0,0 +1,30 @@ +# Changelog + +Release notes for every MateClaw version. The latest documentation always lives in `docs/en/` as the single source of truth — these notes are about what *changed* in each version. + +For historical diffs, check the corresponding git tag. For the "why" behind a feature, follow the link into the full release note. + +--- + +## Releases + +| Version | Date | Highlights | +|---------|------|------------| +| [v1.4.0](./releases/1.4.0) | 2026-05-23 | Persistent Goals — an employee locks a goal and follows it to done on its own · Subagent delegation became a tree (recursive 3 levels + async + digital-employee builder) · Progressive tool/skill disclosure (`enable_tool` + `load_skill`) · Workspace RBAC (4 roles + capability gating) · Feishu as a first-class citizen (interactive / approval / streaming cards + voice / file / audio / video + channel-native tools) | +| [v1.3.0](./releases/1.3.0) | 2026-05-13 | Year one of workflow — 7 step modes assemble employees into business processes · 6 trigger patterns make events drive workflows · Wiki promoted from search index to processing pipeline (user templates + cross-material aggregator + reverse citations) · Per-agent MCP tool binding + multimodal sidecar routing · 4 JVM-native document generation tools + image edit | +| [v1.2.0](./releases/1.2.0) | 2026-05-05 | Agents renamed "digital employees" (role / goal / backstory + 5 career templates) · Skills became the skeleton (manifest + template wizard + LESSONS self-evolution) · ACP integration: Claude Code / Codex now show up as your employees · Admin Runtime Console lets you see every employee working in real time | +| [v1.1.137](./releases/1.1.137) | 2026-04-29 | It learns from yesterday now · One bad model doesn't take the whole thing down · The "almost good" parts are good now · The knowledge base became a library you can open | +| [v1.1.0](./releases/1.1.0) | 2026-04-17 | Auto skill synthesis, multi-agent parallel delegation, Wiki semantic search + two-phase digest, deep thinking, Anthropic prompt caching, declarative hooks, plugin SDK, voice for every channel, ChatConsole multi-channel realtime sync, WeChat stability rebuild | +| [v1.0.418](./releases/1.0.418) | 2026-04-11 | Backend i18n, Flyway migration framework, WorkspacePathGuard sandbox, CronJobTool, Skill ZIP import, security hardening | +| [v1.0.314](./releases/1.0.314) | 2026-04-08 | LLM Wiki knowledge base, TTS/STT, music generation, image/video upgrades, search system with keyless fallback, ChatGPT OAuth login, agent runtime enhancements, database schema unification | +| [v1.0.108](./releases/1.0.108) | 2026-04-06 | Datasource SQL query, multimodal enhancements, desktop dynamic port, OpenRouter free models | +| [v1.0.101](./releases/1.0.101) | 2026-04-05 | Mobile layout, Ollama auto-detection on startup, model grouping, GitHub MCP, drag-and-drop file upload, multi-agent collaboration | +| [v1.0.0](./releases/1.0.0) | 2026-03-20 | Initial release — ReAct + Plan-Execute agents, 12 built-in tools, MCP protocol, 6 channel adapters, Vue 3 admin console | + +--- + +## What to read next + +- [Roadmap](./roadmap) — what's planned, what's in progress, what's done +- [Introduction](./intro) — why MateClaw exists +- [Contributing](./contributing) — how to help ship the next release diff --git a/mateclaw-server/src/main/resources/docs/en/roadmap.md b/mateclaw-server/src/main/resources/docs/en/roadmap.md new file mode 100644 index 00000000..2a1b8c55 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/roadmap.md @@ -0,0 +1,201 @@ +# Roadmap + +> "People don't know what they want until you show it to them." +> +> This isn't a feature list. It's a manifesto about **how your AI assistant should exist.** + +--- + +## What we believe + +Everyone deserves an AI assistant that actually understands them. + +Not a chat toy. Not a tech demo. A **digital counterpart** — one that knows how you work, connects to all your tools, thinks for you, executes for you, and remembers for you. + +**MateClaw is that thing.** + +--- + +## What we've shipped + +### v1.0 — It thinks and acts ✅ Released + +Make an AI assistant a coworker who uses tools, not a chat box. + +- ReAct engine: reason, act, observe, reason again +- Plan-and-Execute orchestration: plan first, then execute step by step +- StateGraph architecture: state-graph-based agent orchestration +- DynamicAgent: loaded from database at runtime, adjustable without restart +- 20 built-in tools: search / shell / file I/O / delegate / multimodal generation / cron / SQL +- Tool Guard + File Guard + Audit Log: every tool call has approval, control, and a record +- SKILL.md skill system: install new capabilities into your AI like apps + +### v1.1 — It's everywhere ✅ Released + +Move AI out of the chat box on a webpage and into every IM your team actually uses. + +- **8 channels**: Web / DingTalk / Feishu / WeCom / Telegram / Discord / QQ / WeChat Personal / Slack +- Session source tracking: every message knows which channel it came from +- 4-layer memory: session context + workspace memory + post-chat extraction + 2 AM consolidation +- DREAMS.md consolidation diary: human-readable audit of memory changes +- Workspace isolation: every agent / skill / wiki / conversation / memory belongs to a workspace +- ChatGPT OAuth + Anthropic Claude Code OAuth: log in with your subscription, no API key +- LLM Wiki + RAG: raw files become structured pages with bidirectional links and summaries + +### v1.2 — It's your coworker ✅ Released (2026-05-05) + +Renamed "agents" to **digital employees** — not vocabulary purism, a worldview shift. + +- **Digital employees** with Role / Goal / Backstory — not a cold system prompt +- **5 career templates**: product researcher / customer support / knowledge curator / data analyst / executive assistant — open one, it works +- **Skills are no longer aliases for tools** — each skill is a backbone with its own SKILL.md + LESSONS.md + workspace filesystem +- **ACP bridge**: Claude Code, Codex, Gemini CLI plug in as employees +- **Backstage runtime console**: for the first time you can **see what each employee is doing right now** — who's running, on which step, how many tokens, kill them in one click +- **Onboarding wizard**: first-login four-step flow from zero to first message +- **Dashboard**: daily usage trend + top agents/tools +- **Doctor**: system health checks + one-click fix + +Full story: [v1.2.0 release notes](./releases/1.2.0.md). + +--- + +## v1.3 — The workflow year ✅ Shipped (2026-05-13) + +> "Focus is about saying no to the hundred other good ideas that there are." + +Each digital employee being able to do work is just the beginning. **Real collaboration needs orchestration.** + +The v1.3 line is **graduating MateClaw from a chatbot framework to a business-process OS** — a flow is no longer the sum of several employees chatting separately, but a publishable, triggerable, replayable **linear-step DSL**. + +Full story: [v1.3.0 release notes](./releases/1.3.0.md). + +### Workflow + +- [x] **7 step modes**: sequential / fan_out / collect / conditional / await_approval / dispatch_channel / write_memory +- [x] **Pebble expression subset** for conditionals + variable references (no side effects, no code execution) +- [x] **JSON-first authoring**: Monaco + JSON-schema validation + static Pebble checking + template dropdown +- [x] **Natural language → workflow draft** (`POST /workflows/draft/generate`): a user describes the flow, an agent emits `graph_json` + compile diagnostics; never publishes directly — a human still reviews +- [x] **Integer revisions**: publish writes a new immutable row; draft is split from published version +- [x] **Run history**: every step's input / output / duration / token / failure chain is recorded +- [x] **Internal payload storage**: large I/O goes through `payload://` URIs — doesn't blow out the DB +- [x] **Cross-workspace ACL**: publish-time validation rejects agent / channel / employeeId references outside the workspace +- [x] **Persistent `await_approval` pause**: survives service restarts + +### Triggers + +- [x] **6 pattern types**: cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion +- [x] **Event governance on by default**: dedup (60s window), per-trigger rate limit, bot-self-msg filter, A→B→A recursion guard +- [x] **CronDelegationPort**: shares ShedLock + Spring TaskScheduler with the legacy cron module without writing into mate_cron_job +- [x] **Cross-instance consistency**: `pattern_version` self-cancellation + periodic syncFromDatabase +- [x] **Structured forms**: each of the 6 pattern types has its own field UI — no need to hand-write patternJson + +### Existing experience upgrades + +- [x] **Image editing** (issue #75): `image_generate` gains `image` / `images` parameters with 5 reference forms (including `msg::` for in-conversation attachments) +- [x] **DashScope OpenAI-compatible variant**: same sk- key, reaches the dot-versioned families (qwen3.5-plus / qwen3.6-plus / qwen3-vl-plus etc.) +- [x] **New Wanxiang / Qwen-Image families**: 14 new image models, 3 new video models (including happyhorse-1.0-t2v) +- [x] **4 document-generation tools**: DocxRenderTool / XlsxRenderTool / PptxRenderTool / PdfRenderTool — Markdown rendered directly into Office files, no subprocess fork, no npm dependency +- [x] **MCP per-agent tool binding**: every employee binds MCP tools individually + status badges (connected / stale / unavailable / orphan) + namespace collisions auto-prefixed + server renames auto-followed +- [x] **Xiaomi MiMo provider**: MiMo V2.5 Pro / V2.5 / V2 Pro / V2 Omni / V2 Flash +- [x] **Multimodal sidecar routing** (issue #87): when a text-only primary model meets an image attachment, the configured vision model captions it first so the primary chat stays cheap; the old "do not call any tools" hard ban is gone, so user-built tools are no longer suppressed; routing badge on the bubble and a hint above the input box make every decision visible + +### Still to do in v1.3 + +- [ ] **Canvas editor (v1)**: today's canvas is read-only chain rendering; the goal is `@vue-flow/core` drag-to-edit +- [ ] **Run replay view**: trace timeline + hover any node to diff input/output +- [ ] **`loop` mode**: iterate N times or per-item over an array +- [ ] **`invoke_skill` mode**: call a skill directly without going through an employee +- [ ] **Inter-trigger priority / dependency**: serial / parallel control when an event hits multiple triggers +- [ ] **Event replay**: a "redispatch" button on `mate_trigger_event` rows + +--- + +## Next: v1.4 — The scenario-application year + +> "When the tools are good enough, hide the tools and put the scenarios in front." + +v1.0 → v1.3 builds out the infrastructure: employees, memory, knowledge bases, tools, skills, workflows, triggers, multimodal, channels. **The next move isn't another bolt** — it's assembling these parts into **scenarios users can drop in and use**. + +The v1.4 keyword is **scenario applications**. Not "more features" — **letting normal users get value without learning 7 step modes and 6 trigger pattern types**. + +### Industry scenario templates (workflow + trigger combos) + +Each one is **a one-click-importable workflow template + trigger config + recommended employee bindings + recommended KB structure**: + +- [ ] **Customer ticket triage**: WeCom / Feishu entry → digital-employee classification → route / escalate / auto-reply → write to customer record +- [ ] **Morning / weekly report automation**: cron trigger → multi-employee parallel data collection → data analyst summarizes → generate PDF/PPTX → multi-channel dispatch +- [ ] **Contract approval flow**: contract upload → legal-employee first review → approval wait → legal-employee revision suggestions → write to archived memory +- [ ] **Market intel monitoring**: webhook trigger (site change) → content_match filtering → business analyst summary → Feishu bot push +- [ ] **New employee onboarding**: webhook (HRIS hire event) → executive assistant pulls doc checklist → training-KB onboarding → multi-day follow-up triggers +- [ ] **Code PR review**: GitHub webhook → code-reviewer employee runs review → comments back to PR → flag critical changes through await_approval + +### Scenario marketplace + +- [ ] **Scenario package format**: one scenario = `workflow.json` + `triggers.json` + `agents/*.md` + `knowledge/*.md` + `README.md`, shareable / installable +- [ ] **Scenario marketplace UI**: browse / try-run / one-click install / ratings + reviews +- [ ] **Scenario package versioning**: upgrade prompts + diff preview + rollback + +### Cross-scenario employee collaboration + +- [ ] **Employee directory profile**: each employee auto-gains "good at / weak at" tags (based on history + skills + tool set) +- [ ] **Scenario suggestions**: user describes "I want a flow that does X" → recommend the closest scenario template + existing employees +- [ ] **Cross-scenario memory sharing**: customer ticket triage and contract approval see the same customer record + +### Hide the infrastructure further + +- [ ] **Natural language → full scenario package**: v1.3 already does "NL → workflow draft"; v1.4 extends it to **the whole scenario** — one sentence yields a draft of workflow + triggers + recommended employees + recommended KB structure +- [ ] **Self-diagnosis wizards**: typical issues like "my workflow stuck waiting on approval" become self-serve diagnostics +- [ ] **Scenario-level dashboards**: not "tokens spent today" but "average customer-ticket handling time today" + +### Foundational capabilities advancing in parallel + +- [ ] **Scenario-level ACL**: installing a scenario package atomically configures the required channel / agent / KB / tool allowlists +- [ ] **Cross-workspace scenario sharing**: scenario templates reusable across workspaces (clone + override) +- [ ] **Scenario cost estimation**: see expected tokens / API calls / trigger frequency before installing + +--- + +## What we deliberately don't do + +> "I'm as proud of the things we haven't done as the things we have done." + +| Cut | Why | When it might return | +|-----|-----|---------------------| +| **Full RBAC permission model** | MateClaw is a digital-employee system, not an enterprise management platform. A single team doesn't need 100 permission combinations | When real multi-team SaaS customers need fine-grained permissions | +| **Multi-tenancy** | Same as above. Premature multi-tenancy is architectural cancer | When there's a clear SaaS commercialization path | +| **SSO / LDAP / SAML** | Enterprise integration is a bottomless pit | When paying enterprise customers explicitly ask | +| **30+ node visual workflow editor** | Most users won't reach for it. **v1.3's 7 step modes already cover 90% of real-world scenarios**; the rest is pushed to LLM natural-language generation | When a user case actually needs 30+ nodes (rare) | +| **Native mobile app** | 8 IM channels + desktop + Web already cover it. On your phone, you use MateClaw via DingTalk / Feishu / Telegram | When Web / IM channels can't deliver an irreplaceable mobile-only feature | +| **Replacing ReAct / Plan-Execute** | Workflow and those two engines **collaborate**, not replace — single-agent multi-turn reasoning still lives there | Never replaces | + +--- + +## Version milestones + +| Version | One line | User experience goal | Status | +|---------|----------|----------------------|--------| +| **v1.0** | It thinks and acts | An AI assistant that uses tools to solve problems | ✅ Released | +| **v1.1** | It's everywhere | 8 channels + 4-layer memory + workspaces + LLM Wiki | ✅ Released | +| **v1.2** | It's your coworker | Digital employees + 5 career templates + backbone-style skills + ACP bridge + Backstage runtime | ✅ Released | +| **v1.3** | It orchestrates business flows | Workflow + triggers + image editing + document generation + per-agent tool binding | ✅ Released | +| **v1.4** | **It lands real scenarios** | **Industry scenario templates + scenario marketplace + NL → workflow + cross-scenario employee profiling** | 📋 Planned | + +--- + +## One More Thing + +We're not building MateClaw to chase ChatGPT, not to be the next Dify, not to add another buzzword to a funding deck. + +We're building it because we believe one thing: + +**AI shouldn't be a chat box on a webpage. It should be your second brain.** + +It lives in your DingTalk, your Feishu, your Telegram. It's read every document you have. It remembers what you said three months ago. It uses your company's internal tools. It consolidates memory while you sleep. **It runs an entire business flow on your behalf.** + +Someday, you'll forget it's a program. + +**That's the day we win.** + +--- + +*Stay hungry. Stay foolish.* diff --git a/mateclaw-server/src/main/resources/docs/en/security.md b/mateclaw-server/src/main/resources/docs/en/security.md new file mode 100644 index 00000000..4404f97c --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/security.md @@ -0,0 +1,575 @@ +# Security & Approval + +**Strong hands, firm limits.** + +MateClaw gives agents real capability — shell access, file writes, browser automation, delegation to other agents, remote tools over MCP. That's the "strong hands" half. This page is about the other half: the limits that keep strong hands from doing stupid things. + +- **JWT auth** — who you are +- **Tool Guard (rule-based)** — what each agent is allowed to do +- **Approval workflow** — when a human needs to decide before execution +- **File Guard** — what the filesystem looks like to an agent +- **Workspace isolation** — what each team can see +- **Audit log** — what everybody did, in order, forever + +If you're running MateClaw in production, read this page top to bottom. + +::: tip Agentic, but not autonomous +Every IT department and CISO in 2025–2026 has the same question before buying AI: + +> **"What if the agent goes off the rails and deletes the wrong thing?"** + +Anyone who tells you "AI won't go off the rails" is lying. MateClaw's answer is different — **the agent asks you first when it matters.** + +When the agent wants to delete a file, send an email, run a write-side SQL, or hit a paid API — any tool call matched by a Tool Guard rule **pauses mid-turn**. An approval notification is pushed to your IM (Feishu / DingTalk / Slack / email). You tap approve, the agent resumes from where it stopped. Every action lands in `mate_tool_guard_audit_log` — append-only, retained as long as you want, CSV-exportable. + +**Agentic — it acts. Not autonomous — it doesn't act on its own initiative for the things that matter.** + +That's the line between "let AI do work for you" and "let AI make decisions for you." MateClaw stays on the left side of that line — which is also the side your CISO doesn't immediately say no to. +::: + +--- + +## JWT authentication + +### How it works + +1. The user posts credentials to `/api/v1/auth/login` +2. The server validates and returns a JWT +3. Every subsequent request includes the token in the `Authorization` header +4. The server validates the token on each request + +### Logging in + +```bash +curl -X POST http://localhost:18088/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin123"}' +``` + +Response: + +```json +{ + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 86400 + } +} +``` + +### Password change + +Users can change their own password from the profile settings dialog. Admins can reset any member's password from member management. + +--- + +### Sliding window renewal + +MateClaw does sliding-window token renewal. When a token's remaining lifetime falls below the configurable `renewal-threshold` (default 2 hours / 7200000ms), the server issues a new token in the `X-New-Token` response header. The frontend picks it up and replaces the stored token transparently. Active users never get kicked out; idle sessions still expire on time. + +### Configuration + +```yaml +mateclaw: + auth: + jwt: + secret: your-secret-key-must-be-at-least-32-characters-long + expiration: 86400000 # 24h in milliseconds + sliding-window: true +``` + +::: warning +**Change the default JWT secret in production.** At least 32 characters. Set via env var (`JWT_SECRET=...`), never commit. +::: + +### Error codes + +| Code | Meaning | Response | +|------|---------|----------| +| 401 | Token missing, expired, or invalid | `{"code": 401, "message": "Unauthorized"}` | +| 403 | Valid token but insufficient permissions | `{"code": 403, "message": "Forbidden"}` | + +Frontend handles both uniformly — redirect to login, clear stored tokens. + +### Default credentials + +MateClaw ships with `admin` / `admin123`. **Change this immediately in any deployment other than your laptop.** + +### Spring Security config + +- **Stateless sessions** — no server-side session; all state in the JWT +- **Public endpoints** — `/api/v1/auth/login`, `/h2-console/**`, `/swagger-ui/**` +- **Protected endpoints** — everything else under `/api/v1/**` +- **CSRF disabled** — not needed for stateless JWT + +--- + +## Tool Guard — rule-based permission engine + +Tool Guard is how MateClaw decides what a tool call is allowed to do. **It's not a flat dangerous-tools list.** It's a rule engine. Each rule specifies: *for this tool, optionally matching these arguments, in this workspace, do X* — where X is `allow`, `deny`, or `require_approval`. + +### The three tables + +| Table | Purpose | +|-------|---------| +| **`mate_tool_guard_config`** | Global config — enabled, default policy, approval timeout, notification channels | +| **`mate_tool_guard_rule`** | Individual rules — tool pattern, optional arg regex, workspace scope, action, priority | +| **`mate_tool_guard_audit_log`** | Every guarded call gets an entry — tool, args, rule matched, decision, user, timestamp | + +### How a rule is evaluated + +``` +Tool call arrives + │ + ▼ +Load rules for this workspace + global rules, sorted by priority + │ + ▼ +For each rule in priority order: + ┌─ Does the tool name match the pattern? + │ └─ No → next rule + ├─ Does the arg pattern match (if any)? + │ └─ No → next rule + └─ Yes on both → apply this rule's action and stop + │ + ▼ +No rules matched → apply default policy + │ + ▼ +Action: allow / deny / require_approval + │ + ▼ +Write audit log entry + │ + ▼ +Execute / reject / suspend for approval +``` + +Rules with higher priority run first. First matching rule wins. A rule can be scoped to a specific workspace or global. + +### Example rules + +``` +Rule 1 (priority 100): ShellExecuteTool, arg matches "^(ls|cat|grep|find)\\s" → allow +Rule 2 (priority 50): ShellExecuteTool → require_approval +Rule 3 (priority 50): WriteFileTool, arg.path starts with "/tmp" → allow +Rule 4 (priority 40): WriteFileTool → require_approval +Rule 5 (priority 30): * → allow (default) +``` + +Read-only shell commands execute immediately. Anything else needs approval. File writes under `/tmp` are free; elsewhere they need approval. Everything else runs. + +### Managing rules + +`Settings → Security & Approval → Tool Guard Rules`: list, create, edit, reorder, disable. Or via config: + +```yaml +mateclaw: + tool: + guard: + enabled: true + default-policy: require_approval + rules: + - tool: ShellExecuteTool + arg-pattern: "^(ls|cat|grep|find)\\s" + action: allow + priority: 100 + - tool: ShellExecuteTool + action: require_approval + priority: 50 + - tool: WriteFileTool + arg-pattern: "^/tmp/" + action: allow + priority: 50 + - tool: WriteFileTool + action: require_approval + priority: 40 +``` + +Or via API: + +```bash +curl -X POST http://localhost:18088/api/v1/security/guard/rules \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "ShellExecuteTool", + "argPattern": "^(ls|cat|grep|find)\\s", + "action": "allow", + "priority": 100 + }' +``` + +### Credential-rule toggles (1.4.0) + +Credential rules now support **per-rule control** — each rule can be enabled/disabled individually, each rule carries its own decision (allow / deny / require_approval), and the entire guard rule set can be **exported and imported as JSON** for migrating between deployments or version-controlling your policy. + +### Dangerous pattern detection + +In addition to user-defined rules, MateClaw's shell tool has built-in detection for patterns that are dangerous no matter what. `find -delete`, `rm -rf /`, piped downloads through `bash`, and similar patterns trigger elevated approval even if a rule would otherwise allow them. + +--- + +## Approval workflow — human in the loop + +When a rule evaluates to `require_approval`, MateClaw doesn't fail the call. It **suspends the agent mid-turn**, creates a pending approval, surfaces it to the user, and resumes exactly where it left off once the user decides. + +::: tip From 1.3.0: workflows ride the same approval rail +The v1.3.0 [workflow](./workflow) `await_approval` step suspends the entire workflow run on the same `mate_tool_approval` table — persisted across restarts. Approval requests fan out to the approver's channel (Feishu / DingTalk / Slack / WeCom); once resolved, the workflow runtime auto-resumes the next step. One audit log, one notification pipeline, one "pause / resume" semantic — covering both agent tool calls and workflow steps. +::: + +### How it flows + +``` +Agent calls tool + │ + ▼ +Tool Guard: require_approval + │ + ▼ +Create mate_tool_approval row (status=pending) + │ + ▼ +Set AWAITING_APPROVAL=true in graph state + │ + ▼ +Emit approval_required SSE event + │ + ▼ +Graph terminates cleanly + │ + ▼ +Frontend shows approval card + │ + ▼ +User clicks Approve or Reject + │ + ▼ +POST /api/v1/approvals/{id}/resolve + │ + ├─ Approved → reload agent, replay tool call, continue reasoning + └─ Rejected → send rejection as observation, continue reasoning +``` + +The "replay" mechanism is important. When the agent resumes, it **doesn't re-reason from scratch** — it skips straight to the approved tool call, executes it, and continues from the observation. No duplicate LLM calls, no wasted tokens. + +### The `mate_tool_approval` table + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `agent_id` | Which agent is waiting | +| `conversation_id` | Which conversation is suspended | +| `tool_name` | The tool being called | +| `tool_args` | JSON of the actual arguments | +| `rule_id` | Which rule triggered the approval | +| `status` | `pending` / `approved` / `rejected` / `expired` | +| `requested_at` | When the approval was created | +| `resolved_at` | When the user decided | +| `resolved_by` | Who decided | +| `notes` | Optional user notes on the decision | + +### Placeholder substitution + +Sometimes the agent's tool arguments contain placeholders — a computed file path, a templated command. The approval workflow **resolves placeholders before showing the dialog**, so users see the actual values they're approving. Approval returns the resolved values too, so what the agent executes is exactly what the user saw. + +### Timeouts + +Pending approvals expire after a configurable timeout (default: 10 minutes). Expired approvals become `rejected`, and the agent treats expiry the same as user rejection. + +### Notifications + +MateClaw can notify through `channel/notification/` adapters — email, in-app alert, DingTalk/Feishu push. Configure in `Settings → Security & Approval → Notifications`. + +### Resolving via API + +```bash +# List pending +curl http://localhost:18088/api/v1/approvals?status=pending \ + -H "Authorization: Bearer " + +# Approve +curl -X POST http://localhost:18088/api/v1/approvals/123/resolve \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"decision": "approved"}' + +# Reject with reason +curl -X POST http://localhost:18088/api/v1/approvals/123/resolve \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"decision": "rejected", "notes": "Not appropriate for this workspace"}' +``` + +--- + +## File Guard + +File Guard is filesystem-level access control. It sits underneath any tool or skill that reads or writes files, and decides what paths are in-bounds. + +### Evaluation pipeline + +``` +File access request + │ + ▼ +Path normalization (resolve .., symlinks, relative paths) + │ + ▼ +Allowlist check: is the path inside an allowed directory? + │ + ▼ +Denylist check: is the path inside a denied directory? + │ + ▼ +Symlink check: does following the path escape the sandbox? + │ + ▼ +Allow / Deny +``` + +### Rules built in + +| Rule | Description | +|------|-------------| +| Workspace isolation | Default access restricted to the workspace directory | +| System path denial | `/etc`, `/usr`, `/bin`, `/boot`, etc. blocked | +| Sensitive file protection | `.ssh`, `.config`, `.env` blocked | +| Path traversal prevention | `../` attacks detected and blocked | +| Symlink check | Symlink targets resolved and re-validated | + +### Configuration + +```yaml +mateclaw: + security: + file-guard: + enabled: true + allowed-paths: + - "${user.dir}/workspace" + - "${java.io.tmpdir}/mateclaw" + denied-paths: + - "/etc" + - "/usr" + - "${user.home}/.ssh" + - "${user.home}/.config" + - "${user.home}/.env" +``` + +Visual editor on `Settings → Security & Approval → File Guard`. + +--- + +## Workspace isolation + +Workspaces are how MateClaw keeps multiple teams' data separate. Every agent, skill, wiki, conversation, and memory file belongs to exactly one workspace. + +### Security primitives that follow workspace boundaries + +- **File Guard** — path allowlists default to `workspace/{workspaceId}/...` +- **Tool Guard rules** — can be scoped to a specific workspace +- **Wiki knowledge bases** — owned by a workspace, readable only by members +- **Memory files** — every agent's memory is under its workspace's directory +- **Channels** — each channel belongs to a workspace + +### Roles (four-tier RBAC) + +Capabilities are **additive** — a higher role inherits everything below it. + +| Role | Capabilities (added on top of the tier below) | +|------|-----------------------------------------------| +| **Viewer** | `chat`, `view:wiki`. Read-only. So that chat works, a Viewer can also read the active model and read an employee's workspace files. | +| **Member** | Viewer + `view:memory`, `view:dashboard`, `manage:wiki`, `manage:agents` | +| **Admin** | Member + `manage:skills`, `manage:channels`, `manage:models`, `manage:security`, `manage:settings` | +| **Owner** | Same as Admin, plus owner-only: delete the workspace, transfer ownership | + +**The backend is the single source of truth for capabilities** — it holds a `RoleCapabilities` mapping, and the frontend never derives them locally. After a workspace switch, or on a capability-related 403, the frontend calls `GET /api/v1/workspaces/{id}/access`, which returns `memberRole`, `isGlobalAdmin`, `effectiveRole`, and `capabilities`. + +**Global admin vs workspace role**: `mate_user.role='admin'` is the system-wide global admin — it manages users, creates workspaces, and spans **all** workspaces with owner-equivalent power even where it isn't a member; `mate_workspace_member.role` is per-workspace. System-level endpoints (models / providers / OAuth / datasources, user management, workspace creation) require a global admin (`@RequireGlobalAdmin`); workspace-scoped endpoints (skills / tools / plugins) require a workspace role — reads need Member, writes need Admin. + +Full details in [Workspaces](./workspaces). + +### What isolation does NOT cover + +- **Shared global config** — JWT secret, model provider keys, MCP server definitions are global +- **Audit logs** — all workspaces' security events are in the same audit log; only admins with audit access read across workspaces + +--- + +## Audit log + +Every security-relevant action is recorded in `mate_audit_event`. **Append-only** — you can't modify an entry, and rows are retained for the configured window (default 90 days). + +### What gets logged + +| Event type | Captured data | +|------------|---------------| +| **Tool calls** | Tool name, args, result summary, duration, agent, workspace | +| **Tool Guard decisions** | Rule matched, action taken, rule ID | +| **Approvals** | Who approved/rejected, when, notes | +| **File Guard decisions** | Path, allow/deny, reason | +| **Skill executions** | Skill name, parameters, agent | +| **Login events** | User, IP, success/failure | +| **Configuration changes** | Old and new values for security-relevant settings | + +### Entry schema + +``` +timestamp When it happened +user_id Who did it (system for automated events) +action What they did +resource What it was done to +details JSON blob with the specifics +result success / failure / denied +ip_address Source IP when applicable +workspace_id Which workspace this belongs to +``` + +### Querying + +`Settings → Security & Approval → Audit Log`: filterable view by time range, event type, user, workspace, result. Export to CSV. + +Via API: + +```bash +curl "http://localhost:18088/api/v1/audit/events?from=2026-04-01&to=2026-04-11&action=tool_call" \ + -H "Authorization: Bearer " +``` + +--- + +## Skill security scanning + +Custom skills are scanned for dangerous patterns before they become active: + +| Check | What it looks for | +|-------|-------------------| +| **Prompt injection** | Attempts to override system prompts, hidden instructions | +| **Dangerous tool references** | Tools not in the allowlist, or tools requiring approval without declaration | +| **External URL references** | Links to untrusted external resources | +| **Script injection** | Embedded scripts or code execution attempts | + +### Severity levels + +| Level | Action | +|-------|--------| +| `CRITICAL` | Install blocked; must be fixed | +| `HIGH` | Warning + admin must confirm | +| `MEDIUM` | Warning displayed; install allowed | +| `LOW` | Logged only | +| `INFO` | Logged only | + +Scan reports live in `Settings → Security & Approval → Skill Scans`. + +--- + +## API key protection + +- API keys encrypted at rest in the database +- Keys **masked** (`sk-****abcd`) in every API response — never returned in full after creation +- MCP server `env_json` and `headers_json` values sanitized the same way +- Environment variable references (`${VAR}`) in MCP config resolve at runtime from the process environment + +--- + +## Network security + +### Production recommendations + +| Recommendation | Details | +|----------------|---------| +| **HTTPS** | Reverse proxy with TLS (Nginx or Caddy) | +| **Disable H2 console** | `spring.h2.console.enabled=false` in production | +| **Firewall** | Only expose the public port | +| **Rate limiting** | Configure at the reverse proxy level | +| **MySQL, not H2** | Use a dedicated MySQL 8 instance for production | + +### Nginx reverse proxy example + +```nginx +server { + listen 443 ssl; + server_name mateclaw.example.com; + + ssl_certificate /etc/ssl/certs/mateclaw.pem; + ssl_certificate_key /etc/ssl/private/mateclaw.key; + + location / { + proxy_pass http://localhost:18080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE support + proxy_buffering off; + proxy_read_timeout 86400s; + } +} +``` + +--- + +## Security best practices + +1. **Change the default password.** Right now. On every deployment. +2. **Set a real JWT secret.** At least 32 characters, via environment variable, never committed. +3. **Least privilege.** Only enable the tools agents actually need. +4. **Default to `require_approval`.** Flip the Tool Guard default policy, then add `allow` rules for safe cases. Newly added tools default to safe. +5. **Configure File Guard.** Lock down allowed/denied paths before any agent touches the filesystem in anger. +6. **Review audit logs regularly.** Set a recurring reminder. Look for anomalies. +7. **Watch your skill scans.** CRITICAL findings shouldn't be bypassed lightly. +8. **Isolate networks.** Ollama, H2 console, internal MCP servers — none should be public. +9. **Don't skip approvals in production.** Auto-approve rules should be narrow and specific. `allow *` is a crisis waiting to happen. + +--- + +## Security configuration reference + +```yaml +mateclaw: + auth: + jwt: + secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} + expiration: 86400 + sliding-window-ratio: 0.5 + + tool: + guard: + enabled: true + default-policy: require_approval + approval-timeout-seconds: 600 + notifications: + email-enabled: false + dingtalk-enabled: false + + security: + file-guard: + enabled: true + allowed-paths: + - "${user.dir}/workspace" + denied-paths: + - "/etc" + - "${user.home}/.ssh" + + audit-log: + enabled: true + retention-days: 90 + + skill: + security-scan: + enabled: true + block-critical: true +``` + +--- + +## Next + +- [Tools](./tools) — tool details and Tool Guard rule patterns +- [Skills](./skills) — skill security scanning details +- [Workspaces](./workspaces) — workspace isolation primitives +- [Agents](./agents) — how approval pauses and resumes an agent turn +- [Configuration](./config) — full configuration reference diff --git a/mateclaw-server/src/main/resources/docs/en/skills.md b/mateclaw-server/src/main/resources/docs/en/skills.md new file mode 100644 index 00000000..6820b64b --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/skills.md @@ -0,0 +1,632 @@ +# Skills + +**A skill is a tool that thinks in sentences.** + +Tools are atomic — read a file, send an HTTP request, run a command. Skills are compositions — "research this topic and write a brief", "review this code and comment on it", "turn my git log into a standup update". A skill is a `SKILL.md` file that combines instructions, parameters, prompt templates, optional scripts, and a list of tools the skill needs. The runtime loads it, renders it with your inputs, and hands the result to the agent. + +If tools are hands, skills are recipes. + +--- + +## Five kinds of skills + +| Type | Where it comes from | Who maintains it | +|------|--------------------|------------------| +| **`builtin`** | Ships with MateClaw under `skills/` in the classpath | The core team | +| **`custom`** | Created by you through the UI, API, or dropping a file into the workspace | You | +| **`dynamic`** | Auto-synthesized by agents during work | The agent + your approval | +| **`mcp`** | Backed by a tool exposed from an MCP server (a same-name `custom` skill shadows it) | The MCP server author | +| **`acp`** | Bridged from an external Agent Client Protocol endpoint (Claude Code, Codex, etc.) | The upstream agent service | + +All five flow through the same runtime pipeline. Only the source differs. + +--- + +## The SKILL.md protocol + +Every skill is one Markdown file with YAML frontmatter. The frontmatter is the contract. The body is the prompt. + +```markdown +--- +name: web-researcher +title: Web Researcher +description: Search the web and summarize findings on a given topic +version: 1.0.0 +type: custom +author: your-name +tools: + - WebSearchTool + - ReadFileTool +tags: + - research + - search +parameters: + - name: topic + type: string + required: true + description: The topic to research + - name: depth + type: string + required: false + default: brief + description: Level of detail (brief, detailed, comprehensive) +--- + +# Web Researcher + +You are a web research assistant. When given a topic, you should: + +1. Use WebSearchTool to find relevant information about {{topic}} +2. Evaluate source credibility +3. Compile findings into a {{depth}} summary +4. Include source URLs in your response + +## Output Format + +Present your findings as: +- **Summary**: 2-3 sentence overview +- **Key Facts**: Bullet-point list +- **Sources**: Numbered list of URLs +``` + +Two things to notice. First, the body is a prompt — not a description of one. It's what the skill will say to the agent at runtime, with `{{topic}}` and `{{depth}}` filled in. Second, the `tools:` list is a contract: the runtime guarantees those tools are available when the skill runs. If the agent doesn't have them, the skill call fails early with a clear error. + +### Frontmatter fields + +| Field | Required | Purpose | +|-------|----------|---------| +| `name` | ✅ | Unique identifier (kebab-case) | +| `title` | ✅ | Human-readable display name | +| `description` | ✅ | One-line summary | +| `version` | ✅ | Semantic version | +| `type` | ✅ | `builtin`, `custom`, `mcp` | +| `author` | — | Skill author | +| `tools` | — | List of tool names the skill requires | +| `tags` | — | Categorization | +| `parameters` | — | Typed input parameters | + +### Parameter schema + +| Field | Required | Purpose | +|-------|----------|---------| +| `name` | ✅ | Parameter name (used in `{{name}}` interpolation) | +| `type` | ✅ | `string`, `number`, `boolean`, `array` | +| `required` | — | Whether it must be provided (default: false) | +| `default` | — | Fallback value if caller omits | +| `description` | ✅ | What the parameter controls | + +### Typed wrapper tools for scripts (new in v1.4) + +A SKILL.md can declare a `scripts:` block that turns each script entrypoint into its **own named tool** with a typed JSON Schema. Instead of one generic `runSkillScript`, the model sees `skill__` tools and fills in schema-described parameters directly. + +```yaml +scripts: + - id: summarize + path: scripts/dispatch.py + fixedArgs: ["summarize"] # prepended verbatim to every call + parameters: + - name: url + type: string + required: true + - id: translate + path: scripts/dispatch.py + fixedArgs: ["translate"] + parameters: + - name: lang + type: string + required: true +``` + +- **One typed tool per entrypoint** — the model gets typed params, not a free-form arg string. +- **`fixedArgs` lets one dispatcher script back several entrypoints** — both entries above call `dispatch.py`, distinguished by the fixed leading arg, so you don't need a separate file per command. +- **Wrappers register/deregister with the skill lifecycle** — they appear when the skill goes live and disappear when it's disabled or archived. Path traversal is blocked: only scripts under the skill's own `scripts/` directory are reachable. A database-only skill (no directory) exposes no wrappers. + +--- + +## The runtime pipeline + +``` +1. RESOLVE Look up the skill by name in mate_skill + │ + ▼ +2. VALIDATE Check that required parameters are provided + │ + ▼ +3. RENDER Replace {{parameter}} placeholders in the SKILL.md body + │ + ▼ +4. INJECT Append the rendered instructions to the agent's system prompt + │ + ▼ +5. BIND TOOLS Verify required tools are available; fail fast if missing + │ + ▼ +6. EXECUTE The agent processes the enriched prompt with bound tools +``` + +Skills don't run scripts by default — they **shape the agent's behavior** for the duration of the call. The agent's next reasoning step sees the skill's rendered instructions as part of its system prompt. The exception is skills that ship with a script — `SkillScriptTool` can execute a skill's bundled script file, gated by Tool Guard. + +### Template rendering + +Skill bodies support `{{parameterName}}` placeholders. With `{topic: "quantum computing", depth: "detailed"}`: + +```markdown +Research the topic "{{topic}}" at a {{depth}} level of detail. +``` + +…renders to: + +```markdown +Research the topic "quantum computing" at a detailed level of detail. +``` + +Missing parameters fall back to defaults. Unknown placeholders are left intact. + +--- + +## Skill storage + +The database is the source of truth, the filesystem is a materialized cache. That's always been the rule for **SKILL.md**, and **as of v1.3 it applies to scripts/ and references/ too**. + +### Database: `mate_skill` + `mate_skill_file` + +`mate_skill` — skill identity and body: + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `name` | Unique name | +| `title` | Display title | +| `description` | One-line summary | +| `type` | `builtin` / `custom` / `mcp` | +| `content` | Full `SKILL.md` content | +| `version` | Semantic version | +| `enabled` | On/off | +| `tags` | JSON array | +| `create_time` / `update_time` | Timestamps | + +`mate_skill_file` (new in v1.3, migration `V112`) — the **canonical copy** of every bundle file: + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `skill_id` | FK to `mate_skill` | +| `file_path` | Relative path like `scripts/run.py` or `references/cfg.md` | +| `content` | UTF-8 text (≤1 MB per file, ≤50 MB per bundle) | +| `content_size` | Byte count (so listings don't have to load the blob) | +| `sha256` | Content fingerprint, drives the syncer's idempotent diff | + +### Filesystem: skill workspace + +``` +~/.mateclaw/skills/ +├── translate/ +│ ├── SKILL.md # Skill definition +│ ├── references/ # Reference materials +│ └── scripts/ # Optional executable scripts +├── code-review/ +│ ├── SKILL.md +│ └── ... +└── .archived/ # Archived old versions + └── translate-20260401-143000/ +``` + +Think of it as "Maven Local Repository, but for skills" — except the local repo can now hydrate itself from the database. + +### Auto-sync on startup + +Two sync passes run at boot, so every node has the latest bundle: + +1. `SkillWorkspaceBootstrapRunner` → `BundledSkillSyncer` scans the classpath `skills/` directory and syncs **bundled skills** into the workspace root. **Only syncs when the target directory doesn't exist**, so it never clobbers local modifications. +2. `SkillFileSyncer` diffs `mate_skill_file` (DB) against the local workspace (FS) by `sha256` and materializes anything missing or stale. + +**Why this matters for multi-instance deployments**: one node accepts the upload, the DB row + file rows are written, every other node either restarts or hits `POST /api/v1/skills/{id}/sync-files` to receive the full bundle. No NFS, no scp loop, even desktop clients can hand a skill off across machines. + +> Upgrade path: pre-v1.3 installs have files on disk but no `mate_skill_file` rows. The first time `SkillFileSyncer` runs on a freshly upgraded node, it **backfills from disk** into the canonical store; from then on the two stay in lockstep. + +### Robust zip install + +Third-party packagers package weirdly — some put `setup.sh` at the zip root, some emit `scripts/` entries before `SKILL.md`. As of v1.3, `ZipSkillFetcher`: + +- **Two-pass extraction** — the entire archive is buffered in memory first (cap-protected at 50 MB), `SKILL.md` is located and the wrapper-dir prefix computed, then entries are classified. **Zip entry order no longer affects the result.** +- **Root-level extension fallback** — files sitting next to `SKILL.md` that aren't already under a known bucket get classified by extension: `.sh / .py / .js / .rb / ...` → `scripts/`, `.md / .json / .yaml / .csv / ...` → `references/`. Unknown extensions are dropped with a `WARN` line so packaging mistakes surface instead of vanishing. +- **Write-then-prune + empty-bundle guard** — reinstalls **write new files first, then prune anything in the bucket that's not in the new bundle**. If the new bundle has zero entries for a bucket (`scripts/` or `references/`), the disk copies for that bucket are **left alone** — a malformed re-extract can no longer wipe your scripts. Pass `forcePrune=true` if you really want to clear a bucket via an intentionally empty bundle. + +> Real failure this catches: the official tencent-meeting-mcp zip puts `setup.sh` at the package root (not under `scripts/`). The old extractor silently dropped it; the new one auto-classifies it as `scripts/setup.sh` and the skill installs ready to run. + +### Configuration + +```yaml +mateclaw: + skill: + workspace: + root: ${user.home}/.mateclaw/skills + auto-init: true + delete-policy: archive # `archive` or `ignore` + bundled-skills-path: skills +``` + +--- + +## Skill Market (and ClawHub) + +The **Skill Market** page (`/skills`) is where you browse, install, edit, and manage skills. Three sources: + +- **Built-in** — skills that ship with MateClaw +- **Your custom skills** — the ones you created +- **ClawHub** — a community skill repository. Browse thousands of community skills, preview them, install with one click. Installed skills land as `custom` type. + +ClawHub is optional — if you're offline or don't want external skills, just don't touch that tab. + +--- + +## Skill Market API + +```bash +# List all skills +curl http://localhost:18088/api/v1/skills \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Create a custom skill +curl -X POST http://localhost:18088/api/v1/skills \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "code-reviewer", + "title": "Code Reviewer", + "description": "Review code for bugs, style issues, and improvements", + "type": "custom", + "content": "---\nname: code-reviewer\n...", + "tags": ["development", "review"] + }' + +# Enable / disable +curl -X PUT http://localhost:18088/api/v1/skills/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"enabled": true}' + +# Delete +curl -X DELETE http://localhost:18088/api/v1/skills/1 \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +Delete policy is configurable — by default, deletion moves the skill workspace to `.archived/` rather than erasing it. + +--- + +## Writing a custom skill — step by step + +1. **Decide what the skill does.** One sentence. +2. **List the tools it needs.** Three or fewer is a good target. +3. **Write the parameters.** Required ones first, optional with defaults. +4. **Write the body.** Address the agent directly: *"You are X. When given Y, do Z."* +5. **Upload** via the Skill Market UI or API. +6. **Bind** the skill to one or more agents. +7. **Test** by sending a message that should trigger the skill. + +Example — "Daily Standup" skill: + +```markdown +--- +name: daily-standup +title: Daily Standup Generator +description: Generate a daily standup update based on recent git activity +version: 1.0.0 +type: custom +tools: + - ShellExecuteTool +parameters: + - name: repo_path + type: string + required: true + description: Path to the git repository +--- + +# Daily Standup Generator + +Generate a standup update by analyzing recent git activity. + +## Steps + +1. Run `git log --oneline --since="yesterday" --author=$(git config user.name)` + in the directory {{repo_path}} +2. Summarize completed work +3. Identify any work-in-progress branches +4. Format as a standup update: + - **Yesterday**: What was completed + - **Today**: What is planned based on open branches + - **Blockers**: Any merge conflicts or failing tests +``` + +--- + +## Workspace isolation + +Each workspace gets its own copy of skills. When you enable a skill for a workspace, its files are staged under that workspace's directory, the skill's tools are scoped to that workspace, and any file the skill writes stays inside the workspace boundary. As of v1.4 the skill **catalog and runtime are scoped per workspace** too, so each workspace sees and runs only its own skills. See [Workspaces](./workspaces). + +--- + +## Auto Skill Synthesis + +Agents that work with you long enough start noticing patterns — a recurring database query, a particular report layout, the exact commands to SSH into your box. Agents can **turn those patterns into skills on their own**. + +The flow: + +1. The agent recognizes a reusable workflow during task execution +2. The agent proposes a new skill (create / edit / patch / delete) +3. You review in ChatConsole — check the content, rename if you want, approve or reject +4. On approval, the skill saves as `dynamic` type, ready for reuse + +**Security scan runs automatically before save** — dangerous patterns (prompt injection, script injection) are blocked. Skills can migrate between agents and export as ZIP. + +The agent's memory grows with you. No more repeating "remember I like tables sorted this way." + +--- + +## Template wizard: start from a starter + +Don't know how to write a SKILL.md? Open the wizard. + +`Skills → Create Wizard`: + +1. Pick a **starter template** (8 of them: researcher, code reviewer, writing assistant, customer-support script, data analysis, Claude Code helper, Codex helper, blank) +2. Fill in the variables — name, parameters, a few sentences of description +3. Upload any supporting files (scripts, references, prompt fragments) +4. Set secrets (API keys, etc.) — **secrets go to a vault, not into SKILL.md** +5. Save + +You don't get just a SKILL.md. You get a **multi-file bundle** — SKILL.md, references/, scripts/, secret references — packaged together. + +### The `skill-authoring` meta-skill (new in v1.4) + +There's now a built-in `skill-authoring` skill, auto-seeded on startup, that teaches an agent (or you) how to author a SKILL.md correctly. It covers: + +- **Required frontmatter** and what each field means +- **Validator limits** — name must match `^[a-z0-9][a-z0-9._-]{0,63}$`, content ≤ 100k characters +- **Built-in vs custom** authoring workflows +- **Directory placement** for scripts/ and references/ +- **Common pitfalls** that fail validation or silently misbehave + +Bind it to an agent and "write me a skill that…" produces a valid bundle on the first try, not after three validation round-trips. + +--- + +## Pre-flight check before installation + +A skill that's installed isn't necessarily a skill that runs — it might need an API key, a CLI tool, a MateClaw feature flag toggled on. + +Used to be: install, run, fail, debug. Now: + +**Pre-flight install dialog** — runs the prerequisite check automatically before the skill goes live: + +- Are the required tools present? +- Are the required API keys configured? +- Are the required feature flags on? +- Are the dependent MCP / ACP endpoints reachable? + +Whatever's missing is reported up front, with a one-click **`[Set Up]`** button that jumps to the right config page. **No more install-then-debug.** + +--- + +## LESSONS.md: skills that learn from experience + +Each skill can carry a `LESSONS.md` — what the skill learned during runs. + +- After a run, the skill can **proactively write a lesson**: "Last time the user didn't like that format, don't do it again" +- Next time the same skill is invoked, LESSONS get auto-injected into the prompt context +- The more it's used, the better it knows **when to step in and when to stay out** + +This is the first cut of skill self-evolution. Skills go from a list of instructions to something with playbooks, experience, and the capacity to grow. + +LESSONS are viewable and editable in the skill detail drawer's **Memory tab**. + +--- + +## Secrets: put the token in the right place + +Lots of skills need API credentials to function — tencent-meeting needs `TENCENT_MEETING_TOKEN`, Slack needs a bot token, Linear needs a personal API key. Those values **don't belong in SKILL.md** (it goes into the prompt and leaks to the LLM), don't belong in scripts (one git push and you're sorry), and editing `~/.zshrc` requires restarting the server and won't follow the skill across machines. + +As of v1.3, every skill has its own **per-skill secret store**. + +### Manage it in the UI + +Skill detail drawer → **Secrets** tab. One table plus a form: + +``` +Key Value Last updated Actions +TENCENT_MEETING_TOKEN sk••••ef 2026-05-12 [Edit] [Delete] + +[+ Add secret] +``` + +- **Plaintext never leaves the server** — listing returns only `preview` (`sk••••ef`-style mask); the add/edit dialog's value field starts blank, saving overwrites whatever was there. +- **Client-side validation** — keys must match `^[A-Za-z_][A-Za-z0-9_]{0,127}$`; bad keys are rejected in the browser before submission. +- **Value field is ``** — shoulder-surfers, screenshots, and password managers all stay out. + +### How it's stored / how it's injected + +| Stage | What happens | +|---|---| +| Write | `POST /api/v1/skills/{id}/secrets` `{key, value}` → AES-encrypted → `mate_skill_secret` | +| Read | Before subprocess launch, `SkillSecretService.getDecrypted(skillId)` AES-decrypts | +| Inject | `ProcessBuilder.environment().putAll(...)` — **overrides parent-process env vars of the same name** | + +The injection rule is **secret-store wins, `.zshrc` is the fallback**. For multi-user / multi-machine deployments, desktop clients, and corporate accounts that don't share databases, the secret store is the more reliable source of truth. + +### REST endpoints + +```bash +# List (masked) +GET /api/v1/skills/{id}/secrets +# Upsert (empty value deletes) +POST /api/v1/skills/{id}/secrets {"key":"...", "value":"..."} +# Delete +DELETE /api/v1/skills/{id}/secrets/{key} +``` + +### A full example: tencent-meeting + +``` +SkillMarket → tencent-meeting-mcp card → detail drawer → Secrets tab + → + Add secret → key=TENCENT_MEETING_TOKEN, value= + → Save + +Then when the agent runs setup.sh or scripts/tencent_meeting.py: + ProcessBuilder env carries $TENCENT_MEETING_TOKEN + → mcporter / Python script calls the Tencent API → meeting ID returned +``` + +No `~/.zshrc` edit, no mateclaw restart needed. + +--- + +## Discoverability: a skill installed should be a skill found + +Installing a new skill used to mean the agent often couldn't find it. Three causes, three fixes, all in v1.3. + +### 1) New skills are **boosted** in the prompt catalog + +The agent's system prompt carries a compact Skills table. Each model gets a row cap based on its max input tokens — qwen-turbo with 8192 tokens gets only **8 entries**. A brand-new skill has zero usage history, so the existing recent / frequent / RECOMMENDED sort buries it behind ~40 older skills, well below the cutoff. + +v1.3 inserts a "**installed in the last 7 days**" sort key at the front of the ranker. Install on Friday, the skill is still in the first frame on Monday — long enough to span a weekend, short enough not to occupy a slot indefinitely. Builtins and virtual MCP/ACP rows are excluded (you didn't "just install" them). + +### 2) `listAvailableSkills()` teaches the LLM how to search wider + +The tool description now explicitly says: + +- The default page is 20 entries; if you see `Showing: 20 of 47`, **retry with `keyword=` or `limit=50`** +- If the user mentions a specific skill name, **skip the catalog** — go straight to `readSkillFile(skillName="", filePath="SKILL.md")` to verify + +Truncated results carry a one-line hint at the end so even small models can see how to follow up. + +### 3) Calling a skill name as a tool **auto-redirects** + +LLMs occasionally call a skill name as if it were a tool (`tencent-meeting-mcp({...})`). The previous behavior was a textual hint telling them to call `readSkillFile` instead — which qwen-turbo-class models often can't act on. They reply "let me get that for you" and end the turn without any further tool call, producing a dead loop. + +As of v1.3, when `ToolExecutionExecutor` sees this case AND `readSkillFile` is bound to the agent, it **transparently invokes readSkillFile on the LLM's behalf** and returns the SKILL.md content (prefixed with `[auto-redirect]` and the original args echoed back) as the tool result. The model has runnable instructions in front of it on its very first attempt and goes straight to `runSkillScript`, no loop. + +> This fix helps small models a lot and doesn't hurt large models (they would have followed the textual hint anyway). + +--- + +## Progressive skill disclosure (new in v1.4) + +Dumping every skill's full SKILL.md into the system prompt doesn't scale — it blows the token budget and churns the prompt cache on every turn. v1.4 flips the model: the prompt carries only a compact catalog, and the agent **pulls a skill's instructions on demand**. + +**`load_skill(skillName, filePath?)`** loads a skill's SKILL.md (or any bundle file via the optional `filePath`) right when the agent decides to use it: + +- **Injected via message history, not the system prompt** — the loaded content arrives as a conversation turn, so the system prompt (and its cache) stays byte-stable across the session. +- **Loaded skills get pinned** to the top of the runtime catalog on later turns, so the agent keeps seeing what it just pulled in. +- The catalog guidance tells the model to `load_skill(skillName=)` before using a skill, and to call it directly when the user names a specific skill. + +```yaml +mateclaw: + skill: + disclosure: + load-skill-tool: + enabled: true # default; set false to fall back to the older readSkillFile flow +``` + +When disabled, the catalog guidance points at `readSkillFile` instead and `load_skill` is not registered. + +--- + +## Skill lifecycle curator (new in v1.4) + +Agents that synthesize skills accumulate cruft — a one-off skill from three weeks ago is still in the catalog, eating a slot. The **curator** is a daily sweep that ages idle, **agent-created** skills through `active → stale → archived` and gets them out of the way without deleting anything. + +- Idle past `staleAfterDays` (default 30) → **stale**; idle past `archiveAfterDays` (default 90) → **archived** (workspace moved to a `.archived/` subdir). `restore` brings an archived skill back. +- **Never touched**: built-ins, pinned skills, MCP/ACP/virtual skills, and any name starting with a protected prefix (default `sys-`, `ops-`). + +### Settings → Skill Curator panel + +- **Preview (dry-run)** — see exactly which skills the next sweep would move, before it runs. +- **Pause / resume** the whole sweep; **activate / deactivate** an individual skill. +- **Last run / next run** timestamps and **per-state counts** (active / stale / archived). + +### Configuration + +```yaml +mateclaw: + skill: + curator: + enabled: true + cron: "0 0 2 * * *" # daily at 02:00 + staleAfterDays: 30 + archiveAfterDays: 90 + scope: AGENT_CREATED # AGENT_CREATED | ALL_DYNAMIC | OFF + protectPrefixes: ["sys-", "ops-"] +``` + +`scope: AGENT_CREATED` touches only skills with a source conversation; `ALL_DYNAMIC` also sweeps manually-created dynamic skills; `OFF` disables the sweep regardless of `enabled`. + +### Lifecycle in the Skill Market + +The Skills page picks up the lifecycle: + +- **Lifecycle tabs** — Enabled / Stale / Archived. +- Cards show a **"last used"** badge. +- The detail drawer adds **manual archive / restore / pin**. +- Manually archiving a still-bound skill triggers a **confirm handshake** — you don't silently pull a skill out from under a digital employee that's still using it. + +--- + +## ACP bridge: plug in external coding agents + +ACP (Agent Client Protocol) is a protocol that lets external agent clients (Claude Code, Codex, other compatible clients) plug into MateClaw as skills. + +Once installed: + +- ACP endpoints **auto-bridge into skill cards** — they show up on the Skills page with a wrapper toolset +- **Visual env editor** — every endpoint's required key, URL, CWD, configurable in the UI +- **Per-session cwd** — every ACP session has its own working directory +- **Errors translated** — upstream messages like "Request not allowed" get translated into something actionable +- **OAuth keychain hijack detection** — if your OAuth token has been hijacked by another app, you're prompted to re-authenticate + +Templates: `claude-code-helper`, `codex-helper` — install and go. + +A digital employee calls an ACP skill the same way it calls a built-in tool. + +### Virtual SKILL.md for MCP/ACP skills (new in v1.4) + +MCP- and ACP-derived skills used to be opaque tool bundles with no readable instructions. v1.4 **synthesizes a read-only virtual SKILL.md** from each MCP/ACP server's metadata (transport, command, args, env, exposed tools), so those integrations show up as **navigable skill catalogs** in the Skills page. Because they're synthesized, virtual SKILL.md rebuilds on every list call — no stale persisted copy to maintain — and `load_skill` can read it just like a real skill, giving the agent a description of what the integration can do before it calls a single tool. + +--- + +## Detail drawer: everything in one place + +Every skill card opens a drawer with eight tabs: + +- **Overview** — identity fields, manifest projection, source, version +- **Body** — `SKILL.md` editor (takes over the full drawer width) +- **Tools** — which tools this skill uses (with effective tool expansion) +- **Features** — capability matrix +- **Security** — content scan results, related Tool Guard rules +- **Lessons** — `LESSONS.md` content +- **Secrets** — env-var-style credentials (new in v1.3; see the "Secrets" section below) +- **Memory** — digital employees bound to this skill + +The card itself is slim — six fields and one status pill. **Clear beats comprehensive.** + +--- + +## Security + +Custom skills go through several checks before they become live: + +- **Content scanning** — `SKILL.md` scanned for prompt injection and script injection on upload +- **Tool requirement check** — `tools:` list must only reference tools that exist +- **Tool Guard compliance** — skills with dangerous tools inherit Tool Guard rules +- **MCP skill constraints** — MCP-backed skills inherit the security constraints of their MCP server + +Full review in [Security & Approval](./security). + +--- + +## Next + +- [Tools](./tools) — tools that skills can use +- [Agents](./agents) — how agents invoke skills during a turn +- [MCP](./mcp) — MCP-backed skills +- [Security & Approval](./security) — skill scanning details diff --git a/mateclaw-server/src/main/resources/docs/en/tools.md b/mateclaw-server/src/main/resources/docs/en/tools.md new file mode 100644 index 00000000..7e3a8c43 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/tools.md @@ -0,0 +1,381 @@ +# Tools + +**A tool is a hand the agent can reach out with.** + +Left to its own devices, a language model is a pattern-matcher wrapped in text. It doesn't know what time it is. It doesn't know what's in your files. It can't search the web, run a command, look at a PDF, delegate to another agent, or open a browser. It can only *talk about* doing those things. + +Tools are how MateClaw fixes this. Each tool is a concrete operation the agent is allowed to invoke — read a file, search the web, execute a shell command, extract text from a PDF, delegate to another agent. When the agent decides it needs one, it emits a **tool call**, the runtime executes it, and the result comes back as an **observation**. + +Fourteen tools ship built-in. Unlimited more can be added through MCP servers, custom skill scripts, or your own `@Tool`-annotated Spring beans. + +--- + +## How a tool call actually happens + +``` +Agent decides it needs a tool + │ + ▼ + Emits a tool call: {"name": "WebSearchTool", "args": {"query": "..."}} + │ + ▼ + ┌─────────────────────┐ + │ Tool registry │ ← look up the tool by name + └─────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Tool Guard │ ← rule-based check: allow / deny / approval + └─────────────────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ + allowed approval pending → user decides → allowed / rejected + │ + ▼ + ┌─────────────────────┐ + │ Execute (timeout) │ ← async, per-tool timeout + └─────────────────────┘ + │ + ▼ + Result → observation → agent's next reasoning step +``` + +Tool Guard is the gatekeeper. Timeouts are per-tool (so one slow tool can't freeze a turn). Execution can be concurrent inside a single Action phase — if the agent calls three independent tools at once, they run in parallel. + +None of this shows up in the agent's prompt. The agent just asks for a tool. The runtime handles everything in front of, during, and after the call. + +--- + +## Tool registration — three paths + +**1. Built-in tools.** The twenty tools that ship with MateClaw — registered into the tool table on startup. + +**2. MCP servers.** External processes speaking the Model Context Protocol expose tools dynamically. MateClaw discovers them via `tools/list` and they appear in the registry alongside built-in ones. See [MCP](./mcp). + +> **Per-agent MCP tool scoping (1.4.0+, #117)**: when an agent has **ticked no specific MCP tool rows**, enabled MCP tools **auto-join** its tool set; once it ticks specific MCP tools, it's **restricted to that set**. Agents bound to skills / built-in tools only keep full access to all MCP tools. + +**3. Skill scripts.** Skill packages can ship executable scripts that get wrapped as tools at runtime. See [Skills](./skills). + +Tool discovery is **blacklist-style** — every discoverable tool is registered by default. Exclude specific tools explicitly. Newly added tools don't get silently missed. + +--- + +## Progressive tool disclosure (1.4.0+) + +As the tool count grows, the system prompt balloons with dozens of full tool schemas — even when a task needs only one or two of them. **Progressive disclosure** splits tools into two tiers so the prompt scales with the **task**, not with the **total tool count**. + +| Tier | How it appears in the system prompt | Callable out of the box? | +|------|-------------------------------------|--------------------------| +| **CORE** | Always advertised in full, with the complete schema | Yes | +| **EXTENSION** | Only a compressed directory — name + source + one-line description; the full schema stays hidden | No — activate with `enable_tool` first | + +**Default tiering**: the generative tools (`image_generate`, `music_generate`, `video_generate`, `model3d_generate`) and `browser_use` default to **EXTENSION**; everything else is **CORE**. + +- **Page control** — the Tools page has Core and Extension sections with a per-row tier toggle for built-in and channel tools; MCP / ACP tools are locked. +- **Persistence** — the tier is stored in `mate_tool.disclosure_tier` and `mate_mcp_server.disclosure_tier`. +- **Config** — `mateclaw.tools.disclosure.mode`, default `progressive`; set it to `legacy` to restore the old "advertise everything" behavior. + +**Why** — to stop context bloat. The system prompt should scale with what the current task needs, not with how many tools you've installed. + +--- + +## The twenty built-in tools + +| Tool | What it does | Dangerous | +|------|--------------|-----------| +| `DateTimeTool` | Current date/time in any timezone | — | +| `WebSearchTool` | Search via the provider chain (Serper / Tavily / DuckDuckGo / SearXNG) | — | +| `ReadFileTool` | Read file contents | — | +| `WriteFileTool` | Write content to a file | ⚠️ | +| `EditFileTool` | Find-and-replace edit | ⚠️ | +| `ShellExecuteTool` | Execute a shell command | ⚠️ | +| `FileTypeDetectorTool` | Detect MIME type and encoding | — | +| `DocumentExtractTool` | Extract text from PDF, DOCX, XLSX | — | +| `WorkspaceMemoryTool` | Read/write the agent's workspace memory | — | +| `SkillFileTool` | Read and manage `SKILL.md` files | — | +| `SkillScriptTool` | Execute skill scripts | ⚠️ | +| `SkillManageTool` | Create / edit / delete skill packages | ⚠️ | +| `BrowserUseTool` | Drive a headless browser | ⚠️ | +| `DelegateAgentTool` | Delegate a task to another agent (parallel supported) | — | +| `MateClawDocTool` | Read built-in project documentation | — | +| `ImageGenerateTool` | Text-to-image / **image-to-image (1.3.0+)** | — | +| `VideoGenerateTool` | Text-to-video / image-to-video generation | — | +| `DocxRenderTool` | **1.3.0+** Markdown → .docx (Word document) | — | +| `XlsxRenderTool` | **1.3.0+** Markdown tables → .xlsx (Excel) | — | +| `PptxRenderTool` | **1.3.0+** Markdown (Marp-style `---` slide breaks) → .pptx | — | +| `PdfRenderTool` | **1.3.0+** Markdown → publication-grade PDF (CJK fonts embedded) | — | +| `CronJobTool` | Create and manage scheduled tasks | ⚠️ | +| `DatasourceTool` | Manage external datasource connections | ⚠️ | +| `SqlQueryTool` | Execute SQL queries on connected datasources | ⚠️ | +| `send_file` | **1.4.0+** Deliver an existing server file as a native IM attachment (#199) | — | +| `enable_tool` | **1.4.0+** Activate an extension-tier tool for this conversation | — | +| `load_skill` | **1.4.0+** Load a skill's `SKILL.md` on demand | — | + +Plus the `MusicGenerateTool` from [Multimodal](./multimodal). And the 14 Wiki tools from [LLM Wiki](./wiki): `wiki_read_page`, `wiki_read_many`, `wiki_list_pages`, `wiki_search_pages`, `wiki_semantic_search`, `wiki_compile_page`, `wiki_trace_source`, `wiki_create_page`, `wiki_delete_page`, `wiki_archive_page`, `wiki_unarchive_page`, `wiki_related_pages`, `wiki_explain_relation`, `wiki_enrich_page`. + +### DateTimeTool + +Returns the current date and time for a given timezone. Zero surprises. + +``` +Input: {"timezone": "America/New_York"} +Output: "2026-04-11T14:30:22" +``` + +### WebSearchTool + +Web search via a **provider chain** — DuckDuckGo and SearXNG as keyless fallbacks, Serper and Tavily when you have keys. Configured in `Settings → System → Search Service` and takes effect without restart. + +``` +Input: {"query": "Spring AI Alibaba latest version", "freshness": "month", "count": 5} +Output: "Spring AI Alibaba 1.1 was released..." +``` + +Features: + +- **Provider chain** — falls through to the next on failure. Keyless providers provide baseline coverage. +- **Advanced parameters** — `freshness` (day/week/month/year), `language`, `count`. +- **Result caching** — recent queries are cached. +- **Security wrapping** — results sanitized before return. +- **Provider-native + tool search coexistence** — models with their own search (ChatGPT, Gemini) can use that natively while tool search is available as fallback. + +### ShellExecuteTool + +Cross-platform shell execution. Linux/macOS uses `/bin/sh -c`; Windows uses `cmd.exe /D /S /C`. **Every call is gated by Tool Guard.** + +Safety design: + +- **Timeout** — 60s default, 300s hard cap +- **Output caps** — stdout and stderr capped at 10,000 bytes each +- **File-backed output** — stdout/stderr to temp file, not pipe +- **Structured result** — `{exitCode, stdout, stderr, timedOut}` +- **Dangerous-pattern detection** — `find -delete`, `rm -rf /`, piped bash downloads trigger elevated approval + +``` +Input: {"command": "ls -la /tmp"} +Output: "total 48\ndrwxrwxrwt 12 root root..." +``` + +### ReadFileTool / WriteFileTool / EditFileTool + +Read is safe. Write and Edit are both gated by Tool Guard. + +### DocumentExtractTool + +PDF, DOCX, XLSX, and friends become plain text. Scanned documents get OCR fallback where available. + +### Office document generation (1.3.0+) + +Four new tools that render Markdown directly into downloadable Office files — **no subprocess fork, no npm dependency**. Generated bytes are cached in memory and returned as a one-time download URL: + +| Tool | Use for | Key capabilities | +|---|---|---| +| `DocxRenderTool.renderDocx` | Reports / memos / contracts / resumes | Headings (# ## ###) / bold (**text**) / lists / tables / images (PNG/JPG/GIF/BMP/SVG → PNG) | +| `DocxRenderTool.renderDocxFromFile` | Same, but markdown is in a workspace file | Avoids the LLM having to repeat its own large markdown body as a tool argument | +| `XlsxRenderTool.renderXlsx` | Financial sheets / data exports / templates | Markdown table syntax → multiple sheets (split by `## SheetName`) | +| `PptxRenderTool.renderPptx` | Decks / project plans / briefings | Marp-style `---` slide breaks; `16:9` (default) / `4:3` aspect | +| `PptxRenderTool.renderPptxFromFile` | Same, but markdown in a file | Preferred when the deck body exceeds 5KB | +| `PdfRenderTool.renderPdf` | Publication-grade documents / weekly reports / templated docs | 1in margins / smart pagination / page numbers / cover page / mixed CJK + Latin (CJK fonts embedded) | + +::: tip Relationship with the existing `skills/docx` skill +The `skills/docx` skill **stays** — it's good at **editing existing .docx** (tracked changes, complex XML ops) and runs `npm install docx` on first use. The four new tools handle the "create-from-scratch" path with **no npm warm-up cost**. Agents prefer these RenderTools; fall back to the skill only when modifying an existing .docx. +::: + +### ImageGenerateTool — image edit support from 1.3.0 + +In v1.2.0 this tool was text-to-image only. v1.3.0 adds two parameters — `image` and `images` — for **multi-image input editing**. See [Multimodal](./multimodal#image-edit). + +### WorkspaceMemoryTool + +Lets an agent read, write, and edit its own workspace memory files — `MEMORY.md`, `PROFILE.md`, daily notes, anything under `workspace/{agentId}/`. Safety rules: `.md` only, no directory traversal. See [Memory](./memory). + +### BrowserUseTool + +Drives a headless browser. Navigate, click, type, extract. Every call gated by Tool Guard. + +### DelegateAgentTool — agents delegating to agents + +One agent can hand off a subtask to another: + +- **`delegateToAgent(agentName, task)`** — call a specific agent by name, run in isolated conversation, return the result +- **`listAvailableAgents()`** — list all available agents with name, type, description + +``` +User: Search for Spring AI news and have Writer summarize it +Agent A: [calls WebSearchTool] + [calls delegateToAgent(agentName="Writer", task="Summarize: ...")] + [receives Writer's response] + Replies with the combined result +``` + +Safety: + +- **Recursion cap** — maximum 3 delegation levels deep +- **Isolated sessions** — the delegated agent runs in its own conversation +- **Result truncation** — delegated results capped at 4000 characters + +### MateClawDocTool + +Reads the built-in MateClaw project documentation. Lets an agent answer "how does X work in MateClaw" questions by consulting actual docs rather than guessing. + +### enable_tool — activate an extension-tier tool (1.4.0+) + +`enable_tool(toolName)` activates an **EXTENSION**-tier tool so it becomes fully callable for the **rest of the conversation**. + +- **Validated** — only tools in the agent's effective set can be activated. +- **Takes effect next turn** — activation lands on the **next reasoning turn** of the same ReAct loop (the agent sees the full schema, then emits the real call). +- **Conversation-scoped, not persisted** — activation lasts only for the current conversation; nothing is written to the database, and a new conversation reverts to the default tiering. + +### load_skill — load a skill on demand (1.4.0+) + +`load_skill(skillName, filePath?)` pulls a skill's `SKILL.md` in only when it's needed — omit `filePath` for the main file, or pass one to read a sub-file inside the skill package. + +- **Injected via message history** — the loaded content goes into **message history**, not the system prompt, so the **prompt cache stays stable** (the system prompt is unchanged, so the cache isn't invalidated). +- **Pinned in later turns** — a loaded skill stays **pinned** for the rest of the conversation, so it doesn't have to be reloaded. +- **Config** — `mateclaw.skill.disclosure.load-skill-tool.enabled`, default true. + +See [Skills](./skills). + +### send_file — deliver an existing file as a native attachment (1.4.0+, #199) + +`send_file(filePath, fileName?)` reads an **existing file** on the server and delivers it as a **native IM attachment** — not a text download link. + +- **Stored in the generated-file cache** — the file is placed in the generated-file cache, and channel adapters (Feishu / DingTalk / Telegram) **auto-detect and deliver** it. +- **Any common file type**, up to a **20 MB** limit. +- **Contrast with `ReadFileTool`** — `ReadFileTool` **extracts text** from a file to feed the agent's reasoning; `send_file` ships the file **as-is** to the user. + +### ReadFileTool — oversized-line paging (1.4.0+, #190) + +For files with a very long single line, `ReadFileTool` adds an optional `startColumn` (a 1-based character offset within `startLine`) to **resume the tail** of that line from where you left off. + +- On truncation it **always returns** `nextStartLine`; +- it **additionally returns** `nextStartColumn` when more of that line remains. + +Feed both back into the next call to page through a giant single-line file in segments. + +--- + +## Tool Guard — the permission layer + +Tool Guard is how MateClaw keeps strong tools from doing stupid things. It's **rule-based**, not a flat dangerous-tools list. Each rule says: *for this tool, with these arguments, in this context, do X* — where X is `allow`, `deny`, or `require_approval`. + +Core pieces: + +- **`mate_tool_guard_rule`** — individual rules with tool pattern, optional arg pattern, action +- **`mate_tool_guard_config`** — global config: enabled/disabled, default policy, approval timeout +- **`mate_tool_guard_audit_log`** — every guarded call leaves an entry + +Example rule: *allow `ShellExecuteTool` when the command starts with `ls`, `cat`, `grep`, or `find`. Require approval for anything else.* + +```yaml +mateclaw: + tool: + guard: + enabled: true + default-policy: require_approval + rules: + - tool: ShellExecuteTool + arg-pattern: "^(ls|cat|grep|find)\\s" + action: allow + - tool: WriteFileTool + action: require_approval +``` + +Or manage interactively on `Settings → Security & Approval`. When a rule requires approval, the runtime persists a row in `mate_tool_approval` and suspends the agent turn. When the user decides, the agent resumes where it paused. Full mechanism in [Security & Approval](./security). + +### Declarative hook system + +Tool Guard rules are a special case of a more general mechanism — the **declarative hook system**. Five lifecycle hooks cover every critical moment in tool and LLM execution: + +| Hook | Fires when | Typical use | +|------|-----------|-------------| +| `before_tool` | Before tool execution | Argument redaction, context injection, extra validation | +| `after_tool` | After tool execution | Result filtering, audit logging | +| `before_llm` | Before LLM call | Prompt enrichment, cache hit check | +| `after_llm` | After LLM returns | Output filtering, token accounting | +| `on_error` | On error | Alerting, fallback strategy | + +Hooks run in-process. They can transform arguments, transform results, mask sensitive fields, and add audit log entries. You can use hooks for things beyond Tool Guard — like injecting a security policy before every LLM call, or auto-redacting sensitive fields from tool returns. + +--- + +## Execution: concurrent, isolated, bounded + +- **Concurrent execution** — within a turn, independent tool calls run in parallel. Guard checks are sequential; execution is concurrent where safe. +- **Per-tool timeouts** — every tool has its own timeout. Defaults: fast tools 30s, shell/browser 60s, generation tools up to 300s. +- **Segment isolation** — when approvals are needed mid-turn, the segment splits at the approval boundary. +- **Observation truncation** — long tool results are automatically truncated before being added to observation history. +- **Error isolation** — one tool failure does not abort the turn. + +--- + +## Tool management via API + +```bash +# List all tools +curl http://localhost:18088/api/v1/tools \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Enable / disable +curl -X PUT http://localhost:18088/api/v1/tools/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"enabled": false}' + +# Test a tool directly +curl -X POST http://localhost:18088/api/v1/tools/WebSearchTool/test \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"query": "Spring AI"}' +``` + +Every provider-backed tool has a test button in the Tools page so you can verify API keys before shipping. + +--- + +## Creating a custom tool + +### Option 1: a `@Tool`-annotated Spring bean + +```java +@Component +public class FactorialTool { + + @Tool(description = "Calculate the factorial of a number") + public String factorial( + @ToolParam(description = "The number to compute factorial for") int n) { + long result = 1; + for (int i = 2; i <= n; i++) { + result *= i; + } + return String.valueOf(result); + } +} +``` + +- Spring `@Component` +- Every `@Tool` method becomes a callable tool +- Use `@ToolParam` on every parameter — that's the LLM description +- Return value is what the agent sees +- **If the tool is dangerous, add a Tool Guard rule for it** + +Restart and the tool is live. + +### Option 2: a skill script + +Don't want to write Java? Bundle behavior into a skill package with a `SKILL.md` and a script. See [Skills](./skills). + +### Option 3: an MCP server + +Capability already exists as an MCP server? Just add the server configuration. See [MCP](./mcp). + +--- + +## Next + +- [Skills](./skills) — higher-level capabilities built on tools +- [MCP](./mcp) — external tool providers +- [Security & Approval](./security) — Tool Guard rules, approval flow, audit log +- [Multimodal](./multimodal) — generation tools (image, video, music, TTS, STT) diff --git a/mateclaw-server/src/main/resources/docs/en/triggers.md b/mateclaw-server/src/main/resources/docs/en/triggers.md new file mode 100644 index 00000000..208dbd97 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/triggers.md @@ -0,0 +1,299 @@ +# Triggers + +::: tip New in 1.3.0 +The trigger system is available from v1.3.0. In v1.2.0 and earlier, workflows and agent conversations could only be invoked manually. +::: + +**What triggers are**: a connector between "events that happen in the system" and "actions to perform". Events can be a cron schedule, a webhook arriving, a channel message, an employee finishing a conversation, or another workflow completing. Actions are either starting a workflow or sending a message to an employee for processing. + +**What triggers are not**: +- Not a replacement cron-job manager — `mate_cron_job` still exists and runs independently; triggers **share** its ShedLock + scheduler base but **do not write into** `mate_cron_job` +- Not an IFTTT / n8n drag-to-edit automation builder — triggers only do "event → action" routing; complex logic belongs in [Workflow](./workflow.md) +- Not a full-feature webhook dispatcher — they handle dedup / rate-limit / bot-self filtering / pattern matching, not arbitrary business-payload parsing + +::: warning v1.3.0 scope +v0 = 6 pattern types + 2 dispatch targets (agent / workflow). Event governance (dedup, per-trigger rate limit, recursion guard, bot-self filtering) is on by default. +::: + +--- + +## One-minute overview + +```jsonc +// A trigger that runs a "morning report" workflow daily at 9 AM +{ + "name": "daily-morning-report", + "patternType": "cron", + "patternJson": { + "cronExpression": "0 0 9 * * *", + "timezone": "Asia/Shanghai" + }, + "targetType": "workflow", + "targetId": 12345, + "payloadTemplate": "{ \"date\": \"{{ now | date('yyyy-MM-dd') }}\" }", + "rateLimitPerMin": 10, + "dedupWindowSecs": 60, + "botSelfFilter": true, + "enabled": true +} +``` + +At 9 AM → backend grabs the ShedLock via `CronDelegationPort` → renders the payload → enqueues an async run of workflow `12345`. Other instances at the same moment are blocked by the lock; no double-fire. + +--- + +## Six pattern types + +Implemented in `TriggerPatternMatcher.java`. Each pattern matches its `pattern_json` block on the trigger row. **Fields not listed here are ignored** by v0's matcher. + +| Pattern | When it fires | `pattern_json` fields | Reuse constraint | +|---|---|---|---| +| `cron` | On a cron expression (**does not flow through ingest**; runs from the scheduler) | `cronExpression`, `timezone` | Reuses the `cron/` module's ShedLock + Spring TaskScheduler; **does NOT write into mate_cron_job, does NOT call CronJobService** | +| `webhook` | Generic event passthrough (**v0 does no further filtering** — secret check happens at the channel layer; the trigger itself just matches `patternType=webhook`) | (none in v0) | Through the unified `POST /api/v1/triggers/events` entry + envelope wrap | +| `channel_message` | Channel receives a message | `channelType` (optional, compared against envelope `data.channelType`), `senderEquals` (optional, exact sender id match) | Side-channel through `ChannelWebhookController`; original routing unaffected | +| `agent_lifecycle` | Agent lifecycle events | `agentId` (optional), `phase` (optional: `spawned` / `terminated` / `crashed`) | Hangs off `ReActLifecycleListener` | +| `content_match` | Substring must appear in the envelope content | `substring` (**required**, case-insensitive contains-match against envelope `data.content`) | Generic content filter; the event source is whatever fed the envelope | +| `workflow_completion` | A workflow run reaches a terminal state | `sourceWorkflowId` (optional), `stateFilter` (optional: `completed` / `failed` / `any`) | Listens to `WorkflowEngine` terminal events; recursion guard below | + +> **Unknown pattern types fail closed by default** — typo'd or future pattern types can't silently fire every trigger in the workspace. +> +> **Not in v1.3.0**: `schedule` (one-shot non-cron like "30 minutes from now"), external MQ listeners (Kafka / Pulsar / RocketMQ), metrics / threshold alerting triggers. + +--- + +## Event governance (on by default) + +### Bot self-msg filtering (default binding is no-op) + +Some channels (Feishu / DingTalk / WeCom) surface bot-emitted messages back as `channel_message` events. The framework wires this through `BotSelfFilter` SPI + each trigger's `bot_self_filter` field (default `true`). + +::: warning v0 default implementation is no-op +The default-bound `NoopBotSelfFilter` returns `false` from `isBotSelf(...)` for every sender. That means `bot_self_filter=true` on a trigger **doesn't actually filter anything in v0** until a channel adapter registers a real `BotSelfFilter` Spring Bean (which replaces the default). This is intentional — a wrong default would silently swallow all legitimate bot-to-bot messages. +::: + +To exempt a single trigger from the framework filter (rare — e.g. a bot emitting a special command to trigger cleanup), set that trigger's `bot_self_filter` to `false`. + +### Event dedup + +When `TriggerEventIngestService` dispatches an event, the engine queries `mate_trigger_event` for the `dedup_key` within the `dedupWindowSecs` window (default 60s). Already present → **dropped**, `fire_count` not incremented. + +Default `dedupWindowSecs = 60`. Raise it to absorb longer gateway re-deliveries; set to `0` to disable (**not recommended**). + +### Per-trigger rate limit + +Each trigger is rate-limited individually: at most `rateLimitPerMin` per minute (default 10). Events past the cap are dropped — **no retry**, **no row in `mate_trigger_event`**; instead `mate_trigger.last_error` is updated to `"rate-limited"` so ops can see it. + +`channel_message` triggers usually want this raised (group bursts); `workflow_completion` triggers usually want it lowered (to slow A→B→A chains). + +### Recursion guard + +A `workflow_completion` trigger fires a workflow which fires another `workflow_completion`… dispatch chain length > 5 → engine cuts + alerts. Intended to break "A writes a message that triggers B, B writes a message that triggers A" loops. + +### Webhook ACK timing + +The HTTP entry (`POST /api/v1/triggers/events`) → envelope wrap → dedup check → bot-self check → rate-limit check → **immediate 200 ACK** → async dispatch. Implications: + +- Upstream gateways (Feishu / DingTalk etc.) get 200 and stop re-delivering +- Dispatch failures → `mate_trigger.last_error` updates; same `dedup_key` on retry is still dedup'd (**no automatic retry**) + +"ACK only after dispatch succeeds" semantics — **not in v0** — fire-and-forget is intentional for surge handling. + +--- + +## Managing triggers from the UI + +::: tip 1.4.0 change: merged into the Scheduler +As of v1.4.0, **Scheduled Jobs** and **Triggers** are merged into a single **Scheduler** page (`Settings → Scheduler`, route `/settings/scheduler`) with three tabs: **Scheduled Jobs** / **Event Triggers** / **Run History**. Each tab shows an item count next to its title; the top-right action button is context-aware (it's "New" on the Scheduled Jobs / Event Triggers tabs, "Refresh" on the History tab); Run History **spans both** — execution records for both scheduled jobs and triggers live here. + +The old routes redirect automatically: `/cron-jobs` and `/settings/triggers` each land on the matching Scheduler tab. +::: + +### Entry point + +`Settings → Scheduler` (sidebar) → **Event Triggers** tab. In v1.4.0 the trigger list was redesigned from the old wide table into **rule cards** — one card per trigger, showing pattern type / target / enabled state at a glance. Click **+ New Trigger** to open the drawer. + +### Creating a trigger + +The drawer has structured forms per pattern type — no hand-written `pattern_json`: + +- `cron` → cron expression input + timezone dropdown + next-fire preview. The expression can be typed by hand, or click the edit button beside the input to open the **visual cron editor** (see below) +- `channel_message` → channel type (optional) + sender id exact-match (optional) +- `agent_lifecycle` → agent (optional) + phase: `spawned` / `terminated` / `crashed` (optional) +- `content_match` → substring (**required**), matched case-insensitively against envelope `data.content` +- `workflow_completion` → upstream workflow (optional) + state filter: `completed` / `failed` / `any` (optional) +- `webhook` → no extra fields in v0 (transparent passthrough) + +Save → trigger persists; with `enabled=true` it's registered with the right engine immediately (cron → ShedLock; others → envelope router). + +### Visual cron editor (new in 1.4.0) + +You don't have to hand-write the cron expression. Click the edit button beside the expression input to open a **segmented editor**: minute / hour / day / month / day-of-week each get a tab, and each segment offers "every / specific value / range / step"; a row of **presets** up top (every minute, on the hour, daily at midnight, every Monday…) fills it in with one click; at the bottom is a **live human-readable preview** that translates the current expression into plain language (e.g. "every day at 09:00"). + +This editor is the **same component shared by Scheduled Jobs and Triggers**: + +- **Scheduled Jobs** use **5-field** cron (minute hour day month day-of-week) +- **Triggers** use **6-field** cron (with seconds: second minute hour day month day-of-week) — an extra leading seconds field + +The input itself also carries a one-line readable preview, so you can confirm what your hand-typed expression parsed to without opening the editor. + +--- + +## Scheduled-task types (task type) + +Every job on the **Scheduled Jobs** tab of the Scheduler has a `task_type` that decides what it does when it runs. This is the authoritative list of cron task types (the six event-trigger pattern types are covered above): + +| task type | Behavior | Binds an employee? | Notes | +|---|---|---|---| +| `text` / `agent` / `reminder` | Starts an employee conversation on the cron schedule | **Yes** (agent required) | Classic scheduled conversation; the result routes to the conversation | +| `wiki_process` | Processes a knowledge base offline on the cron schedule | **No** | New in 1.4.0 — see below | + +### `wiki_process`: off-peak KB processing (new in 1.4.0) + +`wiki_process` lets you schedule **knowledge-base processing** to run offline during low-traffic windows instead of saturating the processing queue the moment an upload finishes. It **binds no employee** — it's a system task: no conversation, no chat. + +When creating one you only fill in: + +- **cron expression** (use the visual editor above, 5-field) +- **KB selector** — which KB this job processes +- an optional **"force reprocess"** toggle — when on, already-processed raw materials are rerun too (`force`) + +On each tick, the job **asynchronously queues** that KB's raw materials for processing and logs one row in Run History, of the form `queued N raw material(s)` (a `(force)` suffix is appended when force is on). **Note it does not route to any conversation** — it just hands work to the processing queue; check progress on the [LLM Wiki](./wiki.md) page. + +### Payload template + +The `payload_template` field is a Pebble template string; the rendered output becomes the input to the dispatch target (agent conversation or workflow run). + +```jsonc +"payload_template": "{ + \"date\": \"{{ now | date('yyyy-MM-dd') }}\", + \"trigger\": \"{{ trigger.name }}\", + \"sourceEvent\": {{ event | toJson }} +}" +``` + +Variables in the template: +- `now` — current time +- `trigger.{name,id,workspaceId}` — the firing trigger +- `event` — the current event envelope (`workspaceId` / `senderId` / `data` JSON, etc.) + +### Inspecting fire history + +`mate_trigger_event` is **dedup metadata only** — one row per accepted event with `trigger_id` / `dedup_key` / `received_at` / `expires_at`, and **no copy of the envelope itself**. To audit the actual content of a particular event, look at channel-layer logs + the agent / workflow run records. + +`mate_trigger.fire_count` honestly records dispatch count (excluding dedup'd / rate-limited events); `mate_trigger.last_error` carries the most recent failure reason. + +--- + +## API reference + +All endpoints under `/api/v1/triggers/`. **What v1.3.0 actually exposes** — the `/webhook/{slug}` / `/test-fire` / `/{id}/events` entries from the RFC are not yet implemented. + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/v1/triggers` | List all triggers in the current workspace | +| `GET` | `/api/v1/triggers/{id}` | Get details | +| `POST` | `/api/v1/triggers` | Create a new trigger; with `enabled=true`, registers with scheduler / router immediately | +| `PUT` | `/api/v1/triggers/{id}` | Update (including enable / disable — flip the `enabled` field); on `pattern_json` change, `pattern_version++` and stale futures self-cancel cross-instance | +| `DELETE` | `/api/v1/triggers/{id}` | Soft delete (equivalent to disable) | +| `POST` | `/api/v1/triggers/events` | **Unified event entry** — any webhook / channel adapter / internal module hands an envelope to the engine; engine runs dedup / bot-self / rate limit / pattern match / dispatch and returns a per-trigger fire / drop summary | + +--- + +## Relationship with the existing cron module + +::: tip Reuse, not replace +Before v1.3.0 MateClaw already had a standalone cron subsystem (`mate_cron_job` table + `CronJobService`). The trigger system **does not replace it** — +- Legacy cron jobs (`task_type = text / agent / reminder`) remain on the `Cron Jobs` page +- New trigger crons live on the `Triggers` page +- Both **share** the underlying ShedLock lock table + Spring TaskScheduler thread pool +- The `mate_cron_job` list **does not show** trigger crons, and vice versa +::: + +Why not merge? Because `mate_cron_job`'s legacy schema (required `task_type` / `agentId`, etc.) doesn't fit a workflow target. Forcing extra columns would break existing product invariants. `CronDelegationPort` is the v0 minimal solution — share the scheduler base, split the persistence layer. Folding `mate_cron_job` into trigger entirely is a future iteration. + +--- + +## Cross-instance consistency (multi-replica deploy) + +`CronDelegationPort` methods are **process-local** — local `ScheduledFuture` lives only in this JVM, no persisted handle. Cross-instance consistency relies on: + +1. Every instance, on startup, calls `syncFromDatabase()` to scan all enabled cron triggers and register locally +2. When a trigger is updated, `pattern_version++` + cancel local future +3. Each fire re-reads the trigger row before executing; mismatched `patternVersion` → **local short-circuit + self-cancel** (means another instance modified it) +4. ShedLock key = `"mate-trigger-{triggerId}"`, mutually exclusive across instances +5. Periodic `@Scheduled(fixedDelay=60s) syncFromDatabase()` as a fallback reconciler + +Practical implication: rolling-deploy multiple replicas needs no extra steps — new instances pick up automatically; old instances finish their last cycle and stop. + +--- + +## Data model + +### `mate_trigger` — trigger configuration + +Key fields: + +| Field | Type | Purpose | +|---|---|---| +| `pattern_type` | varchar | One of the six patterns | +| `pattern_json` | TEXT | The pattern's filter parameters as JSON | +| `target_type` | varchar | `agent` or `workflow` | +| `target_id` | bigint | Foreign key to the agent / workflow | +| `payload_template` | TEXT | Pebble render template | +| `dedup_window_secs` | int | Dedup window in seconds | +| `rate_limit_per_min` | int | Max fires per minute | +| `bot_self_filter` | bool | Enable bot-self filter (default `true`, but the default impl is no-op) | +| `pattern_version` | bigint | Optimistic-concurrency Lamport counter; auto-bumps on every `pattern_json` change; cross-instance fires compare before executing and self-cancel on mismatch | +| `fire_count` | bigint | Effective dispatch count (excluding dedup'd / rate-limited drops) | +| `last_error` | varchar | Most recent failure reason (`"rate-limited"` / exception messages) | +| `enabled` | bool | Soft on/off | +| `deleted` | int | Soft delete | + +### `mate_trigger_event` — dedup metadata + +Used only for dedup decisions. **Does not store envelope copies**: + +| Field | Type | Purpose | +|---|---|---| +| `id` | bigint | Primary key | +| `trigger_id` | bigint | Trigger this row dedups against | +| `dedup_key` | varchar | **Unique index**; the engine consults this within `dedup_window_secs` | +| `received_at` | timestamp | Insertion time | +| `expires_at` | timestamp | Window expiry; the same key can re-enter after this point | + +::: tip Design tradeoff +v0 deliberately **does not persist envelopes inside `mate_trigger_event`** — full-volume channel events would crush the DB. Event-payload audit relies on channel-layer logs + the run records on the agent / workflow side. If "event replay" becomes a real need, an envelope column gets added later. +::: + +--- + +## Known limitations (v1.3.0) + +- **No visualization of trigger → workflow chains** — multiple triggers dispatching to the same workflow appear as two independent lists in the UI +- **No inter-trigger priority / dependency** — when an event hits multiple triggers, dispatches are serialized by ascending DB id +- **No dedicated webhook entry / IP allowlist** — there's no `/webhook/{slug}` route in v0; `/events` is the unified entry. Stricter IP control belongs at the front-door nginx / gateway +- **`agent_lifecycle` granularity is `spawned` / `terminated` / `crashed`** — not "started / completed / failed" per step +- **No event replay** — `mate_trigger_event` only persists dedup metadata, not envelopes; "redispatch this event" requires the upstream source to re-emit + +--- + +## Troubleshooting + +| Symptom | Investigate | +|---|---| +| Cron trigger doesn't fire | 1) `enabled=true`? 2) Does the cron expression + timezone parse to a next-fire time? The editor previews it. 3) Is the ShedLock held by another instance? Check the `shedlock` table. | +| `POST /events` returns 200 but no dispatch happens | The response body contains a per-trigger fire / drop summary — look for `BOT_SELF` / `RATE_LIMITED` / `DEDUPED` / `PATTERN_MISMATCH` | +| `channel_message` doesn't fire | 1) Does the envelope's `data.channelType` match this trigger's `pattern_json.channelType`? 2) `bot_self_filter=true` and a non-default `BotSelfFilter` is filtering it? 3) For `content_match`, the `substring` field must actually appear in `data.content` | +| `agent_lifecycle` doesn't fire | Confirm `pattern_json.phase` is `spawned` / `terminated` / `crashed` (not `started` / `completed` / `failed`) | +| Cron trigger stops firing after restart | Look at startup log for `syncFromDatabase()` errors; common cause is corrupted `pattern_json` failing deserialization | +| `mate_trigger.last_error` reads `"rate-limited"` | Raise `rate_limit_per_min`, or split the trigger into multiple ones partitioned by group | +| `bot_self_filter=true` doesn't seem to filter | Confirm a non-noop `BotSelfFilter` Spring Bean is registered — the default `NoopBotSelfFilter` always returns `false` | + +--- + +## Related + +- [Workflow](./workflow.md) — where dispatches go when `target_type=workflow` +- [Agents](./agents.md) — where dispatches go when `target_type=agent` +- [Channels](./channels.md) — the source of `channel_message` events +- [Security & Approval](./security.md) — webhook secret + ACL backstop diff --git a/mateclaw-server/src/main/resources/docs/en/user-guide.md b/mateclaw-server/src/main/resources/docs/en/user-guide.md new file mode 100644 index 00000000..c756d541 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/user-guide.md @@ -0,0 +1,201 @@ +# User Guide + +You opened MateClaw because you want AI to do work for you. Not because you want to learn new software. + +This guide does one thing: **get you from "installed" to "it's working for me" as fast as possible.** + +--- + +## 60-Second Launch + +| Step | What | Time | +|------|------|------| +| 1 | Double-click, log in with `admin` / `admin123` | 10s | +| 2 | Settings → Models → Add Provider, **enable one**, paste your key | 30s | +| 3 | Chat → pick an Agent → say "Hello" | 10s | +| 4 | Watch the reply stream in → **the system is alive** | — | + +The moment you see a response, you're in the product. Everything after this is about making it **useful to you**. + +--- + +## Models: connect one + +**A fresh MateClaw install has an empty provider list. That's deliberate — you don't need to see 16 providers, you need one that works.** + +`Settings → Models → Add Provider` opens a drawer with the full catalog. + +| Your situation | Recommendation | +|---------------|----------------| +| Nothing set up, want the fastest path | **DashScope** — paste your key from Alibaba Cloud | +| Already have an OpenAI / Anthropic key | Drop it in | +| Have a ChatGPT Plus / Pro account | **ChatGPT OAuth** — browser login, no API key needed | +| Want data to stay on your machine | **Ollama** — auto-detects `localhost:11434` | + +In the drawer, **click Enable** on the provider you want, then fill in the base URL (pre-filled for known providers) and paste your API key, and save. The model appears in the chat screen's model picker immediately. + +::: tip Enable / disable are separate from configure +**Enable** surfaces the provider everywhere; **disable** removes it from the picker but keeps the configuration — switching providers temporarily no longer means deleting the config. +::: + +**One is enough.** Don't spend time configuring five providers — get the system running first, add more later. + +--- + +## Chat: the heart of the product + +Click "Chat" in the sidebar. Pick an agent. Pick a model. Type. Hit enter. + +That's the entire interaction. There is no other entry point. + +### Three things to try right now + +**1. Ask a direct question** + +> Explain the difference between Java virtual threads and platform threads + +The agent answers directly — no tools involved. You're seeing pure reasoning. + +**2. Make it use tools** + +> Search the web for the latest Spring Boot release and summarize the breaking changes + +The agent picks up the search tool, reads results, composes an answer. You see the full "think → act → observe → answer" cycle — that's ReAct in action. + +**3. Give it a multi-step task** + +> First check our Wiki for auth design decisions, then compare against Spring Security 6 best practices, and give me a gap analysis + +The agent breaks this into steps, executes each one, then consolidates. You see the plan and progress on every step. + +If all three work, **you understand 90% of the product.** + +--- + +## Agents: how the AI behaves + +`Agents → New Agent` + +An agent defines exactly five things: + +| Config | One line | +|--------|----------| +| **System prompt** | Who it is, how it talks, what attitude | +| **Model** | Which model to use | +| **Tools** | Which tools it can call | +| **Skills** | Which skill packages it can invoke | +| **Wiki** | Which knowledge bases it can read | + +Start from a template. Templates ship ready to work — rename it, tighten the system prompt, check the tools you want, save. 30 seconds for a new agent. + +::: tip When to create a new agent +When you find yourself repeating the same setup instructions every conversation — that's the signal. Put those instructions in the system prompt so you never have to say them again. +::: + +--- + +## Memory: it remembers you + +MateClaw's memory doesn't require manual management. After each conversation, the system automatically extracts key information and writes it to memory. Next time, the agent works with that context. + +What you can shape: + +- **PROFILE.md** — who you are, your preferences, how you work +- **MEMORY.md** — long-term facts and notes that accumulate over time +- **Daily memory** — system-generated conversation summaries + +Memory is shared across all channels. What you discussed on desktop, the DingTalk agent remembers too. + +--- + +## Wiki: make it read your documents + +`Wiki → New Knowledge Base` + +Drop in PDFs, DOCX, TXT, or point at an entire folder. Wait for digestion — every raw material shows a progress bar, no guessing. + +Once digested: + +1. Bind the knowledge base to an agent +2. Ask about the content +3. The agent automatically retrieves relevant pages and answers with knowledge + +::: tip +Wiki isn't full-text search. It's **semantic retrieval** — ask "what did we decide about authentication" and get the decision, not every page containing the word "auth." +::: + +--- + +## Skills and MCP: extend the boundary + +**Skills** — `Agents → pick one → Skills`. Install from the skill marketplace, or write a `SKILL.md` by hand. + +**MCP** — `Settings → MCP Servers`. Connect external tool servers (filesystem, databases, custom APIs). MCP tools appear in the tool list automatically — the agent doesn't know and doesn't need to know they're external. + +When the 20 built-in tools aren't enough, these two doors open up. + +--- + +## Channels: find it where you already are + +`Channels → pick a platform → paste credentials` + +Eight channels: DingTalk, Feishu, WeCom, WeChat Personal, Telegram, Discord, QQ, Slack. + +::: tip DingTalk & Feishu: just scan a QR (v1.1.0+) +No more "go to the open platform → create app → copy ID and Secret" detour. In the new channel form, click **Bind via QR**, scan with the DingTalk / Feishu app, confirm — **client_id / app_id and the secret auto-fill**. Under 30 seconds end to end. +::: + +Same agent. Same memory. Every channel. + +--- + +## Security: powerful but not out of control + +The **Security** page gives you three controls: + +1. **Tool Guard** — which tools require your approval before execution (shell, SQL, file writes) +2. **File Guard** — which directories the agent can't touch +3. **Audit log** — see everything the agent has done + +The defaults are already safe. If you're using this in production, tighten the shell and SQL approval rules. + +--- + +## Three starter setups + +### A. Personal assistant (fastest) + +Configure one model → use the default agent → start chatting. Memory accumulates automatically. + +### B. Knowledge assistant + +Create an agent → create a Wiki KB → import your docs → bind Wiki to agent. + +### C. Automated worker + +Create a role-specific agent → install skills → connect MCP servers → configure approval rules in Security. + +--- + +## Something broke + +| Symptom | Most likely cause | +|---------|-------------------| +| Backend won't start | Port 18088 is taken. Check `~/.mateclaw/logs/app.log` | +| Model call fails | Wrong API key or network issue. Go back to Settings | +| UI is blank | Ctrl+Shift+R to hard-refresh | +| Ollama says "does not support tools" | Switch to a function-calling model (qwen3, llama3.1:8b+) | +| Still broken | [GitHub Issues](https://github.com/matevip/mateclaw/issues) with the tail of `app.log` | + +--- + +## What's next + +| You want to... | Go to... | +|----------------|----------| +| Understand why the product is built this way | [Introduction](./intro) | +| See the technical architecture | [Architecture](./architecture) | +| Configure more options | [Configuration](./config) | +| Connect more channels | [Channels](./channels) | +| Deep-dive into the agent engine | [Agents](./agents) | diff --git a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md new file mode 100644 index 00000000..fb842cc1 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md @@ -0,0 +1,419 @@ +# WeCom Deep Tuning + +**A bot that actually works for a group of 50 internal employees needs much more than "just connecting".** + +The [Channels → WeCom](./channels#wecom) section covers wiring up the channel; this document covers what MateClaw does **after** the channel is up — every non-obvious optimization, every platform corner the adapter handles, and why. + +Audience: + +- Operators who already have the WeCom channel running and want to understand "why is the group experience like this" +- Developers planning new features who need the platform constraints first +- Tech leads evaluating the bot for a real business team + +--- + +## Platform in one sentence + +**WeCom AI Bot is a "looks-like-a-chat-SDK, actually-an-event-callback" platform.** + +It gives you three primitives: + +1. **Receive events** — long-poll WebSocket or webhook delivers user @-mentions +2. **Reply** (within the same conversation) — `aibot_respond_msg` "attaches" your answer to a specific inbound frame +3. **Push proactively** (not in reply to anything) — `aibot_send_msg`, but **single chats only** + +**The hidden rule that matters most**: primitive #2 and #3 behave differently in groups vs. single chats. Every optimization below is scaffolded around that matrix. + +--- + +## Group multi-user collaboration + +### Default platform behavior + +When users A, B, C all @ the bot in one group, the platform delivers each as a separate frame, but all keyed to the **same chatId**. + +If you naively partition conversations by chatId (the obvious approach), you get: + +- Persisted history is just `user: ...` with no sender prefix — the model sees an unattributed wall when reading prior turns +- The debounce window (500ms / 2.5s adaptive) merges A's and B's rapid messages into one +- A asks "I want X", B follows with "I want Y", and the model thinks "user asked two unrelated things" + +### MateClaw's fix + +**Two layers**: + +**1. Sender-boundary debounce.** When two messages land in the same conversation back-to-back, check senderId first: + +- Same sender → merge (typical case: paste-split fragments) +- Different sender → flush the existing pending immediately, start a new window for the new sender + +The decision lives in [`ChannelMessageRouter.isSameSender`](https://github.com/anthropics/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java). Null-defensive: if either senderId is missing, refuse to merge — better to flush twice than to mis-attribute one fragment. + +**2. `[@sender]` prefix on persisted content + prompt.** Every group message (`chatId != null`) gets wrapped before save and before the LLM call: + +``` +[@XuZhanFu] @MateClawBot I want to query X +[@xuzf] @MateClawBot I want to query Y +``` + +So: + +- The 30th historical message still tells the model who said it +- The persisted timeline reads as `[@A] ...; [@B] ...; [@A] ...`, the model can disambiguate follow-ups, quote-replies, mutual corrections +- Single chats (`chatId == null`) are zero-overhead, behavior unchanged + +`senderName` takes priority over `senderId` (friendlier display); both null → return null (no `[@null]` garbage tag). + +### What you'll see in logs + +``` +[wecom] Sender boundary in conversation wecom:{chatId}: flushing pending from sender=A, accepting new sender=B +``` + +In the DB, `mate_message.content` literally has `[@xxx]` prefix. + +--- + +## Upload constraint matrix + +WeCom enforces **hard size limits** at the chunk-finish step (after all bytes are uploaded). UX without a pre-check: "uploaded for three minutes, nothing came out the other side". + +### Limits + +| Type | Max size | Format requirement | +|------|---------|---------| +| File | **20 MB** | any | +| Image | **10 MB** | any common format | +| Video | **10 MB** | any common format | +| Voice | **2 MB** | **must be AMR** (other formats rejected by platform) | +| Global | **20 MB** | absolute ceiling | + +### MateClaw's handling + +**Client-side pre-check** to avoid pointless uploads. `applyWeComUploadLimits(fileSize, mediaType, contentType)` returns: + +- File > 20 MB → reject, tell user "exceeds 20MB limit" +- Image > 10 MB → downgrade to file upload (still visible as attachment, just no thumbnail) +- Video > 10 MB → downgrade to file upload +- Voice > 2 MB **or** mime ≠ `audio/amr` → downgrade to file upload +- Anything > 20 MB → reject (absolute ceiling, no exception) + +The downgrade carries a friendly note ("image > 10MB, sent as file attachment"), so the user knows what just happened. + +### Magic-byte filename recovery + +WeCom-forwarded files often arrive **without a filename field**. Saving them as `file.bin` breaks every downstream tool that dispatches by extension (PDF readers, DOCX parsers, etc). + +Fix: magic-byte sniff: + +- `%PDF` → `.pdf` +- `PK\x03\x04` is a ZIP container; peek inside the first few entries to distinguish `.docx` / `.xlsx` / `.pptx` / `.odt` / `.epub` / `.jar` +- Other common formats (PNG / JPEG / MP4 / MP3 / WAV) all recognized +- Truly unknown → keep `.bin`, don't pretend it's something else + +Implemented in `WeComChannelAdapter.sniffMagic()` + `refineZipKind()`. + +--- + +## Quoted messages + +Users quoting a previous message (image, file, text, voice, miniprogram) and then asking a new question is **the most common group interaction pattern**. + +### Supported quote types + +| Quote type | What the bot sees | Further processing | +|----------|------------|------------------| +| Text | `[Quote: prior text]\nuser's new question` | ✅ text passed to model | +| Voice | `[Quote: [voice] ASR transcript]\nuser's new question` | ✅ ASR result as context | +| Image | `[Quote: [image]]\nuser's new question` + image attached part | ✅ vision sidecar reads it | +| File | `[Quote: [file: report.pdf]]\nuser's new question` + file attached part | ✅ file tool can read | +| Mixed | Each sub-type expanded by the rules above | ✅ | + +### Implementation notes + +- **Media is downloaded too**: a quoted image/file isn't just a marker string — it's actually downloaded, AES-256-CBC decrypted, persisted to `data/chat-uploads/{conversationId}/...`, and attached as a MessageContentPart for the agent +- **Path alignment**: the conversationId used for media must match the conversationId in `mate_conversation`, otherwise `/api/v1/chat/files/{convId}/{name}` 403s on `isConversationOwner` and the frontend `` shows broken-icon + +Historical bug: an early version's `inboundConversationId()` added a `wecom:group:` infix for groups, but the router persisted as `wecom:{chatId}` without the infix — every group-quoted image was broken until both sides aligned. Fixed. + +--- + +## appmsg message types + +`msgtype=appmsg` is WeCom's extension point for rich-media cards. Four common subtypes: + +| Variant | What it is | Bot handling | +|------|-----------|--------------| +| `appmsg.file` | Forwarded file (PDF / Word / Excel) | Full download pipeline, equivalent to `msgtype=file` | +| `appmsg.image` | Image card | Full download pipeline, equivalent to `msgtype=image` | +| `appmsg.url` | **Public-account article / external link** | See next section | +| `appmsg.miniprogram` | Mini-program | Title surfaced to model; payload not retrievable | + +Unknown subtypes fall back to `[appmsg: title]` so the model at least knows "user shared some kind of rich media". + +### Public-account articles + +mp.weixin.qq.com articles are served as **captcha-gated SSR** — no LLM tool can fetch the body. If the bot pretends it can read it, the model **invents content from the title** (production-observed: "the article makes three points..." — pure hallucination). + +When MateClaw detects `mp.weixin.qq.com` in the link branch, it appends a directive to the model: + +> (Hint: this link is a public-account article. The body needs to be opened in WeChat and pasted by the user. Please ask the user to paste the article text rather than guessing from the title.) + +Effect: the model stops fabricating and asks the user to paste the body. Other normal URLs (github, wikipedia, generic external links) **don't** trigger the hint, since their bodies are fetchable by ordinary tools. + +--- + +## Group proactive push (aibot_send_msg vs aibot_respond_msg) + +### Platform rules + +``` +Single chat: aibot_send_msg ✓ aibot_respond_msg ✓ +Group: aibot_send_msg ✗ aibot_respond_msg ✓ (must bind to a prior frame's reqId) +``` + +In groups, any proactive message from the bot (cron summaries, async-task completions, image-generation results) must **piggyback** on a prior user inbound's frameReqId. Otherwise the platform rejects it. + +### MateClaw's handling + +**LRU cache of recent inbound reqIds**. `lastChatReqIds: ConcurrentHashMap` is updated on every group inbound, capped at 1000 chats. + +**Unified outbound `sendOutboundFrame(chatId, body)`**: + +- Cache hit → `aibot_respond_msg` + cached reqId +- Cache miss → fall back to `aibot_send_msg` (single chat or new chat) + +This way: + +- Cron summaries → group has prior activity → respond succeeds; never any → degrade to send_msg, still fails but doesn't blanket-fail +- Async tasks (image / music / video generation) completing → `AsyncTaskMediaDispatcher` calls the unified outbound +- Multi-chunk LLM reply → same reqId reused + +### What you'll see in logs + +``` +[wecom] Group send via aibot_respond_msg: chatId=..., reqId=... +``` + +--- + +## Async-task forwarding + +Image generation (`image_generate`) / music generation (`music_generate`) / video generation (`video_generate`) / 3D model generation (`model3d_generate`) are all **async tasks** — the agent returns a task id immediately; the actual artifact arrives 30 seconds to several minutes later. + +Earlier bug: artifacts only showed up in the Web console's history view, **invisible in the WeCom group**. + +Fix: `AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`: + +- After task completion, look up the conversation's bound channel via `ChannelSessionStore` +- Skip `web` / `webchat` (SSE already covers them) +- Call the channel adapter's `sendContentParts(targetId, parts)` +- WeCom: image / audio / video / file all supported as native attachments +- Slack: via `filesUploadV2` (see [Slack channel](./channels#slack)) +- Channels without `sendContentParts` (QQ, etc.): catch UnsupportedOperationException + log; one unsupported channel doesn't block the rest + +Files live at `data/chat-uploads/{conversationId}/`, served at `/api/v1/chat/files/{conversationId}/{storedName}`. Frontend and channel attachment views all read by this URL. + +--- + +## Model behavior: faking tool calls + +Observation: **qwen3.6-plus** sometimes "lazes out" in long-context, tool-call-heavy scenarios — it produces a Markdown code block that **mimics** a tool call, but `toolCallCount=0`: + +```` +🎵 《Title》 generation task submitted! +⏳ ETA 1-2 minutes, audio will be pushed when ready... + +```json +{ "prompt": "...", "lyrics": "..." } +``` +```` + +Backend never sees a tool_call → music generation never starts → user never receives the song. + +**Current mitigation**: switch to a model that executes tool_calls reliably (kimi-for-coding, claude-sonnet-4.5, deepseek-r1). Change the agent's default model in [Models](./models). + +Possible future: server-side detection of "task submitted + toolCallCount=0" patterns, with a corrective system message and retry. + +--- + +## Model behavior: self-arguing loops + +Another sporadic failure: the model gets stuck in a "thinking-output" loop, repeating the same Chinese answer dozens of times until max_tokens (16384) runs out. Production-observed pattern: + +``` +"Wait, I should X." → write Chinese answer → "Done." → write same answer → "Wait, Y." → same again → ... +``` + +Users stare at "generating..." for tens of seconds to minutes, finally receive a wall of duplicates. + +### MateClaw's handling + +**Two-layer guard**: + +1. **Detection**: [`hasRepeatingSuffix`](https://github.com/anthropics/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java) checks if the buffer ends with the same 24-240 character unit repeated 4+ times consecutively → immediately disposes the upstream subscription +2. **Dedup + flag**: `dedupTrailingRepeats` collapses N trailing copies to 1; ReasoningNode sets finishReason to `INCOMPLETE`; the frontend renders a truncation banner with a "regenerate" button + +Why not just emit a warning: the user already saw the duplicates in the SSE stream (one-way push, can't unsend), but the **DB-persisted finalAnswer** and **WeCom outbound** both use `finalAnswer` — so the IM group only sees one clean copy of the answer + an INCOMPLETE banner. + +The threshold is **deliberately narrow** (4 verbatim consecutive copies) to avoid false-positives on legitimate "TL;DR / body / TL;DR" three-stage outputs. + +--- + +## Network resilience + +### TLS / socket transient retry + +DashScope / OpenAI / various LLM gateways occasionally produce on the public internet: + +- `bad_record_mac` (TLS RFC 5246 §7.2.2 fatal alert 20) +- `SSLHandshakeException` +- `SocketException: Connection reset by peer` +- `Premature close` / `Broken pipe` + +Previously these would surface as `LLM call failed` red text with no retry. + +Fix: classify all of these as `SERVER_ERROR`, route through the existing exponential-backoff retry: 3s → 6s → 12s (with jitter) up to 5 attempts. See [Agent engine](./agents#error-recovery). + +### Keepalive + +Group replies via `aibot_respond_msg` have a **60-second TTL** per stream — no new data within 60s and the platform drops the slot, the eventual real reply is silently rejected. + +Agents handling complex tasks (multi-tool + LLM reasoning) often exceed 60s. `WeComKeepaliveScheduler` sends a noop "processing..." heartbeat every 30 seconds; the slot never expires. A 180-second hard cap force-finishes the stream so a genuinely-stuck task doesn't keep keepalive ticking forever. + +### Reconnect with exponential backoff + +When the WeCom long-connection drops (NAT timeout, network blip), the adapter reconnects: 2s → 4s → 8s → 16s → 30s cap. **Never gives up** — as long as the process is alive, it'll resume message reception when the network does. + +The control panel's health view shows current reconnect count, ops can read it directly. + +--- + +## Platform-level constraints (not bugs, just limits) + +These are **WeCom platform** constraints, can't be worked around in code, only in configuration: + +### Data permission lock + +API-mode bot ticking **any data permission** in the WeCom admin (e.g. "read messages", "get group info") **auto-restricts the bot to creator only**. Other members' messages get ignored. + +**Fix**: in the admin panel, **uncheck** all 7 data permissions. The bot becomes available to all authorized members. MateClaw uses webhooks for messages, doesn't need data permissions. + +### Visibility × data-permission matrix + +| Visibility | Data permission | Effective | +|---------|---------|---------| +| All staff | All checked | **Creator only** (data lock overrides visibility) | +| All staff | All unchecked | All staff (recommended) | +| Specific dept | All unchecked | Members in those depts | +| Specific people | All unchecked | Listed users | + +### Group requires @bot + +The bot in a WeCom group must be `@`-mentioned to receive a message. Direct messages (1:1) don't need `@`. Platform behavior, no workaround. MateClaw doesn't broadcast-listen to all group messages (and couldn't if it tried). + +--- + +## Debugging tips + +### Verify group attribution + +```sql +SELECT content FROM mate_message +WHERE conversation_id = 'wecom:{chatId}' AND role = 'user' +ORDER BY id DESC LIMIT 5; +``` + +Expect: every user message starts with `[@username]`. + +### Verify media path + +```bash +ls data/chat-uploads/wecom:{chatId}/ +``` + +There **should not** be any `wecom:group:{chatId}` directories with the `group:` infix (early-bug residue, manually clean up). + +### Verify group push routing + +In server logs: + +``` +[wecom] Group send via aibot_respond_msg: chatId=..., reqId=... +``` + +If the group doesn't see the bot's reply but logs show this line with a non-null reqId, the message reached the platform but was rejected (usually: reqId already consumed, or bot kicked from group). + +### Verify keepalive + +```bash +grep "wecom-keepalive" logs/mateclaw.log | tail +``` + +Expect periodic "Heartbeat sent" + "Heartbeat ACK received", with occasional "force-finished stream" hard-finishes. + +--- + +## Known corner cases + +| Scenario | Current behavior | Possible future | +|------|---------|---------| +| First group message is a cron push (no prior chat activity) | Cache empty, falls back to `aibot_send_msg`, platform rejects | Ring-buffer multi-reqId cache (limited gain, not implementing) | +| Model "lazes out" in long sessions | User retries / switch model | Server-side detection + corrective inject | +| 3 different senders concurrent in same group | Serial processing, each user gets own window (works) | — | +| User refuses to paste public-account body | Bot politely guides | — | +| OOXML magic-byte misclassification (very rare) | Falls back to `.zip` | ZIP entry peek covers 90% | + +--- + +## At-a-glance + +``` + ┌─────────────────────┐ + │ WeCom group user │ + └──────────┬──────────┘ + │ inbound (with chatId) + ▼ + ┌────────────────────────────────────────┐ + │ WeComChannelAdapter │ + │ ├─ chunk upload pre-check (4 categories)│ + │ ├─ magic-byte sniff (OOXML peek) │ + │ ├─ AES decrypt + chat-uploads/{convId}/ │ + │ ├─ quote parsing (5 sub-types) │ + │ ├─ appmsg parsing (4 sub-types + hint) │ + │ └─ cache lastChatReqIds[chatId] │ + └──────────────┬─────────────────────────┘ + │ ChannelMessage(content="[@xxx] ...") + ▼ + ┌────────────────────────────────────────┐ + │ ChannelMessageRouter │ + │ ├─ adaptive debounce (500ms / 2.5s) │ + │ ├─ sender boundary cut (group critical) │ + │ ├─ applyGroupTag → DB + LLM │ + │ └─ queue + sessionLock serialize │ + └──────────────┬─────────────────────────┘ + │ + ▼ + ┌──────────┐ + │ Agent │ ← StateGraph + ReAct + └─────┬────┘ + │ finalAnswer / tool_calls + ▼ + ┌────────────────────────────────────────┐ + │ sendOutboundFrame(chatId, body) │ + │ ├─ cache hit → aibot_respond_msg │ + │ ├─ cache miss → aibot_send_msg │ + │ ├─ keepalive (60s TTL extend) │ + │ └─ reconnect backoff (NAT/blip self-heal)│ + └────────────────────────────────────────┘ +``` + +--- + +## Related reading + +- [Channels](./channels) — overview of all 9 channels + setup +- [Agent engine](./agents) — TLS retry, error classification, self-loop detection +- [Models](./models) — switching default model, failover chain +- [Security & approval](./security) — approval flow for high-risk tools in groups +- [Doctor](./doctor) — diagnostic commands for channel troubleshooting diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md new file mode 100644 index 00000000..683024ce --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/wiki.md @@ -0,0 +1,482 @@ +--- +title: LLM Wiki — Structured Knowledge Engine, Not Vector Retrieval +description: LLM Wiki digests raw documents into structured knowledge pages with backlinks, summaries, and source tracing. Lazy ingest indexes uploads instantly and compiles pages on demand; eager ingest produces a full Wiki up front. Agents browse the library — they don't grep a vector store. +head: + - - meta + - name: keywords + content: LLM Wiki,knowledge base,knowledge engine,backlinks,structured knowledge,RAG alternative,knowledge graph,lazy ingest,on-demand compile,semantic search +--- + +# LLM Wiki + +A knowledge base isn't a place you search. It's a place you **read**. + +Most AI knowledge systems do one thing: chunk your files, embed them, hand back fragments at query time. You get pieces. You can't browse them. You can't tell what the system "knows" without asking. Nothing is ever *finished*. + +MateClaw's LLM Wiki does something different. Drop raw material into a knowledge base and the system reads it, digests it, and writes structured Wiki pages — each with a summary, backlinks, and provenance pointers back to the source passage. You can open any page and read it. You can edit it. Agents read summaries automatically and pull full pages on demand. + +**It's a library, not a vector store.** + +::: tip How it differs from the open-source "LLM Wiki" clones +In April 2026, Andrej Karpathy published a GitHub Gist that gave the idea a name: the material you feed an AI shouldn't be re-shredded into vector fragments at query time — it should be read once and written into a readable wiki. Within a month, at least nine `llm-wiki` single-file implementations had appeared on GitHub — useful, local, personal. + +MateClaw's LLM Wiki **is the same idea, raised into a product**: + +- Not one person's notebook — a **team-shared knowledge base** with multi-user access, permissions, audit, and archive +- Not a script that runs once — a capability **agents use continuously**, wired into memory, retrieval, and citation +- Not eager-only — **lazy mode compiles pages on demand**, saving 90%+ of LLM calls at scale +- Not raw markdown dumped on disk — a page layer with **provenance, bidirectional links, manual-edit protection, and reversible archive** +- Not an isolated tool — the **knowledge layer of MateClaw's agent operating system**, threaded through memory, agents, and channel delivery + +> They built a clone. We built a home. +::: + +--- + +## The three-layer model + +A knowledge base is three layers stacked on top of each other: + +1. **Raw material** — the files you dropped in. PDF, Word, Excel, PowerPoint, HTML, markdown, plain text (incl. CSV), or a whole local directory scanned in one go. The system keeps them intact; any claim in the Wiki traces back to the passage that produced it. +2. **Wiki pages** — structured articles the AI writes from the raw material. Each page has a title, a summary, a body, bidirectional links to related pages (`[[like this]]`, plus the alias form `[[target|display text]]`), and provenance pointers back into the raw layer. +3. **Agent surface** — when an agent calls a wiki tool, the system auto-injects the summaries of relevant pages into the prompt. Bodies are fetched on demand. Agents don't read raw files. They read the library. + +This matters because the agent's context window stops getting wasted on re-reading source material every turn. Tokens go to thinking, not to reading the same paragraph for the fifth time. + +--- + +## Creating a knowledge base + +`Wiki → New Knowledge Base`. Name it after what's inside, not who owns it. "Product specs" beats "Team Alpha's KB". + +Once it exists, add material: + +- **Upload files** — drag PDF, Word, Excel, PowerPoint, HTML, markdown, or plain-text (incl. CSV) files into the upload area. Each file becomes a raw material row. +- **Scan a local directory** — desktop only. Point at a folder and MateClaw walks it recursively, respecting `.gitignore`, importing everything that looks like text. +- **Paste text** — for short excerpts or conversation transcripts. + +The system starts indexing as soon as material arrives. You'll see a status indicator on each row: `pending → processing → completed`. If some chunks fail and others succeed, the row lands as `partial` — you keep what worked, instead of throwing the whole document away. + +--- + +## Two ways to ingest: do you need pages right now? + +`Wiki → Config → Ingest Mode` flips between: + +- **Eager (compile pages on upload)** — runs the full LLM pipeline to produce a finished, browsable Wiki. Pick this when you want pages ready to read the moment ingest finishes. The cost is real: many LLM calls per upload, slow, expensive. +- **Lazy (index now, compile later)** — extracts, normalizes, chunks, and embeds. **Zero page-generation LLM calls.** Search works immediately; pages are produced on demand when an agent or user actually needs one. + +Existing KBs default to eager so nothing changes underneath you. New KBs that don't need an instant Wiki should pick lazy — same retrieval quality, a fraction of the cost. The two modes coexist: pages an eager KB has already produced stay put; further uploads honor the current mode. + +> Under lazy, "0 pages" is **success**, not failure. This finally fixes the long-standing annoyance where any upload that produced no pages went red. + +--- + +## What ingestion actually does + +### Eager: the full pipeline + +For each raw material, in order: + +1. **Chunk** — split the source into overlapping passages, attaching structural metadata to each chunk: page number (PDF / PPTX), heading breadcrumb (`Intro / Setup / Linux`), section identifier, and a token-count estimate. +2. **Extract concepts** — ask the LLM to identify entities, decisions, facts, and open questions in each chunk. +3. **Cluster and draft** — group related extractions into candidate Wiki pages and generate structured drafts with summaries. +4. **Link** — find bidirectional references between pages (`[[concept]]` and `[[concept|display text]]`) and compute backlinks. +5. **Persist** — write pages to `mate_wiki_page` with citations pointing back to the raw passages they came from. + +Ingestion is idempotent. Re-run it on the same material and existing pages get updated rather than duplicated. Hand-edited content is protected — `locked` tells the digester to leave human prose alone, and you have to unlock explicitly to let the AI re-draft. + +#### Two-phase digest + +Eager ingest runs in two phases for an order-of-magnitude speedup: + +- **Phase A (route)** — extracts metadata and concept routing, deciding which pages each chunk feeds into. +- **Phase B (merge)** — generates pages in parallel, 60+ at a time. Each raw material gets its own **progress bar** — no more staring at "processing…" wondering what's happening. + +**Resumable**: interrupted mid-import? Hit "Reprocess" and only the unfinished pages re-run; everything already produced stays put. Documents larger than the embedding model's context get mean-pool sub-segmented automatically. + +### Lazy: index now, compile later + +The pipeline collapses to four steps: + +1. **Extract** — pull text out of the binary (PDF / DOCX / …). +2. **Normalize** — jsoup strips HTML noise (nav / footer / ads); markdown heading levels and PDF `--- Page N ---` markers are detected for downstream metadata. +3. **Chunk + metadata** — every chunk carries `page_number`, `header_breadcrumb`, `source_section`, `token_count`. +4. **Embed** — embeddings land asynchronously and the row is marked `completed`. + +When are pages produced? They aren't — until somebody asks. The system retrieves the relevant chunks, asks the LLM for one page, and binds the page's citations to the chunks it actually used. Nothing more. + +--- + +## System pages: overview and log + +Every KB ships with two **system pages**: + +- `slug=overview` — the front door of the knowledge base. Scope, recent updates, coverage stats live here. +- `slug=log` — an append-friendly audit trail of ingest / compile / edit activity. + +Both are flagged `page_type=system, locked=1`: + +- Delete (single, batch, or the cleanup pass during reprocessing) **refuses to remove them** — you'll get a clean error, not a silent drop. +- List, keyword search, semantic search, and related-pages **filter them out by default** so they don't pollute search results or the agent's context window. +- Reading by slug (`wiki_read_page("overview")`) still works — agents can opt in whenever they want. + +> The `locked=1` flag is also yours to set on any hand-curated page. The AI tool surface honors it the same way it honors `lastUpdatedBy="manual"` — they stack. + +--- + +## Transformations: making the KB programmable + +::: tip New in 1.3.0 +The Transformations engine shipped in v1.3.0. In v1.2.0 and earlier, Wiki was retrieval-only — chunk, embed, recall. v1.3.0 teaches the Wiki to **actively process**: user-defined templates, cross-material aggregation, reverse-citation extraction, JSON output, page-as-input transformations, cancel/re-run. Full release story in the [v1.3.0 release notes](./releases/1.3.0). +::: + +By default the Wiki digests raw materials into the pages **it** thinks matter — but "matter" is its judgment, not yours. **Transformations** flip that around: you author a prompt template that says "extract this shape from each source", and the engine runs it, persists the output, and keeps it in sync. + +Open `Wiki → [any KB] → Transformations`. Each template is composed of: + +- **Name** — short lowercase slug used by agent tools to address the template (e.g. `contract-risk-extract`) +- **Title / description** — human-readable +- **Prompt template** — the instruction text; supports `{input_text}` and `{title}` placeholders +- **Model** — defaults to the KB chat model; you can pin a single template to a specific model +- **Apply by default** — toggle on → every newly-ingested raw fires this template automatically +- **Output target** — `None` (stays in run history) or `Save as wiki page` (synthesis page auto-created) +- **Output format** — `Markdown` or `JSON` (with optional schema validation) + +### Seven enterprise templates shipped out of the box + +Available on every new KB; cover common enterprise jobs: + +| Template | What it produces | +|---|---| +| `contract-risk-extract` | Clause-level risk extraction (high / medium / low) with AI-suggested rewrites | +| `meeting-action-items` | Decisions + action items (owner / due date / acceptance criteria) | +| `customer-profile` | Customer emails / CRM records → structured account profile | +| `competitor-update` | Public signals → competitor digest | +| `resume-structured-extract` | Resume → standardized record (education / experience / skills / highlights) | +| `incident-postmortem` | Incident report → 5-Whys + remediation list + similar-incident keywords | +| `paper-imrad` | Paper / tech report → IMRaD summary + key terminology | + +### Four ways to trigger a run + +| Trigger | How it fires | Where it shines | +|---|---|---| +| **Manual** | Pick a source in the UI, click Run | Single-shot prompt iteration | +| **Apply default** | Toggle on the template; upload a new material | "Every new contract gets risk-extracted on arrival" | +| **Agent tool** | Agent calls `wiki_apply_transformation(name, rawId)` | Digital employee decides which template to run | +| **Aggregate** | "Aggregate all runs" button / `wiki_aggregate_transformation` | Map-reduce N per-source outputs into one KB-level synthesis page | + +### Input: raw materials or existing pages + +A template doesn't have to read a raw material — it can also run against an existing wiki page (agent tool: `wiki_apply_transformation_to_page(name, slug)`). This lets you chain templates: A turns a source into a synthesis page, then B reads that page and produces a different view. + +### Output target: run history, or a real wiki page + +- **None** — the result lives only in the run history under the template card. Good for one-off output. +- **Save as wiki page** — every successful run **upserts** to a fixed slug `-`. Re-running updates the same page rather than spawning duplicates. And: + - Page-level embedding is fired automatically so the synthesis page joins semantic search + - The output is parsed for citation hints like "第 N 题 / page X" and chunk-level citations are written linking back to the source chunks + - It joins the relation graph, the hot cache, and becomes directly readable by agents + +### JSON output + Schema validation + +When the format is JSON: + +1. A strict system prompt is injected ("return one JSON document, no prose, no fences") +2. On parse failure the executor retries once with a specific error reminder +3. A JSON Schema can be stored on the template; after parsing, the executor checks required fields and the top-level type +4. Still invalid → the run is failed with the specific reason in the error column + +Valid JSON is wrapped in a fenced ```json block so the existing markdown rendering and save-as-page contract continues to hold while downstream tools can still grep / parse the raw JSON. + +### Cross-material aggregation + +Have 10 contracts each processed by `contract-risk-extract`? Click "Aggregate all runs" on the card: + +- The system loads every completed run of this template within this KB +- Deduplicates by source (only the most recent run per raw contributes) +- Sends them to an LLM with a merge + dedupe system prompt — same clause types are merged, source attributions are preserved, disagreements are surfaced instead of smoothed over +- Upserts the result to a deterministic slug `-aggregate` +- Triggers page embedding so the aggregate joins semantic search + +This is what turns per-source extracts into a KB-level synthesis — no more diff-by-eye across N contracts. + +### Run history + observability + +Every run records: + +- Status: `pending / running / completed / failed / cancelled` +- Duration, model, trigger (manual / apply_default / agent_tool / aggregate) +- Input / output / total tokens reported by the provider (`8.2k↑ / 1.1k↓`), accumulated across retries +- Linked output page (when output_target=page) +- Full output / error message + +The UI lets you: + +- **Cancel** — flag a running run as cancelled. The LLM call still completes server-side because most providers don't support cancellation, but the executor drops the eventual output instead of overwriting that state. +- **Re-run** — one-click rerun against the same input. Works on completed, failed, and cancelled runs alike. +- **Compare** — tick two completed runs → the "Compare selected" button opens a side-by-side modal (older on the left, newer on the right) so prompt iteration finally has a real diff workflow. + +### Typical recipes + +| Scenario | Recipe | +|---|---| +| Legal automation | `contract-risk-extract` + apply default + save as page → every new contract auto-produces a risk report page | +| Sales intelligence | `customer-profile` + apply default → every account material becomes a profile page | +| Engineering memory | `meeting-action-items` + `incident-postmortem` together → decision history and incident lessons accumulate as KB pages | +| Research synthesis | Run `paper-imrad` across a batch, then aggregate → thematic survey page | +| Programmatic downstream | JSON format + schema → the wiki becomes a structured data source for dashboards / pipelines | + +### REST endpoints (base path `/api/v1/wiki/transformations`) + +| Method | Path | Purpose | +|---|---|---| +| `GET` / `POST` / `PUT` / `DELETE` | `/`, `/{id}` | Template CRUD | +| `POST` | `/{id}/apply?sync=true` | Run once (body carries either `rawId` or `pageId`) | +| `POST` | `/{id}/aggregate?kbId=X` | Cross-material aggregation | +| `GET` | `/runs?rawId=` or `?kbId=` or `?transformationId=` | Query run history | +| `POST` | `/runs/{runId}/save-as-page` | Promote a run manually into a wiki page | +| `POST` | `/runs/{runId}/cancel` | Mark a run cancelled | + +--- + +## How agents use the Wiki + +Bind an agent to a knowledge base from `Agents → [your agent] → Knowledge`. From that moment: + +- The agent's system prompt automatically includes a compressed summary of the KB's top-level pages. +- The agent's toolbox grows these wiki tools: + +| Tool | What it does | +|---|---| +| `wiki_search_pages` | Page-level hybrid retrieval (keyword + semantic). | +| `wiki_semantic_search` | Chunk-level semantic search. Hits include `pageNumber` and `section` when known, so the agent can cite "page 12, Setup / Linux" rather than a naked snippet. | +| `wiki_read_page` | Read a single page; trim by section heading or character cap. | +| `wiki_read_many` | **New.** Fetch multiple pages in one call (up to 10 slugs, with a per-page char cap). Replaces multi-turn `wiki_read_page` chains. | +| `wiki_compile_page` | **New.** On-demand page generation for a topic. Citations bind to the evidence chunks the prompt actually used — not to every chunk of the source raw. | +| `wiki_trace_source` | Trace a Wiki page back to its source raw materials. | +| `wiki_related_pages` | Related-page discovery across four signals (shared chunks, shared raws, direct links, semantic neighbors). | +| `wiki_explain_relation` | Score breakdown for the relationship between two pages. | +| `wiki_create_page` / `wiki_delete_page` | Direct page management; deletion respects `locked` / `system`. | +| `wiki_archive_page` / `wiki_unarchive_page` | Soft-archive: hide a page from default list/search/related results without destroying it. Citations and source lineage survive; recoverable. System pages can't be archived. | +| `wiki_list_transformations` | List the transformation templates available to this KB (name, intent, whether apply-default is on). | +| `wiki_apply_transformation` | Run a template against one **raw material**; returns the output, run id, and saved-page info. | +| `wiki_apply_transformation_to_page` | Run a template against an **existing wiki page** (takes a slug, not a numeric id). | +| `wiki_aggregate_transformation` | Map-reduce every completed run of a template across the KB into one synthesis wiki page. | + +The `kbId` parameter resolves automatically from the bound agent — agents never have to guess it. + +A typical agent turn: + +> **User:** "What did we decide about the retry policy last quarter?" +> +> **Agent:** *(reads injected summary, sees a "Retry Policy" page exists, opens that page directly, returns the decision with a source link.)* + +That isn't a vector query. It's literally opening the page — because the page exists. + +### Hot cache: a recent-activity snapshot in every system prompt + +The bound KB doesn't just contribute summaries — it also contributes a small, freshly-rebuilt **hot cache** that gets stitched into the system prompt. Think of it as the page the agent reads first, every turn: + +- **Last updated** — the most recent ingest / page edit +- **Key recent facts** — bullets the rebuilder considers high-signal +- **Recent changes** — page creations and compilations since the last rebuild +- **Active threads** — open questions and unresolved decisions + +The rebuilder fires asynchronously when a conversation ends (`ConversationCompletedEvent`), debounced inside a configurable window (default ~30 s) so a flurry of short turns doesn't churn LLM calls. An admin can also trigger a rebuild manually — that path bypasses the debounce. + +The injection is gated by the `wiki.hot_cache.enabled` feature flag (off → empty injection) and is capped at the **two highest-priority KBs** per agent so the system prompt stays small. + +#### Manage from the KB detail drawer + +`Wiki → [your KB] → Hot cache` shows: + +- **Regenerate** button — async manual rebuild; the panel polls a few seconds later and refreshes +- **Reset** button — soft-delete the row; the next `ConversationCompletedEvent` rebuilds it +- Meta grid: last updated, update reason (`AUTO` / `MANUAL` / `EVENT`), rebuild count, last duration in ms +- Error banner if the last rebuild failed +- The rendered Markdown content in a preview pane + +#### Operator endpoints + +Base path `/api/v1/wiki/hot-cache`: + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/{kbId}` | Current snapshot + meta | +| `POST` | `/{kbId}/regenerate` | Manual rebuild (async, ignores debounce) | +| `DELETE` | `/{kbId}` | Soft-delete; rebuilds on next event | + +The hot cache lives in `mate_wiki_hot_cache` — see the **Data model** section below for the exact columns. + +### A typical lazy turn + +``` +User uploads product-manual.pdf (lazy mode: zero page-generation LLM calls) + ↓ +Agent: wiki_semantic_search("error code 500 retry") + → hit on chunk #1234, page=12, section "Error Handling / Retries" + ↓ +Agent: wiki_compile_page(topic="500 retry policy", maxEvidenceChunks=5) + → produces slug=500-retry-policy, citations bound to those 5 chunks only + ↓ +Agent: wiki_read_page("500-retry-policy") + → returns the structured page with its source-chunk list +``` + +The whole path spends one LLM call, scoped to the five chunks that actually matter. The other 200 chunks in the manual cost nothing extra. + +--- + +## Reading and editing pages + +Every generated page is a first-class document you can open in the Wiki view: + +- Markdown rendered with syntax highlighting. +- Backlinks in the sidebar — see what else references this page. +- A "source" button on every claim that jumps to the raw passage it came from. +- An edit mode where you can rewrite the page directly. +- The delete button is disabled on system / locked pages. + +Edit when the AI got it wrong. Your edits survive the next ingest — `locked` tells the digester to leave human prose alone. Unlock explicitly when you want the AI to re-draft from the source. + +--- + +## Search, source tracing, and semantic retrieval + +- **Semantic search** — ask "what did we decide about auth?" and get the decision, not pages containing "auth". Chunk-level embeddings with cosine retrieval — it understands what you mean. Hits now include `pageNumber` and `section`, so the agent can quote "page 12, Setup / Linux" instead of a free-floating snippet. +- **Hybrid retrieval** — full-text and semantic matching run together, RRF-fused, with a 1-hop relation boost on the top seeds. +- **Full-text search** — covers titles, summaries, bodies, and concept extractions. Works across every KB you have access to. +- **Source tracing** — any claim, any page, has a source link. Click it, you land on the raw passage. Agents have the same capability. +- **Backlinks** — every page shows what other pages link to it. The `[[concept|display text]]` alias form is now parsed correctly: only `concept` becomes a slug, `display text` is purely visual. +- **Related pages** — blends shared-chunk, shared-raw, direct-link, and semantic-neighbor signals. The 1-hop expansion **never seeds from system pages**, so overview/log don't drag every page in the KB into your "related" list. +- **Edit protection** — locked or hand-edited pages aren't overwritten on re-ingest; unlock explicitly to re-draft. + +--- + +## Vision pipeline: images become text + +A wiki that can't read images is half-blind. PDFs are the worst offenders — half the actual information often lives inside the figures. + +When the `wiki.ocr.enabled` feature flag is on, MateClaw runs every uploaded image — and every image *embedded in a PDF page* — through a vision pipeline that extracts a **caption** plus any **visible text** in the image. Those become first-class chunks alongside the surrounding prose, so retrieval finds them, agents quote them, and search results show inline thumbnails with a click-to-zoom lightbox. + +### How it works + +1. **Hash** the image bytes with SHA-256 — the cache is content-addressed, so re-uploading the same diagram in a different KB costs nothing. +2. **Probe `mate_wiki_image_caption_cache`** — on hit, reuse the caption immediately and increment `hit_count`. +3. On miss, walk the configured **vision providers in order** until one returns a non-null caption. +4. **Persist** the caption + visible text + provider id + model + duration into the cache (race-tolerant insert — concurrent uploads of the same bytes are fine). +5. The `VisionResult` flows back into the chunker as additional content for the page that contained the image. + +### Supported providers + +| Provider id | Model | Notes | +|---|---|---| +| `dashscope-vision` | `qwen-vl-max` | DashScope OpenAI-compatible endpoint; reuses the DashScope provider configured in the UI | +| `zhipu-vision` | `glm-5v-turbo` | Zhipu BigModel; OpenAI-compatible | +| `volcano-doubao-vision` | configurable | ByteDance Volcano Doubao vision | + +Providers are auto-detected by order. Configure their keys / base URLs in `Settings → Models` like any other provider — the vision pipeline picks up the credentials from there. + +### Toggling the pipeline + +`Settings → Feature Flags → wiki.ocr.enabled`. Off by default in lightweight installs; on once you've configured at least one vision provider. + +When the flag is **off**, the pipeline short-circuits — uploads still succeed, image chunks just don't carry captions. The `extracted_text` cache for those images is **deferred** rather than poisoned, so flipping the flag back on captures captions on the next upload without forcing a re-ingest. + +### What you see in the UI + +- Search hits that contain image evidence render the thumbnail inline; click to open a lightbox at full resolution. +- The raw-material detail drawer shows captions next to each extracted image so you can sanity-check what the model actually saw. + +--- + +## Health-aware LLM fallback + +Wiki ingest is LLM-heavy, and a single wedged provider used to mean a whole batch died. Now every wiki step (`route`, `create_page`, `merge_page`, `enrich`, …) hits the routing chain through a **health-aware fallback**: if the primary model errors or times out, the next model on the KB's `fallback` list is tried once. Health (success / error / latency) is tracked per provider, so a flapping provider gets demoted automatically until it recovers. + +Configure the fallback list under `Wiki → Config → Model Strategy` next to the per-step picker. + +--- + +## Per-step model selection actually works + +In `Wiki → Config → Model Strategy` you can pick a different model per step: + +```text +heavy_ingest.route → small, cheap model for routing +heavy_ingest.create_page → strong model for full-page authoring +heavy_ingest.merge_page → strong model for content merging +light_enrich.enrich → small, cheap model for wikilink annotation +``` + +Resolution order: + +```text +stepModels[step] → wikiDefaultModelId → system default model +``` + +This UI used to be cosmetic — the Java side dropped the config on the floor and ran every step on the system default. Every LLM call inside the eager pipeline now consults the routing chain: route, create, merge, retry-create, repair, document analysis, light enrich. + +--- + +## Data model (if you're curious) + +Nine tables: + +| Table | Purpose | +|---|---| +| `mate_wiki_knowledge_base` | One row per KB. Owner, name, description, config JSON (`ingestMode`, `wikiDefaultModelId`, `stepModels`, fallback chain). | +| `mate_wiki_raw_material` | One row per upload. Status, byte hash, source path, last successfully-processed hash. | +| `mate_wiki_page` | One row per generated page. Title, summary, body, `source_raw_ids` (provenance), `page_type`, `locked`, version, plus `embedding` / `embedding_model` / `embedding_text_version` so transformation synthesis pages enter semantic search directly. | +| `mate_wiki_chunk` | One row per chunk. content + hash + offsets + embedding, plus `page_number`, `header_breadcrumb`, `source_section`, `token_count`. | +| `mate_wiki_relation` | Cached page-to-page edges (shared chunks, shared raws, direct links, semantic neighbors) used to power the 1-hop retrieval boost and the related-pages tool. | +| `mate_wiki_hot_cache` | One row per KB. Rendered Markdown snapshot + `last_updated`, `update_reason`, `rebuild_count`, `last_rebuild_duration_ms`, `last_rebuild_error`. | +| `mate_wiki_image_caption_cache` | SHA-256 keyed cache of vision-extracted captions. `caption`, `visible_text`, `mime_type`, `capture_model`, `provider_id`, `duration_ms`, `hit_count`. | +| `mate_wiki_transformation` | One row per transformation template. `name`, `title`, `description`, `prompt_template`, `model_id`, `apply_default`, `output_target`, `output_format`, `output_schema`. `kb_id=NULL` = workspace-wide. | +| `mate_wiki_transformation_run` | One row per template execution. `status`, `output`, `error`, `duration_ms`, `model_id`, `triggered_by`, `input_tokens`, `output_tokens`, `total_tokens`, `output_page_id`. | + +`mate_wiki_page` also carries two protection flags: + +- `locked` (V40) — `1` blocks AI tools, batch ops, and re-ingest cleanup from modifying or deleting the page. The built-in `overview`/`log` system pages ship with `locked=1`; users can set it on any hand-curated page too. +- `archived` (V41) — `1` soft-archives the page: gone from default list/search/related results, but the page itself, its citations, and its backlinks are all preserved. Recoverable. + +### Operator endpoints + +For when you don't want to wait for the cron / event hooks to catch up: + +| Endpoint | What it does | +|---|---| +| `POST /api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | Force-rewrite the overview marker region from current stats. | +| `POST /api/v1/wiki/admin/backfill-tokens` | Run one batch of the token-count backfill now; returns `pendingBefore` / `pendingAfter` / `filledThisBatch`. | + +The `mate.wiki` block in `application.yml` controls global knobs (chunk size, parallelism, auto-process-on-upload). Per-KB knobs (ingest mode, step models, fallback chain) live inside the KB's `configContent` JSON and are edited through the config UI. + +> The `token_count` column is nullable for legacy chunks; a low-frequency cron `WikiChunkTokenBackfillJob` fills them with `ceil(charCount / 4)` over time, never blocking ingest. + +--- + +## When to use it + +Reach for a Wiki KB when you have: + +- more than a handful of documents on the same topic +- material you want humans to read and edit, not just retrieve +- information that should outlive any single agent or conversation +- sources where "where did this come from" actually matters + +If you just want to drop one PDF into one conversation, attach it in chat. Wikis are for material that earns its own shelf. + +A short field guide to picking a mode: + +- You need a finished, browsable Wiki **right now** (sharing, presenting, onboarding): eager. +- You're seeding a corpus and want pages produced only when an agent or user reaches for them: lazy + on-demand compile. +- Big corpus, expensive model, unclear whether every document needs a full Wiki page: lazy is the cheaper default. + +--- + +## Next + +- [Agents](./agents) — binding an agent to a KB +- [Memory](./memory) — how Wiki and memory differ (hint: Wiki is deliberate, memory is passive) +- [API Reference](./api) — wiki REST endpoints diff --git a/mateclaw-server/src/main/resources/docs/en/workflow.md b/mateclaw-server/src/main/resources/docs/en/workflow.md new file mode 100644 index 00000000..16042d49 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/workflow.md @@ -0,0 +1,340 @@ +# Workflow + +::: tip New in 1.3.0 +Workflow orchestration is available from v1.3.0. Earlier releases (v1.2.0 and below) do not ship this capability. +::: + +**What workflow is**: a way to compose multiple digital employees plus system actions (approval / channel dispatch / memory write) into a linear-step business process. Each step can be gated by the previous step's output, fan out in parallel, wait for human approval, or persist results into an employee's `MEMORY.md`. + +**What workflow is not**: +- Not a replacement for ReAct / Plan-and-Execute — single-agent multi-turn reasoning still lives in those engines +- Not a low-code drag-and-drop if/else builder — v0 is **JSON-first** (canvas comes in v1) +- Not a 30-node Dify-style orchestrator — MateClaw workflows stay deliberately minimal: **a linear array of steps with one `mode` field expressing the control flow** + +::: warning v1.3.0 scope +v0 = internal alpha. **7 step modes + 6 trigger pattern types**. `loop` and `invoke_skill` are deferred. Run it on a flagship account / internal workspace before rolling out broadly. +::: + +--- + +## One-minute overview + +```json +{ + "schemaVersion": "1.0", + "inputs": [ + { "name": "customer", "type": "json" } + ], + "steps": [ + { + "name": "enrich", + "agentName": "data-analyst", + "promptTemplate": "Enrich and return strict JSON: {{ inputs.customer | toJson }}", + "mode": { "type": "sequential" }, + "outputVar": "enriched", + "outputContentType": "json" + }, + { + "name": "vip-route", + "agentName": "enterprise-sales", + "promptTemplate": "VIP onboarding for {{ outputs.enriched.name }}", + "mode": { + "type": "conditional", + "expression": "{{ outputs.enriched.tier == 'enterprise' }}" + } + }, + { + "name": "notify-feishu", + "agentName": "ops-bot", + "promptTemplate": "Notify feishu: {{ outputs.enriched }}", + "mode": { "type": "fan_out" } + }, + { + "name": "notify-email", + "agentName": "ops-bot", + "promptTemplate": "Notify email: {{ outputs.enriched }}", + "mode": { "type": "fan_out" } + }, + { + "name": "wait-acks", + "mode": { "type": "collect" } + }, + { + "name": "record", + "promptTemplate": "Onboarded {{ inputs.customer.name }}", + "mode": { + "type": "write_memory", + "employeeId": "{{ outputs.enriched.assignedEmployeeId }}", + "file": "MEMORY.md", + "mergeStrategy": "append" + } + } + ] +} +``` + +How it reads: +1. `enrich` asks the data analyst to structure the customer info as JSON +2. If `tier == enterprise`, route to the enterprise-sales employee for VIP onboarding +3. In parallel (fan_out), notify Feishu and notify email +4. `collect` waits for both notifications +5. Append the result to the employee's `MEMORY.md` + +--- + +## Core concepts + +### Seven step modes (v1.3.0) + +| Mode | Behavior | Required fields | Key semantics | +|---|---|---|---| +| `sequential` | Run after the previous step; previous output → `{{input}}` | — | Default mode | +| `fan_out` | Runs in parallel with consecutive `fan_out` steps; all receive the same `{{input}}` | — | Boundary detected at compile time: from this step onward, the first non-`fan_out` / non-`collect` step terminates the group | +| `collect` | Joins the most recent `fan_out` group's outputs with `\n\n---\n\n` into `{{input}}` | — | At least 2 consecutive `fan_out` steps must precede; compile-time check | +| `conditional` | Runs only if the Pebble expression is true | `expression` | When false, skipped; `{{input}}` is preserved (carries over previous step's) | +| `await_approval` | Pauses the run; sends an approval | `approvalKind`, `approverChannels[]` | Resumes to next step on approval; timeout follows workspace policy | +| `dispatch_channel` | Multi-channel delivery of `{{input}}` | `channels[]` | Per-channel failure follows `errorMode` | +| `write_memory` | Writes employee memory file | `employeeId`, `file`, `mergeStrategy` | Four strategies: `append` / `replace_section` / `upsert_kv` / `overwrite` | + +> **Not in v1.3.0**: `loop` (iterate N times or per-item over an array) and `invoke_skill` (call a skill without going through an employee). Coming based on user feedback. + +### Expressions: a Pebble subset + +Workflow does **not** use a full template engine — it supports the same Pebble subset as Kestra, just enough to gate conditionals and reference variables, with no code execution. + +| Category | Syntax | +|---|---| +| Variable references | `inputs.X` / `outputs.varname.field` / `vars.X` / `now` / `flow.id` | +| Operators | `==` `!=` `<` `<=` `>` `>=` `and` `or` `not` `+` `-` | +| Built-in filters | `length` / `lower` / `upper` / `default('x')` / `toJson` / `fromJson` / `date(format)` | +| JSONPath | `\| jq('.field.subfield')` | +| String tests | `\| contains('x')` / `\| startsWith('x')` / `\| matches('regex')` | + +**Not supported** (rejected at compile time): +- User-defined functions / macros +- `include` / `extends` +- File I/O / network I/O +- Any side-effecting operations + +### Output type: text vs json + +Each step's `outputContentType` decides how downstream steps can access it: + +| outputContentType | Default | Pebble access rules | +|---|---|---| +| `text` | ✅ | `outputs.X` is a string; `outputs.X.field` **fails at compile time**; `\| jq(...)` **fails at runtime** | +| `json` | — | Runtime `JSON.parse`; failure follows `errorMode`; field access / `jq(...)` are valid | + +**Agent steps default to `outputContentType=text`** — LLM natural-language output isn't structured JSON. To do conditionals or field access, you must: +1. **Explicitly** request strict JSON in the `promptTemplate` ("return strict JSON: {...}") +2. Set that step's `outputContentType` to `json` + +### Compile-time illegal combinations (publish rejects) + +| Combination | Reason | +|---|---| +| Multiple consecutive `fan_out` with no `collect` to terminate | `{{input}}` for the next step is ambiguous | +| `collect` without preceding `fan_out` | Nothing to collect | +| `await_approval` mixed inside a `fan_out` group | Multiple concurrent approvals fired with no aggregation UX | +| `agentName` references a non-existent / disabled / cross-workspace employee | ACL fail | +| Pebble expression references an undeclared variable | Compile-time | +| `outputs.X.field` but step X is `text` | Compile-time type error | +| `dispatch_channel` references a channel not in the workspace allowlist | ACL | +| `write_memory` references an employeeId outside the workspace | ACL | +| Step count > 200 (default cap) | Runaway-config guard | + +Publish runs `WorkflowCompiler.validate(graphJson) → List`. Each error points at a step name + field path; the Monaco editor highlights them inline. + +--- + +## Using workflow from the UI + +### Entry point + +`Workflows` (sidebar) → list → **+ New**. + +::: tip +The Workflows list is empty on a fresh install. That's intentional — v0 ships no built-in templates; flagship accounts co-author them. +::: + +### Editor (v1.3.0 = JSON only) + +- **Monaco editor**: JSON-schema validation, autocomplete, static Pebble checking +- **Template dropdown**: built-in skeletons fetched from `GET /api/v1/workflows/draft/templates` +- **Pre-compile**: `POST /api/v1/workflows/{id}/compile` returns compile diagnostics — **does not write a revision, does not actually run** +- **Publish**: compile → ACL validate → write a new `mate_workflow_revision` row (integer revision +1) + +::: warning Canvas comes in v1 +The `@vue-flow/core` canvas has a UI shell in v1.3.0, but it renders the step array as a node chain — **not** drag-to-edit. Double-click a node to open its field form; the primary edit path is still JSON. Full visual editing lands in v1.4+. +::: + +### Natural language → workflow draft (v1.3.0) + +`POST /api/v1/workflows/draft/generate` takes a free-form description ("I want a customer ticket triage flow with a Feishu entry, routing by tier — enterprise / pro / standard — to different handlers"), runs an internal agent to emit the corresponding `graph_json`, and **immediately compiles + returns** with diagnostics attached. + +Use cases: +- Authors who don't know the JSON DSL get a publishable first draft to refine in Monaco +- Bulk-feeding old SOP docs through the generator to get candidate workflow templates +- During customer co-creation, turn "how I want this to work" into something visualizable fast + +Response shape: +```json +{ + "graphJson": "...", // can be PUT directly into a draft + "compileErrors": [...], // same diagnostics as /compile + "modelUsed": "qwen-plus", + "tokenUsage": { ... } +} +``` + +::: tip Doesn't replace Monaco editing +The generator **never publishes directly** — it only emits a draft (via `saveDraft`); a human still has to review → compile → publish. The generated JSON may carry compile errors; the author cleans them up before publishing. +::: + +### Run history + +Every run persists as `mate_workflow_run` + `mate_workflow_run_step`. Detail view shows: +- Per-step input / output (payload URI references) +- Per-step duration + token usage +- Cross-step failure chain highlight +- For paused `await_approval` steps: who's approving, how long it's been waiting + +### Trigger sources + +A workflow run can only start through [Triggers](./triggers.md) or via `await_approval` resume — v0 has no "fire one now" endpoint. See API reference above for details. + +::: tip 1.4.0: triggers now live in the Scheduler +As of v1.4.0, **Scheduled Jobs** and **Triggers** are merged into a single **Scheduler** page (`Settings → Scheduler`, route `/settings/scheduler`) with three tabs: **Scheduled Jobs / Event Triggers / Run History**. To attach a trigger to a workflow, create a `target_type=workflow` rule on the Scheduler's **Event Triggers** tab. See [Triggers](./triggers.md). +::: + +--- + +## API reference + +All endpoints live under `/api/v1/workflows/`. Requests must carry the `X-Workspace-Id` header. + +### CRUD + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/v1/workflows` | List all workflows in the current workspace | +| `POST` | `/api/v1/workflows` | Create a new workflow (draft starts empty) | +| `GET` | `/api/v1/workflows/{id}` | Fetch workflow metadata + inline draft | +| `PUT` | `/api/v1/workflows/{id}` | Update workflow metadata (name / description / enabled) | +| `PUT` | `/api/v1/workflows/{id}/draft` | Save the inline draft graph_json (does not compile) | +| `DELETE` | `/api/v1/workflows/{id}` | Soft-delete | + +### Compile / Publish + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/v1/workflows/{id}/compile` | Compile the current draft and return diagnostics — **does not persist a revision** | +| `POST` | `/api/v1/workflows/{id}/publish` | Compile + persist a new revision; updates `latest_revision_id` | + +### Draft generator (built-in in v1.3.0) + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/v1/workflows/draft/templates` | List built-in draft templates | +| `POST` | `/api/v1/workflows/draft/preview-compile` | Compile arbitrary graph_json — surfaces real diagnostics before a workflow row exists | +| `POST` | `/api/v1/workflows/draft/generate` | **Natural language → workflow draft** — describe the flow, an agent emits graph_json + compile diagnostics | + +### Run inspection / resume + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/v1/workflows/{id}/runs?limit=...` | Recent runs of a workflow (default 50) | +| `GET` | `/api/v1/workflows/runs/paused?limit=...` | All paused runs across the workspace (operator entry point) | +| `GET` | `/api/v1/workflows/runs/{runId}` | One run's detail + all step rows (input / output / duration) | +| `POST` | `/api/v1/workflows/runs/{runId}/resume` | Resume from `await_approval` pause (called automatically when an approval lands; not for manual use) | + +::: warning v0 has no standalone "start run" endpoint +There are only two paths to actually start a workflow run: + +1. **Via a trigger** — configure a trigger in [Triggers](./triggers.md) pointing at this workflow (`target_type=workflow`); when an event arrives the engine starts the run +2. **Via `await_approval` resume** — the resume endpoint pushes a paused run forward + +There is **no** `POST /api/v1/workflows/{id}/runs` "fire one now" endpoint in v0. For a dry run, use `/draft/preview-compile` to get compile output (**compile only — no persist, no real run**), or attach a temporary webhook trigger. A manual run-start endpoint is on the RFC but lands in a later release. +::: + +--- + +## Security model + +### Three-layer ACL + +| Role | Capabilities | +|---|---| +| `workflow:author` | Edit drafts, read own runs | +| `workflow:publisher` | Publish revisions; static ACL checks fire here | +| `workflow:operator` | Start/stop triggers, cancel runs, view other people's runs | + +### Per-step execution identity + +Every step carries in its ExecutionContext: +- `workspaceId`: must equal the workflow's workspace +- `actingAgentId`: for `sequential` and the three MateClaw modes → that step's agent; for other modes → publisher as fallback +- `triggeredBy` / `workflowId` / `revisionId` / `runId`: for audit traceability + +### Cross-workspace isolation + +At publish time `WorkflowAclValidator.checkAll(graphJson)` runs: +- `agentName` references must point to an employee in the current workspace +- `dispatch_channel` channels must be in the workspace allowlist +- `write_memory` employeeIds must be inside the current workspace + +Any failure → publish fails, transaction rolls back, **no revision row written, no `latest_revision_id` update**. + +### Relationship with [MCP per-agent tool binding](./mcp.md) + +Workflow **cannot** grant employees additional tools. When an agent step calls a tool, it goes through the same `AgentBindingService.getEffectiveToolNames(agentId)` ACL — what an employee can do inside a workflow is exactly what it can do in normal chat. + +--- + +## Internal storage URI for payloads + +Workflow inputs / outputs / intermediate artifacts above the 4KB default threshold are auto-spilled to the `mate_workflow_payload` table (v1.3.0: same-DB storage) or local filesystem fallback, and replaced inline with a `payload://` URI. This avoids large contexts blowing out the message column — see commit `9c81dba0 feat(workflow): payload fs fallback for medium-size payloads`. + +```text +payload://run/abc123/step/enrich/output → resolved by the backend at access time +``` + +The UI lazy-loads on demand. + +--- + +## Data model + +The workflow subsystem touches 8 tables: + +| Table | Purpose | +|---|---| +| `mate_workflow` | Workflow root (id / name / workspace) | +| `mate_workflow_revision` | Published revisions (integer revision; full graph_json snapshot; immutable) | +| `mate_workflow_run` | One execution (runId / triggerSource / status / startedAt / endedAt) | +| `mate_workflow_run_step` | Per-step input/output/duration inside a run | +| `mate_workflow_run_pause` | Persistent `await_approval` pause state (survives restart) | +| `mate_workflow_payload` | Large-payload internal storage (target for `payload://` URI) | +| `mate_trigger` | Trigger configurations (with cron `pattern_version`) | +| `mate_trigger_event` | Event dedup + rate-limit history | + +--- + +## Known limitations (v1.3.0) + +- **No drag-to-edit canvas** — the canvas is read-only chain rendering; primary edit path is JSON +- **No `loop` step** — can't iterate per-item or retry N times. Workaround: a fixed number of `fan_out` branches, or higher-level scheduling of multiple runs +- **No `invoke_skill` step** — skills must be attached to an agent and invoked through the agent +- **No cross-workspace sharing** — to reuse a workflow template across workspaces, copy it +- **No realtime collaborative editing** — concurrent edits to the same draft: **last write wins** +- **No per-step retry policy** — `errorMode.retry` is step-wide; finer-grained retry is deferred + +--- + +## Related + +- [Triggers](./triggers.md) — workflow's event entry point +- [Approval & security](./security.md) — what `await_approval` plugs into +- [Agents](./agents.md) — what `agentName` references +- [Channels](./channels.md) — what `dispatch_channel` can reach +- [Memory](./memory.md) — which file `write_memory` writes to diff --git a/mateclaw-server/src/main/resources/docs/en/workspaces.md b/mateclaw-server/src/main/resources/docs/en/workspaces.md new file mode 100644 index 00000000..f0628f0b --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/workspaces.md @@ -0,0 +1,303 @@ +# Workspaces + +**A workspace is a box around one team's stuff.** + +MateClaw supports multiple teams in a single deployment by organizing every resource — agents, skills, wiki knowledge bases, conversations, memory files, tool guard rules, channels — into **workspaces**. When you're logged in, you see the workspaces you belong to and nothing else. When you switch workspace, the whole UI re-scopes: different agents, different skills, different knowledge, different channels. + +The point is that one MateClaw deployment can serve a product team, an engineering team, and a research team without their data, agents, or conversations bleeding into each other. + +--- + +## What belongs to a workspace + +Almost everything. The scoped resources: + +| Resource | Scoped how | +|----------|-----------| +| **Agents** | Every agent row has a `workspace_id` foreign key | +| **Skills** | Custom and MCP skills are scoped per workspace; builtin skills are global | +| **Wiki knowledge bases** | Every KB belongs to exactly one workspace | +| **Conversations and messages** | Scoped to the workspace the agent lives in | +| **Workspace memory files** | `workspace/{workspaceId}/{agentId}/...` | +| **Channels** | Each channel binds to one agent, so transitively to one workspace | +| **Tool Guard rules** | Rules can be global or scoped to a specific workspace | +| **File Guard paths** | Allowed/denied paths can be workspace-specific | +| **Cron jobs** | Scoped to the workspace of the agent they trigger | +| **Datasources** | External DB connections, scoped per workspace | +| **Audit events** | Every audit event records its `workspace_id` | + +What's **not** scoped (i.e., global): + +- JWT secret and auth config +- Model providers and API keys (global, with usage-tracked per workspace) +- MCP server definitions (global connections; workspace access is controlled by permissions) +- System-level settings in `mate_system_setting` +- Builtin skills + +--- + +## Workspace roles + +Each user is assigned to a workspace with one of four roles. Capabilities are **additive** — a higher role inherits everything below it: + +| Role | Capabilities (added on top of the tier below) | +|------|-----------------------------------------------| +| **Viewer** | `chat`, `view:wiki`. Read-only. So that chat works, a Viewer can also read the active model and read an employee's workspace files. | +| **Member** | Viewer + `view:memory`, `view:dashboard`, `manage:wiki`, `manage:agents` | +| **Admin** | Member + `manage:skills`, `manage:channels`, `manage:models`, `manage:security`, `manage:settings` | +| **Owner** | Same as Admin, plus owner-only: delete the workspace, transfer ownership | + +A user can belong to multiple workspaces with different roles. When they switch workspace, their effective permissions switch with them. + +### Global admin vs workspace role + +These are two independent permission systems: + +- **Global admin** — `mate_user.role='admin'`, system-wide. Manages users, creates workspaces, and spans **all** workspaces with owner-equivalent power even where it isn't a member. +- **Workspace role** — `mate_workspace_member.role`, one per workspace, the four roles above. + +System-level endpoints (models / providers / OAuth / datasources, user management, workspace creation) require a global admin (`@RequireGlobalAdmin`); workspace-scoped endpoints (skills / tools / plugins) require a workspace role — reads need Member, writes need Admin. + +### Capability scope — the backend is the source of truth + +Roles control **UI visibility** and **API access**, and **the backend is the single source of truth for capabilities**: it holds a `RoleCapabilities` mapping, and the frontend never derives them locally. After a workspace switch, or on a capability-related 403, the frontend calls `GET /api/v1/workspaces/{id}/access`, which returns `memberRole`, `isGlobalAdmin`, `effectiveRole`, and `capabilities`. + +The frontend gates on this: routes declare a required capability; the sidebar filters by capability (no menu flash before load); a Viewer lands on `/chat`; the sidebar also shows notification badges (pending approvals, stuck employees). The backend enforces the same rules on every API endpoint, so a request lacking the capability returns `403 Forbidden`. + +--- + +## Creating a workspace + +`Settings → Workspaces → New Workspace`. + +1. Name it after what the team does, not what the team is called ("Product Research" over "Alpha Team") +2. Optional description +3. Save + +You become the owner of the workspace. You can now invite members. + +### Via API + +```bash +curl -X POST http://localhost:18088/api/v1/workspaces \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Product Research", + "description": "Competitive research and product specs" + }' +``` + +--- + +## Members & roles + +`Settings → Members`. All member management requires **Admin or above**. + +### Add a member + +Enter a username, pick a role (defaults to `member`), save. + +- If the user **doesn't exist**, the account is **created on the spot** — a password is required in that case. +- If the user **exists** and you supply a password, their **password is reset** (useful when an admin removes a member, then re-adds them with a new password). +- Nickname is optional. + +The member immediately sees the workspace in their workspace switcher on next page load. No invite email, no acceptance flow. + +```bash +# Add by username; creates the account with the given password if it doesn't exist +curl -X POST http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "alice", + "password": "init-pass-123", + "nickname": "Alice", + "role": "member" + }' +``` + +### Update a member's role (Admin+, cannot change the Owner) + +```bash +curl -X PUT http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "admin"}' +``` + +> The path is `/members/{memberId}`, **not** `/members/{memberId}/role`. + +### Remove a member (Admin+, cannot remove the Owner) + +```bash +curl -X DELETE http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " +``` + +### List members + +```bash +curl http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " +``` + +--- + +## Switching workspace + +Top-left of the admin console. Click the workspace name to open the switcher; pick another one to switch. The entire UI re-scopes: + +- Sidebar menus re-render based on the new workspace's role +- The agent list refreshes to show agents in this workspace +- The Wiki list, skill list, channel list, etc. all change +- Active conversations stay open (they belong to their own workspace) + +Workspace selection is persisted per user — when you log back in, you land on the last workspace you used. + +--- + +## Security primitives that follow workspace boundaries + +This is where workspace isolation earns its keep. + +### File Guard + +The default allowed-path list for File Guard is `workspace/{workspaceId}/...`. A tool call from an agent in workspace A cannot read or write files that belong to workspace B, regardless of path traversal tricks — the symlink check and path normalization catch it. + +### Tool Guard rules + +Rules can be scoped to a specific workspace. You can have: + +- A **global** rule that says `ShellExecuteTool` needs approval +- A **workspace-specific** rule that says `ShellExecuteTool` is allowed if the command matches a narrow read-only pattern + +Only the second rule applies inside that workspace. Other workspaces see only the global rule. + +### Wiki knowledge bases + +A Wiki KB's data never leaves its workspace. An agent in workspace B cannot read a KB that belongs to workspace A, even if it tries. The Wiki search and read tools resolve `kbId` from the bound agent's workspace; cross-workspace reads are rejected at the API layer. + +### Memory files + +Workspace memory files (PROFILE.md, MEMORY.md, daily notes) live under `workspace/{workspaceId}/{agentId}/`. File Guard enforces the workspace boundary; the memory tools scope their list/read/write operations to the caller's workspace. + +### Channels + +Each channel binds to exactly one agent, so transitively to exactly one workspace. A DingTalk bot configured in workspace A is completely separate from a DingTalk bot configured in workspace B, even if they're configured to connect to the same DingTalk application (you probably don't want that, but it's technically allowed). + +--- + +## What isolation does NOT cover + +- **Shared global config** — JWT secret, model provider API keys, MCP server definitions are global. A workspace admin can't change them. +- **Audit log cross-workspace access** — security admins with the right permissions can query audit events across all workspaces. This is intentional — you want to see suspicious activity regardless of which workspace it happened in. +- **Token usage reporting** — aggregated globally, broken down per-workspace, per-agent, per-model in the Dashboard. +- **Model provider costs** — one billing relationship per provider at the global level; per-workspace quotas are on the [Roadmap](./roadmap). + +--- + +## Moving resources between workspaces + +Not supported directly. You have two options: + +1. **Export and import** — some resources have JSON export (agents via API, wiki KBs via API). Re-create them in the target workspace. +2. **Change ownership** — an admin or owner can directly update the `workspace_id` column in the database for simple resources. This is not officially supported; do it at your own risk and only with a backup. + +We'd like to support first-class moving in a future release. If you need this, leave a note on the [GitHub issue](https://github.com/matevip/mateclaw/issues). + +--- + +## Deleting a workspace + +**Only the owner can delete a workspace.** `Settings → Workspaces → [workspace] → Delete`. + +Deleting a workspace: + +- Soft-deletes every resource belonging to it — agents, skills, KBs, conversations, memory files, channels +- Removes all member associations +- Records an audit event + +Soft delete means the data isn't physically removed — it's marked `deleted = 1` and hidden from queries. If you delete by mistake, a database admin can restore it by flipping the flag. After the configured retention period, deleted data may be permanently purged by a cleanup job. + +--- + +## Workspace management API + +```bash +# List workspaces you belong to +curl http://localhost:18088/api/v1/workspaces \ + -H "Authorization: Bearer " + +# Get one workspace detail +curl http://localhost:18088/api/v1/workspaces/1 \ + -H "Authorization: Bearer " + +# Create +curl -X POST http://localhost:18088/api/v1/workspaces \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "Product Research"}' + +# Update +curl -X PUT http://localhost:18088/api/v1/workspaces/1 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"description": "Updated description"}' + +# Delete (owner only) +curl -X DELETE http://localhost:18088/api/v1/workspaces/1 \ + -H "Authorization: Bearer " + +# Member management +curl http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " + +curl -X POST http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"userId": 42, "role": "member"}' + +curl -X DELETE http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " + +curl -X PUT http://localhost:18088/api/v1/workspaces/1/members/42/role \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "admin"}' +``` + +--- + +## Data model + +**`mate_workspace`** + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `name` | Workspace name | +| `description` | Short description | +| `owner_id` | User ID of the owner | +| `create_time` / `update_time` | Timestamps | +| `deleted` | Logical delete flag | + +**`mate_workspace_member`** + +| Column | Purpose | +|--------|---------| +| `id` | Primary key | +| `workspace_id` | FK to `mate_workspace` | +| `user_id` | FK to `mate_user` | +| `role` | `owner` / `admin` / `member` / `viewer` | +| `joined_at` | When the user joined this workspace | +| `create_time` / `update_time` | Timestamps | + +--- + +## Next + +- [Admin Console](./console) — workspace switcher and UI +- [Security & Approval](./security) — how workspace isolation interacts with Tool Guard and File Guard +- [LLM Wiki](./wiki) — workspace-scoped knowledge bases +- [Memory](./memory) — workspace memory files diff --git a/mateclaw-server/src/main/resources/docs/zh/acp.md b/mateclaw-server/src/main/resources/docs/zh/acp.md new file mode 100644 index 00000000..a09246b7 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/acp.md @@ -0,0 +1,264 @@ +--- +title: ACP 接入 —— 把外部编码 Agent 接进 MateClaw +description: MateClaw 作为 ACP 宿主,通过 stdio 把 prompt 转交给 Claude Code、Codex、OpenCode、Qwen Code 等任意 Agent Client Protocol 端点。内置端点、可视化环境变量编辑、自动桥接技能卡、信任模型、错误翻译。 +head: + - - meta + - name: keywords + content: ACP,Agent Client Protocol,Claude Code,Codex,OpenCode,Qwen Code,外部 Agent,stdio JSON-RPC,编码 Agent 接入 +--- + +# ACP —— Agent Client Protocol + +**ACP 是 MateClaw 把 prompt 交给别人写的 Agent 的方式。** + +Agent Client Protocol 是一个开放规范,定义 Agent 客户端通过 JSON-RPC 调用 Agent 服务端的协议。MateClaw 扮演 **宿主**:拉起一个外部 CLI(Claude Code、Codex、OpenCode、Qwen Code …),通过 stdio 完成 `initialize` → `session/new` → `session/prompt` 三步握手,把流式响应回填到对话里,然后关闭进程。 + +如果说 MCP 是 "插一个工具",ACP 就是 **"插一整个 Agent"**。在 MateClaw 的一次轮次里,调用 Claude Code 和调用任何内置工具没有任何区别——你的 Agent 直接请求 `acp_claude-code_prompt` 然后读取结果即可。 + +--- + +## ACP vs MCP 一眼区分 + +| | **MCP** | **ACP** | +|---|---|---| +| 接什么 | 工具服务器 | Agent | +| 粒度 | 按工具(`tools/list`) | 按 prompt(一次性) | +| MateClaw 的传输 | stdio / streamable_http / sse | stdio | +| 会话模型 | 长连接、多次调用 | 无状态:拉起 → prompt → 关闭 | +| 典型用法 | 文件系统、搜索、自定义数据 API | 把编码任务转给 Claude Code / Codex | +| 在 MateClaw 的呈现 | 工具目录 | 技能目录(自动桥接)+ 工具包装 | + +同一个数字员工可以同时用两套。 + +--- + +## 内置端点 + +随 MateClaw 出厂的 Flyway 迁移会预置四个端点,**默认全部禁用**——你装好对应 CLI 之后再打开。 + +| 标识 | 显示名 | Command | 备注 | +|---|---|---|---| +| `claude-code` | Claude Code | `npx -y @zed-industries/claude-agent-acp` | Anthropic 的 Claude Code,读 `ANTHROPIC_API_KEY` | +| `codex` | OpenAI Codex CLI | `npx -y @zed-industries/codex-acp` | OpenAI 的编码 Agent,读 `OPENAI_API_KEY` | +| `opencode` | OpenCode | `opencode acp` | 多模型 Agent,二进制需在 `PATH` 中 | +| `qwen-code` | Qwen Code | `qwen --acp` | 阿里的编码 Agent,读 `DASHSCOPE_API_KEY` | + +内置行写保护——可以改 `args_json` / `env_json` / `description` / `trusted` / `enabled`,但不能改 slug、不能换 command、不能删除。要跑别的 Agent,**新建一个自定义端点**就行。 + +--- + +## 在控制台配置 + +`设置 → ACP 端点` 是完整的 CRUD 入口。 + +### 新建 / 编辑端点 + +- **Slug** —— 小写标识符(如 `claude-code`),创建后不可修改。技能通过 slug 引用端点。 +- **显示名** —— 技能页展示用的人类标签。 +- **描述** —— 运维备注。 +- **Command** —— 可执行文件(`npx`、`opencode` …),内置行锁定不可改。 +- **Args(JSON 数组)** —— CLI 参数,例如 `["-y","@zed-industries/claude-agent-acp"]`。 +- **Env(JSON 对象)** —— 注入子进程的额外环境变量。可视化编辑器会把 key 命中 `*API_KEY*` / `*TOKEN*` / `*SECRET*` / `*PASS*` 的值自动打码。 +- **Tool parse mode** —— `call_title` / `call_detail` / `update_detail`,决定上游工具调用事件渲染到流式抄本的方式。 +- **Trusted** —— 打开时,MateClaw 会自动同意上游 Agent 发来的 `session/request_permission`;关闭时,所有权限请求一律拒绝(适合非交互场景)。 +- **Enabled** —— 启停开关。禁用的端点不会进入技能目录。 + +### 测试连接 + +点击 **Test** 会拉起进程、跑一遍 `initialize` + `session/new`,再关掉。结果面板显示协议版本、Agent 能力、耗时,失败时附带翻译过的错误提示(见 [信任与错误翻译](#trust-error-translation))。状态会写回行上:`last_status` / `last_tested_at` / `last_error`。 + +### 启用 / 停用 / 删除 + +- **Toggle** —— 把端点从目录里摘掉但不删除。 +- **Delete** —— 仅自定义端点可删,内置端点拒绝删除。 + +任何变更都会发出 `AcpEndpointChangedEvent`,技能目录立刻重新同步——不需要重启服务。 + +--- + +## REST API + +基础路径:`/api/v1/acp/endpoints`,需要 JWT。 + +| Method | Path | 作用 | +|---|---|---| +| `GET` | `/` | 列出全部端点 | +| `GET` | `/{id}` | 取单条 | +| `POST` | `/` | 新建自定义端点 | +| `PUT` | `/{id}` | 局部更新(内置 `command` 锁定) | +| `DELETE` | `/{id}` | 删除自定义端点(内置拒绝) | +| `PUT` | `/{id}/toggle?enabled=true\|false` | 启用 / 停用 | +| `POST` | `/{id}/test` | 跑连接测试 | + +### 新建自定义端点 + +```bash +curl -X POST http://localhost:18088/api/v1/acp/endpoints \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "my-coder", + "displayName": "我的自研 Agent", + "description": "内部编码 Agent", + "command": "npx", + "argsJson": "[\"-y\",\"@my-org/my-acp-agent\"]", + "envJson": "{\"MY_API_KEY\":\"sk-...\"}", + "toolParseMode": "call_detail", + "trusted": true, + "enabled": true + }' +``` + +### 测试端点 + +```bash +curl -X POST http://localhost:18088/api/v1/acp/endpoints/9100002/test \ + -H "Authorization: Bearer " +``` + +返回示例: + +```json +{ + "name": "claude-code", + "command": "npx", + "args": ["-y", "@zed-industries/claude-agent-acp"], + "agentCapabilities": { "loadSession": false, "promptCapabilities": { "image": true } }, + "status": "OK", + "elapsedMs": 1842 +} +``` + +失败时 `status` 为 `ERROR`,`error` 字段是翻译后的提示。 + +--- + +## 端点是怎么被 Agent 用上的 + +两条路径: + +### 1. 自动桥接的虚拟技能(零配置) + +每个启用的端点都会被注册一张虚拟技能卡,并在工具注册表里多出一个名为 `acp__prompt` 的包装工具。它接收一个 `prompt` 字符串参数,返回上游 Agent 累积的文本回答。任何数字员工都可以像调内置工具一样调用它,不用写技能清单。 + +``` +设置 → ACP 端点(打开开关) + ↓ +AcpEndpointChangedEvent + ↓ +技能目录新增 "Claude Code" 卡片 +工具注册表新增 "acp_claude-code_prompt" + ↓ +Agent 调用工具 → AcpDelegationService.prompt() + ↓ +拉起 → initialize → session/new → session/prompt + ↓ +累积 agent-message-chunk 通知 + ↓ +把文本回填到 Agent 的轮次 +``` + +### 2. 手写技能(完全可控) + +技能清单可以声明 `type: acp` 并绑定到某个端点。技能会得到自己的包装工具(`acp___prompt`),可以在每次 prompt 前注入 `systemPrefix`,也可以按会话覆盖 `cwd`。 + +```yaml +# SKILL.md frontmatter +type: acp +acp: + endpoint: claude-code + systemPrefix: | + 你正在 MateClaw 仓库里工作。报完成前一定要先跑 `mvn test`。 + cwd: /workspaces/mateclaw +``` + +`claude-code-helper` 和 `codex-helper` 这两个出厂技能模板就是这么做的。 + +--- + +## 信任与错误翻译 {#trust-error-translation} + +### 信任开关 + +ACP 服务端可以在做敏感动作前(写文件、跑 shell 命令等)发 `session/request_permission` 请宿主放行。MateClaw **不会** 在流式响应中途打断用户去问,而是按端点的 `trusted` 标志决定: + +- `trusted: true` —— 自动选择 Agent 给的第一个选项放行。适合你自己装好的可信 CLI。 +- `trusted: false` —— 所有权限请求一律拒绝。适合沙盒或不可信端点;上游 Agent 会优雅退避。 + +### 错误翻译 + +编码 Agent 的报错出了名地难懂。`AcpRuntimeSupport.translateAuthError()` 识别常见 401 / 403 / "Request not allowed" 模式,把它们改写成可执行建议: + +- 缺密钥 → 提示 "请设置 `ANTHROPIC_API_KEY`" / `OPENAI_API_KEY` / `DASHSCOPE_API_KEY` / `GOOGLE_API_KEY`,按端点对应。 +- Claude Code OAuth 钥匙串劫持 → 建议跑 `claude logout`,把 `~/.claude/` 里盖住你环境变量的旧 OAuth token 清掉。 + +提示会在测试面板里弹出,也会带进 Agent 收到的流式错误信息里。 + +### 超时与限额 + +- `initialize` 握手:15 秒 +- `session/new`:10 秒 +- 整个 `session/prompt` 往返:5 分钟 +- stdio 缓冲上限:单次 50 MiB(行级 `stdio_buffer_limit_bytes` 可改) + +--- + +## 数据库 —— `mate_acp_endpoint` + +| 列 | 类型 | 默认 | 用途 | +|---|---|---|---| +| `id` | BIGINT | — | 主键,内置占用 `9100001`–`9100004` | +| `name` | VARCHAR(64) | — | 唯一 slug,技能引用此字段 | +| `display_name` | VARCHAR(128) | NULL | 显示名 | +| `description` | TEXT | NULL | 运维备注 | +| `command` | VARCHAR(256) | — | 进程命令 | +| `args_json` | TEXT | NULL | CLI 参数(JSON 数组) | +| `env_json` | TEXT | NULL | 环境变量覆盖(JSON 对象) | +| `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` | +| `builtin` | BOOLEAN | FALSE | 内置行写保护 | +| `trusted` | BOOLEAN | TRUE | 自动放行权限请求 | +| `enabled` | BOOLEAN | FALSE | 默认关闭,按需打开 | +| `stdio_buffer_limit_bytes` | BIGINT | 52428800 | 单次 stdio 累积 50 MiB 上限 | +| `last_status` | VARCHAR(32) | NULL | `OK` / `ERROR` | +| `last_tested_at` | DATETIME | NULL | 上次测试时间 | +| `last_error` | TEXT | NULL | 上次测试错误 | +| `workspace_id` | BIGINT | 1 | 绑定的工作空间 | +| `create_time` / `update_time` | DATETIME | — | 时间戳 | +| `deleted` | INT | 0 | 逻辑删除 | + +DDL 在 `db/migration/{h2,mysql}/V68__add_acp_endpoints.sql`。 + +--- + +## 排查 + +### "Command not found" + +`command` 必须在跑 MateClaw 的用户的 `PATH` 里。`which npx`(或 `which opencode` / `which qwen`)确认一下。Docker 里要把 CLI 装进镜像。实在不行就把 `command` 写成绝对路径。 + +### Claude Code 报 "Request not allowed" / 403 + +多半是你 `~/.claude/` 里有个 OAuth token 缓存,盖住了你在环境变量编辑器里设的 `ANTHROPIC_API_KEY`。跑一次 `claude logout`,再点 **Test** 试试。测试面板检测到这种情况会主动提示。 + +### `session/new` 卡住 + +通常是上游 CLI 在首次启动时下载依赖(`npx -y` 会这样)。要么先在 MateClaw 之外手动跑一次 CLI 把依赖预热好,要么直接重试——后续调用都很快。 + +### "Subprocess output exceeded buffer" + +Agent 在一次调用里输出超过了 50 MiB 的 stdio。把端点行的 `stdioBufferLimitBytes` 调大,或者把 prompt 拆成多轮。 + +### 工具没出现在技能页 + +- 确认 `enabled: true`。 +- 确认测试通过(`last_status: OK`)。 +- 看一眼 Agent 的工具绑定——自动桥接的工具默认对所有数字员工可用,除非被明确排除。 + +--- + +## 下一步 + +- [技能系统](./skills) —— 包括手写的 `type: acp` 技能 +- [工具系统](./tools) —— 包装工具是怎么进注册表的 +- [MCP 协议](./mcp) —— 服务工具用的姊妹协议 +- [安全与审批](./security) —— 信任开关怎么和工具守卫配合 diff --git a/mateclaw-server/src/main/resources/docs/zh/agents.md b/mateclaw-server/src/main/resources/docs/zh/agents.md new file mode 100644 index 00000000..a2feb22c --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/agents.md @@ -0,0 +1,372 @@ +--- +title: 多智能体引擎 — ReAct + Plan-and-Execute 双模式 +description: MateClaw 的多智能体系统支持 ReAct 推理循环和 Plan-and-Execute 任务拆解两种模式。Agent 之间可以互相委派,实现真正的多智能体协作。 +head: + - - meta + - name: keywords + content: 多智能体,ReAct,Plan-and-Execute,Agent委派,Spring AI Alibaba,AI Agent +--- + +# 多智能体引擎 + +> **它们现在叫"数字员工"了。** 后台所有出现"智能体"的地方都改了。底层运行时还是 Agent,但 UI 上、概念上、模板上——它们是你团队里的同事。 +> 命名变了,世界观也跟着变了:你给一个员工**角色(Role)**、**目标(Goal)**、**背景故事(Backstory)**,他知道自己是谁、为什么存在;你不用再写一段冷冰冰的 system prompt 让"agent"理解任务。 + +**一个数字员工就是一个带工具的人格。多个员工组成一支团队。** + +短版本是这个。长一点的版本:一个员工是一个名字,一段定义它怎么思考的 system prompt(包含角色 / 目标 / 背景故事),一个真正在思考的模型,一组它被允许调用的工具,可选的知识库,可选的技能包,它自己的一份记忆,以及它处理难题的方式——一步一步反应式(ReAct)还是先写计划再执行(Plan-and-Execute)。 + +你可以有很多个员工。每一个都专门做一类事。你把不同的活交给不同的员工。 + +--- + +## 一个数字员工有什么 + +| 部分 | 是什么 | +|------|--------| +| **名字** | 你和你的团队找它用的 | +| **图标** | 像素艺术风格、按角色配色,一眼认出 | +| **角色 (Role)** | 一句话——"我是产品研究员" / "我是客户支持" | +| **目标 (Goal)** | 一句话——"我帮你看清楚市场怎么动" | +| **背景故事 (Backstory)** | 它来自哪、为什么存在、它在意什么;这些会自动拼到最终 system prompt | +| **员工卡片标语 (Tagline)** | 卡片上展示的一句"自我介绍" | +| **System Prompt** | 它的人格、规则、风格、优先级(角色/目标/背景故事会自动注入) | +| **类型** | `react` 或 `plan_execute` | +| **工具** | 它被允许调用的工具(来自内置工具、MCP、技能包、ACP 桥接) | +| **知识库** | 它可以读的 LLM Wiki(KB 热点会自动注入到 system prompt) | +| **工作空间记忆** | 它自己那一份 `PROFILE.md`、`MEMORY.md`、`SOUL.md`、`AGENTS.md`,以及每日笔记 | +| **最大迭代次数** | 强制收敛前允许走多少轮推理循环 | +| **启用开关** | 关掉它 | + +注意一个**没有**的东西:模型。整个 MateClaw 部署里只有**一个全局默认模型**(在 `设置 → 模型` 里设置),所有 Agent 在运行时用的都是它。Agent 行上那个 `model_name` 字段是历史遗留——**被忽略**。这是刻意的:换模型是整个部署一次点击的事,不是三十次。 + +--- + +## 模板:从一个已经会工作的同事开始 + +你不是从一张白纸开始。`数字员工 → 新建` 打开一个模板选择器。里面有两层: + +### 5 个职业模板(推荐) + +每一个都自带角色、目标、背景故事、合适的工具集、像素艺术头像、专属配色——**打开就能用**: + +- **产品研究员**——竞品调研、市场动态追踪、用户访谈整理 +- **客户支持**——接住客户问的事、查知识库、把不能解决的升级出去 +- **知识管理员**——把零散材料整理进 LLM Wiki、维护双向链接、定期归纳 +- **数据分析师**——查数据源、跑 SQL、出图表、写结论 +- **行政助理**——日程、邮件草稿、跨工具协调 + +### 通用模板(白纸或半成品) + +- **通用助手**——默认的聊天员工 +- **研究 / 代码 / 写作 / 知识策展 / 数据分析**——按用途分类的半成品 +- **自定义**——彻底白纸一张,知道自己要什么就选这个 + +选一个,给它起名字、调一下角色和目标,保存。**一分钟以内就有一个能上岗的同事。** 创建之后每一项都能改。 + +--- + +## 两种思考方式 + +### ReAct —— 思考、行动、观察、继续 + +默认模式。ReAct 模式下的 Agent 跑一个循环:**推理**下一步该做什么,**行动**(可能调一个工具),**观察**结果,决定是再循环一次还是回答。 + +适合: +- 简单问答,需要一两次工具调用 +- 对话式交互,每一轮用户输入都不大 +- 需要 Agent "边学边反应"的任务 + +例子:*"北京今天什么天气?"* → 推理(需要实时数据)→ 行动(调 web search)→ 观察(15–26°C,晴)→ 回答。 + +### Plan-and-Execute —— 先计划、再执行 + +适合更大的任务。Agent 先生成一个**计划**——一个由 2 到 6 个步骤组成的有序列表,然后一步一步执行。完成之后,总结一下所有做过的事。 + +适合: +- 多步研究("调查 X,对比 Y,写一个简报") +- 步骤在一开始就能想清楚的任务 +- 你想**看着进度跑**的任务——计划和每一步的状态会出现在对话旁边的**持久任务清单**里 + +例子:*"研究一下 Spring AI 框架,对比前三个,给我写个简报。"* → 计划(4 步)→ 按顺序执行 → 最终汇总。 + +### 怎么选 + +| 情境 | 用哪个 | 为什么 | +|------|--------|--------| +| 简单问答、单工具调用 | ReAct | 没有计划开销 | +| 信息检索 | ReAct | 一般 2–3 轮就结束 | +| 多步有序任务 | Plan-and-Execute | 显式计划更好看、更好 debug | +| 研究 + 对比 + 写作 | Plan-and-Execute | 每一步的结果喂给下一步 | +| "读这份文件然后告诉我 X" | ReAct | 一个工具,一个答案 | +| "给我写一份关于 X 的结构化报告" | Plan-and-Execute | 多轮收集 + 综合 | + +类型可以随时改。同一份 system prompt 在两种模式下都能工作得不错。 + +--- + +## 多 Agent 并行委派 + +一个 Agent 不是孤军作战。一个 Agent 可以把任务委派给另一个——或者**同时委派给三个**。 + +- **单点委派** —— 把一个子任务交给指定 Agent,在独立会话中执行,结果流式回传 +- **并行委派** —— 同时委派给多个 Agent,每个在自己的隔离会话里跑 +- **子会话实时可见** —— 你在 ChatConsole 里能看到每个子任务的推理、工具调用和进度 +- **路由提示** —— 系统 prompt 里内置,Agent 知道什么时候该自己做、什么时候该委派 + +例子:让代码 Agent 处理 Jira 工单,同时让研究 Agent 拉竞品数据,同时让写作 Agent 起草 Slack 回复。三路并行,结果汇总给编排者。 + +### 多级子员工委派树 + +::: tip 1.4.0 新增 +委派不再只有一层。一个父员工可以委派给子员工,子员工还能再往下委派——**递归最深 3 级**。一支临时团队可以为某个具体任务自己长出层级。 +::: + +三个委派工具,覆盖三种节奏: + +- **`delegateToAgent`** —— 同步委派。把一个子任务交给指定员工,等它跑完、拿到最终结果再返回。可选 `inheritParentContext`:把父会话最近的上下文一起带给子员工,省去重复交代背景。 +- **`delegateParallel`** —— 扇出委派。同时派给多个子员工,各自在隔离会话里跑,结果统一收集回来。 +- **`delegateAsync`** —— 后台委派。立刻返回一个 `task_id`,子员工在后台跑;之后用 **`taskOutput`** 取结果。`taskOutput` 带**归属闸门**——只有最初发起委派的**同一个会话 + 同一个用户**才能读到结果,防止跨会话/跨用户泄露。 + +子员工默认被拒绝一组工具,保证树不失控: + +- `delegateToAgent` / `delegateParallel`(递归护栏——子员工不能再发起同步/并行委派,避免委派风暴) +- `setGoal` 系列 + `remember` 系列(目标与记忆的所有权留在父员工手里) +- `create_employee`(子员工不能凭空造新员工) + +这组默认拒绝列表可通过 `mateclaw.delegation.child-denied-tools` 调整。 + +委派和[目标系统](./goals)配合使用——父员工定目标、拆任务、把子任务委派下去,子员工专注执行。 + +### UI —— 嵌套子员工时间线 + 常驻计划面板 + +ChatConsole 把整棵委派树画出来,不是一串扁平日志: + +- **委派开始**事件清晰标出 +- 每个子员工显示**名字 / 层级深度 / 任务摘要** +- **完成徽标**:成功 / 超时 / 错误,外加耗时、内容长度 +- 每个子员工有稳定的 **id + parentId + depth**,所以嵌套关系在时间线里一眼能看清谁派给了谁 +- **计划面板常驻**——不再只有 Plan-and-Execute 模式才显示,委派树的进度也并入同一个面板 + +--- + +## 一句话造一支团队:数字员工搭建技能 + +::: tip 1.4.0 新增 +不想一个一个手动建员工?给一句话,让"数字员工搭建"技能替你把整支团队搭出来。 +::: + +这个技能从你的一句话出发,走完整条链路: + +1. **澄清需求**——先把模糊的一句话问清楚,确认你真正要解决的问题 +2. **设计角色**——拆成 **2 到 6 个**互补的角色 +3. **逐个创建**——对每个角色调用 `create_employee` 建出真实可用的员工 +4. **串成工作流草稿**——把这几个员工链接成一条[工作流](./workflow)草稿,开箱即可调整 + +配套工具 **`list_capability_catalog`** 让技能先看清当前部署里有哪些工具 / 技能 / 知识库可用,再据此分配角色能力。创建出来的员工**默认即启用**,不用再手动开开关。 + +--- + +## 深度思考 + +不是所有问题都值得深度推理,但有些问题需要。MateClaw 支持按 Agent、按对话打开深度思考模式: + +- **`thinkingLevel`**:`off` / `low` / `medium` / `high` / `max` +- 支持 Anthropic extended thinking、DashScope qwq 推理、OpenAI o1 `reasoning_effort=high` +- 思考块流式进入 UI,做成可折叠面板——你看得见模型怎么想,token 不会浪费在不需要推理的闲聊上 + +--- + +## 雇佣一个数字员工 + +`数字员工 → 新建`: + +1. 选一个模板(5 个职业模板之一,或通用模板,或 Custom) +2. 起名字,挑头像(像素艺术风格的库可选,或自己上传) +3. 写**角色 (Role)**——一句话;**目标 (Goal)**——一句话;**背景故事 (Backstory)**——几句话 +4. 写一句**员工卡片标语 (Tagline)**——卡片上展示的"自我介绍" +5. 选类型(`react` 或 `plan_execute`) +6. 写或改 system prompt(角色 / 目标 / 背景故事会自动拼接进来,不用重复写) +7. 勾选它能用的工具,绑定它该读的知识库 +8. 设置 `max_iterations`(默认 10) +9. 保存 + +立刻生效。从聊天 UI 或 API 开始用。 + +### 工具绑定(per-agent tool picker) + +::: tip 1.3.0 新增 +在 v1.2.0 时,员工的工具绑定是平铺的"勾哪个就能用哪个"列表。v1.3.0 把这块重做成**分组 + 状态感知 + 命名空间感知**的 picker,专门处理 MCP 工具的脏状态。 +::: + +打开数字员工编辑器的"工具"标签页: + +- **按来源分组**:内置工具 / 技能注入工具 / MCP 工具(按 server 名再分组) / ACP 工具 +- **状态徽标**:每个工具有一个标签—— + - `connected` —— 当前可用 + - `stale` —— 这个 MCP server 现在连不上,但绑定还保留(恢复连接后立即可用) + - `unavailable` —— server / skill 已被禁用,绑定保留但运行时不会下发给员工 + - `orphan` —— **不存在**的工具引用(server 删了或 tool 改名了);保存时会**拒绝**保留这种引用,强制清理 +- **命名空间冲突**:两个不同 MCP server 提供同名工具时(比如两个都有 `read_file`),picker 显示完整 prefixed name(`server-a__read_file` / `server-b__read_file`);员工的 system prompt 里把它们映射回原始名以避免混淆 +- **保存时校验**:勾选的每个工具会跑 `AgentBindingService.validate(...)`——任何 orphan 引用直接报错,必须先去掉 +- **MCP server 重命名**:原来挂在某 server 上的绑定**自动跟随**到新名字(按持久化的 tool cache 匹配),不需要手动重新勾 + +UI 入口:`Agents → 选员工 → 工具`。 + +技术细节见 [MCP](./mcp#per-agent-工具绑定)。 + +### System Prompt 最佳实践 + +System prompt 是数字员工的声音、优先级、约束的来源。**角色 / 目标 / 背景故事**和技能指令、工作空间记忆系统会自动拼接到最终 prompt 里——这些部分你不用自己写。 + +**你自己**的 prompt 应该包含: + +1. **它该怎么说话**——语气、风格、措辞偏好("专业但不死板" / "面向客户保持谨慎") +2. **它被允许做什么、被期望做什么**——任务边界 +3. **不确定时怎么办**——"先搜,不要编造" / "跑危险命令前问我" +4. **输出格式**——需要结构就明说 + +**不要**写的东西: + +- 工具描述——会自动注入 +- 工作空间记忆的使用说明——从 `AGENTS.md` 来 +- 框架层的行为(工具调用格式、ReAct 结构)——别跟运行时对着干 + +示例: + +> 你是一个专业的技术文档助手。你的职责: +> +> 1. 根据用户需求搜索并整理技术资料 +> 2. 用清晰、结构化的方式回答问题 +> 3. 确保代码示例语法正确 +> 4. 不确定的时候先搜索,不要捏造 +> +> 原则: +> - 引用外部信息时注明来源 +> - 涉及时效性问题,先获取当前日期再搜索 + +--- + +## 给开发者:Agent 内部是怎么转的 + +只是用 Agent 的话,这一节可以跳。在它上面写代码(加节点、改路由、做扩展)的话,请直接看 [架构说明](./architecture)——StateGraph 拓扑、节点列表、共享状态 key、扩展点都在那一页。 + +--- + +## 生命周期状态 + +| 状态 | 含义 | +|------|------| +| `IDLE` | 等待输入 | +| `PLANNING` | 正在生成计划(Plan-and-Execute 模式) | +| `EXECUTING` | 正在执行工具调用或子任务 | +| `RUNNING` | 活跃的 ReAct 循环或 Plan-Execute 图执行中 | +| `WAITING_USER_INPUT` | 暂停,等用户响应 | +| `DONE` | 完成 | +| `FAILED` | 执行失败 | +| `ERROR` | 错误状态 | + +回合的结束原因: + +| 值 | 含义 | +|----|------| +| `NORMAL` | LLM 给出了直接的最终答案 | +| `SUMMARIZED` | 上下文压缩之后正常完成 | +| `MAX_ITERATIONS_REACHED` | 到达迭代上限被强制收敛 | +| `ERROR_FALLBACK` | 出错后降级的答案 | + +--- + +## 可靠性机制 + +这些是运行时自己在做的事,目的是让 Agent 在你不想去 debug 的那种地方不脆弱: + +- **上下文修剪**——上下文窗口快满时,早期轮次由 LLM 总结、摘要替换原文。缓存 30 分钟。摘要以用户消息形式注入,不是系统消息——防止历史内容被提升成系统级指令的注入风险。 +- **结构化压缩(prompt 过长时)**——当模型返回"prompt 过长"时,运行时走一条四级递进的结构化压缩链:**软裁剪 → 硬清理 → 预修剪 → LLM 结构化摘要**。无论走到哪一级,都**永远保留前缀**——system prompt + 目标锚点不动;最终摘要以 UserMessage 形式注入。委派工具的返回结果**永远不会被压缩**(它们是子员工的成果,丢了就找不回来)。某次摘要失败后有 **10 分钟冷却**,避免在同一个超限回合里反复硬调 LLM。 +- **思考恢复**——流式中途断了,已经写出的思考和内容会持久化,会话重载时还在。 +- **迭代上限处理**——到达 `max_iterations` 不会崩溃,而是强制让 LLM 用现有信息生成一个尽力而为的总结答案。 +- **僵尸流清理**——后台跟踪每一个打开的 SSE 流,被遗弃的会被自动回收。 +- **429 重试**——LLM 限流错误会触发带退避的自动重试。 +- **重复检测**——抓住那些反复在同一个工具调用上打转的 Agent,强行把它拉出循环。 +- **工具超时可配置**——一个慢工具不会冻结整个回合。 +- **渠道健康监控**——失败的渠道适配器走指数退避重启。 + +这些没有一个是用户面的按钮。它们就自己在发生。 + +--- + +## Agent 管理 API + +### 创建 + +```bash +curl -X POST http://localhost:18088/api/v1/agents \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "技术助手", + "description": "专业的技术文档助手", + "agentType": "react", + "systemPrompt": "你是一个专业的技术文档助手...", + "maxIterations": 10 + }' +``` + +### 列表 / 获取 / 更新 / 删除 + +```bash +curl http://localhost:18088/api/v1/agents -H "Authorization: Bearer YOUR_JWT_TOKEN" +curl http://localhost:18088/api/v1/agents/1 -H "Authorization: Bearer YOUR_JWT_TOKEN" + +curl -X PUT http://localhost:18088/api/v1/agents/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"name":"技术助手 v2","maxIterations":15}' + +curl -X DELETE http://localhost:18088/api/v1/agents/1 \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### 流式对话 + +```bash +curl -N "http://localhost:18088/api/v1/agents/1/chat/stream?message=你好&conversationId=default" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +--- + +## 调试 + +在 `application.yml` 里打开 DEBUG 日志: + +```yaml +logging: + level: + vip.mate.agent: DEBUG + vip.mate.agent.graph: DEBUG +``` + +你会看到节点一节点的执行过程:状态切换、dispatcher 路由决策、迭代数、工具调用参数和结果摘要、Tool Guard 检查结果。 + +### 常见问题 + +| 症状 | 可能原因 | +|------|----------| +| Agent 不响应或超时 | 模型配置错、API Key 无效、额度用光 | +| Agent 卡在循环里出不来 | `max_iterations` 太低,或者某个工具反复报错 | +| `MAX_ITERATIONS_REACHED` 经常触发 | 调 system prompt 或者把上限调高 | +| 工具调用悄悄失败 | Tool Guard 在拦——看 `mate_tool_guard_audit_log` | +| 等审批的图恢复不了 | `chatWithReplay` 里 `toolCallPayload` 格式对不上 | + +--- + +## 下一步 + +- [工具系统](./tools)——Agent 能调用什么 +- [技能系统](./skills)——怎么扩展 Agent 能做的事 +- [LLM Wiki](./wiki)——知识怎么被 Agent 读到 +- [记忆系统](./memory)——Agent 怎么跨会话记住东西 +- [工作流](./workflow)(1.3.0+)——把多个数字员工 + 系统动作编排成一条业务流程 +- [触发器](./triggers)(1.3.0+)——让事件自动启动工作流或员工对话 +- [架构说明](./architecture)——StateGraph 运行时深入 diff --git a/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md b/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md new file mode 100644 index 00000000..ec7cf952 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md @@ -0,0 +1,171 @@ +--- +title: 主动型 AI — AI 找你,不是你找 AI +description: 定时任务 + 多渠道交付 = 主动型 AI。每天早上九点把简报推送到飞书,竞品有大动作直接 ping 你的钉钉,AI 在该出现的时候自己出现,不需要你想起它。 +head: + - - meta + - name: keywords + content: 主动型AI,Ambient AI,定时任务,Cron,多渠道交付,飞书简报,钉钉推送,Slack机器人,环境式AI,主动汇报 +--- + +# 主动型 AI + +**AI 找你,不是你找 AI。** + +ChatGPT、Claude、Gemini——每个 AI 助手都在等你打开它。打开浏览器、登录、点开输入框、敲字、等回答。AI 是一个你必须主动走过去的东西。 + +MateClaw 不是。 + +你可以让任何一个 Agent 在任何一个时间,去任何一个聊天软件里,**主动找你**。 + +``` +每天早上 9:00:日报 Agent → 飞书研发群 +每周一 10:00:销售数据 Agent → Slack 频道 +凌晨 4:00 失败的任务 → 执行助理 Agent → DingTalk 私聊 +``` + +这是从「对话型 AI」到 **主动型 AI** 的升维。我们叫它 **Ambient AI**——环境式 AI。它不在某个浏览器标签页里等你,**它在你的工作流里**。 + +--- + +## 它在做什么 + +三件事串在一起: + +1. **Cron Job**——一个能跑 Agent 的调度器(`mate_cron_job`、`mate_cron_job_run` 两张表) +2. **Agent 跑一遍**——触发时间到了,调度器拉起 Agent 上下文,跑一整套工具链(搜索、抓取、读 Wiki、查数据库⋯) +3. **结果通过渠道交付**——Agent 的输出走 `CronJobCompletedEvent` → `CronDeliveryListener` → `ChannelCronResultDelivery`,落到你预设的渠道 + +整个过程**没有人值守**。你设置完,AI 就开始按时上班。 + +--- + +## 三个典型用法 + +### 一、每日简报 + +> 每天早上 9 点,竞品监控 Agent 跑一遍:搜索昨天发了什么 release、读 5 个目标公司的官博、对比 24 小时内的差异,把最重要的三件事写成 Markdown,推送到飞书研发群。 + +`Cron 表达式 0 0 9 * * ?` · `Agent: 竞品监控` · `渠道: 飞书 - 研发群` + +你坐进地铁,手机弹出消息,三件事看完。到工位之前你已经知道今天要追什么。 + +### 二、周报汇总 + +> 每周一上午 10 点,销售助理 Agent 查上周的订单数据库、抽出关键指标、写一份带表格的周报,推送到 Slack 频道。 + +数据来自 [数据源工具](./config),Agent 自动写 SQL 并解释结果。Wiki 里如果有过往周报,会被自动引用做对比。 + +### 三、事件触发 + +> 邮件来了带「老板」标签 → 执行助理 Agent → 总结邮件主旨 + 草拟回复 → 推送到微信。 + +事件触发用 cron 高频轮询 + 触发条件,或者用 [MCP 工具](./mcp) 接外部 webhook。Agent 跑完通过同一条交付链路落到渠道。 + +--- + +## 怎么配 + +`控制台 → 定时任务 → 新建` 三步: + +1. **Cron 表达式**——什么时候跑(标准 6 段 cron,UI 里有图形化编辑器) +2. **Agent**——选一个已经配好工具和系统指令的 Agent +3. **结果交付**——选一个 [渠道](./channels) 作为输出(飞书 / 钉钉 / Slack / 企业微信 / Telegram / 任意已配置的渠道) + +保存。下次触发时间一到,Agent 就开始上班。 + +::: tip 别配 100 个 cron +和模型管理一样——你不需要 100 个定时任务,你需要**一个真的有用的**。先做"早上 9 点的简报",跑两周,看一下哪些信息你真的会读,哪些是噪音,再加第二个。 +::: + +--- + +## 为什么这件事重要 + +整个 2025–2026 年,AI 圈在追同一个目标——**不需要你打开屏幕**。 + +- **Vision Pro** 想让 AI 在你视野里出现,没做成 +- **Humane AI Pin** 想让 AI 在你身上出现,崩了 +- **Echo / Alexa** 当年想让 AI 在你家里出现,停留在天气预报 + +它们都试图用一个新硬件去解决这件事。 + +MateClaw 的答案是:**你团队已经在用的所有聊天软件,就是那个"硬件"**。 + +飞书、钉钉、企业微信、Slack、Telegram、Discord、QQ——你早就开着。AI 出现在你已经看的地方,就足够了。**不需要新设备,不需要新习惯。** + +--- + +## 它和别的 AI 产品有什么不一样 + +| | 对话型 AI | 主动型 AI(MateClaw) | +|---|---|---| +| 触发方式 | 你打开它 | 它在该出现的时间出现 | +| 在哪 | 一个浏览器 tab | 你已经在用的 IM | +| 什么时候说话 | 你问它才说 | 你需要时它就说 | +| 离线时 | 错过 | 等你上线就推 | +| 失败时 | 红色 error | 自动重连,下一次正常推 | + +最右那一列只有 MateClaw 完整实现了——因为只有 MateClaw 同时有: + +- **多 Agent 引擎**(ReAct + Plan-Execute) +- **Cron 调度 + 失败重试** +- **9 个 IM 渠道适配器**(每个都有指数退避重连) +- **持久化记忆**([Memory](./memory),Dreaming 之后越用越懂你) +- **Wiki 知识层**([LLM Wiki](./wiki),让调研有依据) +- **Tool Guard**([Security](./security),敏感操作问你一句再执行) + +Cron 是最后一块拼图——把上面这堆能力**翻译成时间触发**。 + +--- + +## 安全考虑 + +主动 = 需要更严格的权限控制。一个 cron 触发的 Agent 跑得没人盯着,它能调什么工具就直接调,没有"我再确认一下"的机会。 + +所以: + +- **Cron Agent 不会绕过 Tool Guard**——需要审批的工具调用照样卡在那里,等你在 IM 里点一下批准,Agent 才继续。详见 [审批工作流](./security#审批工作流-人在回路) +- 不想被打断的,把敏感工具配成 `deny` 而不是 `require_approval`——让它在权限之外的地方直接停下,不发审批通知 +- **每次 cron 执行都进审计日志**(`mate_audit_event`)——哪个任务在什么时候、用什么工具做了什么,全有记录 + +--- + +## 底层数据(如果你好奇) + +| 表 | 用途 | +|---|---| +| `mate_cron_job` | 每个定时任务一行——Agent ID、cron 表达式、目标渠道、超时、启用开关 | +| `mate_cron_job_run` | 每次执行一行——开始/结束时间、状态、输出摘要、错误信息(如有) | + +代码组织: + +- `vip.mate.cron.service.CronJobLifecycleService` —— 任务生命周期管理 +- `vip.mate.cron.service.CronJobRunner` —— 单次执行 +- `vip.mate.cron.delivery.ChannelCronResultDelivery` —— 把 Agent 输出落到渠道 +- `vip.mate.cron.delivery.CronDeliveryListener` —— 监听 `CronJobCompletedEvent` +- `vip.mate.cron.CronChatOriginFactory` —— 构造 cron 来源标记,会话能查到这条对话来自哪个定时任务 + +### API + +```bash +# 列出所有定时任务 +curl http://localhost:18088/api/v1/cron-jobs \ + -H "Authorization: Bearer " + +# 立刻试跑一次(不影响下次定时触发) +curl -X POST http://localhost:18088/api/v1/cron-jobs/{id}/run-now \ + -H "Authorization: Bearer " + +# 看历史执行 +curl http://localhost:18088/api/v1/cron-jobs/{id}/runs \ + -H "Authorization: Bearer " +``` + +--- + +## 下一步 + +- [多渠道接入](./channels) —— 把 AI 输出送到哪一个 IM +- [Agent 引擎](./agents) —— cron 调度的就是你配好的 Agent +- [安全与审批](./security) —— 敏感操作不让 cron 自动跑 +- [LLM Wiki](./wiki) —— 给 cron Agent 一座知识库做调研 diff --git a/mateclaw-server/src/main/resources/docs/zh/api.md b/mateclaw-server/src/main/resources/docs/zh/api.md new file mode 100644 index 00000000..f67c6c76 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/api.md @@ -0,0 +1,549 @@ +# API 参考 + +所有 REST 端点前缀 `/api/v1/`。所有响应遵循同一个信封格式: + +```json +{ + "code": 200, + "message": "success", + "data": { } +} +``` + +除了 `/api/v1/auth/login`,所有端点都需要 `Authorization` header 里带 JWT: + +``` +Authorization: Bearer +``` + +深入的行为细节去读对应的功能页——[聊天与消息](./chat)、[Agent 引擎](./agents)、[工具系统](./tools)、[安全与审批](./security)、[LLM Wiki](./wiki)、[多模态创作](./multimodal)、[记忆系统](./memory)、[多渠道接入](./channels)、[模型配置](./models)、[工作空间](./workspaces)、[目标](./goals)、[Doctor](./doctor)。 + +--- + +## 认证 + +``` +POST /api/v1/auth/login # 登录,获取 JWT +GET /api/v1/users/me # 获取当前用户 +PUT /api/v1/users/me # 更新个人资料 +PUT /api/v1/users/me/password # 修改密码 +``` + +**登录示例:** + +```bash +curl -X POST http://localhost:18088/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' +``` + +响应: + +```json +{ + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 86400 + } +} +``` + +--- + +## 聊天 + +``` +POST /api/v1/chat/{agentId}/message # 发送消息 +GET /api/v1/chat/{agentId}/stream?conversationId= # SSE 流式 +POST /api/v1/chat/{conversationId}/stop # 停止进行中的流 +GET /api/v1/chat/{conversationId}/pending-approvals # 列出等待的审批 +``` + +**发送消息:** + +```bash +curl -X POST http://localhost:18088/api/v1/chat/1/message \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"content":"你好,你能做什么?", "conversationId":"conv-abc123"}' +``` + +**SSE 流式示例:** + +```bash +curl -N http://localhost:18088/api/v1/chat/1/stream?conversationId=conv-abc123 \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +事件类型和 schema 在 [聊天与消息](./chat) 里。 + +### 会话 + +``` +GET /api/v1/conversations # 列表(?page&size&agentId) +GET /api/v1/conversations/page?page=&size=&keyword= # 分页会话(带关键词搜索) +GET /api/v1/conversations/{id}/messages # 取消息 +PUT /api/v1/conversations/{id}/model # 设置该会话使用的模型 +DELETE /api/v1/conversations/{id} # 删除 +DELETE /api/v1/conversations/{id}/messages # 清空消息 +GET /api/v1/conversations/{id}/status # 会话状态 +``` + +--- + +## Agent + +``` +GET /api/v1/agents # 列表(分页) +GET /api/v1/agents/{id} # 获取 +POST /api/v1/agents # 创建 +PUT /api/v1/agents/{id} # 更新(部分) +DELETE /api/v1/agents/{id} # 软删除 + +GET /api/v1/agents/{id}/chat/stream?message=...&conversationId=... # 流式对话 + +GET /api/v1/agents/{id}/workspace/files # 列文件 +GET /api/v1/agents/{id}/workspace/files/{filename} # 取内容 +PUT /api/v1/agents/{id}/workspace/files/{filename} # 写入 +DELETE /api/v1/agents/{id}/workspace/files/{filename} # 删除 +GET /api/v1/agents/{id}/workspace/prompt-files # 哪些文件被注入 +PUT /api/v1/agents/{id}/workspace/prompt-files # 设置注入的文件列表 + +GET /api/v1/agents/{agentId}/workspace/memory/export # 导出记忆快照 +POST /api/v1/agents/{agentId}/workspace/memory/import/preview # 预览导入(不落库) +POST /api/v1/agents/{agentId}/workspace/memory/import # 导入记忆快照 + +GET /api/v1/agents/templates # 列出模板 +POST /api/v1/agents/templates/{id} # 从模板创建 +``` + +--- + +## 工具 + +``` +GET /api/v1/tools # 列表 +PUT /api/v1/tools/{id} # 更新 +PUT /api/v1/tools/{id}/toggle?enabled={bool} # 开关 +PUT /api/v1/tools/{id}/disclosure-tier # 设置披露层级(core / extension) +POST /api/v1/tools/{name}/test # 直接测试 +``` + +--- + +## 技能 + +``` +GET /api/v1/skills # 列表(?type=builtin|custom|mcp&tag=...) +GET /api/v1/skills/{id} # 获取 +POST /api/v1/skills # 创建 +PUT /api/v1/skills/{id} # 更新 +DELETE /api/v1/skills/{id} # 删除 +PUT /api/v1/skills/{id}/toggle?enabled={bool} # 开关 +GET /api/v1/skills/runtime/active # 当前活跃的技能 +GET /api/v1/skills/runtime/status # 运行时状态 +POST /api/v1/skills/runtime/refresh # 重载运行时 +``` + +--- + +## MCP 服务 + +``` +GET /api/v1/mcp/servers # 列表 +GET /api/v1/mcp/servers/{id} # 获取 +POST /api/v1/mcp/servers # 创建 +PUT /api/v1/mcp/servers/{id} # 更新(PATCH 语义) +DELETE /api/v1/mcp/servers/{id} # 删除 +PUT /api/v1/mcp/servers/{id}/toggle?enabled={bool} # 开关 +POST /api/v1/mcp/servers/{id}/test # 测试连接 +POST /api/v1/mcp/servers/refresh # 刷新所有 +``` + +请求体 schema 见 [MCP 协议](./mcp)。 + +--- + +## LLM Wiki + +``` +GET /api/v1/wiki/kbs # 列知识库 +POST /api/v1/wiki/kbs # 创建 KB +GET /api/v1/wiki/kbs/{id} # 获取 KB 详情 +PUT /api/v1/wiki/kbs/{id} # 更新 KB +DELETE /api/v1/wiki/kbs/{id} # 删除 KB + +POST /api/v1/wiki/kbs/{kbId}/raw # 上传原始材料 +GET /api/v1/wiki/kbs/{kbId}/raw # 列原始材料 +DELETE /api/v1/wiki/raw/{id} # 删除原始材料 +POST /api/v1/wiki/raw/{id}/reprocess # 重新消化 + +GET /api/v1/wiki/kbs/{kbId}/pages # 列页面 +GET /api/v1/wiki/pages/{id} # 获取页面 +PUT /api/v1/wiki/pages/{id} # 编辑页面 +DELETE /api/v1/wiki/pages/{id} # 删除页面 +POST /api/v1/wiki/pages/{id}/lock # 锁定页面 +POST /api/v1/wiki/pages/{id}/unlock # 解锁 + +GET /api/v1/wiki/kbs/{kbId}/search?q=... # 全文搜索 +GET /api/v1/wiki/pages/{id}/backlinks # 反向链接 +``` + +Agent 可调的 wiki 工具(`wiki_search`、`wiki_read`、`wiki_backlinks`)自动解析 `kbId`。 + +--- + +## 多模态 + +``` +POST /api/v1/image/generate # 生成图像 +POST /api/v1/image/edit # 编辑图像 +POST /api/v1/video/generate # 生成视频 +POST /api/v1/video/from-image # 图生视频 +POST /api/v1/music/generate # 生成音乐 +POST /api/v1/tts/synthesize # 文本转语音 +POST /api/v1/stt/transcribe # 语音转文本 + +GET /api/v1/image/jobs/{id} # 查异步图像任务状态 +GET /api/v1/video/jobs/{id} # 查异步视频任务状态 +``` + +见 [多模态创作](./multimodal)。 + +--- + +## 记忆 + +``` +POST /api/v1/memory/{agentId}/emergence # 手动触发整合 +POST /api/v1/memory/{agentId}/summarize/{conversationId} # 手动触发提取 +GET /api/v1/memory/{agentId}/dreaming/status # 上次/下次运行 + 最新 DREAMS.md 条目 +``` + +--- + +## 安全与审批 + +### Tool Guard 规则 + +``` +GET /api/v1/security/guard/config # 全局配置 +PUT /api/v1/security/guard/config # 更新全局配置 +GET /api/v1/security/guard/rules # 列自定义规则 +GET /api/v1/security/guard/rules/builtin # 列内置规则 +POST /api/v1/security/guard/rules # 创建规则 +PUT /api/v1/security/guard/rules/{id} # 更新规则 +DELETE /api/v1/security/guard/rules/{id} # 删除规则 +PUT /api/v1/security/guard/rules/{id}/toggle?enabled={bool} # 开关规则 +``` + +### File Guard + +``` +GET /api/v1/security/guard/config/file-guard # 获取配置 +PUT /api/v1/security/guard/config/file-guard # 更新配置 +``` + +### 审批 + +``` +GET /api/v1/approvals?status=pending # 列 pending 审批 +POST /api/v1/approvals/{id}/resolve # 批准或拒绝 +``` + +请求体: + +```json +{ "decision": "approved" } +``` + +或 + +```json +{ "decision": "rejected", "notes": "原因" } +``` + +### 审计日志 + +``` +GET /api/v1/security/audit/logs # 查询(?toolName, ?decision, ?from, ?to) +GET /api/v1/security/audit/stats # 统计 +GET /api/v1/audit/events # 完整审计事件查询 +``` + +--- + +## 模型 + +``` +GET /api/v1/models # 列出模型 +GET /api/v1/models/enabled # 仅列已启用 +GET /api/v1/models/default # 默认模型 +GET /api/v1/models/active # 活跃模型 +PUT /api/v1/models/active # 设置活跃 +POST /api/v1/models # 创建模型配置 +PUT /api/v1/models/{id} # 更新 +DELETE /api/v1/models/{id} # 删除 +POST /api/v1/models/{id}/default # 设为默认 + +PUT /api/v1/models/{providerId}/config # 更新供应商配置 +POST /api/v1/models/custom-providers # 创建自定义供应商 +DELETE /api/v1/models/custom-providers/{providerId} # 删除自定义供应商 + +POST /api/v1/models/{providerId}/models # 往供应商加模型 +DELETE /api/v1/models/{providerId}/models/{modelId} # 移除模型 + +POST /api/v1/models/{providerId}/discover # 发现模型 +POST /api/v1/models/{providerId}/discover/apply # 应用已发现 +POST /api/v1/models/{providerId}/test-connection # 测试供应商连接 +POST /api/v1/models/{providerId}/models/{modelId}/test # 测试单个模型 +``` + +### 遗留端点 + +``` +GET /api/v1/model-providers # 遗留——优先用 /api/v1/models +POST /api/v1/model-providers +PUT /api/v1/model-providers/{id} +DELETE /api/v1/model-providers/{id} + +GET /api/v1/model-configs # 遗留——优先用 /api/v1/models +POST /api/v1/model-configs +PUT /api/v1/model-configs/{id} +DELETE /api/v1/model-configs/{id} +``` + +--- + +## 渠道 + +``` +GET /api/v1/channels # 列表 +POST /api/v1/channels # 创建 +PUT /api/v1/channels/{id} # 更新 +DELETE /api/v1/channels/{id} # 删除 +PUT /api/v1/channels/{id}/toggle?enabled={bool} # 开关 +GET /api/v1/channels/status # 每个渠道的连接状态 +GET /api/v1/channels/health # 聚合健康视图 + +GET /api/v1/channels/webhook/weixin/qrcode # 微信 iLink 二维码 +GET /api/v1/channels/webhook/weixin/qrcode/status # 扫码状态 + +POST /api/v1/channels/qrcode/qq/begin # 发起 QQ 扫码绑定 +GET /api/v1/channels/qrcode/qq/status # QQ 扫码绑定状态 +``` + +### 渠道 webhook 回调 + +| 渠道 | 回调 URL | +|------|----------| +| 钉钉 | `POST /api/v1/channels/webhook/dingtalk` | +| 飞书 | `POST /api/v1/channels/webhook/feishu` | +| 企业微信 | `POST /api/v1/channels/webhook/wecom` | +| Telegram | `POST /api/v1/channels/webhook/telegram` | +| Discord | *(Gateway——无 webhook)* | +| QQ | `POST /api/v1/channels/webhook/qq` | +| Slack | `POST /api/v1/channels/webhook/slack` | +| 微信 | `POST /api/v1/channels/webhook/weixin` | + +--- + +## 定时任务 + +``` +GET /api/v1/cron-jobs # 列表 +POST /api/v1/cron-jobs # 创建 +PUT /api/v1/cron-jobs/{id} # 更新 +DELETE /api/v1/cron-jobs/{id} # 删除 +PUT /api/v1/cron-jobs/{id}/toggle?enabled={bool} # 开关 +POST /api/v1/cron-jobs/{id}/run # 立即执行 +``` + +--- + +## 工作流(1.3.0+) + +完整字段、step mode、Pebble 语法见 [工作流](./workflow)。 + +``` +GET /api/v1/workflows # 列表 +GET /api/v1/workflows/{id} # 获取(含已发布 revision + 草稿) +POST /api/v1/workflows # 新建 +PUT /api/v1/workflows/{id}/draft # 保存草稿(graph_json) +POST /api/v1/workflows/{id}/publish # 发布草稿为新 revision +DELETE /api/v1/workflows/{id} # 删除 + +POST /api/v1/workflows/draft/generate # 自然语言生成 graph_json 草稿 +POST /api/v1/workflows/{id}/preview-compile # 静态检查 + Pebble 校验,不发布 + +POST /api/v1/workflows/{id}/runs # 起一个 run(异步) +GET /api/v1/workflows/{id}/runs # run 列表 +GET /api/v1/workflows/runs/{runId} # run 详情 + 每步 input/output/token/duration +POST /api/v1/workflows/runs/{runId}/resume # await_approval 后恢复 +POST /api/v1/workflows/runs/{runId}/cancel # 取消运行中 +``` + +--- + +## 触发器(1.3.0+) + +6 种 pattern type、事件治理、跨实例一致性见 [触发器](./triggers)。 + +``` +GET /api/v1/triggers # 列表 +GET /api/v1/triggers/{id} # 获取 +POST /api/v1/triggers # 新建 +PUT /api/v1/triggers/{id} # 更新 +DELETE /api/v1/triggers/{id} # 删除 +PUT /api/v1/triggers/{id}/toggle?enabled={bool} # 开关 + +POST /api/v1/triggers/events # 通用事件入口(webhook / 桥接外部系统) + # 立即 ACK 200,异步派发 +GET /api/v1/triggers/{id}/events # 该 trigger 的事件历史 +``` + +--- + +## 目标(1.4.0+) + +目标完成评分、自动跟进的行为细节见 [目标](./goals)。 + +``` +POST /api/v1/goals # 新建目标 +GET /api/v1/goals/{id} # 获取目标 +PATCH /api/v1/goals/{id} # 更新目标(部分) +GET /api/v1/goals/{id}/events # 该目标的评估事件历史 +``` + +--- + +## Token 用量 + +``` +GET /api/v1/token-usage?startDate=&endDate=&modelName=&providerId= +``` + +--- + +## 系统设置 + +``` +GET /api/v1/settings # 所有设置 +PUT /api/v1/settings # 更新多个 +GET /api/v1/settings/language # 当前语言 +PUT /api/v1/settings/language # 更新语言 +PUT /api/v1/settings/{key} # 更新单个 key +``` + +--- + +## 仪表盘 + +``` +GET /api/v1/dashboard/summary # 用量汇总卡片 +GET /api/v1/dashboard/trends # 趋势图(?range=7d|30d|90d) +GET /api/v1/dashboard/top-agents # 最常用 Agent +GET /api/v1/dashboard/top-tools # 最常用工具 +``` + +--- + +## 工作空间 + +``` +GET /api/v1/workspaces # 列表 +GET /api/v1/workspaces/{id} # 获取 +POST /api/v1/workspaces # 创建 +PUT /api/v1/workspaces/{id} # 更新 +DELETE /api/v1/workspaces/{id} # 删除(仅 owner) +GET /api/v1/workspaces/{id}/access # 当前用户访问信息(见下) +``` + +### 成员与 RBAC(1.4.0+) + +`/access` 返回调用者在该工作空间内的有效权限,前端据此渲染路由和菜单: + +```json +{ + "memberRole": "editor", + "isGlobalAdmin": false, + "effectiveRole": "editor", + "capabilities": ["workspace.read", "conversation.write", "..."] +} +``` + +``` +GET /api/v1/workspaces/{id}/members # 列成员 +POST /api/v1/workspaces/{id}/members # 添加成员 +PUT /api/v1/workspaces/{id}/members/{memberId} # 更新成员(角色等) +DELETE /api/v1/workspaces/{id}/members/{memberId} # 移除成员 +``` + +--- + +## Doctor(健康检查) + +``` +GET /api/v1/doctor/run # 运行所有检查 +GET /api/v1/doctor/checks # 缓存的检查结果 +``` + +--- + +## 错误响应 + +```json +{ + "code": 400, + "message": "Validation failed: name is required" +} +``` + +### 常见状态码 + +| 状态码 | 含义 | +|--------|------| +| 200 | 成功 | +| 400 | 错误请求 | +| 401 | 未授权 | +| 403 | 禁止 | +| 404 | 未找到 | +| 500 | 服务端错误 | + +--- + +## 分页 + +列表端点按一致的 shape 返回分页结果: + +```json +{ + "code": 200, + "data": { + "records": [ ], + "total": 42, + "current": 1, + "size": 20, + "pages": 3 + } +} +``` + +| 字段 | 用途 | +|------|------| +| `records` | 当前页的条目数组 | +| `total` | 总条数 | +| `current` | 当前页(从 1 开始) | +| `size` | 每页条数 | +| `pages` | 总页数 | + +--- + +## 下一步 + +- [快速开始](./quickstart)——让服务器跑起来 +- [安全与审批](./security)——JWT + 审批流程 +- [聊天与消息](./chat)——SSE 事件格式 +- [LLM Wiki](./wiki)——Wiki 端点行为 diff --git a/mateclaw-server/src/main/resources/docs/zh/architecture.md b/mateclaw-server/src/main/resources/docs/zh/architecture.md new file mode 100644 index 00000000..75bfc597 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/architecture.md @@ -0,0 +1,379 @@ +# 架构说明 + +**MateClaw 是怎么拼起来的,一页讲完。** + +**用** MateClaw 的人看 [项目介绍](./intro)。**在 MateClaw 上面建东西**的人——加工具、新渠道、自定义记忆 provider、新的 Agent 图节点——看这一页。 + +--- + +## 一张图的产品 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MateClaw │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ Web 控制台 │ │ 桌面端 │ │ IM 渠道 │ │ +│ │ Vue 3 SPA │ │ Electron │ │ 钉钉 / 飞书 / │ │ +│ │ (src/static) │ │ + 内置 │ │ 企业微信 / Telegram/ │ │ +│ │ │ │ JRE 21 │ │ Discord / QQ / ... │ │ +│ └──────┬──────┘ └──────┬───────┘ └──────────┬──────────┘ │ +│ │ HTTP/SSE │ HTTP/SSE │ SPI │ +│ └────────┬────────┴───────────────────────┘ │ +│ │ │ +│ ┌───────────────▼──────────────────────────────────────────┐ │ +│ │ Spring Boot 后端(vip.mate.*) │ │ +│ │ │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ │ +│ │ │ auth │ │ channel │ │ agent │ │ │ +│ │ │ (JWT) │ │ adapters │ │ (StateGraph │ │ │ +│ │ └────────────┘ └─────┬──────┘ │ runtime) │ │ │ +│ │ │ │ │ │ │ +│ │ ┌────────────┐ │ │ ┌──────────────┐ │ │ │ +│ │ │ workspace │ │ │ │ ReasoningN │ │ │ │ +│ │ │ isolation │ │ │ │ ActionN │ │ │ │ +│ │ └────────────┘ │ │ │ Observation │ │ │ │ +│ │ │ │ │ PlanGenN │ │ │ │ +│ │ ▼ │ │ StepExecN │ │ │ │ +│ │ ┌──────────────┐ │ │ FinalAnsN │ │ │ │ +│ │ │ Message │ │ └──────────────┘ │ │ │ +│ │ │ Router ├──▶ │ │ │ │ +│ │ └──────────────┘ └──────┼─────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────────────────────────────────────▼────────────────┐ │ │ +│ │ │ Tool Registry │ │ │ +│ │ │ │ │ │ +│ │ │ 内置 @Tool + MCP client + 技能脚本 │ │ │ +│ │ └─────────┬───────────────────────────────────────────┬──┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌──────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │ +│ │ │ Tool Guard │ │ 审批 │ │ 审计日志 │ │ │ +│ │ │ (规则) │ │ 工作流 │ │ 管道 │ │ │ +│ │ └──────────────┘ └─────────────┘ └───────────────────┘ │ │ +│ │ │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ │ +│ │ │ memory │ │ wiki │ │ skill │ │ │ +│ │ │ 多层 │ │ (分层) │ │ (SKILL.md 运行时) │ │ │ +│ │ │ SPI │ │ digester │ │ │ │ │ +│ │ └────────────┘ └────────────┘ └────────────────────┘ │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────────────────────┐ │ │ +│ │ │ MyBatis Plus / H2 或 MySQL │ │ │ +│ │ │ │ │ │ +│ │ │ mate_agent / mate_message / mate_wiki_* / │ │ │ +│ │ │ mate_tool_guard_* / mate_workspace / ... │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**一个 JAR。一个进程。The whole widget。** + +--- + +## 仓库布局 + +``` +mateclaw/ +├── mateclaw-server/ # Spring Boot 后端(心脏) +│ └── src/main/java/vip/mate/ +│ ├── MateClawApplication.java +│ ├── agent/ # StateGraph 运行时、节点、边、状态 +│ ├── planning/ # Plan 和 SubPlan 持久化 +│ ├── workflow/ # 工作流引擎(1.3.0+):DSL 编译、线性 runtime、payload spill +│ ├── trigger/ # 触发器引擎(1.3.0+):6 种 pattern、事件治理、CronDelegationPort +│ ├── tool/ # ToolRegistry、@Tool bean、MCP(per-agent 绑定)、guard +│ ├── approval/ # 审批工作流(兼任 workflow 的 await_approval 暂停/恢复桥接) +│ ├── skill/ # 动态技能包 +│ ├── wiki/ # LLM Wiki + Transformations 引擎(1.3.0+) +│ ├── memory/ # 记忆层 + SPI +│ ├── workspace/ # 工作空间实体 + 会话 + 文档 +│ ├── channel/ # 渠道 SPI + 适配器(1.3.0+: WeCom v2、reply queue、leader lease) +│ ├── llm/ # LLM provider 配置(1.3.0+: 多模态 sidecar 路由) +│ ├── auth/ # Spring Security + JWT +│ ├── audit/ # 审计事件管道 +│ ├── cron/ # 定时任务引擎(trigger.cron 通过 CronDelegationPort 复用) +│ ├── task/ # 异步任务运行时 +│ ├── dashboard/ # 指标聚合 +│ ├── datasource/ # 外部 DB 连接 +│ ├── stt/ # 语音转文字 +│ ├── tts/ # 文字转语音 +│ ├── system/ # 系统设置、bootstrap、引导 +│ ├── config/ # Spring 配置 +│ ├── common/ # 共享工具 +│ └── exception/ # 全局异常处理器 +│ └── src/main/resources/ +│ ├── application.yml +│ ├── db/migration/ # Flyway 迁移脚本(h2/ + mysql/) +│ ├── db/data.sql # 种子数据 +│ ├── prompts/ # LLM prompt 模板 +│ ├── skills/ # 捆绑的技能包 +│ └── static/ # 前端构建产物 +├── mateclaw-ui/ # Vue 3 管理控制台 +├── mateclaw-desktop/ # Electron 桌面壳 +├── mateclaw-webchat/ # 可嵌入的聊天小部件 +├── matevip-sites/ # 营销和文档站点(pnpm workspace) +├── docs/ # 这份文档(VitePress) +├── deploy/ # 生产部署配置 +├── docker-compose.yml +└── .env.example +``` + +后端是**一个模块化的单体**。其他项目是不和后端共享代码的独立包。 + +--- + +## Agent 运行时是一张 StateGraph + +**这是你给后端贡献代码时最重要的事。** + +**MateClaw 的 Agent 运行时不是一个类层次。** 没有 `BaseAgent` → `ReActAgent` → `MyCustomAgent` 的继承链。运行时是一张**由节点和条件边组成的 StateGraph**(来自 `spring-ai-alibaba-graph`),在运行时由 `AgentGraphBuilder` 装配。 + +### 关键文件 + +- `agent/graph/StateGraphReActAgent.java`——装配 ReAct 循环 +- `agent/graph/plan/StateGraphPlanExecuteAgent.java`——装配 Plan-and-Execute 图 +- `agent/graph/node/`——`ReasoningNode`、`ActionNode`、`ObservationNode`、`FinalAnswerNode`、`SummarizingNode`、`LimitExceededNode`、`GoalEvaluationNode` +- `agent/graph/plan/node/`——`PlanGenerationNode`、`StepExecutionNode`、`PlanSummaryNode`、`DirectAnswerNode` +- `agent/graph/edge/` + `plan/edge/`——基于状态决定下一个节点的 dispatcher 函数 +- `agent/graph/state/MateClawStateKeys.java`——共享 state 对象的 key +- `agent/graph/state/MateClawStateAccessor.java`——state map 的类型化访问器(**别直接动 map**) +- `agent/graph/lifecycle/ReActLifecycleListener.java`——节点级插桩 hook +- `agent/AgentGraphBuilder.java`——按 Agent 配置拼装节点和边的 builder +- `agent/GraphEventPublisher.java` + `agent/graph/NodeStreamingChatHelper.java`——流式事件怎么从图里逃到 SSE 流里 + +### 怎么扩展 + +**加 Agent 行为**——在 `agent/graph/node/` 创建新节点,或在 `agent/graph/edge/` 创建新边 dispatcher。把它接进 `AgentGraphBuilder`。通过 `MateClawStateAccessor` 读写 state。 + +**不要**创建新的 `XxxAgent` 类。你会把图已经在做的事情重新实现一遍。 + +### 目标评估节点(1.4.0+) + +图(ReAct 和 Plan-Execute 都有)现在在 `FinalAnswerNode` 把最终答案流式输出之后再跑一个 `GoalEvaluationNode`:它给目标完成度打分,并可选地注入一条自动跟进消息,把没达成的目标继续推进。 + +### 其他 1.4.0 运行时变化 + +- **渐进式工具/技能披露**——工具披露层把工具分成核心层(core)和扩展层(extension)两档;`enable_tool` / `load_skill` 让员工按需激活扩展工具、按需加载技能,从而把系统提示保持得足够小。 +- **多级子员工委派树**——父员工到子员工的委派是递归的、有深度上限的,构成一棵树;子图的事件实时回流到根会话。 +- **ChannelToolProvider SPI**——渠道(比如飞书)可以把平台能力直接作为员工工具暴露出来,不需要单独的 MCP 服务器。 +- **工作空间 RBAC**——能力(capability)由后端的「角色 → 能力」映射解析,同时门禁 REST 接口和前端路由/菜单。 + +### 共享 state key + +| Key | 用途 | +|-----|------| +| `USER_MESSAGE` | 当前用户输入 | +| `MESSAGES` | 从 `mate_message` 加载的会话消息 | +| `OBSERVATION_HISTORY` | 本回合的工具调用结果 | +| `CURRENT_ITERATION` | 已经循环过多少次 | +| `MAX_ITERATIONS` | 上限 | +| `TOOL_CALLS` | 当前工具调用列表 | +| `AWAITING_APPROVAL` | 有调用需要人工审批时置 true | +| `FINAL_ANSWER` | Agent 的响应 | +| `FINISH_REASON` | 图为什么结束 | + +--- + +## 数据流 —— 单次回合 + +``` +1. POST /api/v1/chat/{agentId}/message + ↓ +2. ChatController.sendMessage() + ↓ +3. ConversationManager.loadOrCreate(conversationId) + ↓ +4. AgentGraphBuilder.build(agentEntity) ← 解析出编译好的图 + ↓ +5. graph.invoke(initialState) ← StateGraph 执行开始 + ↓ + ReasoningNode → Dispatcher → ActionNode → ObservationNode →(循环或结束) + ↓ +6. 工具调用走: + ToolRegistry.resolve() → + Tool Guard 规则评估 → + (需要审批时)mate_tool_approval 行 + SSE 事件 + AWAITING_APPROVAL=true + (内联允许时)ToolExecutionExecutor.execute() → observation + ↓ +7. Segment 通过以下方式流到客户端: + GraphEventPublisher → NodeStreamingChatHelper → SSE 流 + ↓ +8. 结束时: + FinalAnswerNode 聚合结果 + ConversationManager 把 segment 持久化到 mate_message + ConversationCompletedEvent 发出(异步记忆提取启动) + ↓ +9. 响应关闭 +``` + +--- + +## 扩展点 + +这些是你可以在上面建东西的 SPI 和插件点: + +### `@Tool` 标注的 Spring bean + +写一个带 `@Tool` 方法的 `@Component`。启动时被 `ToolRegistry` 捡起来。每个 `@Tool` 方法都成为一个可调用工具。 + +```java +@Component +public class MyCustomTool { + @Tool(description = "LLM 看到的描述") + public String doThing(@ToolParam(description = "...") String input) { + return "result"; + } +} +``` + +### `ChannelAdapter` SPI + +实现 `vip.mate.channel.ChannelAdapter`(或支持流式的 `StreamingChannelAdapter`)。注册成 Spring bean。通过 `ChannelWebhookController` 加 webhook 端点。见 [多渠道接入](./channels)。 + +```java +public interface ChannelAdapter { + void onMessage(ChannelMessage message); + void sendMessage(String channelId, String content); + String getChannelType(); +} +``` + +### `MemoryProvider` SPI + +实现 `vip.mate.memory.spi.MemoryProvider` 来插入自定义记忆后端(向量、图、外部服务)。**多个 provider 可以在 Agent 上堆叠**。见 [记忆系统](./memory)。 + +### MCP 服务 + +通过 stdio、streamable_http、sse 连接外部工具服务。它们的工具自动出现在工具注册表里——Agent 代码**不知道它们是外部的**。见 [MCP 协议](./mcp)。 + +### 技能包 + +把指令 + 工具 + 可选脚本打包进一个 `SKILL.md`。通过 UI 或 API 上传。Agent 在运行时可以调用它们。见 [技能系统](./skills)。 + +### Agent 图节点和边 + +更深的定制——在 `agent/graph/node/` 加新节点或在 `agent/graph/edge/` 加新 dispatcher。在配置 flag 后面接进 `AgentGraphBuilder`。State 访问走 `MateClawStateAccessor`。 + +--- + +## 持久化 —— 一份 schema,两种数据库 + +MateClaw 用 **MyBatis Plus**(不是 JPA)做数据库访问。约定: + +- 所有表前缀 `mate_` +- `snake_case` 列、`camelCase` Java 字段、自动映射 +- 每张表有 `create_time`、`update_time`、`deleted`(逻辑删除) +- **Flyway** 管理 schema 迁移——`db/migration/h2/` 和 `db/migration/mysql/` 各有一套方言脚本,启动时自动选择 +- `FlywayRepairConfig` 在每次启动时先 `repair()` 再 `migrate()`,checksum 变更和部分失败的迁移自动修复 +- 种子数据由 `DatabaseBootstrapRunner` 从 `db/data-*.sql` 加载,幂等执行 + +### 表分组 + +**身份和配置**——`mate_user`、`mate_system_setting`、`mate_model_config`、`mate_model_provider`、`mate_datasource`、`mate_mcp_server` + +**Agent 和计划**——`mate_agent`、`mate_agent_skill`、`mate_agent_tool`、`mate_plan`、`mate_sub_plan` + +**会话**——`mate_conversation`、`mate_message`、`mate_channel`、`mate_channel_session` + +**工具和审批**——`mate_tool`、`mate_tool_approval`、`mate_tool_guard_rule`、`mate_tool_guard_config`、`mate_tool_guard_audit_log` + +**技能和工作空间**——`mate_skill`、`mate_workspace`、`mate_workspace_member`、`mate_workspace_file` + +**知识和记忆**——`mate_wiki_knowledge_base`、`mate_wiki_raw_material`、`mate_wiki_page`、`mate_wiki_transformation`、`mate_wiki_transformation_run`(1.3.0+)、`mate_memory_recall` + +**工作流和触发器(1.3.0+)**——`mate_workflow`、`mate_workflow_revision`、`mate_workflow_run`、`mate_workflow_step_run`、`mate_workflow_payload`、`mate_trigger`、`mate_trigger_event` + +**运维**——`mate_cron_job`、`mate_cron_job_run`、`mate_async_task`、`mate_usage_daily`、`mate_audit_event`、`mate_doctor_check` + +--- + +## 流式 —— 为什么用 SSE 不用 WebFlux + +MateClaw 用 **Spring MVC**,不是 Spring WebFlux。**WebFlux 在依赖图里被明确排除。** + +为什么:Spring MVC + SSE 足以把 LLM 响应流式到前端。它更容易推理、更容易调试、不强迫整个栈变成响应式。 + +::: tip 虚拟线程(JDK 21) +`spring.threads.virtual.enabled=true` 已开启。Tomcat 请求线程、`@Scheduled` 任务和 `@Async` 方法全部运行在虚拟线程上。SSE 长连接不再占用平台线程——并发连接数不再受线程池大小约束。 +::: + +流式流程: + +1. 客户端打开 `GET /api/v1/chat/{agentId}/stream`,带 `Accept: text/event-stream` +2. Controller 返回 `SseEmitter` +3. Agent 图在工作线程上运行;节点执行把事件发给 `GraphEventPublisher` +4. 事件序列化成 SSE 格式写进 emitter +5. `ChatStreamTracker` 监视被遗弃的流并清理它们 + +同样的 SSE 模式被支持流式的渠道适配器复用(钉钉 AI Card、Web)。 + +--- + +## 前端架构 + +**Vue 3 + TypeScript + Composition API + ` + + +``` + +--- + +## 测试 + +### 后端测试 + +```bash +cd mateclaw-server +mvn test # 全部测试 +mvn test -Dtest=StateGraphReActAgentTest # 单个类 +mvn test -Dtest=StateGraphReActAgentTest#testChat # 单个方法 +``` + +### 前端类型检查和 lint + +```bash +cd mateclaw-ui +pnpm build # vue-tsc 类型检查 + vite build +pnpm lint # ESLint 自动修复 +``` + +### 手动测试清单 + +- [ ] 后端启动无错 +- [ ] 前端编译无类型错误(`pnpm build`) +- [ ] 用默认凭证能登录 +- [ ] 模型在 UI 里配好了 +- [ ] 对话能流式返回 +- [ ] 新功能按 PR 描述工作 +- [ ] 浏览器控制台没有错误 +- [ ] 如果改了用户面行为,**文档也更新了** + +--- + +## 文档变更 + +PR 改了用户面行为——新功能、重命名的端点、改过的配置 key——**在同一个 PR 里更新文档**。 + +文档在 `docs/`。挑相关页面更新 `docs/en/` 和 `docs/zh/`。中英版本**独立写作**,不是翻译——和已有页面的语气和风格保持一致。 + +```bash +cd docs +pnpm build +``` + +**PR 开出来之前 build 必须零错误通过。** + +--- + +## Pull request 流程 + +1. **标题**——conventional commit 格式 +2. **描述**——做了什么、为什么、怎么做的;链接 issue +3. **截图**——UI 改动带 before/after +4. **测试**——描述你怎么测的 +5. **破坏性变更**——在最上面清楚标注 + +### PR 模板 + +```markdown +## What + +改动的简短描述。 + +## Why + +为什么需要这个改动(链接 issue)。 + +## How + +技术方案。 + +## Testing + +怎么测的。 + +## Screenshots (if UI changes) + +Before / After。 +``` + +--- + +## 报告 bug + +- MateClaw 版本(或 commit hash) +- Java 版本和操作系统 +- **精确的**复现步骤 +- 预期 vs 实际行为 +- 相关日志输出 + +**好的 bug 报告得到好的修复。** + +--- + +## 下一步 + +- [快速开始](./quickstart)——搭建走一遍 +- [项目介绍](./intro)——架构概览 +- [架构说明](./architecture)——给开发者的 StateGraph 深入 +- [路线图](./roadmap)——我们接下来在做什么 diff --git a/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md b/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md new file mode 100644 index 00000000..3d13d1e8 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md @@ -0,0 +1,518 @@ +# MateClaw Desktop UI 热更新设计 + +## 背景 + +当前 `mateclaw-desktop` 的运行链路不是“Electron 直接加载 `mateclaw-ui`”,而是: + +1. Electron 启动并显示本地 Splash。 +2. Electron 用内置 JRE 启动 `mateclaw-server.jar`。 +3. `mateclaw-ui` 已提前构建到 `mateclaw-server/src/main/resources/static`。 +4. `BrowserWindow` 最终加载 `http://localhost:18088`。 + +这意味着: + +- 现在的 UI 资源和后端 JAR 强绑定。 +- 任何 `mateclaw-ui` 改动,都要重新打包 `mateclaw-server.jar`,再跟着桌面安装包一起发布。 +- 现有 `electron-updater` 只能做“整包升级”,不能做“仅 UI 升级”。 + +因此这里要解决的问题,不是开发态 HMR,而是生产态 OTA:让 `mateclaw-ui` 可以脱离桌面安装包独立更新。 + +## 目标 + +- 支持 `mateclaw-ui` 独立于 `mateclaw-desktop` 发布。 +- UI 更新不要求用户下载新的桌面安装包。 +- 保持当前本地 `localhost` 架构,不把桌面应用直接改成远程网站壳子。 +- 更新失败可回滚到内置 UI。 +- 不影响现有 `electron-updater` 的整包升级能力。 + +## 非目标 + +- 第一阶段不做后端 JAR 热更新。 +- 第一阶段不允许 UI 任意突破当前后端 API 边界。 +- 第一阶段不替换现有 Electron Splash / 自动升级通道。 + +## 现状约束 + +### 1. UI 当前被打进 Spring Boot JAR + +`mateclaw-ui` 构建输出目录目前是: + +- `../mateclaw-server/src/main/resources/static` + +也就是 UI 构建产物直接进入后端静态资源目录,最终随 JAR 发布。 + +### 2. Desktop 最终加载的是后端地址 + +`mateclaw-desktop` 主窗口业务页当前加载: + +- `http://localhost:18088` + +所以 UI 热更新不能只改 Electron `dist`,必须让后端在运行时能切换静态资源来源。 + +### 3. 现有整包升级已存在 + +桌面端已经接入 `electron-updater`,并通过 GitHub Releases 发布整包升级。 + +因此新方案应当与它并存: + +- Shell / JRE / JAR 升级:继续走 `electron-updater` +- 纯前端升级:新增 UI OTA 通道 + +## 方案结论 + +推荐采用: + +**方案 A:外置 UI Bundle + 本地优先加载 + Manifest 驱动的 OTA 更新** + +核心思路: + +1. `mateclaw-ui` 产出独立的静态包(zip)。 +2. `mateclaw-desktop` 在启动时检查 UI 更新 Manifest。 +3. 下载并校验新的 UI 包后,解压到 `userData/ui-bundles//`。 +4. Electron 启动后端时,通过环境变量把“当前启用的 UI 目录”传给 Spring Boot。 +5. Spring Boot 优先从外部目录提供静态资源;若外部目录不存在或损坏,则回退到 JAR 内置 `classpath:/static/`。 +6. UI 更新完成后刷新窗口即可生效,无需安装新桌面包。 + +这是对当前架构改动最小、兼容性最强的路径。 + +## 为什么不选其他方案 + +### 方案 B:Electron 直接加载远程站点 + +不推荐作为主方案。 + +缺点: + +- 桌面应用退化成网站壳子,离线能力明显变差。 +- 安全面更大,远程页面注入风险更高。 +- 当前路由是 `history` 模式,本地与远程混用会增加协议、资源路径和鉴权处理复杂度。 +- 对现有 `localhost + Spring Boot` 架构破坏过大。 + +### 方案 C:把 UI 更新继续塞进 JAR 差分包 + +不满足目标。 + +原因: + +- UI 仍与后端耦合。 +- 每次 UI 变更都要重新发 JAR 和桌面安装包。 +- 无法做到真正的“仅 UI 热更新”。 + +## 目标架构 + +```text +Electron Shell +├── Splash UI(本地 dist) +├── UI Update Manager(新增) +├── Bundled JRE +├── mateclaw-server.jar +└── BrowserWindow → http://localhost:18088 + ├── 优先读取 userData/ui-bundles/current/ + └── fallback 到 classpath:/static/ +``` + +运行时目录建议: + +```text +~/Library/Application Support/MateClaw/ # macOS 示例 +├── data/ +├── ui-bundles/ +│ ├── current.json +│ ├── 1.0.3+20260405/ +│ │ ├── index.html +│ │ ├── assets/... +│ │ └── meta.json +│ └── 1.0.4+20260410/ +└── logs/ +``` + +## 关键设计 + +### 1. UI 包格式 + +每个 UI 发布产物建议包含: + +- `index.html` +- `assets/*` +- `logo/*` +- `icons/*` +- `meta.json` + +`meta.json` 示例: + +```json +{ + "uiVersion": "1.0.4", + "buildId": "20260410.1", + "minDesktopVersion": "1.0.0", + "minServerApiVersion": "1.0", + "maxServerApiVersion": "1.x", + "sha256": "..." +} +``` + +说明: + +- `uiVersion`:前端语义版本。 +- `buildId`:构建批次,便于排查。 +- `minDesktopVersion`:限制旧 Electron shell。 +- `minServerApiVersion` / `maxServerApiVersion`:约束 UI 与当前后端 API 兼容性。 + +### 2. 更新 Manifest + +桌面端不直接猜测最新版本,而是请求一个 Manifest。 + +建议格式: + +```json +{ + "channel": "stable", + "latest": { + "uiVersion": "1.0.4", + "buildId": "20260410.1", + "url": "https://download.example.com/mateclaw/ui/1.0.4/ui-bundle.zip", + "sha256": "..." + }, + "minimumDesktopVersion": "1.0.0", + "compatibleServerApi": "1.x", + "signature": "base64..." +} +``` + +Manifest 最好放在稳定的静态地址,不要依赖 GitHub API 动态查询 release 列表。 + +发布源建议优先级: + +1. 自有 CDN / OSS / COS / R2 +2. GitHub Releases 直链 + +如果主要用户在国内,建议不要把 GitHub 当唯一源。 + +### 3. Spring Boot 静态资源加载改造 + +需要在 `mateclaw-server` 中新增静态资源优先级: + +1. 外部目录 `file:${mateclaw.ui.dir}/` +2. 内置资源 `classpath:/static/` + +实现建议: + +- 新增配置项 `mateclaw.ui.dir` +- 在 `WebMvcConfigurer` 中注册资源处理器 +- 对 `/assets/**`、`/icons/**`、`/logo/**`、`/favicon.ico`、`/index.html` 和 SPA 路由统一转发 +- 当外部目录不存在时自动回退内置资源 + +这样 BrowserWindow 仍然访问 `http://localhost:18088`,但内容已经可由外置 UI 包覆盖。 + +### 4. Electron 侧 UI Update Manager + +新增一个独立的 UI 更新管理器,职责: + +1. 读取当前启用 UI 版本。 +2. 拉取远程 Manifest。 +3. 判定兼容性。 +4. 下载 zip。 +5. 校验 `sha256` 与签名。 +6. 解压到临时目录。 +7. 原子切换 `current.json` 或 `current` 软链接。 +8. 通知渲染层“有可用 UI 更新”或“更新已完成”。 + +建议时机: + +- 启动后 3 到 10 秒后台检查 +- 用户手动点击“检查前端更新” +- 设置页允许切换更新通道(stable / beta) + +### 5. 激活策略 + +建议采用“两阶段激活”: + +#### 启动前已下载完成 + +- Electron 在启动 Java 前先解析当前 UI 指针 +- 将 `MATECLAW_UI_DIR` 注入到 Java 进程环境变量 +- 本次启动直接加载新 UI + +#### 运行中下载完成 + +- 下载成功后先不杀后端 +- 标记“下次重启生效”是最稳妥方案 +- 若要做到即时生效,可尝试: + - 切换 `current` 指针 + - 通知前端 `window.location.reload()` + +第一阶段推荐: + +**下载后提示“重启应用以应用前端更新”** + +原因是: + +- 简化缓存一致性问题 +- 避免运行态资源引用一半新一半旧 +- 降低与长连接、SSE、登录态的耦合风险 + +### 6. 回滚策略 + +至少支持三层回退: + +1. 下载失败:保持当前 UI +2. 解压或校验失败:丢弃新包,保持当前 UI +3. 新 UI 启动异常:回退到上一版本 UI,最差回退到 JAR 内置 UI + +建议机制: + +- `current.json` 记录当前版本、上一版本、状态 +- UI 启动成功后,前端调用 `/api/v1/system/ui/boot-ok` 或通过 preload IPC 上报“本次版本已健康启动” +- 若启动后短时间内崩溃或白屏,下次启动自动回滚上一版本 + +### 7. 安全要求 + +UI 热更新本质上是在本地执行新的前端资源,必须做完整校验。 + +最低要求: + +- HTTPS 下载 +- `sha256` 校验 +- Manifest 签名校验 + +推荐增加: + +- 使用 Ed25519 公钥验签 +- 公钥随桌面端内置 +- 不允许跳过签名校验加载生产更新包 + +否则该通道会成为远程代码注入入口。 + +### 8. 缓存与资源路径 + +需要处理几个细节: + +- `index.html` 不应长缓存 +- `assets/*` 可使用内容 hash 长缓存 +- 外部 UI 包目录最好按版本隔离 +- `current` 只做版本指针,不直接覆盖旧目录 + +这和 Vite 的产物模式天然兼容。 + +### 9. 版本兼容规则 + +建议明确一条产品规则: + +- 只涉及 UI 表现、交互、文案、前端容错的变更,可以走 UI OTA +- 需要新增/修改后端 API、数据库结构、JRE 资源、Electron 权限能力的变更,必须走桌面整包升级 + +否则很容易出现: + +- UI 已升级 +- 本地 JAR 太旧 +- 页面调用了不存在的 API + +所以 Manifest 中必须带兼容约束。 + +## 发布链路设计 + +### 当前链路 + +1. `mateclaw-ui` 构建到后端 `static/` +2. Maven 打 JAR +3. Electron 打包安装包 +4. GitHub Releases 发布 + +### 新链路 + +#### 桌面整包发布 + +继续保持现状: + +1. 构建 UI +2. 打进 JAR +3. 打包桌面安装包 +4. 走 `electron-updater` + +#### UI 独立发布 + +新增: + +1. `mateclaw-ui` 单独构建到临时目录 +2. 生成 `meta.json` +3. 打 zip +4. 计算 `sha256` +5. 生成并发布 `ui-manifest.json` +6. 上传到 CDN / Release 资产 + +这样: + +- 新装用户依然有 JAR 内置 UI 可用 +- 老用户可在后续自动收到 UI OTA + +## 推荐实施阶段 + +### Phase 1:基础可用 + +目标: + +- 支持下载 UI 包 +- 支持启动时优先加载外部 UI +- 支持失败回退到内置 UI +- 下载完成后“下次重启生效” + +需要改动: + +- `mateclaw-server` + - 支持外部静态目录优先级 + - 提供 SPA fallback +- `mateclaw-desktop` + - 增加 UI Update Manager + - 增加 Manifest 拉取、下载、校验、解压、指针切换 + - 将 UI 版本信息暴露给 Splash / 设置页 +- `mateclaw-ui` + - 构建时生成 `meta.json` + - 设置页增加当前 UI 版本展示 + +这是最值得先落地的一版。 + +### Phase 2:产品化 + +目标: + +- 设置页支持“检查前端更新” +- 提示更新说明 +- 支持 stable / beta 通道 +- 支持启动失败自动回滚 + +### Phase 3:增强体验 + +目标: + +- 部分场景无需整应用重启即可刷新 UI +- 支持灰度发布 +- 支持按平台分发不同 UI 包 + +## 建议新增模块 + +### `mateclaw-desktop` + +建议新增: + +- `electron/main/ui-updater.ts` +- `electron/main/ui-runtime.ts` + +职责拆分: + +- `ui-updater.ts`:远程检查、下载、校验、解压、切换 +- `ui-runtime.ts`:读取当前 UI 指针、提供给 Java 进程环境变量 + +### `mateclaw-server` + +建议新增: + +- `vip.mate.config.ExternalUiProperties` +- `vip.mate.config.UiResourceConfig` +- `vip.mate.system.controller.DesktopRuntimeController` + +职责: + +- 配置外部 UI 目录 +- 注册静态资源与 SPA fallback +- 对前端暴露当前运行版本信息 + +### `mateclaw-ui` + +建议新增: + +- 构建脚本:生成 `meta.json` +- 设置页:显示 + - Desktop 版本 + - UI 版本 + - 后端版本 + - 更新通道 + - 最近检查时间 + +## 接口建议 + +桌面 preload / IPC 可以新增: + +- `uiUpdater.getState()` +- `uiUpdater.check()` +- `uiUpdater.download()` +- `uiUpdater.applyOnRestart()` + +后端接口建议新增: + +- `GET /api/v1/runtime/version` + +返回示例: + +```json +{ + "desktopVersion": "1.0.0", + "serverVersion": "1.0.0", + "serverApiVersion": "1.0", + "uiVersion": "1.0.4", + "uiSource": "external" +} +``` + +这样前端可以明确展示当前正在跑的是哪个 UI 包。 + +## 风险点 + +### 1. 前后端版本漂移 + +这是最大风险。 + +控制手段: + +- UI Manifest 增加兼容性约束 +- 约定 API 破坏性变更只能走整包升级 + +### 2. 白屏回滚不完善 + +如果只做下载和切换,不做启动成功确认,坏包可能导致用户持续白屏。 + +所以至少要有: + +- 上一版本指针 +- 启动健康上报 + +### 3. 更新源可达性 + +若继续完全依赖 GitHub Releases,国内网络环境下成功率可能不稳定。 + +建议尽快切到稳定 CDN。 + +### 4. 安全边界扩大 + +远程前端资源可执行,签名和 hash 校验是必须项,不是可选优化。 + +## 最小落地建议 + +如果现在就开始做,建议按下面顺序推进: + +1. 先改 `mateclaw-server`,让它支持“外部目录覆盖 classpath static”。 +2. 再改 `mateclaw-desktop`,把 UI 包下载到 `userData/ui-bundles/`,并通过环境变量传给 Java。 +3. 再补 Manifest、签名校验和版本展示。 +4. 最后再做“运行中更新提示”和“自动回滚”。 + +## 结论 + +对当前 MateClaw 架构,最合适的不是把桌面端改成远程站点壳,而是: + +**保留 `Electron + localhost Spring Boot` 架构,引入“外置 UI Bundle 覆盖内置 static 资源”的 OTA 机制。** + +这样可以: + +- 保持离线可用 +- 最大限度复用现有桌面端架构 +- 将 UI 发布频率从桌面整包中解耦 +- 把风险控制在“前端资源替换”这一层 + +第一阶段建议做到: + +- 启动时自动检查 UI 更新 +- 后台下载 +- 校验后写入外部目录 +- 下次重启生效 +- 失败自动回退到 JAR 内置 UI + +这版最稳,也最容易在现有代码上渐进落地。 diff --git a/mateclaw-server/src/main/resources/docs/zh/desktop.md b/mateclaw-server/src/main/resources/docs/zh/desktop.md new file mode 100644 index 00000000..93dc4a31 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/desktop.md @@ -0,0 +1,284 @@ +# 桌面应用 + +**双击。等 30 秒。登录。开始用。** + +四句话的桌面版。**不需要装 Java**,不需要打开浏览器,不需要 docker compose 文件,不需要记住端口号。MateClaw 桌面版把 Electron、JRE 21 运行时、打包的 Spring Boot 服务 JAR **全部装进一个安装包**。**你的用户永远不会知道下面跑的是 Java。** + +这一页给想**运行、构建、调试**桌面版的人看。 + +--- + +## 架构 + +``` +┌──────────────────────────────────────────┐ +│ Electron 外壳 │ +│ ┌────────────────────────────────────┐ │ +│ │ BrowserWindow (Chromium) │ │ +│ │ ┌──────────────────────────────┐ │ │ +│ │ │ Vue 3 前端 (dist/) │ │ │ +│ │ │ Element Plus + Tailwind │ │ │ +│ │ └────────────┬─────────────────┘ │ │ +│ └───────────────┼────────────────────┘ │ +│ │ HTTP / SSE │ +│ ┌───────────────▼────────────────────┐ │ +│ │ Spring Boot 后端 (子进程) │ │ +│ │ 127.0.0.1 上的动态端口 │ │ +│ │ 内置 JRE 21 + H2 文件数据库 │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ electron-updater 自动更新 │ │ +│ └────────────────────────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +一个进程树里住着三样东西: + +1. **Electron 主进程**——窗口、托盘、IPC、后端生命周期 +2. **BrowserWindow(Chromium)**——渲染 Vue 3 前端(和 Web 版同一份代码) +3. **Spring Boot 后端**——作为子进程被主进程启动,只监听 localhost + +后端在启动时**动态挑一个空闲端口**,这样就不会和你机器上别的东西冲突。 + +### 核心特性 + +- 原生窗口,不依赖浏览器 +- 系统托盘集成,后台运行 +- **内置 JRE 21**——用户**永远不用装 Java** +- **自动更新**通过 electron-updater +- **本地优先的数据** +- **动态后端端口** +- **UI 热更新**——前端资源可以独立更新,不用重新打包 +- 跨平台(macOS、Windows、Linux) + +--- + +## 支持的平台 + +| 平台 | 架构 | 状态 | +|------|------|------| +| macOS | Intel(x64) | 稳定 | +| macOS | Apple Silicon(ARM64) | 稳定 | +| Windows | x64 | 稳定 | +| Linux | x64 | 稳定 | + +--- + +## 前置要求(构建需要,运行不需要) + +**运行**这个 app?下载 + 安装。完。 + +**构建**这个 app? + +| 工具 | 版本 | 用途 | +|------|------|------| +| Node.js | 18+ | 前端构建 + Electron | +| pnpm / npm | 8+ / 9+ | 包管理器 | +| Java | 21+ | 后端编译 + 开发模式(生产构建自带 JRE) | +| Maven | 3.8+ | 后端构建 | + +--- + +## 模块布局 + +``` +mateclaw-desktop/ +├── electron/ +│ ├── main/index.ts # 主进程——后端生命周期、自动更新、托盘 +│ └── preload/index.ts # IPC 桥 +├── src/ # Vue 3 渲染进程源码 +├── resources/ +│ ├── jre/ # 内置 JRE +│ └── app.jar # 打包好的 Spring Boot 后端 JAR +├── build/ # 应用图标 +├── electron-builder.json # 打包配置 +├── package.json +└── vite.config.ts +``` + +--- + +## 开发模式 + +```bash +cd mateclaw-desktop +pnpm install +pnpm dev +``` + +开发模式下: + +1. Vite 起前端开发服务器(HMR) +2. Electron 主进程启动加载 Vite 开发 URL +3. 主进程在空闲端口启动 Spring Boot JAR 子进程 +4. 前端通过 HTTP/SSE 和后端对话 + +前端改动触发 HMR。主进程改动自动重启 Electron。 + +--- + +## 生产构建 + +```bash +cd mateclaw-desktop +pnpm build && npx electron-builder --mac # macOS +pnpm build && npx electron-builder --win # Windows +pnpm build && npx electron-builder --linux # Linux +``` + +产物落在 `release/`: + +| 平台 | 产物 | 说明 | +|------|------|------| +| macOS | `.dmg` + `.zip` | 拖进 Applications | +| Windows | `.exe`(NSIS) | 可自定义安装目录 | +| Linux | `.AppImage` | 加执行权限直接跑 | + +### 构建的完整前置流程 + +```bash +# 1. 构建前端静态资源 +cd mateclaw-ui +pnpm install && pnpm build + +# 2. 构建后端 JAR +cd ../mateclaw-server +mvn clean package -DskipTests + +# 3. 把 JAR 拷到桌面项目 +cp target/mateclaw-server.jar ../mateclaw-desktop/resources/app.jar + +# 4. 下载平台特定的 JRE +cd ../mateclaw-desktop +bash scripts/download-jre.sh + +# 5. 构建桌面安装包 +pnpm build && npx electron-builder +``` + +--- + +## Java 后端生命周期管理 + +Electron 主进程通过 Node.js `child_process` 管理 Spring Boot 后端: + +1. **启动**——用内置 JRE 把 JAR 作为子进程启动,传入一个动态端口,等就绪 +2. **就绪检查**——轮询 `http://127.0.0.1:{port}` 直到响应,然后加载前端 +3. **运行时**——前端通过 REST + SSE 通信 +4. **关闭**——发出优雅关机信号,等进程退出,关窗口 + +后端在会话中途崩了的话,主进程会发现并弹带日志尾巴的错误对话框。**不会白屏发呆。** + +--- + +## 自动更新 + +集成 electron-updater,从 GitHub Releases 自动检测和下载新版本。 + +### 流程 + +1. 启动时检查 GitHub Releases +2. 发现新版本时,UI 弹通知显示版本 + changelog +3. 用户确认后下载,带实时进度条 +4. 下载完成后选**立即安装**或**下次启动时安装** +5. App 退出、替换文件、重启 + +### 配置 + +```json +{ + "publish": [ + { + "provider": "github", + "owner": "matevip", + "repo": "mateclaw" + } + ] +} +``` + +### UI 热更新(不用重新打包) + +**前端资源可以独立热更新**——只改前端的修复不需要重发新安装器。看 `mateclaw-desktop/scripts/` 和 `desktop-ui-hot-update.md`。 + +--- + +## 数据存储 + +| OS | 路径 | +|----|------| +| macOS | `~/Library/Application Support/MateClaw/data/` | +| Windows | `%APPDATA%/MateClaw/data/` | +| Linux | `~/.local/share/MateClaw/data/` | + +日志、工作空间文件、技能脚本、Wiki 内容都在同一个用户目录下。做重大变更前**备份**。 + +--- + +## `electron-builder.json` 参考 + +| 设置 | 用途 | +|------|------| +| `appId` | `vip.mate.mateclaw`——系统注册和代码签名 | +| `productName` | 标题栏和安装器里显示的应用名 | +| `publish` | 自动更新源(GitHub Releases) | +| `extraResources` | JRE 和 `app.jar` | +| `mac.target` | `dmg` + `zip`,`arm64` 和 `x64` | +| `win.target` | `nsis` 安装器 | +| `linux.target` | `AppImage` | +| `mac.hardenedRuntime` | 签名和公证必需 | +| `nsis.oneClick` | `false`——让 Windows 用户选安装目录 | + +--- + +## 环境变量 + +桌面 app 读环境变量和独立后端一样。但有更简单的方式:**启动之后通过设置页面配置所有东西**。API key 进到加密的 `mate_model_provider` 表里。 + +--- + +## 故障排查 + +### 白屏 + +1. 后端没起来——看日志 +2. 端口冲突——动态端口选择器处理大部分情况,严格防火墙可能导致失败 +3. 内置 JRE 损坏——重装 app +4. 看日志(位置见下面) + +### 代码签名警告 + +- **macOS**——右键选**打开**绕过 Gatekeeper(首次启动)。生产分发:Apple Developer 证书做签名和公证。看 `mateclaw-desktop/CODESIGNING.md`。 +- **Windows**——SmartScreen 警告 → **更多信息 → 仍要运行**。生产分发:EV 代码签名证书。 + +### 桌面 app 启动不了 + +1. 安装版自带 JRE——不需要装 Java。开发版:确认 `java -version` 显示 21+。 +2. 看日志: + - macOS:`~/Library/Logs/MateClaw/` + - Windows:`%APPDATA%/MateClaw/logs/` + - Linux:`~/.local/share/MateClaw/logs/` +3. 从终端启动看控制台输出 +4. 确认后端选的端口没被防火墙挡 + +### 企业微信授权弹窗 + +企业微信二维码授权**必须在应用内弹窗打开**(不是系统浏览器),这样 `postMessage` 回调才能工作。MateClaw 在 `setWindowOpenHandler` 里对 `work.weixin.qq.com` 域名做了特殊处理——**自动作为应用内弹窗打开**。 + +--- + +## 注意 + +- 首次启动 10–30 秒(数据库初始化) +- 关窗口**不会**停后台服务——用系统托盘菜单完全退出 +- **定期备份用户数据目录** +- 内置 JRE 意味着安装包 80–120 MB + +--- + +## 下一步 + +- [快速开始](./quickstart)——最快走完桌面体验 +- [配置说明](./config)——运行时设置 +- [控制台](./console)——跑在 Electron 窗口里的 UI diff --git a/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md b/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md new file mode 100644 index 00000000..4bd5ae83 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/docker-deploy.md @@ -0,0 +1,310 @@ +# Docker 部署 + +桌面端之外的唯一推荐生产部署方式。一条 `docker compose up -d` 起三个容器:MySQL、SearXNG、mateclaw-server。 + +这一页覆盖**要求、步骤、验证、常见坑**。配置变量明细请看 [配置说明](./config)。 + +--- + +## 前置要求 + +| 项 | 最低 | 推荐 | 备注 | +|---|---|---|---| +| Docker Engine | 24.0+ | 最新稳定 | `docker --version` 确认 | +| Docker Compose | v2.20+ | v2.30+ | `docker compose version`(注意是 `compose` 不是 `compose`) | +| 宿主 RAM | 4 GB | 8 GB+ | 浏览器工具启动时 Chromium 会吃 1-2 GB | +| 磁盘空间 | 6 GB | 20 GB+ | 镜像约 2 GB + MySQL 数据 + 工作空间文件 | +| /dev/shm | 默认 | compose 已自动设 2 GB | Chromium 用共享内存做渲染,默认 64 MB 会 SIGBUS | +| 网络 | 出公网 | — | 拉镜像 + 调 LLM API | + +**不需要**:宿主装 Java / Node / Maven / Chrome / Python —— 全部在镜像里。 + +--- + +## 三个容器 + +| 服务 | 镜像 | 作用 | 暴露端口 | +|---|---|---|---| +| `mysql` | `mysql:8.0` | 业务数据存储 | `3306` | +| `searxng` | 本地构建 `./docker/searxng/` | 无 API Key 搜索兜底 | `8088` | +| `mateclaw-server` | 本地构建 `mateclaw-server/Dockerfile` | Spring Boot 后端 + 内置浏览器 | `18080` | + +--- + +## SearXNG 搜索服务 + +### 为什么要自己打镜像 + +`docker/searxng/Dockerfile` 从官方 `searxng/searxng:latest` 派生,**把自定义 `settings.yml` 打进 `/etc/searxng/settings.yml`**。这不是洁癖,是**必须**: + +- **上游默认只开 `html` 格式**,mateclaw 后端请求的是 `GET /search?q=...&format=json` —— 默认配置下直接返回 HTML 错误页,`SearXNGSearchProvider` 解析失败返回空列表,UI 显示"搜索暂时不可用" +- **上游默认启用反爬 Limiter 插件**,拦截没有 JS / Cookie 的服务端调用,回 HTTP 429 + +我们的 `docker/searxng/settings.yml` 做了三件事: + +1. `search.formats: [html, json]` —— 放开 JSON +2. `server.limiter: false` —— 关闭反爬限流 +3. 收敛引擎列表到可靠子集(DuckDuckGo / Bing / Brave / Wikipedia / Google / Startpage),裁掉默认那几十个不常用的 + +**不要**改成从宿主 bind-mount `settings.yml`,早期版本踩过坑 —— 宿主目录不存在时 Docker 会自动创建空目录把文件盖掉,容器起来就没配置了。如果要改 settings.yml,编辑 `docker/searxng/settings.yml` 然后: + +```sh +docker compose build searxng +docker compose up -d searxng +``` + +### 搜索 provider 降级链 + +后端 `SearchProviderRegistry` 按以下优先级选: + +1. 用户在「设置 → 搜索」里显式指定的 provider(`searchProvider` 配置项) +2. 按 `autoDetectOrder` 遍历,**优先选已配置 API Key 的付费 provider**(Serper order=1,Tavily order=2) +3. 回退到 keyless —— SearXNG(order=50)优先于 DuckDuckGo(order=100) + +一台全新容器、啥 API Key 都没配的情况下,默认就是 **SearXNG 接所有搜索流量**。 + +### 验证 SearXNG 通路 + +```sh +# 1. 直接打容器 +curl -s 'http://localhost:8088/search?q=test&format=json' | head -5 +# 期望:{"query": ..., "results": [...]} +# 如果拿到 HTML:settings.yml 没生效 + +# 2. 从 mateclaw-server 容器内部打 +docker exec mateclaw-server wget -qO- 'http://searxng:8080/search?q=test&format=json' | head -5 +# 如果不通:compose 网络有问题 + +# 3. 在 UI 聊天里让 agent 搜点东西,看后端日志 +docker compose logs -f mateclaw-server | grep "搜索 provider" +# 期望看到:搜索 provider 解析: searxng (source=keyless-fallback) +``` + +### 想用外部 SearXNG + +假如你已经在别处部署了 SearXNG 实例,可以在 `.env` 里: + +```properties +SEARXNG_BASE_URL=https://your-searxng.example.com +``` + +然后把 `docker-compose.yml` 里的 `searxng` 服务块注释掉。但记得**你那个实例也要满足同样的 JSON + Limiter 要求**。 + +--- + +## 浏览器自动化 + +### 镜像里到底装了什么 + +后端镜像以 `mcr.microsoft.com/playwright:v1.52.0-noble` 为基础(Ubuntu Noble 24.04,glibc),由 `mateclaw-server/Dockerfile` 的第三阶段拉起,额外装: + +- `openjdk-21-jre-headless` —— 跑 Spring Boot JAR +- `fonts-noto-cjk` —— 中文页面截图不出豆腐块 +- `fonts-noto-color-emoji` —— Emoji 渲染 +- `tzdata` —— 时区 `Asia/Shanghai` + +Playwright 官方镜像已经把三大浏览器预装在 `/ms-playwright/`: + +- `chromium-XXXX/chrome-linux/chrome` —— 主力 +- `firefox-XXXX/firefox/firefox` +- `webkit-XXXX/pw_run.sh` + +所有系统依赖(`libnss3` / `libgbm1` / `libasound2` / `libx11-xcb1` / `libxkbcommon` / …)随镜像装好了。**不需要 `playwright install`,也不怕 Alpine-musl 兼容性坑**。 + +Dockerfile 里显式设的环境变量: + +```dockerfile +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +``` + +—— Playwright Java 启动时按这个路径找预装浏览器,**不会**再去 `$HOME/.cache/ms-playwright` 联网下载。 + +### BrowserLauncher 的 7 级降级 + +`vip.mate.tool.browser.BrowserLauncher` 按如下优先级启动浏览器,任何一级命中即止: + +1. `CONFIG_CDP` —— 配了 `MATECLAW_BROWSER_CDP_URL` → 直接 attach 已运行的 Chrome +2. `CONFIG_PATH` —— 配了 `MATECLAW_BROWSER_CHROME_PATH` 或 `CHROME_PATH` env → 用指定 exe +3. `CONFIG_CHANNEL` —— 配了 `MATECLAW_BROWSER_CHANNEL=chrome|msedge` → 走 Playwright channel +4. `AUTO_CHANNEL` —— 自动试 `chrome` / `msedge` channel(Docker 镜像里这一步必然命中) +5. `AUTO_PATH` —— 扫标准安装路径(`/usr/bin/google-chrome` / `chromium-browser` / `snap/bin/chromium` / `microsoft-edge` / `brave-browser`) +6. `BUNDLED` —— Playwright bundled chromium(镜像里这一步也一定通) +7. `EXTERNAL_CDP` —— 最后兜底,自己 `fork` 系统 chrome 带 `--remote-debugging-port=0`,读 stderr 抠 DevTools URL,`connectOverCDP` 接回来(openfang 的套路) + +Docker 部署下 **默认走第 4 或第 6 级**,零配置可用。如果你的用例要接外部 Chrome,配第 1 级;要用宿主机装的某个特殊 Chrome,配第 2 级。 + +### `/dev/shm` 必须 2 GB + +`docker-compose.yml` 给 `mateclaw-server` 设了 `shm_size: 2gb`。Docker 默认给每个容器只 64 MB `/dev/shm`,Chromium 用共享内存做 GPU / 页面渲染,跑 3 个 tab 就会 SIGBUS 挂掉,表现为 Playwright `TargetClosedError: Target page, context or browser has been closed`。**不要改小这个值**。 + +### SSRF 防护 + +BrowserUseTool 在 `navigate` 前会过 `UrlSafetyChecker`,**硬阻断**以下 host: + +- `localhost` / `127.0.0.1` / `::1` / `0.0.0.0` +- 169.254.169.254(AWS / GCP / Azure IMDS)、100.100.100.200(阿里云 IMDS)、192.0.0.192(Azure IMDS alt) +- 所有 link-local / private / multicast IP 段 + +也就是说 LLM 生成一个恶意 URL 指向云元数据端点偷凭据这条路是封死的。如果你有内网抓取需求需要放行特定地址,关 `mateclaw.browser.ssrf-check-enabled` 或改 `UrlSafetyChecker` 的白名单。**生产环境谨慎**。 + +### 验证浏览器通路 + +```sh +# 1. 启动自检(不实际启浏览器,查环境齐不齐) +curl -s http://localhost:18080/api/v1/system/browser-health | jq . +# 期望:overall: "healthy",system.browsers 找到 chromium 路径 + +# 2. 在 agent 里让它调浏览器 +# browser_use(action="diagnose") # 返回策略链 trace +# browser_use(action="start") # 实际启动 +# browser_use(action="open", url="https://example.com") +# browser_use(action="screenshot") # 返回 base64 PNG +``` + +--- + +## 第一次部署 + +```sh +git clone https://github.com/matevip/mateclaw.git +cd mateclaw + +# 1. 必填项写到 .env +cp .env.example .env +vi .env # 见下方必填表 +``` + +**必填**(compose 启动会强制校验,缺项直接退出避免把默认值带进生产): + +| 变量 | 说明 | +|---|---| +| `DB_PASSWORD` | 业务库账号密码,建议 16+ 位 + 大小写 + 数字 + 符号 | +| `DB_ROOT_PASSWORD` | MySQL root 密码,**与上面不同** | + +**强烈建议**(不填不会报错,启动日志里 WARN): + +| 变量 | 说明 | +|---|---| +| `JWT_SECRET` | JWT 签名密钥,`openssl rand -base64 48` 生成 | +| `MATECLAW_CORS_ALLOWED_ORIGINS` | 生产白名单,如 `https://mateclaw.example.com` | + +然后起服务: + +```sh +docker compose up -d --build # 首次构建,约 3-10 分钟 +docker compose logs -f mateclaw-server +``` + +首次启动会跑 Flyway 迁移(~5 秒)+ 应用内种子数据(~3 秒),然后绑 `0.0.0.0:18080`。 + +浏览器打开 `http://localhost:18080`,`admin / admin123` 登录,**立刻在「设置 → 安全」改密码**。 + +--- + +## 构建加速 + +### 美国 / 欧洲服务器 + +**默认就是最快的**:`mateclaw-server/pom.xml` 里 `` 的优先级是 `Maven Central → Google CDN → Aliyun`,Central 直连最快。 + +### 中国服务器 + +切 Aliyun 优先:改 `mateclaw-server/Dockerfile` 的 `mvn` 命令加 `-Paliyun-first`,或者(更简单)在 `docker-compose.yml` 加一行 `build args` 传进去。 + +```dockerfile +# 原 +RUN mvn dependency:go-offline -q +RUN mvn package -DskipTests -q + +# 改 +ARG MAVEN_PROFILE= +RUN mvn dependency:go-offline -q ${MAVEN_PROFILE:+-P${MAVEN_PROFILE}} +RUN mvn package -DskipTests -q ${MAVEN_PROFILE:+-P${MAVEN_PROFILE}} +``` + +然后: + +```sh +docker compose build --build-arg MAVEN_PROFILE=aliyun-first mateclaw-server +``` + +Aliyun Spring 镜像和公共仓库会被推到最前,中国出口不用经美国骨干。 + +--- + +## 可选开关 + +全部支持在 `.env` 里通过环境变量 override,**不填即用容器内默认**: + +| 变量 | 默认 | 用途 | +|---|---|---| +| `SERPER_API_KEY` | — | Google 搜索 API(付费,质量高) | +| `SEARXNG_SECRET` | 内置开发 secret | 只在把 8088 端口暴露到公网时才填 | +| `SEARXNG_BASE_URL` | `http://searxng:8080` | 想接外部 SearXNG 实例时填 | +| `MATECLAW_BROWSER_CDP_URL` | — | 接外部 Chrome sidecar(CDP 端点) | +| `MATECLAW_BROWSER_CHROME_PATH` | — | 用宿主机 Chrome 覆盖镜像内置 | +| `MATECLAW_BROWSER_CHANNEL` | — | `chrome` / `msedge` 等,强制指定 Playwright channel | + +**LLM 的 API Key(DashScope / OpenAI / Anthropic / DeepSeek / Kimi / ...)不在 `.env` 里配** —— 启动后在 UI「设置 → 模型 → 添加供应商」里添加,支持热更新。容器**零 API Key 也能起来**,登录后到模型页配第一家供应商即可。 + +--- + +## 验证 + +起来之后按顺序跑: + +```sh +# 1. 三个容器都 healthy +docker compose ps + +# 2. 基础健康检查 +curl -s http://localhost:18080/api/v1/system/health | jq . + +# 3. 浏览器工具自检(Linux 上最容易挂的地方) +curl -s http://localhost:18080/api/v1/system/browser-health | jq . +# 期望 overall: "healthy" + +# 4. SearXNG 返回 JSON(不是 HTML 错误页) +curl -s 'http://localhost:8088/search?q=hello&format=json' | head -5 +``` + +任何一条不通再翻下一节。 + +--- + +## 常见坑 + +**构建阶段 `mvn dependency:go-offline` 卡死** +美国服务器拉 Aliyun 镜像慢。pom.xml 默认把 Maven Central 放最前,应该快。如果还是慢,网络不通——检查出站防火墙。 + +**`mateclaw-server` 启动前就 unhealthy** +`docker compose logs mateclaw-server` 看 Flyway 迁移是否成功。通常是 DB_PASSWORD 含特殊字符被 shell 吃了 —— 用双引号包住。 + +**浏览器工具报 "Target page closed" / SIGBUS** +`shm_size: 2gb` 没生效。`docker inspect mateclaw-server | grep ShmSize` 看实际值。老版本 Docker Engine 要升级到 24.0+。 + +**搜索返回 "搜索暂时不可用"** +SearXNG 容器没起来或 JSON 格式被镜像默认 settings 禁用。我们自己构建 `./docker/searxng/` 已经改好;如果用了旧的 volume 缓存要清:`docker compose down -v searxng && docker compose up -d searxng`。 + +**LLM 回答里出现乱码 / 豆腐块** +镜像已经装了 `fonts-noto-cjk` 和 `fonts-noto-color-emoji`,不是字体问题。检查前端浏览器 locale。 + +--- + +## 升级 + +```sh +git pull +docker compose build mateclaw-server # 只重建后端 +docker compose up -d mateclaw-server +``` + +MySQL 数据卷(`mysql_data`)不会动,Flyway 自动跑增量迁移 + 自愈 checksum 变化。**版本号写在 `mateclaw-server/pom.xml` 和 git tag**,生产环境建议钉 tag 而不是 `dev` 分支。 + +--- + +## 下一步 + +- [配置说明](./config) —— 所有环境变量和运行时开关 +- [Doctor 健康检查](./doctor) —— UI 里自带的启动体检 +- [安全与审批](./security) —— 生产部署前的加固清单 diff --git a/mateclaw-server/src/main/resources/docs/zh/doctor.md b/mateclaw-server/src/main/resources/docs/zh/doctor.md new file mode 100644 index 00000000..96ad4abd --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/doctor.md @@ -0,0 +1,233 @@ +# Doctor + +**Doctor 页面回答一个问题:这个东西现在是不是真的在正常工作?** + +MateClaw 有很多活动部件——后端、数据库、模型供应商、MCP 服务、IM 渠道、cron 任务、记忆整合、wiki 消化。出问题时,**症状**("我的 Agent 不响应")通常有一个**具体的原因**("DashScope API Key 昨天过期了")埋在离你能看到的地方好几层远的地方。Doctor 是一个单页,**一次性跑所有检查**,告诉你哪些是绿的、哪些是黄的、哪些是红的。 + +通过 `设置 → Doctor` 打开,或者直接跳 `/doctor`。 + +--- + +## 它检查什么 + +每一项检查独立运行,报告三种状态之一: + +- **✅ OK**——一切按预期工作 +- **⚠️ 警告**——在工作但降级了(例如在用 fallback provider、接近配额、一个非关键的 cron 任务暂停了) +- **❌ 错误**——以一种你需要修的方式坏了 + +### 核心基础设施 + +| 检查 | 验证什么 | +|------|----------| +| **后端版本** | MateClaw 在跑,报告它的版本 | +| **数据库连接** | 配置的数据源可达,查询成功 | +| **数据库 schema** | 所有预期的 `mate_*` 表存在;迁移状态干净 | +| **磁盘使用** | 数据目录有足够空闲空间(低于 20% 警告,低于 5% 错误) | +| **H2 console 暴露** | 生产 profile 里启用了 H2 console 会警告 | +| **JWT secret 强度** | 还在用默认 JWT secret 会警告 | + +### 模型 + +| 检查 | 验证什么 | +|------|----------| +| **活跃模型** | 默认模型配置存在且启用 | +| **供应商连通性** | 每个启用的供应商最近通过了连接测试 | +| **API Key 存在** | 每个标记为启用的云供应商都配了 key | +| **Ollama 可达** | 如果配了 Ollama,本地实例可达 | + +### Agent 和工具 + +| 检查 | 验证什么 | +|------|----------| +| **工具注册表** | 内置工具和 MCP 工具加载无错 | +| **Tool Guard 配置** | 至少存在一条 Tool Guard 规则(用 `default-policy: allow` 会警告) | +| **默认 Agent** | 默认 Agent 存在且启用 | +| **Agent 模板** | 内置模板存在且可加载 | + +### 记忆和 Wiki + +| 检查 | 验证什么 | +|------|----------| +| **记忆整合 cron** | 每个 Agent 的整合 cron 任务存在且启用 | +| **上次整合运行** | 过去 7 天没有跑过整合会警告 | +| **Wiki 消化队列** | 没有卡住的 `pending` 或 `processing` 原始材料 | +| **Wiki schema** | `mate_wiki_*` 表存在且可查询 | + +### 渠道 + +| 检查 | 验证什么 | +|------|----------| +| **渠道健康监控** | 每个启用的渠道报告 `connected` 或正在主动重连 | +| **每渠道状态** | 每个 IM 渠道的连接状态和上次错误 | +| **Webhook URL 可达** | 生产环境下 webhook 模式的渠道没配公网 URL 会警告 | + +### MCP + +| 检查 | 验证什么 | +|------|----------| +| **启用的 MCP 服务** | 每个启用的 MCP 服务是 `connected` | +| **工具数** | 每个连接成功的服务报告至少一个工具 | +| **孤儿子进程** | 没有超过它父 client 存活的 stdio 子进程 | + +### Cron 和异步 + +| 检查 | 验证什么 | +|------|----------| +| **Cron 引擎** | 计划任务执行器在运行 | +| **过期任务** | 任何任务超时超过 24 小时会警告 | +| **异步任务队列** | `mate_async_task` 队列长度在正常范围 | + +--- + +## 检查怎么跑 + +Doctor 两种方式跑: + +### 按需 + +点 Doctor 页面上的**运行所有检查**。按钮并行触发所有检查;UI 在每项检查完成时流式返回结果。大多数检查在一秒内完成;最慢的(MCP 服务连接测试)可能要 10–30 秒。 + +### 按计划 + +Doctor 也在后台**每 15 分钟自动跑一次**。结果缓存在内存里并持久化到 `mate_doctor_check`,这样打开页面时它**立刻加载**——你看到的是上次缓存的状态,直到你点**运行所有检查**。 + +在 `application.yml` 里调整计划: + +```yaml +mateclaw: + doctor: + enabled: true + schedule-minutes: 15 + cache-ttl-minutes: 10 +``` + +--- + +## 读结果 + +每个检查返回: + +```json +{ + "name": "DashScope 供应商连通性", + "category": "Models", + "status": "ok", + "message": "连接测试成功(延迟:240ms)", + "lastChecked": "2026-04-11T14:30:22", + "details": { + "provider": "dashscope", + "baseUrl": "https://dashscope.aliyuncs.com", + "latencyMs": 240 + }, + "fixUrl": "/settings/models" +} +``` + +UI 渲染: + +- 顶部的**分类 tab**——基础设施、模型、Agent、记忆、Wiki、渠道、MCP、Cron +- **状态计数器**——绿 / 黄 / 红 +- **检查列表**——名字、状态、消息、距上次检查的时间、"查看详情"展开、可选的"修复"按钮跳到相关设置页 +- **历史图**——(每个检查)最近 50 次运行的 sparkline,一眼看出抖动的检查 + +--- + +## 修复按钮 + +对可操作的检查,Doctor 行包含一个**修复**按钮,直接跳到相关的设置页面: + +- 模型供应商失败 → `设置 → 模型` +- Tool Guard `default-policy: allow` → `设置 → 安全与审批` +- 生产环境的 H2 console → `设置 → 系统`(或显示一个可复制的配置片段) +- JWT 默认 secret → `设置 → 系统`(或显示一个配置片段) +- MCP 服务断开 → `工具 → MCP 服务` +- 卡住的 wiki 消化 → `Wiki → [KB] → 原始材料` + +点修复带你到**你能解决问题的那个具体页面**。可能的话,目标页面会预过滤高亮失败的条目。 + +--- + +## Doctor API + +```bash +# 跑所有检查(同步) +curl http://localhost:18088/api/v1/doctor/run \ + -H "Authorization: Bearer " + +# 获取缓存的检查结果 +curl http://localhost:18088/api/v1/doctor/checks \ + -H "Authorization: Bearer " + +# 只跑特定分类 +curl http://localhost:18088/api/v1/doctor/run?category=models \ + -H "Authorization: Bearer " + +# 历史结果 +curl "http://localhost:18088/api/v1/doctor/history?check=dashscope-connectivity&limit=50" \ + -H "Authorization: Bearer " +``` + +--- + +## 在运维中使用 Doctor + +### 作为 uptime 监控的健康端点 + +把你的外部 uptime 监控(UptimeRobot、Pingdom、内部 Prometheus)指向: + +``` +GET /api/v1/doctor/checks +``` + +端点返回 HTTP 200 带 JSON 汇总——聚合的通过/失败计数和按分类细分。你的监控应该在 `errorCount > 0` 时报警。 + +要更简单的健康检查,用: + +``` +GET /actuator/health +``` + +这遵循 Spring Boot 的标准格式。 + +### 升级时 + +部署新 MateClaw 版本之后跑 Doctor 验证没有回归: + +1. 打开 `/doctor` +2. 点**运行所有检查** +3. 看有没有之前没有的黄或红 +4. **特别注意数据库 schema**——升级后 schema 不匹配通常意味着某个迁移没跑 + +### 出问题时 + +用户报告"它不工作"时 Doctor 是第一个去看的地方。打开页面,看哪个检查是红的,点**修复**,解决问题。**如果没有检查是红的但用户仍然有问题**,大概率是 Doctor 还没覆盖的东西——开一个 [GitHub issue](https://github.com/matevip/mateclaw/issues) 让我们加一个检查。 + +--- + +## 数据模型 + +**`mate_doctor_check`** + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `name` | 检查名字 | +| `category` | 检查分类 | +| `status` | `ok` / `warning` / `error` | +| `message` | 人类可读的消息 | +| `details` | 额外细节的 JSON | +| `last_checked` | 上次运行时间 | +| `run_duration_ms` | 检查耗时 | +| `workspace_id` | 范围(全局检查为 null) | + +历史结果进 `mate_doctor_check_history`,同样的列加上一个保留期清理任务。 + +--- + +## 下一步 + +- [控制台](./console)——Doctor 所在的 UI +- [配置说明](./config)——你可能基于 Doctor 警告配置的东西 +- [安全与审批](./security)——Doctor 在 Tool Guard 里检查什么 +- [贡献指南](./contributing)——缺了什么就加一个 Doctor 检查 diff --git a/mateclaw-server/src/main/resources/docs/zh/faq.md b/mateclaw-server/src/main/resources/docs/zh/faq.md new file mode 100644 index 00000000..205c82aa --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/faq.md @@ -0,0 +1,418 @@ +# 常见问题(FAQ) + +常见问题 + 真答案。你的问题不在这里就看对应的功能页,或者去 [GitHub issue](https://github.com/matevip/mateclaw/issues) 开一个。 + +--- + +## 安装和搭建 + +### 需要什么 Java 版本? + +**Java 17 或更高。** MateClaw 用了 Java 17 引入的特性。用 `java -version` 验证。 + +用桌面端的话,**完全不需要装 Java**——安装器自带 JRE 21。 + +### 要一个云 API key 才能开始? + +不用。三条无 key 的路径: + +- **Ollama**——本地 GPU 推理;MateClaw 启动时在 `localhost:11434` 自动探测 +- **ChatGPT OAuth**——有 ChatGPT Plus 或 Pro 订阅的话走浏览器 OAuth 流程——**不需要 API Key** +- **OpenRouter 免费档**——200+ 免费模型,一个 key 就能访问 + +**启动 MateClaw 也不需要把任何 API Key 设成环境变量。** 所有供应商配置都在启动后通过 UI 的 `设置 → 模型` 来做。 + +### 怎么拿 DashScope API Key? + +1. 去[阿里云 DashScope 控制台](https://dashscope.console.aliyun.com/) +2. 注册或登录 +3. 创建一个 API Key +4. 在 MateClaw 里进 `设置 → 模型 → DashScope` 粘贴 + +### 后端起不来——18088 端口被占了 + +换端口: + +```bash +mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=19090" +``` + +**桌面端会动态挑一个空闲端口**,所以在那里看不到这个错误。 + +### 启动时 H2 数据库锁错误 + +```bash +rm -f data/mateclaw.mv.db.lock +``` + +或者清空数据目录重新开始: + +```bash +rm -rf data/ +``` + +--- + +## 认证 + +### 默认凭证是什么? + +用户名 `admin`,密码 `admin123`。**任何真实部署都要立刻改。** + +### 我的 JWT token 老过期 + +MateClaw 实现了**滑动窗口续签**——token 剩余 25% 时服务器在响应头 `X-New-Token` 里发一个新 token。前端自动处理。 + +手动调 API(curl、Postman)的话,读 `X-New-Token` header 用新值做后续请求。 + +### 怎么改 admin 密码? + +UI 里 `设置 → 安全` 最简单。或者直接改数据库(BCrypt 编码): + +```sql +UPDATE mate_user SET password = '$2a$10$...' WHERE username = 'admin'; +``` + +--- + +## 模型 + +### 怎么配置模型? + +**全部通过 UI。** `设置 → 模型 → 添加供应商`。选供应商、粘 API Key(或为 ChatGPT Plus OAuth、或 Ollama 跳过)、保存、测试。**模型配置 100% 通过 UI 管理**——没有 `spring.ai.*` YAML 需要改。 + +LLM API Key 不读环境变量——`DASHSCOPE_API_KEY` 之类的设置不会生效。容器零 Key 就能启动,登录后加供应商即可。 + +### 怎么在 MateClaw 里用 GPT-4? + +`设置 → 模型 → 添加供应商`。要么粘你的 OpenAI API Key,要么如果你有 ChatGPT Plus/Pro 就用 **OpenAI OAuth**——浏览器窗口弹出让你登录。保存后从模型选择器挑 `gpt-4o`(或任何模型)。 + +### Ollama 模型很慢 + +本地模型性能取决于硬件: + +- 内存小的机器用小模型(7B 而不是 14B) +- 确保 Ollama 有 GPU 访问(`ollama ps` 应该显示 GPU) +- 有条件调大 Ollama 内存上限 +- `qwen2.5:7b` 或 `qwen3:latest` 是好平衡 + +### 能同时用多个供应商吗? + +可以。配多个供应商,把不同的模型配置分给不同的 Agent。每个 Agent 用自己的模型——或者继承全局默认。 + +### 怎么给有些 Agent 挂便宜模型、给另一些挂推理模型? + +- **全局活跃模型**设成便宜通用的(`qwen-plus`、`gpt-4o-mini`) +- 按 Agent 覆盖:推理重的 Agent 单独绑 `o3` 或 `qwen-max` +- 聊天窗口里的分组模型选择器也能按会话切 + +--- + +## 工具和搜索 + +### 怎么切换搜索 provider? + +`设置 → 系统 → 搜索服务`。从 Serper、Tavily、DuckDuckGo、SearXNG 里挑。开启 **fallback**。立刻生效。 + +无 key 选项(DuckDuckGo、SearXNG)让你不需要 API Key 也能搜索。 + +### 怎么加一个自定义工具? + +写一个 Spring `@Component`: + +```java +@Component +public class MyCustomTool { + + @Tool(description = "获取天气信息") + public String getWeather(@ToolParam(description = "城市名") String city) { + return "晴,25°C"; + } +} +``` + +启动时自动注册。见 [工具系统](./tools)。 + +**工具做任何危险的事情时,给它加一条 Tool Guard 规则。** + +### WebSearchTool 返回空结果 + +在 `设置 → 系统 → 搜索服务` 里配一个搜索供应商。无 key 选项(DuckDuckGo、SearXNG)不需要 API Key。 + +### Tool Guard 一直在挡我的工具调用 + +**这是刻意设计的**——危险工具要审批。三种放宽方式: + +1. **给具体的命令模式加一条 allow 规则**(`设置 → 安全与审批 → Tool Guard 规则`)。例子:`ShellExecuteTool`,参数模式 `^(ls|cat|grep|find)\s` → `allow`。 +2. **把默认策略调成 `allow`**(`application.yml`): + ```yaml + mateclaw: + tool: + guard: + default-policy: allow # 生产不推荐 + ``` +3. **完全关掉 Tool Guard**(**只开发**): + ```yaml + mateclaw: + tool: + guard: + enabled: false + ``` + +**生产安全:** 保持 `default-policy: require_approval`,为你信任的具体模式加有针对性的 allow 规则。 + +### 怎么配 MCP 服务? + +UI 里用 `工具 → MCP 服务`。三种传输模式:stdio、streamable_http、sse。配置变更立刻生效。见 [MCP 协议](./mcp)。 + +--- + +## LLM Wiki + +### Wiki 和记忆有什么区别? + +**Wiki 是刻意的。记忆是被动的。** + +- **Wiki**——你扔文档进去,系统消化成结构化页面,Agent 读这些页面。**你建的**、**你编辑的**、**你审核的**。 +- **记忆**——作为对话副产品自动构建。Agent 提取看起来值得记住的东西,每夜整合模式。 + +**源材料可查询**用 Wiki(产品规格、设计文档、过去决策)。**累积的上下文**用记忆(偏好、在做什么)。 + +### Agent 有知识库为什么还在瞎猜? + +因为你没把 Agent 绑到 KB 上。`Agents → [某个 Agent] → 知识库`——在那里绑。**Agent 没显式绑定之前,wiki 工具不会被注入。** + +### 消化很慢 + +调 `application.yml` 里的 `mate.wiki.digestion-concurrency`。默认 2——LLM 额度允许就调到 4 或 8。 + +--- + +## 记忆 + +### 记忆不工作 + +1. **确认自动提取开着**——检查 `mate.memory.auto-summarize-enabled` +2. **确认对话达到阈值**——`min-messages-for-summarize`(默认 4)、`min-user-message-length`(默认 10) +3. **检查冷却**——同一个 Agent 在 `cooldown-minutes`(默认 5 分钟)内不能触发第二次 +4. **看日志**——`vip.mate.memory` 在 DEBUG 级别显示每一次尝试 + +### 记忆整合任务没跑 + +整合由 `mate_cron_job` 里的种子数据驱动,每个 Agent 每天凌晨 2 点。检查: + +- `enabled` 列是 `1` 吗? +- 种子 cron 任务在吗?(`SELECT * FROM mate_cron_job WHERE task_type = 'memory_emergence'`) + +### 我不喜欢 Agent 记住的关于我的东西 + +直接在 Agent 工作空间视图里编辑 `PROFILE.md` 或 `MEMORY.md`。**锁定**你编辑过的页面。见 [记忆系统](./memory)。 + +--- + +## 审批 + +### 我批准了一个工具调用但 Agent 没恢复 + +1. `AWAITING_APPROVAL` 还是 true 吗?(`GET /api/v1/agents/{id}`) +2. 审批真的持久化了吗?(`GET /api/v1/approvals/{id}`) +3. Agent 日志里 replay 尝试附近有错误吗? +4. Replay 失败的话,Agent 应该在聊天里暴露一个错误 + +### 我想批量批准这个 Agent 未来的工具调用 + +你想要的是**一条 allow 规则**,不是一次性全批准。`设置 → 安全与审批 → Tool Guard 规则 → 添加规则`。 + +### Pending 审批能放多久? + +默认 10 分钟,之后过期变成 `rejected`。用 `mateclaw.tool.guard.approval-timeout-seconds` 配置。 + +--- + +## Agent + +### Agent 卡在 RUNNING 状态 + +常见原因: + +1. **工具调用超时**——某个工具在等挂住的外部服务 +2. **超过迭代上限**——`MAX_ITERATIONS_REACHED` 处理器强制给尽力而为的答案 +3. **等审批**——Tool Guard 暂停了执行 +4. **看日志**: + ```bash + mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.vip.mate.agent=DEBUG" + ``` + +### 怎么判断我的 Agent 在用对的工具? + +展开聊天界面的**思考面板**。每次工具调用、参数、结果都看得见。Agent 在调错工具的话,**收紧 system prompt** 引导它。 + +--- + +## 渠道 + +### 钉钉 / 飞书 webhook 收不到消息 + +1. 服务器不是公网可达 +2. 大部分平台要求 HTTPS +3. 验证 token 错了 +4. Bot 没被加到群里或没有消息权限 + +**更简单:** 用 **stream / 长连接 / WebSocket 模式**而不是 webhook。钉钉 Stream、飞书 WebSocket、Telegram Long-Polling、Discord Gateway、Slack Socket mode——**都不需要公网 IP**。 + +### 能同时用多个渠道吗? + +可以。每个渠道独立、绑一个 Agent。可以同时跑 web 控制台、钉钉、Telegram,绑不同的 Agent(或同一个——你说了算)。 + +### Telegram / Discord 访问不到 API(国内网络) + +在渠道配置里配 `http_proxy`: + +```json +{ + "bot_token": "...", + "http_proxy": "http://127.0.0.1:7890" +} +``` + +--- + +## 数据备份 + +### 怎么备份数据? + +**H2(开发 / 桌面):** 停服务,拷贝 `./data/mateclaw.mv.db`: + +```bash +cp ./data/mateclaw.mv.db ./backup/mateclaw-$(date +%Y%m%d).mv.db +``` + +**MySQL(生产):** + +```bash +mysqldump -u root -p mateclaw > mateclaw-backup-$(date +%Y%m%d).sql +``` + +**Docker:** + +```bash +docker exec mateclaw-mysql mysqldump -u root -p${MYSQL_ROOT_PASSWORD} mateclaw > backup.sql +``` + +**桌面端**数据在每个用户目录下: + +- macOS:`~/Library/Application Support/MateClaw/` +- Windows:`%APPDATA%/MateClaw/` +- Linux:`~/.local/share/MateClaw/` + +--- + +## 桌面应用 + +### 桌面 app 启动不了 + +安装器自带 JRE 21。看日志: + +- macOS:`~/Library/Logs/MateClaw/` +- Windows:`%APPDATA%/MateClaw/logs/` +- Linux:`~/.local/share/MateClaw/logs/` + +从终端启动。Windows 右键 → 解除锁定。macOS "系统设置 → 隐私与安全性"允许未签名应用。 + +### 怎么更新桌面 app? + +**自动更新**通过 electron-updater。启动时检查 GitHub Releases 并弹提示。也可以手动从 [Releases](https://github.com/matevip/mateclaw/releases) 下载。 + +--- + +## Docker + +### Docker 容器起不来 + +```bash +docker compose logs mateclaw-server +docker compose logs mateclaw-mysql +``` + +常见: + +- MySQL 还没就绪 +- 端口冲突(18080、3306) +- 缺 `.env`——从 `.env.example` 拷一份 + +### 怎么在 Docker 里访问数据库? + +```bash +docker exec -it mateclaw-mysql mysql -u root -p mateclaw +``` + +--- + +## 调试 + +### 怎么开 DEBUG 日志? + +```yaml +logging: + level: + vip.mate: DEBUG + vip.mate.agent: DEBUG + vip.mate.agent.graph: DEBUG + org.springframework.ai: DEBUG +``` + +或: + +```bash +mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.vip.mate=DEBUG" +``` + +### 怎么访问 H2 console? + +1. 访问 `http://localhost:18088/h2-console` +2. JDBC URL:`jdbc:h2:file:./data/mateclaw` +3. 用户名:`sa` +4. 密码:(空) + +**生产环境关掉它。** + +### 怎么观察 SSE 流式事件? + +浏览器 DevTools → Network → 筛选 `EventStream`。或: + +```bash +curl -N -H "Authorization: Bearer " \ + "http://localhost:18088/api/v1/chat/1/stream?conversationId=1" +``` + +--- + +## 前端 + +### 构建后前端显示空白页 + +```bash +cd mateclaw-ui +pnpm build +ls ../mateclaw-server/src/main/resources/static/ +# 应该包含 index.html 和资源文件 +``` + +### 深色模式不持久化 + +存在 `localStorage`。清了浏览器数据会丢。 + +### UI 感觉卡顿 + +- 把日志调回 INFO +- 检查 `java -Xmx` 设置 +- 在老会话上点**清空消息** + +--- + +## 下一步 + +- [快速开始](./quickstart)——搭建 walkthrough +- [配置说明](./config)——完整配置参考 +- [贡献指南](./contributing)——怎么报 bug 和提功能请求 +- [GitHub Issues](https://github.com/matevip/mateclaw/issues)——文档没答案的时候去这里 diff --git a/mateclaw-server/src/main/resources/docs/zh/goals.md b/mateclaw-server/src/main/resources/docs/zh/goals.md new file mode 100644 index 00000000..553672f5 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/goals.md @@ -0,0 +1,253 @@ +--- +title: 持久化目标 — 跨多轮锁定,让员工自己跟进 +description: MateClaw 的 Goal 系统让数字员工把跨多轮的任务锁成一个目标,自己评估进度、自己续命,直到完成或耗尽预算。 +head: + - - meta + - name: keywords + content: Goal,目标管理,Agent,多轮对话,自动评估,auto-followup,持久化,MateClaw +--- + +# 持久化目标 + +> **以前你每轮都要把上下文重复一遍。现在你定一个目标,员工自己跟。** + +一次对话里你说"帮我把这个博客部署到 fly.io",员工答完一轮就停了。下一轮你要再问"DNS 配好没?证书呢?测试跑了吗?"——你在替它记目标。 + +Goal 把这件事翻过来。**你说一次,员工锁住目标,自己每轮自检:还差什么?要不要自己再做一步?** + +它不是聊天里的一个新功能。它是员工的一种**状态**。员工头像周围多了一圈光,光填多少就是离完成多远。完成了,光消失。 + +--- + +## 它在视觉上长什么样 + +不是一个 banner。不是一个 dialog。不是一个独立的标签页。 + +是 **assistant 头像周围的一圈环**。 + +| 状态 | 视觉 | 含义 | +|------|------|------| +| 无目标 | 头像就是头像 | 这条对话没绑目标,跟过去一样 | +| 进行中 | 头像 + 橙色环 | 有目标在跟,光填到进度处 | +| 评估中 | 头像 + 沙金呼吸光晕 | 后台正在判断这轮答案 | +| 已完成 | 头像 + 绿色环(短暂出现) | 目标达成,环随后消失,对话继续 | +| 预算耗尽 | 头像 + 红橙色环 | 用完 budget,需要你决定加预算还是放手 | + +**hover 头像**才显示完整 tooltip — 标题 + 还差什么。不 hover 就不打扰你。这是设计意图。 + +--- + +## 怎么定一个目标 + +三种方式,按门槛从低到高: + +### 方式 1 — 让员工自己定 + +你只要在第一次描述任务时让员工知道这是个长任务: + +> 我要做一个完整的项目:把 README 翻译成英文、提 PR、走 review、合并。这跨多轮,**请你用 setGoal 锁定**,每轮自我评估,turnBudget=8,autoFollowup 开启。 + +员工识别到"长任务"+"明确要求 setGoal"两条信号,会自动调用工具创建目标,title 从对话上下文自动归纳。你只需点开它的回答,看见头像旁边多了一圈光,就知道目标已锁。 + +### 方式 2 — 直接命令工具 + +不想让员工判断,你直接告诉它调哪个工具、传什么参数: + +> 请立刻调用 setGoal 工具,title="部署博客到 fly.io",turnBudget=10,autoFollowup=true。不要问任何前置确认。 + +"不要问前置确认"这一句很重要 — 否则员工会先问"代码在哪?域名是什么?" 它的本能就是先澄清。 + +### 方式 3 — 通过 API 程序化创建 + +对自动化、外部脚本,REST 端点直接可用: + +``` +POST /api/v1/goals +{ + "conversationId": "conv-xxx", + "agentId": "1000000001", + "workspaceId": 1, + "title": "部署博客到 fly.io", + "description": "...", + "exitCriteria": "DNS+SSL+健康检查+测试通过", + "turnBudget": 10, + "llmCallBudget": 200, + "autoFollowupEnabled": false +} +``` + +完整接口列表见 [API 参考](./api)。 + +--- + +## 一个目标里有什么 + +最少四样: + +| 字段 | 含义 | +|---|---| +| **标题 (title)** | 短句,光环 hover 时显示 | +| **描述 (description)** | 完整诉求 | +| **退出判据 (exitCriteria)** | LLM 可读的判据,evaluator 按这个打分(比如 "DNS 配好+测试通过") | +| **预算 (turnBudget + llmCallBudget)** | 防失控上限 | + +可选: + +- **自动延续 (autoFollowupEnabled)**:开了之后,员工答完一轮如果觉得"还没完成",会自己接着做下一步,不等你催 +- **冷却 (followupCooldownSeconds)**:两次自动延续之间至少隔多久 + +--- + +## 它在后台是怎么运转的 + +每次员工回答完一轮,后台会跑一个评估节点。这个节点: + +1. 取员工这一轮的最终回答 + 最近几条消息上下文 +2. 调一个轻量 evaluator(建议指向便宜的小模型)问:完成度多少(0~1)?还差什么?该继续还是已完成? +3. 把答案写到 `mate_agent_goal_event` 时间线表里 +4. 决定下一步:完成 / 预算耗尽 / 继续 / 自动延续 + +**关键不变量**:评估发生在 final answer 已经串给你看完之后 — **不阻塞用户看回答**。你看到回答出现 → 短暂后头像旁边的光环进度变化。 + +### 自动延续是怎么发生的 + +如果 `autoFollowupEnabled=true` 且这一轮 evaluator 判 "continue",后台会: + +1. 写一条 `followup_injected` 事件到时间线 +2. 给对话末尾 APPEND 一条用户消息:"Continue working on the goal. Still missing: {gap}. Take the next concrete step." +3. 让员工再跑一轮 reasoning,**这一轮的回答就直接接在第一轮后面** + +你的体感是:员工答完一段 → 停半拍 → **继续往下做** — 就像一个人做完一步停了一下想了想然后继续。 + +--- + +## 4 个内置工具(员工可用) + +员工的工具集里默认包含这 4 个(无需手动绑定,是 agent-wide 系统级工具): + +| 工具 | 用途 | 触发提示词示例 | +|---|---|---| +| **setGoal** | 创建目标 | "请用 setGoal 锁定本次目标,title=..." | +| **addGoalCriterion** | 追加子准则到已有目标 | "再加一条准则:必须支持 IPv6" | +| **completeGoal** | 显式标记完成 | "所有事项已做完,请 completeGoal" | +| **getGoalStatus** | 查询当前 goal 状态 | "我们现在进展到哪了?" | + +完成时 (`completeGoal` 或 evaluator 判 score≥0.95),员工会把这个目标的总结同步到[长期记忆](./memory),后续对话能查得回来。 + +--- + +## 子员工不能改父员工的目标 + +[多员工协作](./agents)里 parent 员工可以委派 child 员工干活。Child **看不到**这 4 个 goal 工具 — 目标是 parent 会话的状态,child 是无状态的执行体。 + +> 这一条是设计意图,不是 bug。child 帮 parent 做事,但目标的"所有权"留在 parent 那。 + +--- + +## 预算耗尽时 + +``` +turnsUsed >= turnBudget 或 (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget +``` + +任一条命中 → 目标状态翻为 **exhausted**,不再触发评估、不再注入 follow-up,光环变橙红色。员工的最后一轮回答会正常发送给你。 + +你的选择: + +- **加预算 + 恢复** — 通过 `PATCH /api/v1/goals/{id}` 改 budget 后 resume(v1 暂未给 UI 提供按钮,可以走 API 或先 abandon 重新创建) +- **放手** — 调 abandon,conversation 上释放槽位,可以重设新目标 + +--- + +## 状态机 + +``` + create + ↓ + active + ↓ ↑ + paused + + active ──evaluator score≥0.95 / completeGoal──→ completed (终态) + ↓ + active ──turns_used/llm_calls 用完 ─────────→ exhausted (终态) + ↓ + active ──user abandon ─────────────────────→ abandoned (终态) +``` + +终态 (completed / exhausted / abandoned) 不能复活。要继续就开新 goal — 这是有意保留的简单约束,避免 "重启" 带来的预算账目混乱。 + +**一会话一目标**:每个 conversation 同一时刻最多一个 active goal。终态 goal 留在历史里不占名额。底层用 H2 / MySQL 的生成列 + 唯一索引保证并发安全,service 层 + DB 层双重防御。 + +--- + +## 这套系统不做什么 + +按设计原则保留了几个"不做": + +- **不做嵌套目标 / 目标树** — 一个 conversation 一个目标,不堆 OKR +- **不做"目标模板"** — 每个目标是手写的,不是从库里挑的 +- **不做跨 conversation 迁移目标** — 想要那效果,请用[工作流](./workflow) +- **不暴露评估分数给用户** — 那个 `completionScore` 是工程内部协议,不是用户语言。UI 用一圈光说话,hover 显示 evaluator 写的 gap 文本(自然语言)。后端日志和 API 里仍可见数值,方便调试 + +--- + +## 完整事件时间线(drawer 抽屉视图) + +每个目标都有一份只增不删的事件时间线,按时间倒序展示: + +| 事件 | 触发 | +|---|---| +| `created` | setGoal 工具或 REST POST | +| `evaluated` | 每轮答完,evaluator 跑完一次 | +| `followup_injected` | autoFollowup 触发,注入了 prompt | +| `completed` | evaluator 判完成或 completeGoal 工具 | +| `exhausted` | budget 用尽 | +| `paused` / `resumed` / `abandoned` | 用户手动操作 | +| `criterion_added` | addGoalCriterion 工具 | + +通过 `GET /api/v1/goals/{id}/events` 拉取(详见 [API 参考](./api))。 + +--- + +## 配置项 + +`application.yml`: + +```yaml +mateclaw: + goal: + # 主开关;关闭后图节点对所有调用 pass-through + enabled: true + # 默认 turn 预算 + default-turn-budget: 20 + # 默认 LLM 调用预算(agent + evaluator 之和) + default-llm-call-budget: 200 + # 自动延续之间至少隔多久(秒) + auto-followup-cooldown-seconds: 0 + # 评估器使用的模型;空字符串 = 沿用对话当前模型(便宜的小模型推荐:qwen-turbo / glm-4-flash) + evaluator-model: "" + # 评估 prompt 携带的历史消息条数上限 + evaluator-context-messages: 8 +``` + +--- + +## 数据库 + +两张表,都用 `mate_` 前缀: + +| 表 | 用途 | +|---|---| +| `mate_agent_goal` | 目标本体;含 status / budget / 双 LLM 计数器 / 自动延续配置 | +| `mate_agent_goal_event` | 目标的事件追加日志,drawer 时间线读它 | + +迁移由 Flyway 跑 `V120__agent_goal.sql`(H2 + MySQL 双方言)。 + +--- + +## 一句话总结 + +**Goal 不是给员工加一个功能。是改它的状态。** + +以前的员工"答完就忘"。Goal 让员工跨多轮记住一件事 — 它在干什么、还差什么、什么时候算完。你只用说一次。剩下的,让头像旁边那圈光替你跟。 diff --git a/mateclaw-server/src/main/resources/docs/zh/index.md b/mateclaw-server/src/main/resources/docs/zh/index.md new file mode 100644 index 00000000..6e4081e5 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/index.md @@ -0,0 +1,44 @@ +--- +layout: home + +hero: + name: MateClaw + text: 公司允许部署的那一个 AI。 + tagline: 别的 AI 助手是给一个人用的。MateClaw 是给一个团队用的——多用户工作空间、敏感操作走审批、完整审计日志、生产级健康监控。一个 JAR 包跑在自己机器上,数据不出门。 + image: + src: /logo.png + alt: MateClaw + actions: + - theme: brand + text: 开始使用 → + link: /zh/quickstart + - theme: alt + text: 阅读文档 + link: /zh/intro + - theme: alt + text: GitHub + link: https://github.com/matevip/mateclaw + +features: + - icon: 🧑‍💼 + title: 数字员工,不是聊天机器人 + details: 你雇佣同事,不是开聊天框。每位有角色 / 目标 / 背景故事、像素艺术头像、专属配色——5 个职业模板开箱可用。ReAct + Plan-and-Execute 双模式,员工之间并行委派。 + - icon: 🧩 + title: 技能是骨架,不是插件 + details: 一份 SKILL.md + 一份 LESSONS.md(用得越多越聪明)。8 个起步模板,向导 5 步出包,安装前自动 Pre-flight 检查。MCP / ACP 双桥接,连 Claude Code、Codex 都能进来当员工。 + - icon: 📚 + title: 被塑形的知识 + details: LLM Wiki 把原始文件消化成结构化、带双向链接和摘要的知识页面。是一本你能翻的书,不是一个你只能查的向量库。热点缓存自动注入到员工的 system prompt。 + - icon: 🧬 + title: 会越积越多的记忆 + details: 会话上下文、对话后的结构化提取、工作空间记忆文件、定时 Dreaming 整合。明天的对话从今天停下的地方继续。 + - icon: 👀 + title: 你看得见每位员工在干什么 + details: Admin 运行时控制台 — 谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示,多员工协作不打架,长任务必须有真实证据才回答。 + - icon: 🔀 + title: 业务流程,不再是手工接力 + details: 工作流把多位员工 + 系统动作(审批 / 渠道分发 / 写记忆)按 7 种 step mode 编排成一条可发布、可触发、可重放的业务流;触发器把"系统里发生的事"自动启动这条流——6 种 pattern 覆盖 cron、webhook、渠道消息、员工生命周期、内容匹配、工作流完成。 + - icon: 🌐 + title: 工作发生的每一个真实场所 + details: Web 控制台、内置 JRE 21 的桌面端,以及 8 个聊天渠道。同一个大脑、同一份记忆,跟着团队走到哪里就到哪里。 +--- diff --git a/mateclaw-server/src/main/resources/docs/zh/intro.md b/mateclaw-server/src/main/resources/docs/zh/intro.md new file mode 100644 index 00000000..c6391f02 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/intro.md @@ -0,0 +1,90 @@ +--- +title: MateClaw 项目介绍 — 自部署多智能体 AI 操作系统 +description: MateClaw 是基于 Spring AI Alibaba 的开源多智能体 AI 操作系统。ReAct + Plan-and-Execute 双引擎、LLM Wiki 知识库、四层记忆系统、MCP 工具协议、8 渠道统一接入。一个 JAR 包自部署,数据不出门。 +head: + - - meta + - name: keywords + content: MateClaw,多智能体,AI操作系统,自部署AI,Spring AI Alibaba,ReAct,Plan-and-Execute,MCP,LLM Wiki,记忆系统,Tool Guard,开源 +--- + +# MateClaw — 自部署多智能体 AI 操作系统 + +**你的多智能体 AI,跑在你自己的机器上,按你自己的规则。** + +MateClaw 是一整套可以自部署的 AI 操作系统。一个 JAR 包,一套登录,数据不出门。 + +**它和别的 AI 不一样的三件事——** + +**主动**:它在该出现的时候自己出现。每天早上 9 点把简报推送到你的飞书,竞品有大动作直接 ping 你的钉钉。**不在浏览器 tab 里等你。** → [主动型 AI](./ambient-ai) + +**会做梦**:你睡了它跑一次整合,把今天零散的对话整合成对你的理解,写进 `MEMORY.md`。第二天它从昨天结束的地方继续,**不是从零开始**。 → [记忆系统](./memory) + +**可审批**:Agent 想删文件、发邮件、写数据库——触发 Tool Guard 规则就**在回合中途暂停**,审批请求推到你的 IM,你点批准 Agent 才接着跑。**会动手,但不擅自动手。** → [安全与审批](./security) + +它同时活在你的桌面、浏览器,以及团队每天在用的聊天软件里——同一个大脑,同一份记忆,跟着团队走到哪里就到哪里。 + +模型自己选。DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、MiniMax、智谱、OpenRouter。本地跑就上 Ollama,手头有 ChatGPT Plus 账号就 OAuth 登进去直接用。先配一个,后面随时加。 + +--- + +## 它在对抗什么 + +市面上大多数 AI 产品只做一层。 + +给你一个聊天框,但明天打开就又从零开始;给你一个工具执行器,但不给你按"暂停"的机会;给你一个知识库,只会检索碎片却说不清它到底知道什么;给你桌面端,却进不了团队在用的聊天软件。或者——所有东西都给了,但全都跑在别人家的云上,你的数据顺便给别人付房租。 + +MateClaw 换了一个打法:**所有东西放在一个屋檐下,跑在你自己能摸到的硬件上。** + +--- + +## 它实际在做什么 + +**它会把活儿干完。** Plan-and-Execute 会把复杂任务拆成有序的步骤,一步一步执行,中途哪一步炸了就重新调整。ReAct 管更小的循环——思考、行动、观察、继续。你会看到计划在滚,看到工具被调,看到思考过程,看到它最终收尾。 + +**它会记住。** 会话上下文、对话后的结构化提取、工作空间的记忆文件、定时整合,再加一轮"dreaming"——把昨天的线索串起来。记忆不是聊天功能上贴的一张贴纸,是系统越用越懂你的底层机制。 + +**它会把知识嚼碎。** 扔一份 PDF。扔一整个文件夹。扔一千篇 Markdown 笔记。LLM Wiki 会把它们消化成结构化的、带双向链接和摘要的知识页面——不是一个向量库,是一本你能翻的书。Agent 自动注入页面摘要,需要细节时再去取整页。 + +**它手上有真工具。** 内置工具:搜索、文件读写、shell、时间、图像、音乐、视频、语音识别、语音合成。任何别的东西都能接 MCP 服务。你自己的技能包只要写个 `SKILL.md` 就能装进工作空间。所有工具都过一层 Tool Guard,必要时还能走人工审批——手能伸得远,但边界清清楚楚。 + +**它会在每一个真实的工作面上出现。** Web 控制台、桌面端(内置 JRE 21,用户不需要装 Java),还有八个聊天渠道:钉钉、飞书、企业微信、微信、Telegram、Discord、QQ、Slack。Slack 里回复的 Agent 和浏览器里的 Agent 是同一个——同一份记忆、同一套技能、同一种性格。 + +--- + +## 为什么"自部署"这件事很重要 + +把 MateClaw 跑在你自己的机器上,不是合规打个勾那么简单。它改变的是这个产品**到底是什么**。 + +**你的数据不再给别人付房租。** 对话、日志、文档、记忆——没有一条拿去训练别人的模型,没有一条在别人家的队列里排队,没有一条离开你的机器,除非是你自己把某个渠道接了出去。 + +**路线图是你的。** 记忆整合的规则你不喜欢?自己改。需要一个厂商不给你做的工具?自己加。Apache 2.0,不是 "source available",不是 "open core",不用等别人的季度产品评审。 + +**账单是你自己算的。** 一开始上 DashScope,等本地 GPU 到了就切 Ollama,某个高价值 Agent 单独挂 OpenAI,其他的走便宜的。Agent 配置和工具图不关心底下的模型接口是什么。 + +**部署面是实打实的。** 一个 JAR 包。一个 Spring Boot 进程。不用装 Python,不用装 Node。桌面端自己带环境,Docker Compose 一共 18 行。 + +--- + +## 底下是什么 + +- **后端**——Spring Boot 3.5 + Spring AI Alibaba 1.1。Agent 运行时是一张 StateGraph,reasoning、action、observation、plan generation、step execution 都是图上的节点。MyBatis Plus 持久化。流式走 SSE(WebFlux 被明确拒之门外)。 +- **前端**——Vue 3 + TypeScript。Pinia 管状态,Element Plus + Tailwind 做 UI,支持深色模式。前端 build 的产物直接进后端 JAR 的 `static/`,一个进程服务两端。 +- **桌面端**——Electron 包 JRE 21 + 后端 JAR。双击启动,用户完全不需要知道底下跑的是 Java。 +- **渠道**——每个渠道是一个 `ChannelAdapter` SPI 实现。Web 走 SSE,IM 各自走平台的长连接或 webhook。 +- **存储**——开发用 H2 文件数据库,生产用 MySQL 8。Flyway 管理 schema 迁移,每种方言各有一套脚本。 + +--- + +## 三条进入方式 + +大概率你想做三件事之一。 + +**想用起来?** → [快速开始](./quickstart),桌面端 60 秒到第一条消息。 + +**想搞清楚它?** → 按这个顺序读:[Agent 引擎](./agents) → [LLM Wiki](./wiki) → [记忆系统](./memory) → [多模态创作](./multimodal)。这四页就是产品本身。 + +**想在它上面建东西?** → [API 参考](./api) 和 [贡献指南](./contributing)。 + +--- + +这页上写的所有东西都可以质疑。如果哪里读不通,是文档的问题,不是你的——去 GitHub 告诉我们。 diff --git a/mateclaw-server/src/main/resources/docs/zh/mcp.md b/mateclaw-server/src/main/resources/docs/zh/mcp.md new file mode 100644 index 00000000..9274cb24 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/mcp.md @@ -0,0 +1,429 @@ +--- +title: MCP 协议集成 — Model Context Protocol 工具扩展 +description: MateClaw 作为 MCP 客户端,通过 Model Context Protocol 接入任意外部工具服务器。JSON-RPC 动态发现、SSE/stdio 双传输、与内置工具无缝统一。 +head: + - - meta + - name: keywords + content: MCP,Model Context Protocol,MCP客户端,工具协议,JSON-RPC,AI工具扩展,Anthropic MCP +--- + +# MCP 协议 + +**MCP 是 MateClaw 跟"别人写的工具"对话的方式。** + +Model Context Protocol 是 Anthropic 提出的一个开放标准,用来把 AI 模型和外部工具/数据连起来。一个 MCP 服务是一个进程——本地或远程——通过 JSON-RPC 向外宣告一组工具。MateClaw 扮演 MCP **客户端**:连接上去、通过 `tools/list` 发现工具、把它们当原生工具暴露给你的 Agent。**从 Agent 的视角,一个内置的 `@Tool` Spring bean 和一个从 MCP 服务来的工具,没有任何区别。** + +这是 MateClaw 的**逃生口**。你需要一个 MateClaw 没自带的能力——沙盒目录的文件访问、Tavily 搜索、某个自定义的企业数据服务、一整套浏览器自动化——大概率已经有现成的 MCP 服务,你可以把它接进来,不用写一行 Java。 + +--- + +## MCP 到底是什么 + +``` +┌───────────────────────┐ ┌───────────────────────┐ +│ MateClaw │ │ MCP Server │ +│ (MCP Client) │ │ (工具提供方) │ +│ │ JSON-RPC │ │ +│ Agent Engine ───────┼──────────────┼──► Tool A │ +│ │ │ Tool B │ +│ Tool Registry ◄──────┼──────────────┼─── Tool Discovery │ +│ │ │ (tools/list) │ +└───────────────────────┘ └───────────────────────┘ +``` + +核心概念: + +- **MCP 客户端**——MateClaw,负责连接 MCP 服务、发现工具、转发工具调用 +- **MCP 服务**——第三方工具服务器 +- **工具发现**——客户端发送 `tools/list` 请求拿到服务器上所有工具 +- **工具调用**——Agent 决定调一个工具时,客户端转发给对应的 MCP 服务执行 + +新工具能力变成 Agent 可用的——**不改代码、不重启服务**。 + +--- + +## 三种传输类型 + +### stdio(标准 I/O) + +MateClaw 启动一个本地子进程,通过 stdin/stdout 交换 JSON-RPC 消息。 + +``` +MateClaw ── stdin ──► MCP Server 子进程 + ◄─ stdout ── +``` + +**适用:** 本地 Node.js/Python MCP 工具包、命令行工具封装、开发调试。 +**优势:** 不需要网络配置,开箱即用,进程隔离。 +**限制:** 仅本地。 + +### streamable_http(可流式 HTTP) + +标准 HTTP POST 发 JSON-RPC,响应通过 HTTP 流返回。**生产环境推荐。** + +``` +MateClaw ── HTTP POST ──► 远程 MCP 服务 + ◄─ HTTP Stream ── +``` + +**适用:** 云部署的 MCP 服务、前面有负载均衡的场景。 +**优势:** 标准 HTTP,CDN/防火墙友好,支持认证头。 + +### sse(Server-Sent Events) + +早期 HTTP 传输模式,用 SSE 做服务端到客户端推送。遗留兼容,新项目优先选 `streamable_http`。 + +### 传输对比 + +| 特性 | stdio | streamable_http | sse | +|------|-------|-----------------|-----| +| 部署 | 仅本地 | 本地或远程 | 本地或远程 | +| 网络要求 | 无 | HTTP 可达 | HTTP 可达 | +| 认证 | 环境变量 | HTTP Headers | HTTP Headers | +| 进程管理 | MateClaw 管理子进程 | 外部 | 外部 | +| 推荐 | 本地工具 | 远程服务 | 遗留兼容 | + +--- + +## UI 配置 + +`工具 → MCP 服务 → 添加 MCP 服务`。填: + +- **名称**——唯一标识符(字母、数字、`_`、`-`、`.`、空格;1–128 字符) +- **描述**——可选 +- **传输类型**——`stdio`、`streamable_http`、`sse` +- **命令**(stdio)——`npx`、`node`、`python` 等 +- **参数**(stdio)——JSON 数组(例如 `["-y", "@anthropic/mcp-filesystem", "/path"]`) +- **工作目录**(stdio)——可选 +- **环境变量**(stdio)——JSON 对象;支持 `${ENV_VAR}` 引用 +- **URL**(streamable_http / sse)——服务端点 +- **HTTP Headers**(streamable_http / sse)——JSON 对象 +- **连接超时**——默认 30 秒 +- **读取超时**——默认 30 秒 + +保存。启用状态时 MateClaw 自动尝试连接并发现工具。 + +### 测试、启用、状态 + +- **测试连接**——发送 `tools/list`,返回结果、延迟、工具列表 +- **启用/禁用开关**——断开连接但保留配置 +- **状态**——`connected` / `disconnected` / `error` 带错误详情 + +--- + +## REST API 配置 + +完整 CRUD 在 `/api/v1/mcp/servers`。 + +### 列表 + +```bash +curl -s http://localhost:18088/api/v1/mcp/servers \ + -H "Authorization: Bearer " | jq +``` + +响应里的 `headersJson` 和 `envJson` 字段自动**脱敏**(`sk-****abcd`)。 + +### 创建 —— stdio + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "argsJson": "[\"-y\", \"@anthropic/mcp-filesystem\", \"/home/user/workspace\"]", + "enabled": true + }' +``` + +### 创建 —— streamable_http + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "remote-tools", + "transport": "streamable_http", + "url": "https://mcp.example.com/mcp", + "headersJson": "{\"Authorization\": \"Bearer your-api-key\"}", + "connectTimeoutSeconds": 15, + "readTimeoutSeconds": 60, + "enabled": true + }' +``` + +### 更新(PATCH 语义) + +```bash +curl -X PUT http://localhost:18088/api/v1/mcp/servers/{id} \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"description": "更新后的描述", "readTimeoutSeconds": 60}' +``` + +### 删除 / 开关 / 测试 / 刷新 + +```bash +curl -X DELETE http://localhost:18088/api/v1/mcp/servers/{id} \ + -H "Authorization: Bearer " + +curl -X PUT "http://localhost:18088/api/v1/mcp/servers/{id}/toggle?enabled=false" \ + -H "Authorization: Bearer " + +curl -X POST http://localhost:18088/api/v1/mcp/servers/{id}/test \ + -H "Authorization: Bearer " + +curl -X POST http://localhost:18088/api/v1/mcp/servers/refresh \ + -H "Authorization: Bearer " +``` + +**内置服务**(`builtin=true`)**不能删除**。 + +--- + +## 实战示例 + +### 示例 1 —— 文件系统 MCP(stdio) + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "filesystem", + "description": "文件系统读写(限制在指定目录内)", + "transport": "stdio", + "command": "npx", + "argsJson": "[\"-y\", \"@anthropic/mcp-filesystem\", \"/home/user/workspace\"]", + "enabled": true + }' +``` + +发现的工具:`read_file`、`write_file`、`list_directory`、`search_files`、`get_file_info`。 + +安全:`@anthropic/mcp-filesystem` **只允许访问启动参数里指定的目录及其子目录**。 + +### 示例 2 —— 带认证的远程 HTTP + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "internal-data-service", + "transport": "streamable_http", + "url": "https://mcp-api.internal.example.com/mcp", + "headersJson": "{\"Authorization\": \"Bearer sk-your-api-key\", \"X-Team-Id\": \"engineering\"}", + "connectTimeoutSeconds": 10, + "readTimeoutSeconds": 120, + "enabled": true + }' +``` + +**Header 值支持环境变量引用**:`{"Authorization": "Bearer ${MCP_API_KEY}"}` 在运行时被替换,**secret 不落库**。 + +### 示例 3 —— Tavily 搜索(stdio + 环境变量) + +```bash +curl -X POST http://localhost:18088/api/v1/mcp/servers \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "tavily-search", + "transport": "stdio", + "command": "npx", + "argsJson": "[\"-y\", \"@anthropic/mcp-tavily\"]", + "envJson": "{\"TAVILY_API_KEY\": \"${TAVILY_API_KEY}\"}", + "enabled": true + }' +``` + +--- + +## MCP 工具是怎么变成 Agent 可用的 + +``` +应用启动 + │ + ▼ +遍历启用的 MCP 服务 + │ + ▼ +按传输类型连接 → 调用 initialize → 列出工具 → 缓存 + │ + ▼ +工具注册表(聚合内置工具 + MCP 工具) + │ + ▼ +Agent 工具集 +``` + +**关键:** Agent 每次需要调工具时都拉**最新**的活跃工具列表,所以添加或删除 MCP 服务**不需要重启**。从 Agent 的视角看,**MCP 工具和内置工具完全一样**,没有差别。 + +--- + +## Per-agent 工具绑定 + +::: tip 1.3.0 新增 +v1.2.0 之前所有员工默认能用全部 MCP 工具——这是个全局开关。v1.3.0 把粒度拆细到**每个员工独立绑定哪些 MCP 工具**,并加了脏状态识别 + 命名空间防撞。 +::: + +### 三个解决的问题 + +**问题 1:工具命名空间冲突** +两个 MCP server 都暴露 `read_file`——agent 调用时哪个赢?v1.3.0 在内部使用**带 server 前缀的稳定 callback name**(`{serverName}__{toolName}`),并把它持久化到 `mate_mcp_server.cached_tools`。两个 read_file 在 picker 里显示为 `serverA__read_file` 和 `serverB__read_file`,agent 看到的 prompt 里映射回原始名以减少 token + 不让 LLM 困惑。 + +**问题 2:MCP server 改名 / 工具改名 → 员工绑定全部失效** +v1.2.0 时 server 一改名,绑这个 server 的员工全瞎了。v1.3.0 引入**持久化 tool cache**:每次成功 list-tools 后把工具元数据写到 `mate_mcp_server.cached_tools` JSON 列。agent binding 校验时如果 server 暂时连不上,就走 cache fallback——绑定保留为 `stale`,连接恢复后立即可用。 + +**问题 3:员工保存时静默接受不存在的工具引用** +v1.2.0 时员工配置里写了一个 `nonexistent-server.weird-tool`,保存成功,运行时报错。v1.3.0 在保存时跑 `AgentBindingService.validate(...)`: + +| 状态 | 含义 | 保存行为 | +|---|---|---| +| `connected` | server 在线,工具可见 | ✅ 正常保留 | +| `stale` | server 暂时离线但 cache 里有 | ✅ 保留(标记 stale) | +| `unavailable` | server 被禁用 | ✅ 保留(标记 unavailable) | +| `orphan` | server / tool 完全不存在了 | ❌ 拒绝保存,提示用户清理 | + +### 工具状态在哪里看 + +`Agents → 选员工 → 工具`——见 [数字员工的工具绑定](./agents#工具绑定per-agent-tool-picker)。 + +### 数据契约 + +- `mate_mcp_server.cached_tools`(v1.3.0 新列):JSON 数组,每个元素 `{name, description, inputSchema, lastSeenAt}` +- `mate_agent_tool.tool_name`:存的是**带前缀的 callback name** `{serverName}__{toolName}` 而不是原始名,这样 server 改名时 join 失败立刻可观测 +- `AgentBindingService.getEffectiveToolNames(agentId)` 是工具下发的唯一入口——agent 每个回合都跑一遍,确保运行时和编辑期看到的工具集一致 + +### 服务端规则 + +- MCP server 列表里**不可以编辑** ACP 桥接进来的 MCP 工具(它们是 ACP server 自己生命周期管的) +- 工具被 mark unavailable 后,agent system prompt 里**不再列出它**——LLM 不会想到调用它,但绑定数据保留 +- `returnDirect=true` 的工具(直接把工具输出当回答)走同一套 ACL,**不绕过** binding + +--- + +## 连接管理 + +### 启动时自动连接 + +所有 `enabled=true` 的 MCP 服务在应用启动时自动连接。单个服务失败不会阻塞其他或启动。 + +### 线程安全 + +活跃的 client 表是并发安全的,每个服务有独立的锁。 + +### 连接替换 + +**"先连新的,再断旧的"** 策略:建一个新 client、初始化、放进池、关闭旧的。新 client 失败时旧的保持不变。 + +### 子进程清理 + +stdio 服务:禁用/删除、配置替换、应用关闭(`@PreDestroy`)、连接失败时都会清理。 + +### 状态监控 + +每次连接操作后持久化: + +- `last_status`——`connected` / `disconnected` / `error` +- `last_error`——错误消息 +- `last_connected_time`——上次成功连接时间 +- `tool_count`——当前发现的工具数 + +### 手动刷新 + +`POST /api/v1/mcp/servers/refresh` 断开所有现有连接并重连所有启用的服务。用于排查连接问题。 + +--- + +## 数据库存储 —— `mate_mcp_server` + +| 列 | 类型 | 默认值 | 用途 | +|----|------|--------|------| +| `id` | BIGINT | — | 主键 | +| `name` | VARCHAR(128) | — | 唯一标识符 | +| `description` | TEXT | NULL | 服务描述 | +| `transport` | VARCHAR(32) | `stdio` | `stdio` / `streamable_http` / `sse` | +| `url` | VARCHAR(512) | NULL | 远程 URL | +| `headers_json` | TEXT | NULL | HTTP headers JSON | +| `command` | VARCHAR(512) | NULL | 启动命令 | +| `args_json` | TEXT | NULL | 命令参数 JSON 数组 | +| `env_json` | TEXT | NULL | 环境变量 JSON;支持 `${VAR}` | +| `cwd` | VARCHAR(512) | NULL | 工作目录 | +| `enabled` | BOOLEAN | TRUE | 开关 | +| `connect_timeout_seconds` | INT | 30 | HTTP 连接超时 | +| `read_timeout_seconds` | INT | 30 | 请求响应超时 | +| `last_status` | VARCHAR(32) | `disconnected` | 上次连接状态 | +| `last_error` | TEXT | NULL | 上次错误消息 | +| `last_connected_time` | DATETIME | NULL | 上次成功连接时间 | +| `tool_count` | INT | 0 | 发现的工具数 | +| `builtin` | BOOLEAN | FALSE | 是否内置 | +| `create_time` / `update_time` | DATETIME | — | 时间戳 | +| `deleted` | INT | 0 | 逻辑删除 | + +### 敏感数据脱敏 + +API 响应里 `headers_json` 和 `env_json` 的值自动**脱敏**。`args_json` 按原样返回。 + +### 环境变量引用 + +- `${VAR_NAME}`——精确匹配和替换 +- `$VAR_NAME`——正则匹配 + +**明文 secret 不进数据库。** + +--- + +## 故障排查 + +### "命令找不到"(stdio) + +1. 确认命令在运行 MateClaw 的用户的 PATH 里 +2. 验证:`which npx` 或 `npx --version` +3. Docker:确认命令在容器里装了 +4. 用完整路径:`/usr/local/bin/npx` + +### 连接超时 + +1. HTTP/SSE:确认 URL 可达(`curl -v `) +2. 检查防火墙规则 +3. 调大 `connectTimeoutSeconds` / `readTimeoutSeconds` +4. stdio:第一次 `npx -y` 可能要下包 + +### SSL/TLS 错误 + +1. 确认远程 SSL 证书有效 +2. 自签证书:把 CA 证书加进 JVM trust store +3. 确认 JDK 支持需要的 TLS 版本 + +### 工具没显示 + +1. 看 `tool_count > 0` +2. 用测试连接确认 `discoveredTools` 非空 +3. 确认 MCP 服务实现了 `tools/list` +4. 看后端日志里 MCP 工具发现相关的输出 + +### 工具调用失败 + +1. 看后端日志的具体错误 +2. 确认 MCP 服务进程还在跑(stdio) +3. 确认远程服务可达(HTTP/SSE) +4. 看 `readTimeoutSeconds` 够不够 +5. 试刷新连接 + +### 孤儿子进程(stdio) + +`@PreDestroy` 钩子正常会清理。MateClaw 被强杀(`kill -9`)的话子进程可能残留。`ps aux | grep mcp` 找到并杀掉。 + +--- + +## 下一步 + +- [工具系统](./tools)——MCP 工具和内置工具的关系 +- [技能系统](./skills)——MCP 支撑的技能 +- [配置说明](./config)——完整配置参考 diff --git a/mateclaw-server/src/main/resources/docs/zh/memory.md b/mateclaw-server/src/main/resources/docs/zh/memory.md new file mode 100644 index 00000000..d0a10750 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/memory.md @@ -0,0 +1,452 @@ +--- +title: AI 记忆系统 — 四层记忆生命周期(提取-整合-Dreaming-召回) +description: MateClaw 的四层记忆生命周期:即时上下文、对话后提取、工作空间持久化(PROFILE.md/MEMORY.md)、定时 Dreaming 整合。让 AI 越用越懂你,不再每天从零开始。 +head: + - - meta + - name: keywords + content: AI记忆,记忆系��,Dreaming,PROFILE.md,MEMORY.md,记忆生命周期,长期记忆,记忆提取,记忆整合 +--- + +# AI 记忆系统 + +**记忆是系统越用越懂你的机制。** + +MateClaw 里其他所有东西,在你配置完之后就静止了。Agent、工具、知识库——你改它们的时候才改。记忆是**唯一一个**会自己改变的部分,变化是实际使用过程的副产品。这就是整个设计的核心意图。 + +::: tip 它在你睡着的时候做了一个关于你的梦 +不是营销词。是 `memory/dreaming/` 包里真实跑的代码。 + +每天凌晨 2 点(默认时间,可改),系统跑一次调度任务,名字就叫 **Dreaming**:扫一遍今天和你聊天的每个 Agent 的对话痕迹,把零散的线索整合成对你的理解,过滤掉一次性的、矛盾的、过期的,把高频出现的提升进 `MEMORY.md`,整个"看见了什么、得出了什么、改写了什么"的过程追加进 `DREAMS.md`——一条人类可读的审计线。 + +第二天早上你打开它,它**从昨天结束的地方继续**,不是从零开始。 + +> 别的 AI 每天从零开始。MateClaw 从昨天结束的地方继续。 +::: + +这一页讲组成记忆的四个层、每个 Agent 的记忆文件、以及 Agent 自己怎么在对话中读写这些文件。 + +--- + +## 四个层 + +``` + ┌────────────────────────────────────────────────────────────┐ + │ 1. 当下这一回合 │ + │ 正在说的话、刚刚说过的话、按 token 预算自动裁剪 │ + │ 更新时机:每一回合 │ + └────────────────────────────────────────────────────────────┘ + │ + ▼(对话完成之后) + ┌────────────────────────────────────────────────────────────┐ + │ 2. 对话结束后的提取 │ + │ 从对话里挑出值得记住的事,写进 PROFILE.md / MEMORY.md │ + │ 和当天的日常笔记 │ + │ 更新时机:每次有意义的对话结束后异步跑 │ + └────────────────────────────────────────────────────────────┘ + │ + ▼(默认每天凌晨 2 点,可调) + ┌────────────────────────────────────────────────────────────┐ + │ 3. 夜里整合(Dreaming) │ + │ 扫一遍最近的日常笔记,找出反复出现的模式, │ + │ 合并进 MEMORY.md,把过程记到 DREAMS.md │ + │ 更新时机:定时触发,可手动 │ + └────────────────────────────────────────────────────────────┘ + │ + ▼(下一次对话直接用最新版本) + ┌────────────────────────────────────────────────────────────┐ + │ 4. 工作空间文件进入 system prompt │ + │ 四个 markdown 文件每一回合都被注入 │ + │ 更新时机:底下文件一变,下一回合就生效 │ + └────────────────────────────────────────────────────────────┘ +``` + +每一层跑在不同的时间尺度上。当下是**这一回合**。提取是**每一次对话之后**。整合是**每天夜里**。文件注入是**每一回合都用当前最新版本**。加在一起它们形成一个循环——你说的话变成上下文,上下文变成文件,文件变成 system prompt,system prompt 变成 Agent 明天知道的东西。 + +--- + +## 多层记忆 + 可插拔 Provider + +记忆这一层不是一个硬编码的实现。它是一个**接口**——多层架构允许你**堆叠 provider**: + +- **默认 Provider** 就是这页后面讲的基于工作空间文件的记忆。MateClaw 出厂就带这个,对大多数人来说这一个就够了。 +- **自定义 Provider** 可以插入用于专用检索——基于向量的长期记忆、图结构记忆、外部记忆服务。 +- **分层**意味着同一个 Agent 可以同时和多个 provider 对话。短期 provider 返回最近上下文;语义 provider 返回相关记忆;Wiki provider 返回权威引用。它们在读取时组合。 + +对大多数 Agent 来说,**默认的就够了**,这一节可以跳过。如果你在做某种专用的东西——需要记住上千条事实并用向量搜索、需要图结构记忆——这里就是插入点。开发细节看 [架构说明](./architecture)。 + +--- + +## 每个 Agent 都有的四个文件 + +每个 Agent 都有自己的工作空间。四个 markdown 文件是长期记忆的骨架: + +``` +workspace/{agentId}/ +├── AGENTS.md # Agent 怎么用记忆 —— 行为指南 +├── SOUL.md # Agent 是谁 —— 核心身份、人格、边界 +├── PROFILE.md # 你是谁 —— 用户画像、偏好、背景 +├── MEMORY.md # 什么重要 —— 关键决策、项目上下文、待办 +└── memory/ + ├── 2026-04-09.md # 日常笔记 —— 今天发生了什么,追加模式 + ├── 2026-04-10.md + └── 2026-04-11.md +``` + +前四个会在**每一回合注入到 system prompt**(只要 `enabled=true`)。每日笔记不会——它们喂给整合服务用。 + +### 每个文件是干什么的 + +- **AGENTS.md**——Agent 自己的使用说明书。什么时候该写记忆、每个地方放什么、有哪些工具可以操作记忆。种子:`enabled=true`,`sort_order=0`。 +- **SOUL.md**——Agent 从根上是谁。自我意识、演化指引、隐私与边界原则。想在深层修改 Agent 的性格时编辑它。种子:`enabled=true`,`sort_order=1`。 +- **PROFILE.md**——Agent 学到的关于你的东西。名字、职业、技术栈、沟通偏好。对话里出现值得保留的东西时记忆提取器会更新它。全覆盖写入。种子:`enabled=true`,`sort_order=2`。 +- **MEMORY.md**——Agent 认为重要到值得留下的东西。活跃项目、未决定的事、打开的线索、你让它记住的东西。提取器和整合器都会更新它。种子:`enabled=true`,`sort_order=3`。 + +::: tip 1.3.0 新增:工作流可以写记忆 +v1.3.0 起,[工作流](./workflow) 的 `write_memory` step 可以在流程跑完时把结果直接写进某位员工的 `MEMORY.md`(或任意启用的 memory 文件),支持 4 种合并策略:`append` / `replace_section` / `upsert_kv` / `overwrite`。这意味着记忆不再只能由对话提取或 Dreaming 写入——一条业务流程的产物也可以被沉淀。 +::: + +### 每日笔记 + +对话亮点按日期归档,**追加模式**——同一天里的多次对话全部累加到同一个文件。这些不会注入到 system prompt(`enabled=false`)。它们存在是为了让整合器凌晨两点跑的时候有东西可扫。 + +--- + +## 短期:上下文窗口 + +每一次 LLM 调用之前,MateClaw 都会构造真正送出去的那个 prompt: + +``` +[System Prompt] ← 永远在最前 +[工作空间文件注入] ← AGENTS / SOUL / PROFILE / MEMORY +[对话上下文摘要] ← 只有在早期轮次被压缩过时才有 +[Message 1: user] +[Message 2: assistant] +... +[当前用户消息] ← 永远在最后 +``` + +工作空间文件按 `sort_order` 排序拼进 system prompt,格式: + +``` +--- AGENTS.md --- +(内容) + +--- SOUL.md --- +(内容) + +--- PROFILE.md --- +(内容) + +--- MEMORY.md --- +(内容) +``` + +只注入 `enabled=true` 的文件。 + +### 上下文爆了怎么办 + +三层防御: + +**第一层:主动压缩。** 估算总 token 超过预算的 75%(默认窗口 12.8 万 token),系统让 LLM 总结早期轮次。最近 2 轮(4 条消息)保留原文。结果缓存 30 分钟。 + +**第二层:紧急恢复。** 如果 LLM 仍然返回上下文超限,系统不再调 LLM,直接丢掉更早的消息、保留最后 2 轮、重试一次。 + +**第三层:硬截断。** 总结之后还是超,从前往后继续丢消息直到 prompt 装得下。最近 2 条永远不动。 + +> **安全设计**——摘要以**用户消息**形式注入,**不是**系统消息。刻意的:防止早期用户输入的压缩版本被提升成系统级指令,关掉一条注入攻击路径。 + +### 配置 + +```yaml +mate: + agent: + conversation: + window: + default-max-input-tokens: 128000 + compact-trigger-ratio: 0.75 + preserve-recent-pairs: 2 + summary-max-tokens: 300 +``` + +--- + +## 对话后提取 + +一次对话结束之后,系统会异步地把值得记住的东西提取出来、写进 PROFILE.md、MEMORY.md、当天的日常笔记。发生在用户响应路径之外——**永远不会阻塞下一个回合**。 + +### 什么时候触发 + +一个回合完成之后,系统在后台线程处理这次对话。需要满足几个条件才会真的跑提取: + +- 自动总结开关打开 +- 不是定时任务自己触发的对话(防止递归) +- 消息数达到下限(默认 4 条) +- 最后一条用户消息够长(默认至少 10 字符) + +全部通过,开始提取。 + +### 并发控制 + +- **冷却**——同一个 Agent 在默认 5 分钟内不会重复提取 +- **按 Agent 加锁**——同一个 Agent 已经有一个提取任务在跑,新任务直接跳过 + +### LLM 实际在做什么 + +1. 从对话历史里加载消息 +2. 读当前的 PROFILE.md、MEMORY.md、今天的日常笔记 +3. 构造 transcript:最多 30 条消息,每条截断到 2000 字符 +4. 用记忆总结的 prompt 模板调 LLM +5. 解析 JSON 响应 +6. 执行写入 + +### LLM 响应 schema + +| 字段 | 类型 | 作用 | +|------|------|------| +| `should_update` | boolean | 记忆是否需要更新 | +| `reason` | string | 原因(用于审计) | +| `daily_entry` | string | 追加到今天日常笔记的内容 | +| `memory_update` | string | MEMORY.md 的全新全量内容 | +| `profile_update` | string | PROFILE.md 的全新全量内容 | + +### 文件写入规则 + +- **PROFILE.md**——全覆盖,只在 `profile_update` 非空时写 +- **MEMORY.md**——全覆盖,只在 `memory_update` 非空时写 +- **memory/YYYY-MM-DD.md**——追加,文件不存在时用日期标题新建 + +--- + +## 整合与 Dreaming + +第三层按计划跑。它的工作是看着日常笔记堆起来,周期性地问自己:*这里的模式是什么?哪些东西应该被提升进核心记忆?哪些东西过期了应该被遗忘?* + +### 它做什么 + +1. 列出 Agent 所有 `memory/*.md` 文件,取最近 7 天 +2. 读这些日常笔记 + 当前 MEMORY.md +3. 用整合 prompt 模板调 LLM +4. LLM 返回 `{should_update, reason, memory_content}` +5. 如果 `should_update` 为 true,MEMORY.md 被 `memory_content` 全覆盖 + +### 触发方式 + +- **自动**——每个 Agent 在系统定时任务里有一行,每天凌晨 2 点跑一次 +- **手动**——`POST /api/v1/memory/{agentId}/emergence` + +### 为什么不会递归 + +整合跑起来的时候会通过 Agent 触发一次"对话"。没有保护的话,那次对话会再触发对话后记忆提取监听器,循环下去。 + +事件上带触发源标记,提取监听器看到是定时任务触发的就直接跳过。 + +### DREAMS.md —— 整合日记 + +每次整合跑完会往 `workspace/{agentId}/DREAMS.md` 追加一条短记录: + +- 它看了什么 +- 它找到了什么模式 +- 因此 MEMORY.md 变了什么 +- 日期 + +这给你一条人类可读的审计线——你可以打开 DREAMS.md,看**记忆是怎么一步步走到当前状态的**。这个文件也有自我增长的上限,超过阈值会对旧记录做总结。 + +### 打分式 Emergence + 召回追踪 + +整合不是盲目地总结。它会追踪: + +- **哪些记忆条目在最近的对话里真的被主动召回**——Agent 的读取模式反过来影响整合对"什么重要"的判断 +- **打分式 emergence**——候选模式按频率 + 近期性 + 显式召回打分,只有高分的才能进 MEMORY.md +- **多闸门过滤**——低信号的提取(一次性提及、矛盾、用户后来主动纠正过的)会在变成记忆之前被过滤掉 +- **Dreaming 状态 API**——`GET /api/v1/memory/{agentId}/dreaming/status` + +### 完整生命周期(开关控制) + +记忆从"夜里梦一次"升级成完整的逐轮生命周期。这套行为落在开关后面——开源版默认关,生产构建打开。 + +它做的事: + +- **每一轮都被记账** —— 每一回合开始和结束时系统都在记笔记,不只是夜里整合的时候 +- **事实投影** —— 对话被拆成结构化的"事实"行,Agent 可以查询。带信任度评分 + 衰减。 +- **结构化的夜间报告** —— 整合产出一份完整报告,可以按主题手动重做 +- **晨报卡片** —— 第二天第一次对话浮出昨天的报告;逐条 Confirm / Edit / Forget +- **矛盾收件箱** —— 新事实和老事实冲突时给一个决策队列,而不是悄悄覆盖 +- **显式遗忘** —— 你说"忘掉",它就真的忘掉,从所有地方 +- **反馈打分** —— 检索到的事实点👍/👎,反馈进入信任度评分 +- **SOUL 自动演化** —— Agent 的人格档会从累积的事实里自我重写 +- **月度归档** —— 老报告滚进压缩的月度归档,时间线里能查 +- **记忆浏览器** —— 时间线、事实、矛盾、变更对比、信任度面板 + +`application.yml` 启用: + +```yaml +mateclaw: + memory: + dream-v2: + enabled: true + fact-projection: true + contradictions: true + morning-card: true +``` + +--- + +## Agent 自己读写自己的记忆 + +记忆不是单向地"发生在 Agent 身上"的事。Agent 自己在对话过程中可以主动读写自己的文件——通过一组工作空间记忆工具: + +| 方法 | 作用 | +|------|------| +| `list_workspace_memory_files` | 列出 Agent 的文件,可按文件名前缀过滤,按 `sort_order` 排序 | +| `read_workspace_memory_file` | 读某个文件的内容 | +| `write_workspace_memory_file` | 创建或覆盖一个文件(全覆盖) | +| `edit_workspace_memory_file` | 按精确查找替换编辑(增量更新,支持 `replaceAll`) | + +### 关键词搜索自己的记忆 + +::: tip 1.4.0 新增 +员工不止能读整个文件——它在对话中可以按**关键词搜索自己工作空间里的全部记忆文件**,直接定位到某一行。 +::: + +这是一个 Agent 运行时能力:员工给一个关键词,系统在它自己的工作空间记忆文件里做检索: + +- **分词**——中文按 2 字滑动窗口切,拉丁文按空格切,两种语言都能命中 +- **按文件加权打分**——`AGENTS.md` / `MEMORY.md` / `PROFILE.md` 这类核心文件的命中权重高于每日笔记 +- **返回结果**——每条命中给出:文件名 + 行号 + 80 字上下文片段(命中词高亮) + 相关性分数 +- **扫描范围**——最多扫约 50 个候选文件,按分数从高到低排序 + +适用场景:员工想确认"我之前是不是记过这件事"、跨多天笔记找回某个具体决定,而不需要把整份文件读进上下文。 + +### 示例 + +**列表:** + +```json +// 输入 +{"agentId": 1, "filenamePrefix": "memory/"} +// 输出 +{"agentId": 1, "count": 3, "files": [ + {"filename": "memory/2026-04-09.md", "enabled": false, "fileSize": 512}, + ... +]} +``` + +**读取:** + +```json +// 输入 +{"agentId": 1, "filename": "MEMORY.md"} +// 输出 +{"agentId": 1, "filename": "MEMORY.md", "enabled": true, "content": "..."} +``` + +**编辑:** + +```json +// 输入 +{"agentId": 1, "filename": "MEMORY.md", "oldText": "旧内容", "newText": "新内容"} +// 输出 +{"agentId": 1, "filename": "MEMORY.md", "replacements": 1} +``` + +### 安全约束 + +- 只允许 `.md` 文件 +- 不允许绝对路径,不允许 `..` 目录穿越 +- `write` 是全覆盖——在乎已有内容就先 `read` +- 新建的文件默认 `enabled=false` + +--- + +## 记忆快照导出 / 导入 + +::: tip 1.4.0 新增 +一个员工积累的整份记忆可以打包成一个 ZIP 带走——备份、迁移到另一套部署、或者克隆一个"已经认识你"的同事。 +::: + +快照把一个员工的核心记忆打包成单个 ZIP: + +- `AGENTS.md` / `MEMORY.md` / `PROFILE.md` / `SOUL.md` / `KNOWLEDGE.md` +- 每日笔记(`memory/YYYY-MM-DD.md`) +- 一份 `manifest.json`(记录包里有什么、来自哪个员工) + +### 三个端点 + +| 方法 | 路径 | 权限 | 作用 | +|------|------|------|------| +| GET | `/api/v1/agents/{agentId}/workspace/memory/export` | Viewer | 导出 ZIP——只读权限也能做备份 | +| POST | `.../workspace/memory/import/preview` | Member | **干跑**:解析 ZIP,逐文件给出 create / update / skip 分类,不写任何东西 | +| POST | `.../workspace/memory/import` | Member | 应用导入,**原子写入** | + +先 preview 看清差异,确认后再 import——导入前你永远知道会改动什么。 + +### 安全护栏 + +- **白名单**——只接受上面列出的那几类文件,其余忽略 +- **防 zip 炸弹**——条目数 ≤ 500、单条解压 ≤ 1 MB、总计 ≤ 16 MB,超了直接拒绝 +- **不序列化 UI 开关状态**——`enabled` / `sortOrder` 不进快照;导入到新员工时由目标端按种子规则决定,不会把源端的开关状态强加过来 + +### UI + +- **Agent Context 页面右侧面板**有 **Export / Import** 两个按钮 +- 导入时先弹出**差异对比**(哪些新建、哪些覆盖、哪些跳过),确认后才真正写入 + +--- + +## 配置参考 + +### 记忆提取 & 整合 + +```yaml +mate: + memory: + # --- 自动提取 --- + auto-summarize-enabled: true + min-messages-for-summarize: 4 + min-user-message-length: 10 + skip-cron-conversations: true + summary-max-tokens: 1000 + max-transcript-messages: 30 + + # --- 并发 --- + cooldown-minutes: 5 + + # --- 整合 / dreaming --- + emergence-enabled: true + emergence-day-range: 7 +``` + +配置前缀:`mate.memory`。 + +### 上下文窗口 + +```yaml +mate: + agent: + conversation: + window: + default-max-input-tokens: 128000 + compact-trigger-ratio: 0.75 + preserve-recent-pairs: 2 + summary-max-tokens: 300 +``` + +--- + +## API 接口 + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/v1/memory/{agentId}/emergence` | 手动触发整合 | +| POST | `/api/v1/memory/{agentId}/summarize/{conversationId}` | 对某次对话手动触发提取 | +| GET | `/api/v1/memory/{agentId}/dreaming/status` | 查询上次运行、下次计划、最新 DREAMS.md 条目 | + +--- + +## 下一步 + +- [Agent 引擎](./agents)——Agent 在一个回合里怎么用记忆 +- [LLM Wiki](./wiki)——**刻意的**知识层,和被动的记忆对照 +- [工具系统](./tools)——记忆读写工具是众多工具之一 +- [配置说明](./config)——完整配置参考 +- [架构说明](./architecture)——后端代码组织、SPI 扩展点 diff --git a/mateclaw-server/src/main/resources/docs/zh/model3d.md b/mateclaw-server/src/main/resources/docs/zh/model3d.md new file mode 100644 index 00000000..f1684185 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/model3d.md @@ -0,0 +1,227 @@ +# 3D 模型生成 + +文生 3D / 图生 3D 一键完成。配一次凭据,Agent 就可以通过 `model3d_generate` 工具生成 `.glb` 模型,结果直接挂回到对话气泡里,可拖拽旋转预览。 + +--- + +## 现状 + +| 维度 | 说明 | +|---|---| +| **当前 Provider** | 腾讯混元 3D(`ai3d.tencentcloudapi.com`,区域 `ap-guangzhou`)| +| **可用模型** | `HY-3D-3.1` / `HY-3D-3.0` / `HY-3D-Express` | +| **输出格式** | `.glb`(GLTF 二进制,单文件含贴图,前端 `` 直接渲染)| +| **生成耗时** | 1-3 分钟(Pro 慢 / Rapid 快)| +| **鉴权方式** | TC3-HMAC-SHA256(SecretId + SecretKey)| + +模型路由策略(自动按 model 字段决定接口): + +| 模型 | 调用 Action | 特性 | +|---|---|---| +| **HY-3D-3.1**(默认)| `SubmitHunyuanTo3DProJob` | 最高精度。支持 PBR 材质、多视角输入(多张图)、白模(GenerateType=Geometry)| +| **HY-3D-3.0** | 同上 | Pro 老一代,与 3.1 共享调用 | +| **HY-3D-Express** | `SubmitHunyuanTo3DRapidJob` | 极速版,仅支持 `Prompt` 或 `ImageUrl`,速度最快 | + +--- + +## 一、获取腾讯云凭据 + +混元 3D 接口走传统 CAM 鉴权(**不是 OpenAI 风格的 sk-xxx Bearer Key**),需要 SecretId + SecretKey 一对。 + +1. 登录腾讯云控制台,打开 **[访问管理 → 访问密钥 → API 密钥管理](https://console.cloud.tencent.com/cam/capi)** +2. 点「新建密钥」,腾讯云一次性给出: + - `SecretId`(`AKID` 开头,约 36 字符) + - `SecretKey`(约 32 字符) +3. **务必两个都保存好** —— SecretKey 关闭页面后无法再次查看。 + +::: tip 关于「API Key 管理」页面的 sk-xxx +腾讯云控制台还有另一个「API Key 管理」页面,给的是单个 `sk-` 前缀的 Bearer token。**那个 key 是 TokenHub(聊天补全)服务用的**,端点是 `tokenhub.tencentmaas.com`,**不能用于混元 3D**。3D 必须用上面 CAM 拿到的 SecretId + SecretKey。 +::: + +## 二、开通混元 3D 服务 + +打开 **[腾讯云混元 3D 控制台](https://console.cloud.tencent.com/ai3d)**,首次进入会要求同意服务协议 / 开通免费体验。 + +如果跳过这一步,工具会立即返回错误: + +``` +[Hunyuan3D] SubmitHunyuanTo3DProJob failed: 资源不足。 (ResourceInsufficient) +``` + +部分模型(特别是 `HY-3D-3.1`)可能需要单独申请白名单 / 购买配额,看控制台首页的额度说明。 + +## 三、在 MateClaw 里配置凭据 + +1. 进入 **「模型与凭据」** 页,找到 **「腾讯混元 3D」** 卡片(V71 迁移自动注册) +2. 点 **「更换」** / **「配置」** +3. **API Key** 字段填入 **`SecretId:SecretKey`**(中间一个英文冒号,**没有空格**): + ``` + AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:abcdefghijklmnopqrstuvwxyz123456 + ``` +4. **Base URL** 留默认 `https://ai3d.tencentcloudapi.com`(系统会自动附加 region 路由) +5. 保存 + +::: warning 单输入框的临时折中 +当前模型与凭据卡片是通用 UI,只暴露一个 API Key 输入框。后续会拆成 SecretId / SecretKey 两个独立输入框(自动拼 `:`)。 +::: + +## 四、启用 3D 生成功能 + +进入 **「设置 → 3D 生成」**: + +- **启用 3D 模型生成**:开启 +- **首选 3D 提供商**:选 `腾讯混元 3D` +- **提供商回退**:默认开启即可(目前只有一个 provider,回退暂时无意义,但保留以便未来扩展) + +点 **「保存系统设置」**。 + +## 五、在聊天里使用 + +直接说自然语言,Agent 会自动选用 `model3d_generate` 工具: + +``` +生成一个 3D 模型:可爱的卡通小恐龙,绿色,圆滚滚的眼睛 +``` + +``` +快速生成一个 3D 模型:一个红色苹果 ← LLM 会选 HY-3D-Express +``` + +``` +根据这张图生成 3D 模型:https://example.com/foo.png ← 图生 3D +``` + +``` +生成一个白模 3D:机械齿轮(不要贴图) ← Geometry 模式 +``` + +预期流程: + +1. **工具立即返回**(毫秒级),告诉你 `taskId=xxx` +2. **后端 worker 异步轮询腾讯云**,每 8 秒查一次状态 +3. **1-3 分钟后** SSE 事件 `async_task_completed` 自动推到对话 +4. **前端 ``** 渲染 `.glb`,可拖拽 / 旋转 / 缩放 + +--- + +## 工具参数(`model3d_generate`) + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `prompt` | String | 是\* | 文本描述,最多 1024 字符 | +| `imageUrl` | String | 是\* | 参考图片 URL(图生 3D 模式)| +| `model` | String | 否 | `HY-3D-3.1` (默认) / `HY-3D-3.0` / `HY-3D-Express` | +| `enableTexture` | Boolean | 否 | `true`(默认)/ `false`(白模,仅 Pro 支持)| +| `enablePbr` | Boolean | 否 | `true` 启用 PBR 材质(更逼真,仅 Pro 支持,默认 `false`)| + +\* `prompt` 和 `imageUrl` 二选一,不能同时为空。Pro 接口下两者也不能同时给(除非走 Sketch 模式,当前未暴露)。 + +--- + +## 故障排查 + +### 1. `3D 模型生成功能未启用,请在系统设置中开启` + +→ 没启用功能开关。回到 **设置 → 3D 生成**,把「启用 3D 模型生成」打开并保存。 + +### 2. `Provider api_key must be "SecretId:SecretKey" (colon-joined)` + +→ 凭据格式不对。可能填了: +- 单个 SecretId(没拼 SecretKey) +- 单个 SecretKey +- 单个 sk-xxx(那是 TokenHub 的,不能用) +- 中间用了空格而不是 `:` + +正确格式:`AKIDxxxx...:zzzz...`(一个英文冒号)。 + +### 3. `资源不足。 (ResourceInsufficient)` + +→ 腾讯云端业务错误,**跟代码无关**。常见原因: +- 混元 3D 服务还没开通 +- 免费体验配额耗尽 +- 当前模型(特别是 HY-3D-3.1)需要审批 / 付费 + +去 [混元 3D 控制台](https://console.cloud.tencent.com/ai3d) 检查配额。 + +### 4. `invalid params, first_frame_image` + +→ 图生 3D 时 `imageUrl` 不可访问。腾讯需要从公网抓图,私有网络 / `localhost` URL 不行。检查: +- URL 在浏览器无痕模式能直接打开 +- 域名 + 文件后缀符合腾讯要求(`jpg/png/jpeg/webp`,128-5000px,≤8MB) + +### 5. 任务跑了 15 分钟还没回 + +→ Worker 默认 15 分钟超时。看后端日志: + +```bash +grep '\[Hunyuan3D\]\|\[Model3dGen\]' logs/mateclaw.log | tail -10 +``` + +查 polling 是不是被网络抖动卡死,或腾讯端长期 `RUN`/`WAIT` 没推进。可手动取消任务(重启后端 + 它会被标 failed)。 + +### 6. 模型生成出来了但前端只看到下载链接,不能拖拽 + +→ 腾讯返回的是 OBJ-zip 包(OBJ + 贴图 + MTL 多文件),不是单文件 GLB。代码已优先选 GLB 条目(`pickBestResultFile`),如果腾讯当次只返回 OBJ,前端会按 zip 显示。**切换到默认 `HY-3D-3.1` 通常会返回 GLB**。 + +--- + +## 架构(一图速览) + +``` +[ 用户 ] ─ 自然语言 ─▶ [ Agent ] ─▶ model3d_generate + │ + (model 字段路由) + │ + ┌─────────────────────────┴──────────────────┐ + ▼ ▼ + SubmitHunyuanTo3DProJob SubmitHunyuanTo3DRapidJob + (HY-3D-3.1 / HY-3D-3.0) (HY-3D-Express) + │ │ + └──────────── ai3d.tencentcloudapi.com ──────┘ + │ + 返回 JobId (24h 有效) + │ + AsyncTaskService 每 8s 轮询 Query{Pro,Rapid}HunyuanTo3DJob + │ + 状态 → DONE? ──▶ 拿 ResultFile3Ds[] + │ + 优先选 GLB > FBX > OBJ + │ + 下载到 data/chat-uploads/ + │ + 写 mate_message (type=model3d) + │ + 广播 SSE async_task_completed + │ + ▼ + 前端 useChat 识别 modelUrl ─▶ MessageBubble 桥接虚拟附件 + │ + 渲染 .glb +``` + +--- + +## 后台日志关键标记 + +正常一次完整流程的日志(按时间序): + +``` +[ToolExecutor] Executing tool: model3d_generate +[Hunyuan3D] SubmitHunyuanTo3DProJob submitted job: 1441791994... (model=HY-3D-3.1) +[AsyncTask] Created task d73723f14c7c4167 (providerTaskId=pro:1441791994...) +[AsyncTask] Started polling for task d73723f14c7c4167 (interval=8s, timeout=15min) +[ToolExecutor] Tool model3d_generate returned 80 chars +…等 1-3 分钟… +[Model3dDownloader] Downloading 3D model from https://hunyuan-prod-….cos.../...glb to data/chat-uploads/.../model_d73723f14c7c4167.glb +[Model3dDownloader] Downloaded NNNN bytes +[Model3dGen] Task d73723f14c7c4167 completed, model saved: /api/v1/chat/files/.../model_d73723f14c7c4167.glb +``` + +--- + +## 相关文档 + +- [多模态创作总览](./multimodal.md) +- [模型与凭据配置](./models.md) +- [工具系统](./tools.md) +- 设计稿:`rfcs/202605/01-generative-async-pipeline.md` diff --git a/mateclaw-server/src/main/resources/docs/zh/models.md b/mateclaw-server/src/main/resources/docs/zh/models.md new file mode 100644 index 00000000..db649f09 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/models.md @@ -0,0 +1,483 @@ +# 模型配置 + +**先配一个。后面随时加。** + +MateClaw 不关心你用哪个 LLM。它通过五个协议适配器跟所有主流供应商对话,支持 15+ 个云端供应商和 4 个本地运行时,你可以在运行时**不动 Agent 配置**直接切模型。MateClaw 唯一的意见是——**从一个开始,需要再加**,不是第一天就把所有东西配好。 + +--- + +## 支持什么 + +### 云端供应商 + +| 供应商 | 示例模型 | 协议 | 说明 | +|--------|----------|------|------| +| **DashScope**(阿里云) | Qwen-Max、Qwen-Plus、Qwen-Turbo、Qwen-VL、Qwen-Long | dashscope | 默认开箱即用 | +| **DashScope(兼容模式)** | Qwen3.5-Plus、Qwen3.6-Plus、Qwen3 VL Plus 等点号版本号系列 | openai | 见下方"两个 DashScope 区别" | +| **百炼 Token Plan** | 阿里百炼 token 包月套餐 | dashscope | 7 个种子模型;支持长 token | +| **OpenAI** | GPT-4o、GPT-4o-mini、GPT-5.5、o1、o3、o4-mini | openai | 标准 OpenAI API | +| **OpenAI OAuth(ChatGPT Plus/Pro)** | 通过订阅用 GPT-4o、o3、o4-mini | openai | 浏览器 OAuth,**不需要 API Key** | +| **Anthropic** | Claude 4.7、Claude 4.6 Sonnet、Claude 4.5 Haiku | anthropic | 原生 Messages API | +| **Anthropic Claude Code OAuth** | 通过 Claude Pro/Max/Team 订阅用 Claude 4.7 / 4.6 | anthropic | 浏览器 OAuth + 手动粘贴流,**不需要 API Key** | +| **Google Gemini** _(原生)_ | gemini-2.5-flash、gemini-3-pro-image-preview、gemini-2.5-flash-image | gemini | 原生 `generateContent` API(非 OpenAI 兼容)——见下方"原生 Gemini" | +| **xAI / Grok** | Grok 3、Grok 4 | openai | OpenAI 兼容(base URL + API Key);UI 带 xAI 品牌图标 | +| **DeepSeek** | deepseek-chat、deepseek-coder、**DeepSeek V4 flash + pro**(支持思考模式) | openai | OpenAI 兼容 | +| **Kimi(Moonshot)** | moonshot-v1-8k/32k/128k | openai | OpenAI 兼容 | +| **智谱 AI** | GLM-5-Turbo、GLM-5V-Turbo、GLM-5、GLM-5.1 | openai | OpenAI 兼容 | +| **MiniMax** | abab6.5、abab5.5;扩展视频模型目录 + 国内端点 | openai | OpenAI 兼容 | +| **SiliconFlow CN/INTL** | 托管路由推理 | openai | 双端点,OpenAI 兼容 | +| **OpenCode** | 代码场景路由 | openai | OpenAI 兼容 | +| **OpenRouter** | 200+ 模型含免费档 | openai | 一个 key 路由到任何上游 | +| **小米 MiMo** _(1.3.0+)_ | MiMo V2.5 Pro / V2.5 / V2 Pro / V2 Omni / V2 Flash | openai | 小米 MiMo 平台 | +| **任何 OpenAI 兼容服务** | 你自己的 vLLM 等 | openai | 自定义 base URL | + +### 本地运行时 + +| 运行时 | 示例模型 | 协议 | 说明 | +|--------|----------|------|------| +| **Ollama** | Gemma 3/4、Qwen 3、Llama 3.1、DeepSeek R1、Mistral | ollama | **启动时在 `localhost:11434` 自动检测** | +| **LM Studio** | 任何 GGUF 模型 | openai | OpenAI 兼容服务器 | +| **llama.cpp** | 任何 GGUF 模型 | openai | 通过 llama-server | +| **MLX** | Apple Silicon 上的 mlx-lm | openai | OpenAI 兼容服务器 | + +### 协议适配器 + +五个协议覆盖一切: + +| 协议 | 谁在用 | +|------|--------| +| **OpenAI** | OpenAI、Kimi、DeepSeek、MiniMax、智谱、OpenRouter、LM Studio、llama.cpp、MLX | +| **Anthropic** | Claude 家族 | +| **DashScope** | Qwen 家族 | +| **Gemini** | Google Gemini 家族 | +| **Ollama** | 通过 Ollama 跑的本地模型 | + +任何 OpenAI 兼容服务都能接——把 `base-url` 指过去就行。 + +--- + +## 两个 DashScope 区别 + +阿里云 DashScope 同一把 `sk-` API Key,**两个端点**面向不同模型族: + +| 项 | DashScope | DashScope(兼容模式) | +|---|---|---| +| 端点 | `dashscope.aliyuncs.com/api/v1`(native) | `dashscope.aliyuncs.com/compatible-mode/v1`(OpenAI-compatible) | +| 协议 | DashScope 原生协议 | OpenAI 协议(同 GPT-4 / DeepSeek / Kimi 一样) | +| 内置 web 搜索(`enable_search`) | ✅ 支持 | ❌ 不支持 | +| 适用模型 | Qwen-Max / Plus / Turbo / Long、Qwen-VL、Qwen3-Max、DeepSeek-V3.2 等 | **带点号版本号**的新模型族:Qwen3.5-Plus、Qwen3.6-Plus、Qwen3 VL-Plus 等 | + +**为什么分两个**:阿里把 dot-versioned 新模型族(`qwen3.5-*` / `qwen3.6-*` / `qwen3-vl-*`)只放在兼容模式端点上发布;用 native 协议调它们会返回 `400 InvalidParameter`。两个 provider 可以**共用同一把 sk- Key**,复制粘贴一次就好。 + +**怎么选**: +- 想用 Qwen-Max / Plus / Turbo + 内置搜索 / DeepSeek-V3.2 → **DashScope** +- 想用 Qwen3.5-Plus / Qwen3.6-Plus / Qwen3 视觉理解 → **DashScope(兼容模式)** +- **两个都启用**也可以——同一把 Key,只是模型出现在不同卡片下 + +--- + +## 原生 Gemini + +::: tip 1.4.0 新增 +Gemini 不再走 OpenAI 兼容层——MateClaw 直接对接 Google 的**原生 `generateContent` API**。 +::: + +很多产品把 Gemini 当成"又一个 OpenAI 兼容端点"来接,结果在系统指令、函数调用、内联图片这些地方处处碰壁。MateClaw 走的是 Gemini 自己的协议: + +- **原生 chat builder** —— 正确映射 `systemInstruction`(系统指令)、`functionCall` / `functionResponse`(工具调用回合)、以及内联图片 part(多模态输入) +- **流式 SSE 解析** —— 按 Gemini 的流式响应格式逐块解析 +- **JSON Schema 清洗** —— 自动剥掉 Gemini 不接受的 JSON Schema 关键字,避免工具定义被拒 +- **启动探活** —— 启动时发一个轻量请求确认凭证与模型可用 + +配置方式:`设置 → 模型 → 添加供应商`,选 **Gemini** 供应商,填 API Key。示例模型:`gemini-2.5-flash`、`gemini-3-pro-image-preview`、`gemini-2.5-flash-image`。图像生成走原生路径,详见 [多模态创作 → 图像生成](./multimodal#图像生成-六个供应商)。 + +--- + +## 添加一个供应商 + +**新装的 MateClaw 主列表是空的。这是故意的。** + +你不需要看见 16 个供应商,你需要**一个能跑的**。 + +`设置 → 模型 → 添加供应商`——按钮打开一个抽屉,里面是完整目录。本地运行时(Ollama、LM Studio、llama.cpp、MLX,**不需要 API Key**)排在前面,云端供应商(DashScope、OpenAI、Anthropic、DeepSeek 等)在后面。 + +三步: + +1. **找到要的那一行,点启用**——这个供应商进入主列表 +2. **填 base URL(已知供应商预填)+ 粘贴 API Key**——加密存储,UI 脱敏 +3. **保存 → 测试连接**——系统发一个轻量请求验证 + +抽屉关掉之后,主列表只显示你启用过的供应商。**模型选择器、聊天页、Agent 编辑器——所有看得到模型的地方,都只看得到你启用过的。** + +::: tip 老用户升级(V55 迁移) +已经在用的供应商不会被关掉。V55 把符合以下任意一种条件的供应商自动标记为启用: +- 配过真实 API Key +- 有 OAuth token +- 最近 30 天被聊天会话使用过 +- 是当前默认模型所在的供应商 + +没用过、留在数据库里占位的供应商,会回到抽屉里——你下次需要时再启用。 +::: + +--- + +## 启用 / 禁用一个供应商 + +主列表上每张供应商卡片都有**启用 / 禁用**开关。**先启用,才可用**——这是 v1.1.0 之后整个产品契约的核心。 + +- **禁用**——供应商从模型选择器、聊天页、Agent 编辑器里立刻消失。**配置不丢**,重新启用后原样恢复 +- **如果你禁用的是当前默认模型所在的供应商**,系统会自动把默认模型切到一个还启用着的供应商上的模型——不会让下一条消息直接报错 +- **启用**——供应商重新出现在所有看得到模型的地方。从未填过 API Key 的话,会提示你去配 + +这把"我有这个供应商的 Key 但今天不想用它"和"我没这个供应商"分开。临时切供应商不需要删配置。 + +### ChatGPT OAuth —— 不需要 API Key + +有 ChatGPT Plus 或 Pro 账号?MateClaw 可以通过**浏览器 OAuth** 对接 OpenAI 的 chat 端点——你按平常方式登录,你的订阅被直接使用。GPT-4o、o3、o4-mini 立刻可用。 + +`设置 → 模型 → 添加供应商 → OpenAI OAuth`。浏览器窗口弹出。Token 交换在后端完成,**凭证不离开你的机器**。 + +### 设备授权(Device Authorization Grant)—— 远程 / 无头部署专用 + +浏览器回调式 OAuth 要求 IDP 的重定向能落回 *你的浏览器* 能访问的某个 `localhost` 端口。这事儿在 MateClaw 跑你笔记本上时没问题,一旦你把它放到服务器、容器、或任何不向客户端暴露 loopback socket 的宿主上,就立刻坏掉。 + +针对这种情况,OpenAI OAuth 会自动切到 **设备授权(RFC 8628)**——和 ChatGPT 桌面端、`gh auth login` 用的是同一个流程。不需要回调,不需要端口映射。 + +非 localhost 宿主下,`设置 → 模型 → 添加供应商 → OpenAI OAuth` 会弹出一个对话框,里面有: + +- 一个短的**用户码**(等宽字体,可复制) +- 一个**验证 URL**:`auth.openai.com/codex/device`——任何设备的任何浏览器都能打开 +- 一个**实时倒计时**,显示设备码还剩多久过期(默认 15 分钟) + +把用户码填进浏览器、授权完成,对话框会在后端轮询拿到 `COMPLETED` 的瞬间自动关闭。 + +**MateClaw 怎么决定走哪个流:** + +| `mateclaw.oauth.openai.deployment-mode` | 行为 | +|---|---| +| `auto` *(默认)* | `localhost` / `127.0.0.1` / `::1` → 浏览器回调;其它 host → 设备授权 | +| `local` | 强制走浏览器回调(loopback 服务器) | +| `device_code` | 强制走设备授权 | +| `manual_paste` | 强制走旧的"复制回调 URL 粘回来"流 | + +如果 `local` 模式起不来 loopback 端口(端口被占、沙箱拒绝),会自动降级到 `manual_paste`。 + +**后端端点**(`/api/v1/oauth/openai/device`): + +| Method | Path | 用途 | +|---|---|---| +| `POST` | `/start` | 开一个会话,返回 `deviceAuthId` / `userCode` / `verificationUrl` / `intervalSeconds` / `expiresInSeconds` | +| `POST` | `/poll` | 按 `deviceAuthId` 轮询,返回 `PENDING` / `COMPLETED` / `EXPIRED` | +| `POST` | `/cancel` | 丢弃会话(比如用户关了对话框) | + +前端按 OpenAI 返回的 `intervalSeconds`(一般 5 秒)轮询;服务端再设一个最小轮询间隔(默认 3 秒)兜底,避免被打。过期的会话每 5 分钟扫一次清掉。 + +token 持久化和刷新走的是和浏览器回调流**完全相同**的代码路径,所以对话框关了之后行为没有任何差别。 + +### Anthropic Claude Code OAuth + +同样的套路、同样的结果:有 Claude Pro / Max / Team 订阅?走 **Claude Code 自己用的那套 OAuth 流程** 登录——不需要 `sk-ant-…` 的 API Key。Claude 4.7 / 4.6 / 4.5 Haiku 通过订阅上线。 + +`设置 → 模型 → 添加供应商 → Anthropic Claude Code OAuth`。支持两种流程: + +- **浏览器回调** —— 本地安装,浏览器弹窗,点完授权 token 落到 MateClaw +- **MANUAL_PASTE** —— 远程服务器部署、浏览器到不了后端时,本地浏览器完成授权后把 token 粘回来 + +通过 anti-abuse 反滥用门:注入 Claude Code 身份到系统 prompt,请求形态(UA / accept 头 / `system` 数组形式 / `mcp_` 工具名前缀)与 Claude Code 在线协议完全对齐,请求不会被拒绝。 + +--- + +## 模型发现 + +提供模型列表的供应商(OpenAI、Ollama、LM Studio、OpenRouter 等)支持**模型发现**——一键让 MateClaw 拉取这个供应商下的所有模型。 + +- `设置 → 模型 → [供应商卡片] → 发现模型` +- 系统查询供应商的 `/v1/models` 端点 +- 发现的模型带名字、上下文窗口、价格 +- 逐个或批量添加 + +对 OpenRouter 特别有用——**让 200+ 免费档模型全都可见**。挑一个免费模型零成本有一套能用的环境。 + +### Ollama 启动时自动检测 + +不用手动配。启动时: + +1. **Ping** `http://127.0.0.1:11434` +2. **发现**——通过 `/v1/models` 拉取已拉的模型 +3. **注册**——加进 `mate_model_config` +4. **启用**——自动启用匹配的预配置模型 +5. **标签重写**——把种子里的 `:latest` 重写为实际安装的版本(`deepseek-r1:latest` → `deepseek-r1:7b`),不再因为 `model not found` 报 404 + +Ollama 没跑就**静默跳过**。 + +::: tip 默认行为 +- 无工具支持的模型(`deepseek-r1`、`gemma*`、`phi3/4` 等)不会被意外激活为默认——它们进入黑名单 +- 在 native DashScope 协议下不可用的模型在启动时自动清理;带点号版本号的 Qwen 系列改由 DashScope(兼容模式)provider 承载 +- DashScope 模型发现做协议感知探测,跳过非聊天模态 +::: + +**预配置的 Ollama 模型**(默认禁用,发现后自动启用): + +| 模型 | `model_name` | +|------|-------------| +| Gemma 3 | `gemma3:latest` | +| Gemma 4 | `gemma4:latest` | +| Qwen 3 | `qwen3:latest` | +| Llama 3.1 | `llama3.1:latest` | +| DeepSeek R1 | `deepseek-r1:latest` | +| Mistral | `mistral:latest` | + +配置: + +```bash +# 从 ollama.com 安装 Ollama,然后: +ollama pull gemma3 +ollama pull qwen3 +``` + +重启 MateClaw。自动发现、添加、启用。 + +--- + +## 数据库 schema + +### `mate_model_provider` + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `name` | 供应商标识符 | +| `display_name` | 人类可读的名字 | +| `protocol` | `dashscope` / `openai` / `ollama` / `anthropic` / `gemini` | +| `base_url` | API 基础 URL | +| `api_key` | 加密的 API Key | +| `oauth_tokens` | OAuth tokens(ChatGPT Plus/Pro) | +| `is_local` | 本地运行时为 true | +| `enabled` | 供应商总开关——禁用后从所有模型选择器消失,配置保留(v1.1.0+) | + +### `mate_model_config` + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `provider_id` | 外键到 `mate_model_provider` | +| `model_name` | 实际的模型标识符 | +| `display_name` | 人类可读的名字 | +| `temperature` | 默认温度(0.0–2.0) | +| `max_tokens` | 最大输出 token | +| `top_p` | top-p 采样 | +| `group_name` | UI 分组("Reasoning"、"Fast"、"Vision" 等) | +| `enabled` | 模型开关 | + +### 嵌入模型 + +不用配 `EMBEDDING_API_KEY` 环境变量。嵌入模型就是 `mate_model_config` 里 `model_type='embedding'` 的普通行。`设置 → 模型` 里和聊天模型列在一起。知识库从下拉里选它的嵌入模型。 + +::: tip 1.4.0 新增([issue #79](https://github.com/matevip/mateclaw/issues/79)) +**任意供应商都能提供嵌入模型。** 在 `设置 → 模型` 的嵌入区域里,配一个来自任何供应商的嵌入模型——直接**复用那家供应商的 API Key**,不再单独要 `EMBEDDING_API_KEY`。每个知识库从下拉里挑自己的嵌入模型。无密钥的本地代理用一个空操作占位 key;协议从该供应商的聊天模型 / protocol 设置里自动解析,不用再手填。 +::: + +### Anthropic prompt 缓存 + +系统 prompt、Agent 人格、工具定义——在 Anthropic 兼容端点上自动带 `cache_control: ephemeral`。第一次请求热身,之后每次缓存命中。Dashboard 里有 `cache_read_tokens` / `cache_write_tokens` 日维度统计。 + +### 思考深度 / `reasoning_effort` + +**哪些模型会看这个参数**:`reasoning_effort` 只对 OpenAI reasoning 族(`gpt-5*` / `o1*` / `o3*` / `o4*`)有效,且只通过 OpenAI / Azure-OpenAI 两家 provider 下发。任何别的 provider(DeepSeek、Kimi、DashScope、Ollama、自托管 OpenAI-兼容网关等)收到这个参数都会报错或触发异常行为。 + +**三点产品契约**: + +1. **Chat 类不带思维链的模型**,即使用户在前端 UI 选择"深度思考 = high",系统也**不执行** thinking——不是 UI 问题,是能力属性。模型选择器换到不支持的模型后"思考深度"选项自动灰掉。 +2. **Provider 的 `generateKwargs.reasoningEffort` 配置**只对白名单 provider 有效。在 DeepSeek / Kimi / 其他 OpenAI-兼容 provider 上配它会被**无条件丢弃**并打 WARN,不会实际下发。 +3. **Failover 切换**时会再次校验:如果 primary 是 GPT-5 而 fallback 是 DeepSeek,`reasoning_effort` 会在出站前被剥除,泄漏到 DeepSeek 的不会触发 400。 + +**DeepSeek thinking 的正确用法**:DeepSeek 的 thinking 模式**不接受** `reasoning_effort` 参数。 + +- `deepseek-reasoner`:模型本身自带 thinking,无需任何配置。 +- `deepseek-chat` 想开启 thinking:按 DeepSeek 官方文档在 provider 的 `generateKwargs.extra_body` 里加 `{"thinking": {...}}`,**不要**填 `reasoningEffort`。 + +**Kimi K2.5 thinking**:模型自带 thinking,也不接受 `reasoning_effort`。 + +**多轮 tool call + thinking**:带 thinking 的模型(DeepSeek-Reasoner / GPT-5 / Kimi K2.5 / 小米 MiMo)在 ReAct 多轮 tool call 场景下,历史消息的 `reasoning_content` 会正确回传给 provider;跨用户问题边界时自动清除,同一问题内的子轮次全部保留——符合 DeepSeek 的"同问题子轮必须回传、跨问题时清"契约。 + +**小米 MiMo 思考模式多轮修复**([issue #189](https://github.com/matevip/mateclaw/issues/189)):MiMo 思考模式的 `reasoning_content` 现在能在多轮对话里正确保留,不再在后续轮次丢失。 + +--- + +## 分组模型选择器 + +当你部署里配了一堆模型之后,聊天界面上的模型选择器按供应商和标签分组。带搜索的下拉框允许你按名字、供应商、分组过滤——"所有 Qwen"、"所有 reasoning 模型"、"所有 7B 以下"。分组通过 `group_name` 列定义。 + +当 Agent 可以按任务绑定不同模型之后,这变成了**刚需**——Plan-Execute 用 reasoning 模型、Chat 用便宜快速的、图像理解用视觉模型。 + +--- + +## 运行时切换活跃模型 + +MateClaw 用一个**活跃模型**作为全局默认。没有指定自己模型的 Agent 都用它。 + +- **UI:** `设置 → 模型 → [模型卡片] → 设为活跃` +- **API:** `PUT /api/v1/models/active` + +**立刻生效**——不需要重启。下一条消息用新模型。进行中的对话不受影响。 + +也支持按 Agent 覆盖:把某个 Agent 绑定到特定模型配置。 + +::: tip 1.4.0 新增 +- **按会话选模型**([issue #150](https://github.com/matevip/mateclaw/issues/150)):在聊天界面里可以为**当前这一条会话**临时切换模型,不影响全局活跃模型和别的会话。详见 [聊天与消息](./chat)。 +- **单个坏模型 id 不再连累整个供应商**:发现 / 探活时遇到一个无效的模型标识符,只跳过那一个模型,供应商下其余模型照常可用。 +::: + +--- + +## 单模型测试 + +每个模型卡片都有**测试**按钮。点一下,系统发一个简单 prompt,给你看: + +- 实际响应文本 +- 延迟 +- Token 用量 +- 错误 + +加了新供应商或怀疑 key 过期时用它。 + +--- + +## 多模态旁路(系统级) + +::: tip 1.3.0 新增 +让纯文本主模型也能"看图回答",参见 [issue #87](https://github.com/matevip/mateclaw/issues/87)。 +::: + +入口:**设置 → 模型 → 多模态旁路**。两个独立的卡片: + +| 卡片 | 用途 | 状态 | +|------|------|------| +| **视觉旁路模型** | 用户上传图片时调用一次,把图片转成结构化描述,再交给主对话模型 | 已上线 | +| **视频旁路模型** | 同样的思路用于视频 | 预留(v1 不接路由,仅持久化配置) | + +数据库存的是 `mate_model_config.id`(不是 modelName)——同一 `model_name` 在不同 provider 下都能存在(如 `qwen-vl-max` 同时挂 DashScope 和 OpenAI-Compatible),存名字会撞。两条 setting key: + +- `default.vision_model` +- `default.video_model` + +下拉只列**支持对应 modality 的模型**——筛选逻辑走后端 `ModelCapabilityService.supports(...)`,未启用 / 没声明 vision 能力的模型都不会出现在选项里。每张卡片有独立的"保存"按钮,互不干扰。 + +什么时候触发?运行时由 `MultimodalRouter` 决策([源码](https://github.com/matevip/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java)): + +- 主模型已支持图片 → 不路由(走原 native multimodal 路径) +- 主模型不支持图片 + 配了视觉旁路 → SIDECAR 策略,视觉模型转描述 +- 主模型不支持图片 + 没配视觉旁路 → 跳过附件 + 文本提示让用户去配 + +具体的用户流程、徽章、提示条详见 [聊天与消息 → 主模型不支持图片?走"多模态旁路"](./chat#主模型不支持图片走多模态旁路)。 + +--- + +## 多模型 Failover + +::: tip OpenAI 挂了 30 分钟,我的 AI 没停过一秒 +上次 DashScope 限流抽风的 30 分钟里,我们的服务可用率是 100%。 + +用户看到的是回答正常说完——没有红色 error,没有"服务暂时不可用,请稍后再试"。**主 provider 在用户那一句话回答的中途**自动切到下一个健康的 provider,断点之后的 token 直接接上。 + +不是工程师讲的"自动重试"——是**用户感知不到的故障转移**。 +::: + +每个 provider 加进来都进入 `AvailableProviderPool`,启动时探活,配置变更自动重探。 + +- **自动 fallback** —— 主 provider 返回 `AUTH_ERROR` / `BILLING` / `MODEL_NOT_FOUND` / `NETWORK` / `5xx` 时,运行时滚到下一个 provider,而不是把错误抛到 UI +- **每个 agent 自定义优先级** —— 在 `设置 → 模型` 的拖拽编辑器里把某个 agent 锁成 "OpenAI 优先 → Anthropic → DashScope" +- **池子状态实时可见** —— 每个 provider 用绿/琥珀/红徽章标健康状态 +- **4 协议探活** —— DashScope、OpenAI 兼容、Anthropic、Ollama 风格 +- **手动重探 + 配置变更自动重探** —— 换 key 不用重启 +- **出口 sanitizer** —— provider 专属选项(如 OpenAI 推理模型的 `reasoning_effort`)在 failover 到不支持的 provider 时被剥离,泄漏的选项不会让 fallback 报 400 +- **UI 区分 401 与会话过期** —— provider 认证错误和用户会话过期现在显示不同消息、不同处置 + +--- + +## API 配置 + +```bash +# 列已启用的供应商(主列表看到的) +curl http://localhost:18088/api/v1/models \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 列完整目录(含未启用项)——Add Provider 抽屉用的就是这个 +curl http://localhost:18088/api/v1/models/catalog \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 启用一个供应商 +curl -X POST http://localhost:18088/api/v1/models/{providerId}/enable \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 禁用一个供应商(如其下模型为当前默认会自动切换) +curl -X POST http://localhost:18088/api/v1/models/{providerId}/disable \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 添加一个模型配置 +curl -X POST http://localhost:18088/api/v1/models \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "providerId": 1, + "modelName": "qwen-plus", + "displayName": "Qwen Plus", + "temperature": 0.7, + "maxTokens": 4096, + "groupName": "Fast", + "enabled": true + }' + +# 设置活跃模型 +curl -X PUT http://localhost:18088/api/v1/models/active \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"providerId": "openai", "model": "gpt-4o"}' + +# 发现模型 +curl -X POST http://localhost:18088/api/v1/models/{providerId}/discover \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 测试连接 +curl -X POST http://localhost:18088/api/v1/models/{providerId}/test-connection \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +--- + +## 所有配置都走 UI + +::: tip +**模型配置 100% 通过 UI 管理。** 没有任何 `spring.ai.*` 的 YAML 需要你手动改。所有供应商、所有 API Key、所有模型配置、所有切换——全部在 `设置 → 模型` 里,底层存在 `mate_model_provider` 和 `mate_model_config` 数据库表。 +::: + +UI 处理了你原本在 YAML 里会做的一切,外加几件 YAML 做不到的事: + +- **添加供应商**——选类型、粘 key、保存。数据库加密存储,UI 里脱敏显示。 +- **测试连接**——上线前先验证供应商。 +- **模型发现**——支持 `/v1/models` 的供应商一键拉取整个列表。 +- **单模型测试**——发一个测试 prompt,看真实响应、延迟、token 用量。 +- **运行时切换活跃模型**——不重启、不重载配置,下一条消息生效。 +- **按 Agent 覆盖**——把某个 Agent 绑定到特定的模型配置。 + +LLM API Key **不再读取环境变量**——`DASHSCOPE_API_KEY` / `OPENAI_API_KEY` 这类设置已经没有任何效果。所有供应商、Key、模型都住在 UI 里。新装的实例启动时数据库里没有供应商,到「设置 → 模型 → 添加供应商」加你的第一家即可。 + +### 参考:Qwen 模型怎么挑 + +如果你用 DashScope,大致阵容是这样: + +| 模型 | 上下文 | 适合 | +|------|--------|------| +| `qwen-max` | 32K | 复杂推理、分析 | +| `qwen-plus` | 32K | 通用 | +| `qwen-turbo` | 8K | 快速响应 | +| `qwen-vl-max` | 32K | 视觉 + 语言 | +| `qwen-long` | 1M | 超长文档 | + +--- + +## 下一步 + +- [配置说明](./config)——完整配置参考 +- [Agent 引擎](./agents)——Agent 怎么使用模型 +- [控制台](./console)——模型管理 UI diff --git a/mateclaw-server/src/main/resources/docs/zh/multimodal.md b/mateclaw-server/src/main/resources/docs/zh/multimodal.md new file mode 100644 index 00000000..ea276064 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/multimodal.md @@ -0,0 +1,199 @@ +# 多模态创作 + +语音、音乐、图像、视频——MateClaw 一开始就把它们当一等公民,不是事后贴上去的贴纸。 + +市面上大多数 AI 产品把多模态生成当插件处理:需要的时候装一个。MateClaw 反过来——它从第一天起就把多模态当作核心基础设施:**六个图像供应商、六个视频供应商、三个 TTS、两个 STT、两个音乐后端**,全部在同一套工具接口后面统一,Agent 调用时不用关心底下跑的是谁。 + +配一次,所有地方都能用。 + +--- + +## 盒子里有什么 + +### 图像生成 —— 六个供应商 + +| 供应商 | 模型家族 | 说明 | +|--------|----------|------| +| **DashScope** | 通义万相 | 阿里的图像模型,默认云端选项 | +| **OpenAI** | DALL-E 3 | 标准 DALL-E 端点 | +| **fal.ai** | Flux | 通过 fal.ai 跑 Flux,快 | +| **Google(Nano Banana)** | gemini-3-pro-image-preview、gemini-2.5-flash-image | 走原生 Gemini 路径;**支持图像编辑**——见下方 [Nano Banana](#nano-banana) | +| **智谱** | CogView | 对中文 prompt 原生支持 | +| **MiniMax** | —— | 同步异步都可以 | + +图像生成工具会自动挑默认供应商,也可以在调用时强制指定某一家。异步生成返回一个 job id,Agent 轮询;图片落地后会挂到**原来的那条消息上**,不是创建一条新的。 + +::: tip 1.3.0 新增 +DashScope 通义万相在 v1.3.0 起接入了**统一多模态生成端点**(`multimodal-generation/generation`),新增 14 个图像模型(含 6 个**支持图像编辑**的模型)。详见下方 [图像编辑](#image-edit) 一节。 +::: + +#### Image edit + +::: tip 1.3.0 新增 +图像编辑(图生图)自 v1.3.0 起支持。在 v1.2.0 及更早版本里,`image_generate` 工具只能做"文生图"。 +::: + +`image_generate` 工具新增 `image` / `images` 两个参数: + +| 参数 | 形态 | 说明 | +|---|---|---| +| `image` | 单张参考图 | 字符串:路径 / `file://` / `data:image/...` / `http(s)://` / `msg::` | +| `images` | 多张参考图(最多 5 张) | 数组:每个元素同上 | + +工具内部统一把这五种引用形式归一化为内存 buffer 后丢给 provider。**5 种引用形式**: + +1. **本地路径** —— `/abs/path.png` / `~/x.png` / `./rel.png` +2. **`file://` URL** —— 绝对路径变体 +3. **`data:image/png;base64,...`** —— 内联 base64 / 百分号编码 +4. **`http(s)://...`** —— 带 SSRF 校验,禁止内网地址 +5. **`msg:[:]`** —— 引用同会话内某条消息上的图片附件,**非视觉模型也能直接用**——agent 不需要"看见"图片字节,只要在对话历史里见过这个 messageId 即可 + +```text +用户:(上传一张日落图,messageId=12345)把背景改成森林 +Agent:image_generate(prompt="把背景改成森林", + image="msg:12345:0", + model="qwen-image-edit") +``` + +**支持图像编辑的模型**(DashScope 通义万相): +- `wan2.7-image` / `wan2.7-image-pro`(**T2I + 编辑**) +- `qwen-image-edit` / `qwen-image-edit-plus` / `qwen-image-edit-max`(**纯编辑**) + +在 [模型配置](./models#两个-dashscope-区别) 文档里有更全的模型清单。 + +#### Nano Banana + +::: tip 1.4.0 新增 +Google 的图像生成走 **Nano Banana Pro**(`gemini-3-pro-image-preview`),通过[原生 Gemini 路径](./models#原生-gemini)调用,不经过 OpenAI 兼容层。 +::: + +因为走的是原生 `generateContent` 端点,图像工具会把输入图片作为**内联 part**直接传给模型——所以 Nano Banana 不只是文生图,**还支持图像编辑**(图生图)。用法和上面的 [Image edit](#image-edit) 完全一致:传 `image` / `images` 参数引用一张或多张参考图即可。 + +- **Nano Banana Pro** —— `gemini-3-pro-image-preview`(默认) +- **Nano Banana** —— `gemini-2.5-flash-image`(另一个 Google 图像模型) + +### 视频生成 —— 六个供应商 + +- **DashScope**——通义万相视频 +- **Runway**——API 调 Gen-2 / Gen-3 +- **MiniMax(Hailuo)**——文生视频 + 图生视频 +- **Fal**——快速推理管线 +- **CogVideo**——智谱 CogVideoX +- **Kling**——快手可灵视频生成 + +异步挂载逻辑和图像一样。视频渲染完成后直接出现在 Agent 当初说"正在处理"的那个气泡里。 + +### 音乐生成 —— 两个供应商 + +- **Google Lyria**——高质量音乐生成 +- **MiniMax**——支持歌词 + 风格 prompt + +音乐生成工具接收 prompt、可选风格标签、可选歌词。输出是一条 MP3 挂在消息上。 + +### 3D 模型生成 —— 一个供应商 + +- **腾讯混元 3D**——`HY-3D-3.1` / `HY-3D-3.0`(Pro,支持 PBR / 多视角 / 白模)/ `HY-3D-Express`(极速版) + +文生 3D 与图生 3D 双模式,输出 `.glb`,前端 `` 直接渲染可拖拽预览。完整配置步骤见 **[3D 模型生成](./model3d.md)**。 + +### 语音合成(TTS)—— 三个供应商 + +- **DashScope CosyVoice**——中英文,韵律自然 +- **OpenAI TTS**——alloy、echo、fable、onyx、nova、shimmer 六种音色 +- **MiniMax T2A**——中文音色,带情感标签 + +任何 Assistant 消息上都有一个喇叭图标,点一下就朗读出来。用哪个声音取决于你在设置里激活的 TTS 供应商。 + +### 语音识别(STT)—— 两个供应商 + +- **DashScope Paraformer**——中文优先,低延迟 +- **OpenAI Whisper**——多语言行业基准 + +在聊天输入框按住麦克风图标讲话,松手转文本。识别结果可以在发送前再改一遍。 + +--- + +## 怎么配 + +所有多模态供应商都在 `设置 → 模型 → [类别]` 里。添加一次 API Key,然后把它标记为这个类别的默认。 + +```yaml +# application.yml —— 最小配置示例 +mate: + image: + default-provider: dashscope + video: + default-provider: dashscope + tts: + default-provider: cosyvoice + stt: + default-provider: paraformer + music: + default-provider: dashscope +``` + +如果你想让某个 Agent 总是用 Flux 出图、CosyVoice 发声,可以在 Agent 级别单独覆盖。 + +--- + +## Agent 怎么用 + +每一个多模态能力都是一个工具: + +| 工具 | 签名 | +|------|------| +| `image_generate` | `(prompt, style?, size?)` | +| `video_generate` | `(prompt, duration?)` | +| `music_generate` | `(prompt, style?, lyrics?)` | + +Agent 调用它们和调用任何其他工具一样。工具层负责供应商选择、重试、异步轮询、附件挂载。 + +--- + +## 异步生成 + 消息挂载 + +图像和视频生成往往比一个普通的 Agent 回合要慢。MateClaw 处理这件事的方式: + +1. Agent 调用生成工具。 +2. 工具立刻返回一个 job id 和占位附件。 +3. 后端在后台轮询供应商。 +4. 结果落地后,挂到**原来的那条 Assistant 消息上**,不是新建一条。 + +它工作得很干净:图片会出现在 Agent 当初说"正在处理"的那个气泡里,不是飘在一条新消息里。 + +--- + +## 产品里的哪些地方能看到 + +- **聊天**——拖图片进输入框给视觉模型用;按住麦克风语音输入;点任何回答上的喇叭朗读;生成的媒体直接内嵌。 +- **Agents**——可以单独开启或关闭某个 Agent 的多模态工具。 +- **工具页**——每个供应商都有一个测试按钮,方便在上线前验证 Key。 +- **桌面端**——上面所有功能,外加本地文件系统访问用于批处理。 + +--- + +## 什么时候用什么 + +- **图像**——文档配图、幻灯片、概念可视化、营销素材。起步用 DashScope 或 Flux;需要精确的文字渲染就用 DALL-E 3。 +- **视频**——短视频 demo、社交内容、产品动画。追求质量用 Runway,中文场景用 MiniMax,想本地云就 DashScope。 +- **音乐**——背景音乐、Demo 音效、创意尝试。目前两家,后面还会扩。 +- **TTS**——无障碍朗读、有声书式阅读、多语言内容。中文用 CosyVoice,英语要多样化就 OpenAI。 +- **STT**——语音输入、会议转写、口述工作流。中文用 Paraformer,其他语言用 Whisper。 + +--- + +## 多模态输入:主模型不支持?走旁路 + +::: tip 1.3.0 新增 +本页讲的是**生成(输出)**。**输入侧**的多模态——上传图片给纯文本主模型——走另一套路径:「多模态旁路」(sidecar)。详见 [聊天与消息 → 主模型不支持图片?走"多模态旁路"](./chat#主模型不支持图片走多模态旁路) 和 [模型配置 → 多模态旁路(系统级)](./models#多模态旁路-系统级)。 +::: + +简而言之:在「设置 → 模型 → 多模态旁路」配一个视觉模型,主模型不支持图片时系统会**自动把图片转描述**再喂给主对话模型,主模型保持便宜,路由全程在聊天 UI 可见(路由徽章 + 输入框上方提示条)。 + +--- + +## 下一步 + +- [聊天与消息](./chat)——附件输入、多模态旁路路由、生成的媒体如何挂载到消息上 +- [模型配置](./models)——供应商配置 UI、多模态旁路设置 +- [工具系统](./tools)——承载多模态生成的工具层 diff --git a/mateclaw-server/src/main/resources/docs/zh/quickstart.md b/mateclaw-server/src/main/resources/docs/zh/quickstart.md new file mode 100644 index 00000000..d3373b7d --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/quickstart.md @@ -0,0 +1,86 @@ +# 快速开始 + +60 秒到第一条消息。**只有一条路:桌面端。** + +Docker 和源码启动在 [配置说明](./config) 和 [贡献指南](./contributing) 里。这一页只做一件事——用尽可能快的速度,把你从"什么都没有"送到"一个能工作的 Agent"面前。 + +--- + +## 1. 下载 + +去 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 拿最新安装包。 + +- **Windows**——`MateClaw-Setup-x.y.z.exe` +- **macOS**——`MateClaw-x.y.z.dmg` +- **Linux**——`MateClaw-x.y.z.AppImage` + +不用装 Java。不用装 Node。不用装 Maven。桌面端已经把 JRE 21 和后端 JAR 打包好了。 + +## 2. 启动、登录 + +双击。首次启动要 10 到 30 秒,后端在后台起来。 + +账号 `admin`,密码 `admin123`。**进去之后第一件事**:`设置 → 安全` 里改密码。现在就改。 + +## 3. 配一个模型 + +`设置 → 模型 → 添加供应商`。 + +挑一个就行,别贪多: + +- **DashScope**——云上最省事的起点,去阿里云控制台复制 Key 粘进来 +- **OpenAI / Anthropic**——手头有 Key 就直接填 +- **Ollama**——本地 GPU 用户,会自动识别 `localhost:11434` +- **ChatGPT OAuth**——有 Plus 或 Pro 账号,走浏览器登录一下,就能直接用 GPT-4o、o3、o4-mini + +保存。模型会立刻出现在 Chat 页面的模型选择器里。 + +## 4. 打个招呼 + +左侧导航点 `聊天`。选一个 Agent。选刚配好的模型。输入: + +> *你好。你现在能做什么?* + +回车。看 token 流出来。 + +看到回答了——**系统活了,你已经在产品里了**。接下来所有事情都是在让它**对你有用**,而不是在让它"能跑"。 + +--- + +## 接下来先试这几件事 + +系统装好了。然后呢? + +**试一次带工具的对话。** 输入:"帮我搜一下 Spring Boot 最新版本,总结一下里面的 breaking changes。" 看 Agent 自己去调搜索工具、执行、观察结果、返回答案——这就是 ReAct 在工作。 + +**建一个自己的 Agent。** `Agents → 新建 Agent`,从模板开始(模板是开箱即用的),重命名、改 system prompt、勾选允许的工具、保存。Agent 是你从"一个聊天窗口"升级到"一整支 AI 团队"的方法。 + +**建一个知识库。** `Wiki → 新建知识库`,扔一份 PDF 或者指一个本地文件夹。等它消化完(每条 raw material 上都有进度条)。消化完之后把这个 KB 绑到一个 Agent 上,问里面的内容。底下到底发生了什么看 [LLM Wiki](./wiki)。 + +**接一个聊天渠道。** `渠道` 里选钉钉、Telegram 或者八个支持的平台里随便一个,贴 Bot 凭证。同一个 Agent 会立刻开始在那个渠道里回复——**带着它在你桌面端的全部记忆**。 + +每一件事在侧边栏里都有独立的文档页,想深入时再去读。 + +--- + +## 出了问题? + +第一次跑通本应该很顺。如果没跑通—— + +- **安装器打不开**——Windows 下右键 → 属性 → 解除锁定;macOS 下去"系统设置 → 隐私与安全性"允许未签名应用。 +- **后端起不来**——看 `~/.mateclaw/logs/app.log`(Windows:`%USERPROFILE%\.mateclaw\logs\`)。十有八九是 18088 端口被占了。 +- **模型调用报错**——API Key 填错了,或者网络不通。回设置里检查,或者换一家试试。 +- **界面白屏**——Ctrl/Cmd + Shift + R 强刷。Electron 的缓存比较顽固。 +- **还是不行**——去 [GitHub Issues](https://github.com/matevip/mateclaw/issues) 开一个 Issue,把 `app.log` 的尾巴贴上。我们真的会看。 + +--- + +## 其他部署方式 + +- **Docker**——`cp .env.example .env` 填好密码,`docker compose up -d --build`。完整的前置要求、Maven 镜像选择(中国 / 美国)、浏览器工具自检、升级流程看 [Docker 部署](./docker-deploy)。 +- **从源码跑**——`mateclaw-server/` 里 `mvn spring-boot:run`,`mateclaw-ui/` 里 `pnpm dev`。细节在 [贡献指南](./contributing)。 +- **桌面端内部**——打包、签名、自动更新。看 [桌面应用](./desktop)。 + +--- + +下一步:去 [项目介绍](./intro) 看"为什么有这个东西",或者直接跳到 [Agent 引擎](./agents) 看产品本身。 diff --git a/mateclaw-server/src/main/resources/docs/zh/releases.md b/mateclaw-server/src/main/resources/docs/zh/releases.md new file mode 100644 index 00000000..0e63f1a7 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/releases.md @@ -0,0 +1,30 @@ +# 更新日志 + +每个 MateClaw 版本的发布说明。最新的文档始终以 `docs/zh/` 为单一事实源——这些说明讲的是每个版本里**改了什么**。 + +历史 diff 看对应的 git tag。功能背后的"为什么"点进完整的发布说明。 + +--- + +## 发布列表 + +| 版本 | 日期 | 亮点 | +|------|------|------| +| [v1.4.0](./releases/1.4.0) | 2026-05-23 | 持久化目标——员工锁住目标自己跟到完成 · 子员工委派变成一棵树(递归 3 层 + 异步 + 数字员工构建器) · 渐进式工具/技能披露(`enable_tool` + `load_skill`) · 工作空间 RBAC(四级角色 + 能力门禁) · 飞书做成一等公民(互动/审批/流式卡片 + 语音/文件音视频 + 渠道原生工具) | +| [v1.3.0](./releases/1.3.0) | 2026-05-13 | 工作流元年——7 种 step mode 把员工组装成业务流程 · 触发器 6 种 pattern 让事件自动启动流程 · Wiki 从搜索索引升级为处理流水线(用户模板 + 跨材料聚合 + reverse-citation) · MCP per-agent 工具绑定 + 多模态旁路路由 · 4 个 JVM 原生文档生成工具 + 图像编辑 | +| [v1.2.0](./releases/1.2.0) | 2026-05-05 | 智能体改名"数字员工"(角色 / 目标 / 背景故事 + 5 职业模板) · 技能成了骨架(manifest + 模板向导 + LESSONS 自我进化) · ACP 接入:Claude Code / Codex 变成你的员工 · Admin 运行时控制台让你看见每个员工正在干什么 | +| [v1.1.137](./releases/1.1.137) | 2026-04-29 | 它会从昨天学习了 · 一个模型坏了不会整体掉线 · "差一点就好"的地方现在好了 · 知识库变成了一座图书馆 | +| [v1.1.0](./releases/1.1.0) | 2026-04-17 | Agent 自动技能合成、多 agent 并行委派、Wiki 语义搜索 + 两阶段摘要、深度思考、Anthropic prompt 缓存、声明式 Hook、插件 SDK、全渠道语音、ChatConsole 多渠道实时同步、微信稳定性重建 | +| [v1.0.418](./releases/1.0.418) | 2026-04-11 | 后端国际化 (i18n)、Flyway 数据库迁移框架、WorkspacePathGuard 路径沙箱、CronJobTool 定时任务、Skill ZIP 导入、安全加固 | +| [v1.0.314](./releases/1.0.314) | 2026-04-08 | LLM Wiki 知识库、TTS/STT、音乐生成、图像/视频升级、带 keyless fallback 的搜索系统、ChatGPT OAuth 登录、Agent 运行时增强、数据库 schema 统一 | +| [v1.0.108](./releases/1.0.108) | 2026-04-06 | 数据源 SQL 查询、多模态增强、桌面动态端口、OpenRouter 免费模型 | +| [v1.0.101](./releases/1.0.101) | 2026-04-05 | 移动端布局、Ollama 启动时自动探测、模型分组、GitHub MCP、拖拽文件上传、多 Agent 协作 | +| [v1.0.0](./releases/1.0.0) | 2026-03-20 | 首次发布——ReAct + Plan-Execute Agent、12 个内置工具、MCP 协议、6 个渠道适配器、Vue 3 管理控制台 | + +--- + +## 接下来读什么 + +- [路线图](./roadmap)——计划中的、进行中的、已完成的 +- [项目介绍](./intro)——MateClaw 为什么存在 +- [贡献指南](./contributing)——怎么帮忙发布下一个版本 diff --git a/mateclaw-server/src/main/resources/docs/zh/roadmap.md b/mateclaw-server/src/main/resources/docs/zh/roadmap.md new file mode 100644 index 00000000..d7ff8007 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/roadmap.md @@ -0,0 +1,201 @@ +# 路线图 + +> "人们不知道自己想要什么,直到你把它摆在他们面前。" +> +> 这不是一份功能清单。这是一个关于**你的 AI 助手应该如何存在**的宣言。 + +--- + +## 我们的信念 + +每个人都值得拥有一个真正理解自己的 AI 助手。 + +不是一个聊天玩具。不是一个技术 demo。而是一个**数字分身**——它知道你的工作方式,连接你的所有工具,替你思考、替你执行、替你记住。 + +MateClaw 就是这个东西。 + +--- + +## 我们已经做到了什么 + +### v1.0 —— 它能思考和行动 ✅ 已发布 + +让一个 AI 助手成为"会用工具的同事",不是一个聊天框。 + +- ReAct 引擎:思考、行动、观察、再思考 +- Plan-and-Execute 编排:先制定计划,再逐步执行 +- StateGraph 架构:基于状态图的 Agent 编排 +- DynamicAgent:从数据库加载配置,运行时随时调整 +- 20 个内置工具:搜索 / Shell / 文件 / 委派 / 多模态生成 / 定时任务 / SQL 查询 +- ToolGuard + FileGuard + AuditLog:每一次工具调用有审批、有控制、有记录 +- SKILL.md 技能系统:像装 App 一样给 AI 装新能力 + +### v1.1 —— 它无处不在 ✅ 已发布 + +把 AI 从"网页上的对话框"搬进你团队真正在用的每一个 IM。 + +- **8 个渠道**:Web / 钉钉 / 飞书 / 企业微信 / Telegram / Discord / QQ / 微信个人 / Slack +- 会话来源追踪:每条消息都知道来自哪个渠道 +- 4 层记忆:会话上下文 + 工作空间记忆 + 对话后提取 + 每天凌晨 2:00 自动整合 +- DREAMS.md 整合日记:人类可读的记忆变更审计 +- 工作空间隔离:每个 agent / skill / wiki / conversation / memory 都属于一个工作空间 +- ChatGPT OAuth + Anthropic Claude Code OAuth:用订阅直接登录,不需要 API Key +- LLM Wiki 知识库 + RAG:把原始文件吃进去变成结构化、有双向链接、有摘要的知识页 + +### v1.2 —— 它是你的同事 ✅ 已发布(2026-05-05) + +把"智能体"换成**数字员工**——这不是术语洁癖,是世界观换了。 + +- **数字员工**:每位有角色(Role)、目标(Goal)、背景故事(Backstory),不是冰冷的 system prompt +- **5 个职业模板**:产品研究员 / 客户支持 / 知识管理员 / 数据分析师 / 行政助理——开箱即用 +- **技能不再是工具的别名,是骨架**:每个技能有自己的 SKILL.md + LESSONS.md + workspace 文件空间 +- **ACP 桥接**:Claude Code、Codex、Gemini CLI 这些顶级编码 Agent 以"员工"身份接入 +- **Backstage 运行时控制台**:你第一次能**看见每个员工正在干什么**——谁在跑、跑到哪一步、占多少 token、卡住了一键回收 +- **Onboarding wizard**:首次登录四步从零到第一条消息 +- **Dashboard**:日维度 usage 趋势 + 头部 agent / tool 排行 +- **Doctor**:系统健康检查 + 一键修复 + +完整故事:[v1.2.0 Release Notes](./releases/1.2.0.md)。 + +--- + +## v1.3 —— 工作流元年 ✅ 已发布(2026-05-13) + +> "聚焦不是对要关注的事情说 Yes。而是对其他一百个好点子说 No。" + +数字员工各自能干活只是起点。**真正的协作需要编排**。 + +v1.3 的主线是**让 MateClaw 从"chatbot 框架"升级为"业务流程 OS"**——一条业务流不再是几个员工各自聊天的总和,而是一份可发布、可触发、可重放的**线性 step DSL**。 + +完整故事:[v1.3.0 Release Notes](./releases/1.3.0.md)。 + +### 工作流(Workflow) + +- [x] **7 种 step mode**:sequential / fan_out / collect / conditional / await_approval / dispatch_channel / write_memory +- [x] **Pebble 表达式子集**作为条件判断 + 变量引用语言(不带副作用、不能跑代码) +- [x] **JSON-first 编辑**:Monaco + JSON schema 校验 + Pebble 静态检查 + 模板下拉 +- [x] **自然语言 → 工作流草稿**(`POST /workflows/draft/generate`):用户描述需求,agent 生成 graph_json + 编译诊断;不直接发布,仍要人工审阅 +- [x] **整数 revision**:发布写新行不可变;草稿与已发布版本分离 +- [x] **运行历史**:每个 step 的 input / output / 耗时 / token / 失败链路都被记录 +- [x] **payload 内置存储**:大输入输出走 `payload://` URI,不撑库 +- [x] **跨 workspace ACL**:发布期校验 agent / channel / employeeId 引用都在当前 workspace 内 +- [x] **`await_approval` 持久化暂停**:服务重启不丢 + +### 触发器(Trigger) + +- [x] **6 种 pattern type**:cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion +- [x] **事件治理默认开**:去重(60s 窗口)、per-trigger 限速、bot self-msg 过滤、A→B→A 递归切断 +- [x] **CronDelegationPort**:和老 cron 模块共享 ShedLock + Spring TaskScheduler,不写 mate_cron_job +- [x] **跨实例一致性**:`pattern_version` 自取消机制 + 周期 syncFromDatabase +- [x] **结构化表单**:6 种 pattern 各自有专属字段输入,不需要手写 patternJson + +### 升级现有体验 + +- [x] **图像编辑**(issue #75):`image_generate` 工具新增 `image` / `images` 参数,支持 5 种引用形式(含 `msg::` 引用会话内附件) +- [x] **DashScope 兼容模式**:复用同一把 sk- Key 接通点号版本号系列(qwen3.5-plus / qwen3.6-plus / qwen3-vl-plus 等) +- [x] **新万相 / qwen-image 系列**:14 个新图像模型,3 个新视频模型(含 happyhorse-1.0-t2v) +- [x] **4 个文档生成工具**:DocxRenderTool / XlsxRenderTool / PptxRenderTool / PdfRenderTool —— Markdown 直接渲染为 Office 文件,不 fork 子进程不依赖 npm +- [x] **MCP per-agent 工具绑定**:每个员工独立绑定 MCP 工具 + 状态徽标(connected / stale / unavailable / orphan)+ 命名空间冲突自动前缀化 + server 改名自动跟随 +- [x] **小米 MiMo provider**:MiMo V2.5 Pro / V2.5 / V2 Pro / V2 Omni / V2 Flash +- [x] **多模态旁路路由**(issue #87):纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜;硬禁令拆掉后用户自定义工具不再被压制;路由徽章 + 输入框提示让决策全程可见 + +### v1.3 还要做的 + +- [ ] **画布编辑器(v1)**:当前画布是只读链式渲染,目标是 `@vue-flow/core` 的可拖拉编辑 +- [ ] **运行回放视图**:trace timeline + 任意节点 hover 看 input/output diff +- [ ] **`loop` mode**:迭代 N 次或对数组逐项处理 +- [ ] **`invoke_skill` mode**:直接调 skill 不经过员工 +- [ ] **trigger 间优先级 / 依赖**:同一事件命中多 trigger 时的串行 / 并行控制 +- [ ] **事件回放**:`mate_trigger_event` 加 "重新派发"按钮 + +--- + +## 下一站:v1.4 —— 场景应用元年 + +> "当工具足够好,就把工具藏起来,把场景推到前面。" + +v1.0 → v1.3 把基础设施做齐了:员工、记忆、知识库、工具、技能、工作流、触发器、多模态、多渠道。**下一步不是再造一颗螺丝**,是把这些零件组装成**用户一打开就能落地的场景**。 + +v1.4 的关键词是**场景应用**。不是"加更多功能",是**让普通用户不用学 7 种 step mode、6 种 trigger pattern 就能直接用**。 + +### 行业场景模板(Workflow + Trigger 联动) + +每一个都是一份**可一键导入的工作流模板 + 触发器配置 + 推荐员工绑定 + 推荐知识库结构**: + +- [ ] **客户工单分流**:企业微信 / 飞书入口 → 数字员工分类 → 路由 / 升级 / 自动回复 → 写进客户档案 +- [ ] **晨报 / 周报自动化**:cron trigger → 多员工并行采数 → 数据分析员工汇总 → 生成 PDF/PPTX → 多渠道分发 +- [ ] **合同审批流**:上传合同 → 法务员工初审 → 审批等待 → 法务员工修订建议 → 写归档记忆 +- [ ] **市场情报监控**:webhook trigger(站点变更)→ 内容判断(content_match)→ 商业分析员工总结 → 飞书机器人推送 +- [ ] **新员工 onboarding**:webhook(HRIS 入职事件)→ 行政助理拉文档清单 → 培训知识库引导 → 多日跟进 trigger +- [ ] **代码 PR 审查**:GitHub webhook → 代码审查员工跑 review → 评论回写 PR → 关键改动转 await_approval + +### 场景市场(Scenario Marketplace) + +- [ ] **场景包格式**:一个场景 = `workflow.json` + `triggers.json` + `agents/*.md` + `knowledge/*.md` + `README.md`,可分享 / 安装 +- [ ] **场景市场 UI**:浏览 / 试运行 / 一键安装 / 评分评论 +- [ ] **场景包版本管理**:升级提示 + diff 预览 + 回滚 + +### 让数字员工跨场景协作 + +- [ ] **员工目录画像**:每位员工自动生成"擅长 / 不擅长"标签(基于历史交互 + 技能 + 工具集) +- [ ] **场景智能推荐**:用户描述"我想要 X 流程" → 推荐最适合的场景模板 + 已有员工 +- [ ] **跨场景记忆共享**:客户工单分流和合同审批流见到的都是同一个客户档案 + +### 把基础设施进一步藏起来 + +- [ ] **自然语言 → 完整场景包**:v1.3 已有"自然语言 → 工作流草稿",v1.4 把它扩展到**整个场景**——一句话描述出 workflow + trigger + 推荐员工 + 推荐 KB 结构的完整草案 +- [ ] **典型问题向导**:把"我的工作流卡在审批没人审"这种问题做成自助诊断 +- [ ] **场景级仪表盘**:不是"今天 token 用了多少",是"今天客户工单平均处理多久" + +### 同步推进的基础能力 + +- [ ] **场景级 ACL**:场景包安装时一次性把所需的 channel / agent / KB / 工具的 allowlist 都配好 +- [ ] **跨 workspace 场景共享**:场景模板能在多个工作空间间复用(克隆 + 覆盖配置) +- [ ] **场景运行成本预估**:安装前看见预期 token / API 调用 / 触发频率 + +--- + +## 我们故意不做的事 + +> "我对我们没做过的事情和我们做过的事情一样感到自豪。" + +| 砍掉的功能 | 为什么 | 什么时候才该做 | +|-----------|--------|--------------| +| **完整 RBAC 权限模型** | MateClaw 是数字员工系统,不是企业管理平台。单团队不需要管理 100 种权限组合 | 当真正出现需要细粒度权限的多团队 SaaS 客户时 | +| **多租户** | 同上。过早的多租户是架构癌症 | 当有明确的 SaaS 商业化路径时 | +| **SSO / LDAP / SAML** | 企业集成是个无底洞 | 当付费企业客户明确要求时 | +| **30+ 节点的可视化工作流编辑器** | 用户大多用不上。**v1.3 的 7 种 step mode 已经覆盖 90% 实际场景**,剩下的复杂度推到 LLM 自然语言生成 | 真有用户场景需要 30+ 节点时(很少) | +| **移动端原生 App** | 8 个 IM 渠道 + 桌面端 + Web 已经覆盖。你在手机上用钉钉 / 飞书 / Telegram 就在用 MateClaw | 当 Web / IM 渠道有不可替代的移动专属能力时 | +| **替代 ReAct / Plan-Execute** | 工作流和这两条引擎**是协作关系**,不是替代——单 agent 多轮推理仍在那两条引擎里 | 永远不替代 | + +--- + +## 版本里程碑 + +| 版本 | 一句话 | 用户体验目标 | 状态 | +|------|--------|-------------|------| +| **v1.0** | 它能思考和行动 | 一个能用工具解决问题的 AI 助手 | ✅ 已发布 | +| **v1.1** | 它无处不在 | 8 个渠道 + 4 层记忆 + 工作空间 + LLM Wiki | ✅ 已发布 | +| **v1.2** | 它是你的同事 | 数字员工 + 5 个职业模板 + 骨架式技能 + ACP 桥接 + Backstage 运行时 | ✅ 已发布 | +| **v1.3** | 它能编排业务流 | 工作流 + 触发器 + 图像编辑 + 文档生成 + per-agent 工具绑定 | ✅ 已发布 | +| **v1.4** | **它能落地场景** | **行业场景模板 + 场景市场 + 自然语言生成工作流 + 跨场景员工画像** | 📋 规划中 | + +--- + +## One More Thing + +我们做 MateClaw,不是为了追赶 ChatGPT、不是为了做下一个 Dify、不是为了融资 PPT 上多一个 buzzword。 + +我们做它,是因为我们相信一件事: + +**AI 不应该是一个网页上的对话框。它应该是你的第二个大脑。** + +它住在你的钉钉里、你的飞书里、你的 Telegram 里。它读过你所有的文档。它记得你三个月前说过的话。它会用你公司的内部工具。它在你睡觉的时候整理记忆。**它能替你跑一整条业务流程**。 + +总有一天,你会忘记它是一个程序。 + +**那一天,就是我们成功的那一天。** + +--- + +*Stay hungry. Stay foolish.* diff --git a/mateclaw-server/src/main/resources/docs/zh/security.md b/mateclaw-server/src/main/resources/docs/zh/security.md new file mode 100644 index 00000000..bce775c2 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/security.md @@ -0,0 +1,575 @@ +# 安全与审批 + +**手能伸得远,但边界清清楚楚。** + +MateClaw 给 Agent 真实的能力——shell 访问、文件写入、浏览器自动化、委托给其他 Agent、通过 MCP 调用远程工具。这是"手能伸得远"那一半。这一页讲的是另一半:**不让强有力的手干蠢事的边界**。 + +- **JWT 认证**——你是谁 +- **Tool Guard(基于规则)**——每个 Agent 被允许做什么 +- **审批工作流**——执行前什么时候需要人来决定 +- **File Guard**——Agent 眼里的文件系统长什么样 +- **工作空间隔离**——每个团队能看到什么 +- **审计日志**——所有人做过的所有事,按时间顺序,永远保留 + +生产环境跑 MateClaw 的话,从头到尾读完这页。 + +::: tip Agentic, but not autonomous +每个公司的 IT 部门和 CISO 在买 AI 之前问的同一个问题: + +> **"它会不会跑飞了,删了我不该删的东西?"** + +任何说"AI 不会跑飞"的人都在骗你。MateClaw 的答案不一样——**敏感操作问你一句再执行。** + +Agent 想删文件、发邮件、跑写入型 SQL、调付费 API——任何一条 Tool Guard 规则匹配上的调用,会**在回合中途暂停**,审批通知推到你的 IM(飞书 / 钉钉 / Slack / 邮件),你点批准,Agent 从暂停的地方接着跑。每一个动作进 `mate_tool_guard_audit_log`——按时间序、永远保留、可导出 CSV。 + +**会动手(agentic),但不擅自动手(not autonomous)。** + +这是「让 AI 替你干活」和「让 AI 替你做主」之间那条线。MateClaw 站在线的左边——也是你的 CISO 第一次不会一上来就否决的那一边。 +::: + +--- + +## JWT 认证 + +### 怎么工作的 + +1. 用户往 `/api/v1/auth/login` 提交凭证 +2. 服务器校验之后返回一个 JWT +3. 之后所有请求在 `Authorization` header 里带 token +4. 服务器在每个请求上校验 token + +### 登录 + +```bash +curl -X POST http://localhost:18088/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin123"}' +``` + +响应: + +```json +{ + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 86400 + } +} +``` + +### 修改密码 + +用户可以在个人设置里修改自己的密码。管理员可以在成员管理里重置任何成员的密码。 + +--- + +### 滑动窗口续签 + +MateClaw 实现了滑动窗口 token 续签。当 token 剩余有效期低于 `renewal-threshold`(默认 2 小时 / 7200000ms)时,服务器在响应头 `X-New-Token` 里发一个新 token。前端自动拿到新 token 替换旧的,**用户感知不到**。活跃用户不会被踢下线;空闲会话该过期还是过期。 + +### 配置 + +```yaml +mateclaw: + auth: + jwt: + secret: your-secret-key-must-be-at-least-32-characters-long + expiration: 86400000 # 24 小时,毫秒 + sliding-window: true +``` + +::: warning +**生产环境必须改默认 JWT secret。** 至少 32 字符。用环境变量(`JWT_SECRET=...`)设置,**不要 commit**。 +::: + +### 错误码 + +| 状态码 | 含义 | 响应 | +|--------|------|------| +| 401 | Token 缺失、过期或无效 | `{"code": 401, "message": "Unauthorized"}` | +| 403 | Token 有效但权限不足 | `{"code": 403, "message": "Forbidden"}` | + +前端统一处理——跳登录页、清空存储的 token。 + +### 默认凭证 + +MateClaw 出厂带 `admin` / `admin123`。**除了你自己笔记本之外的任何部署都必须立刻改。** + +### Spring Security 配置 + +- **无状态会话**——服务端不存 session;所有状态都在 JWT 里 +- **公共端点**——`/api/v1/auth/login`、`/h2-console/**`、`/swagger-ui/**` +- **受保护端点**——`/api/v1/**` 下的其他所有路径 +- **CSRF 关闭**——无状态 JWT 不需要 + +--- + +## Tool Guard —— 基于规则的权限引擎 + +Tool Guard 是 MateClaw 决定一次工具调用被允许做什么的机制。**它不是一个扁平的"危险工具清单"。** 它是一个规则引擎。每条规则说:*对这个工具,可选匹配这些参数,在这个工作空间里,做 X*——X 是 `allow`、`deny`、或 `require_approval`。 + +### 三张表 + +| 表 | 用途 | +|----|------| +| **`mate_tool_guard_config`** | 全局配置——开关、默认策略、审批超时、通知渠道 | +| **`mate_tool_guard_rule`** | 单条规则——工具模式、可选参数正则、工作空间范围、动作、优先级 | +| **`mate_tool_guard_audit_log`** | 每一次受守护的调用一条记录——工具、参数、匹配的规则、决定、用户、时间戳 | + +### 一条规则是怎么被评估的 + +``` +收到工具调用 + │ + ▼ +加载这个工作空间 + 全局的所有规则,按优先级排序 + │ + ▼ +按优先级遍历每条规则: + ┌─ 工具名匹配模式吗? + │ └─ 不 → 下一条 + ├─ 参数模式匹配吗(如果有)? + │ └─ 不 → 下一条 + └─ 都匹配 → 执行这条规则的动作,停 + │ + ▼ +没有规则匹配 → 执行默认策略 + │ + ▼ +动作:allow / deny / require_approval + │ + ▼ +写一条审计日志 + │ + ▼ +执行 / 拒绝 / 挂起等审批 +``` + +优先级更高的规则先执行。**第一个匹配的规则赢**。一条规则可以限定到特定的工作空间,也可以是全局的。 + +### 示例规则 + +``` +规则 1(优先级 100):ShellExecuteTool,参数匹配 "^(ls|cat|grep|find)\\s" → allow +规则 2(优先级 50): ShellExecuteTool → require_approval +规则 3(优先级 50): WriteFileTool,arg.path 以 "/tmp" 开头 → allow +规则 4(优先级 40): WriteFileTool → require_approval +规则 5(优先级 30): * → allow(默认) +``` + +只读的 shell 命令立刻执行。其他的需要审批。`/tmp` 下的文件写入自由;其他地方需要审批。其他所有工具放行。 + +### 管理规则 + +`设置 → 安全与审批 → Tool Guard 规则` 提供完整 UI。或者走配置文件: + +```yaml +mateclaw: + tool: + guard: + enabled: true + default-policy: require_approval + rules: + - tool: ShellExecuteTool + arg-pattern: "^(ls|cat|grep|find)\\s" + action: allow + priority: 100 + - tool: ShellExecuteTool + action: require_approval + priority: 50 + - tool: WriteFileTool + arg-pattern: "^/tmp/" + action: allow + priority: 50 + - tool: WriteFileTool + action: require_approval + priority: 40 +``` + +或者走 API: + +```bash +curl -X POST http://localhost:18088/api/v1/security/guard/rules \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "ShellExecuteTool", + "argPattern": "^(ls|cat|grep|find)\\s", + "action": "allow", + "priority": 100 + }' +``` + +### 凭证规则的开关(1.4.0) + +凭证规则现在支持**逐条开关**——每条规则可单独 enable / disable,可单独设定决定(allow / deny / require_approval),整套守护规则还可以 **JSON 导出 / 导入**,方便在多套部署之间迁移或版本化管理。 + +### 危险模式检测 + +除了用户定义的规则之外,MateClaw 的 shell 工具内置了一套危险模式检测——不管你的规则怎么写,有些模式本身就是危险的。`find -delete`、`rm -rf /`、用管道把 `bash` 接到下载上之类的模式,**即使有规则本来会 allow,也会强制触发更高级别的审批**。 + +--- + +## 审批工作流 —— 人在回路 + +当一条规则评估为 `require_approval` 时,MateClaw 不会简单地让调用失败。它会**在回合中途挂起 Agent**,创建一条 pending approval,呈现给用户,等用户决定之后**从暂停的地方恢复执行**。 + +::: tip 1.3.0 起:工作流也走同一套审批 +v1.3.0 的 [工作流](./workflow) `await_approval` step 通过同一套 `mate_tool_approval` 表挂起整条 workflow run,跨服务重启不丢;审批结果通过 channel 通知(飞书 / 钉钉 / Slack / 企微)推回审批人,resolve 后 workflow runtime 自动 resume 下一 step。也就是说——同一份审计、同一份通知、同一种"暂停—恢复"语义,同时覆盖 Agent 工具调用和 workflow step。 +::: + +### 工作流 + +``` +Agent 调用工具 + │ + ▼ +Tool Guard:require_approval + │ + ▼ +创建 mate_tool_approval 行(status=pending) + │ + ▼ +图状态里 AWAITING_APPROVAL=true + │ + ▼ +发出 approval_required SSE 事件 + │ + ▼ +图干净地终止 + │ + ▼ +前端显示审批卡片 + │ + ▼ +用户点 Approve 或 Reject + │ + ▼ +POST /api/v1/approvals/{id}/resolve + │ + ├─ Approved → 重新加载 Agent,replay 工具调用,继续推理 + └─ Rejected → 把拒绝作为 observation 返回,继续推理 +``` + +"replay" 机制很重要。Agent 恢复时**不会从头重新推理**——它直接跳到已经批准的工具调用、执行、从观察继续。**没有重复的 LLM 调用,没有浪费的 token。** + +### `mate_tool_approval` 表 + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `agent_id` | 哪个 Agent 在等 | +| `conversation_id` | 哪个会话被挂起 | +| `tool_name` | 要调的工具 | +| `tool_args` | 实际参数的 JSON | +| `rule_id` | 触发审批的规则 | +| `status` | `pending` / `approved` / `rejected` / `expired` | +| `requested_at` | 审批被创建的时间 | +| `resolved_at` | 用户决定的时间 | +| `resolved_by` | 谁决定的 | +| `notes` | 决定时可选的备注 | + +### 占位符替换 + +有时候 Agent 的工具参数里带占位符——一个被计算出来的文件路径、一条带模板的命令。审批工作流**在弹出对话框之前就替换完占位符**,用户看到的是**真正的值**。审批同样返回替换后的值,Agent 执行的**就是**用户看到的。 + +### 超时 + +Pending approval 在一个可配置的超时后过期(默认 10 分钟)。过期的审批变成 `rejected`,Agent 把这个过期当作用户的拒绝一样对待。 + +### 通知 + +MateClaw 可以通过 `channel/notification/` 适配器通知——邮件、应用内提醒、钉钉/飞书推送。在 `设置 → 安全与审批 → 通知` 里配置。 + +### API 方式处理审批 + +```bash +# 列出 pending 审批 +curl http://localhost:18088/api/v1/approvals?status=pending \ + -H "Authorization: Bearer " + +# 批准 +curl -X POST http://localhost:18088/api/v1/approvals/123/resolve \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"decision": "approved"}' + +# 拒绝并带原因 +curl -X POST http://localhost:18088/api/v1/approvals/123/resolve \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"decision": "rejected", "notes": "这个工作空间不适合"}' +``` + +--- + +## File Guard + +File Guard 是文件系统级的访问控制。它坐在读写文件的任何工具或技能下面,决定哪些路径在边界内。 + +### 评估管道 + +``` +文件访问请求 + │ + ▼ +路径规范化(解析 ..、符号链接、相对路径) + │ + ▼ +白名单检查:路径在允许的目录里吗? + │ + ▼ +黑名单检查:路径在被拒的目录里吗? + │ + ▼ +符号链接检查:顺着链接走会跳出沙盒吗? + │ + ▼ +允许 / 拒绝 +``` + +### 内置规则 + +| 规则 | 说明 | +|------|------| +| 工作空间隔离 | 默认访问限定在工作空间目录内 | +| 系统路径拒绝 | `/etc`、`/usr`、`/bin`、`/boot` 等默认拒绝 | +| 敏感文件保护 | `.ssh`、`.config`、`.env` 拒绝 | +| 路径穿越防护 | `../` 攻击被检测和阻止 | +| 符号链接检查 | 符号链接的目标被解析并重新校验 | + +### 配置 + +```yaml +mateclaw: + security: + file-guard: + enabled: true + allowed-paths: + - "${user.dir}/workspace" + - "${java.io.tmpdir}/mateclaw" + denied-paths: + - "/etc" + - "/usr" + - "${user.home}/.ssh" + - "${user.home}/.config" + - "${user.home}/.env" +``` + +可视化编辑器在 `设置 → 安全与审批 → File Guard`。 + +--- + +## 工作空间隔离 + +工作空间是 MateClaw 把多个团队的数据隔开的方式。每个 Agent、技能、Wiki、会话、记忆文件都**属于且只属于一个工作空间**。 + +### 沿工作空间边界生效的安全基元 + +- **File Guard**——路径白名单默认是 `workspace/{workspaceId}/...` +- **Tool Guard 规则**——可以限定到特定的工作空间 +- **Wiki 知识库**——归属于工作空间,只有成员能读 +- **记忆文件**——每个 Agent 的记忆在它工作空间的目录下面 +- **渠道**——每个渠道归属于一个工作空间 + +### 角色(四级 RBAC) + +权限**叠加**——高角色继承低角色的全部能力。 + +| 角色 | 能力(继承下层后新增) | +|------|------------------------| +| **Viewer** | `chat`、`view:wiki`。只读。为了让聊天能跑通,Viewer 还能读取当前激活模型、读取员工的工作空间文件。 | +| **Member** | Viewer + `view:memory`、`view:dashboard`、`manage:wiki`、`manage:agents` | +| **Admin** | Member + `manage:skills`、`manage:channels`、`manage:models`、`manage:security`、`manage:settings` | +| **Owner** | 与 Admin 相同,外加 owner 专属:删除工作空间、转移所有权 | + +**后端是能力的唯一真相源**——后端维护一份 `RoleCapabilities` 映射,前端从不本地推导。切换工作空间后、或遇到与权限相关的 403 时,前端调用 `GET /api/v1/workspaces/{id}/access`,拿回 `memberRole`、`isGlobalAdmin`、`effectiveRole`、`capabilities`。 + +**全局管理员 vs 工作空间角色**:`mate_user.role='admin'` 是系统级全局管理员——管理用户、创建工作空间,以 owner 等同的权限横跨**所有**工作空间(即便它不是某工作空间的成员);`mate_workspace_member.role` 是每工作空间的角色。系统级端点(模型 / provider / OAuth / 数据源、用户管理、创建工作空间)要求全局管理员(`@RequireGlobalAdmin`);工作空间级端点(技能 / 工具 / 插件)要求工作空间角色——读需要 Member、写需要 Admin。 + +完整细节在 [工作空间](./workspaces)。 + +### 工作空间隔离**不**覆盖的 + +- **共享的全局配置**——JWT secret、模型 provider key、MCP 服务定义都是全局的 +- **审计日志的跨工作空间访问**——带权限的安全管理员可以跨所有工作空间查询审计事件 + +--- + +## 审计日志 + +每一个安全相关的动作都被记在 `mate_audit_event`。**仅追加**——你不能改一条已有的记录,按配置的窗口(默认 90 天)保留。 + +### 记什么 + +| 事件类型 | 捕获的数据 | +|----------|------------| +| **工具调用** | 工具名、参数、结果摘要、耗时、Agent、工作空间 | +| **Tool Guard 决定** | 匹配的规则、执行的动作、规则 ID | +| **审批** | 谁批准/拒绝、什么时候、备注 | +| **File Guard 决定** | 路径、允许/拒绝、原因 | +| **技能执行** | 技能名、参数、Agent | +| **登录事件** | 用户、IP、成功/失败 | +| **配置变更** | 安全相关设置的旧值和新值 | + +### 记录结构 + +``` +timestamp 什么时候发生 +user_id 谁做的(自动事件是 system) +action 做了什么 +resource 对什么做的 +details 具体细节的 JSON +result success / failure / denied +ip_address 源 IP(可用时) +workspace_id 属于哪个工作空间 +``` + +### 查询 + +`设置 → 安全与审批 → 审计日志`:按时间范围、事件类型、用户、工作空间、结果过滤。导出 CSV。 + +API: + +```bash +curl "http://localhost:18088/api/v1/audit/events?from=2026-04-01&to=2026-04-11&action=tool_call" \ + -H "Authorization: Bearer " +``` + +--- + +## 技能安全扫描 + +自定义技能在变活之前会被扫描危险模式: + +| 检查 | 找什么 | +|------|--------| +| **Prompt 注入** | 覆盖 system prompt 的企图、隐藏指令 | +| **危险工具引用** | 不在允许列表里的工具,或请求了需要审批却没声明的工具 | +| **外部 URL 引用** | 技能正文里指向不可信外部资源的链接 | +| **脚本注入** | 嵌入的脚本或代码执行企图 | + +### 严重级别 + +| 级别 | 动作 | +|------|------| +| `CRITICAL` | 安装被阻止;必须修好 | +| `HIGH` | 警告 + 管理员必须确认 | +| `MEDIUM` | 显示警告;允许安装 | +| `LOW` | 仅记录 | +| `INFO` | 仅记录 | + +扫描报告在 `设置 → 安全与审批 → 技能扫描` 里。 + +--- + +## API Key 保护 + +- API keys 在数据库里**加密存储** +- Keys 在所有 API 响应里**脱敏显示**(`sk-****abcd`)——创建之后永远不会完整返回给前端 +- MCP 服务的 `env_json` 和 `headers_json` 值按同样方式脱敏 +- MCP 配置里的环境变量引用(`${VAR}`)在运行时从进程环境解析 + +--- + +## 网络安全 + +### 生产建议 + +| 建议 | 细节 | +|------|------| +| **HTTPS** | 用反向代理 + TLS(Nginx 或 Caddy) | +| **关掉 H2 console** | 生产环境 `spring.h2.console.enabled=false` | +| **防火墙** | 只开放对外端口 | +| **限流** | 在反向代理层配置 | +| **MySQL,不是 H2** | 生产用独立的 MySQL 8 实例 | + +### Nginx 反向代理示例 + +```nginx +server { + listen 443 ssl; + server_name mateclaw.example.com; + + ssl_certificate /etc/ssl/certs/mateclaw.pem; + ssl_certificate_key /etc/ssl/private/mateclaw.key; + + location / { + proxy_pass http://localhost:18080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE 支持 + proxy_buffering off; + proxy_read_timeout 86400s; + } +} +``` + +--- + +## 安全最佳实践 + +1. **改默认密码。** 现在就改。每一个部署都改。 +2. **设一个真正的 JWT secret。** 至少 32 字符,通过环境变量,永远不要 commit。 +3. **最小权限。** 只启用 Agent 真的需要的工具。 +4. **默认 `require_approval`。** 把 Tool Guard 的 `default-policy` 翻成 `require_approval`,然后为安全场景加 `allow` 规则。**新加的工具默认安全**。 +5. **配好 File Guard。** 在任何 Agent 真的碰文件系统之前把 allowed/denied 路径锁死。 +6. **定期看审计日志。** 设个定时提醒。找异常。 +7. **盯住技能扫描。** CRITICAL 发现不该被轻易绕过。 +8. **网络隔离。** Ollama、H2 console、内部 MCP 服务——这些都不该对公网可达。 +9. **生产环境别跳过审批。** 自动批准规则应该**窄而具体**。`allow *` 是定时炸弹。 + +--- + +## 安全配置参考 + +```yaml +mateclaw: + auth: + jwt: + secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} + expiration: 86400 + sliding-window-ratio: 0.5 + + tool: + guard: + enabled: true + default-policy: require_approval + approval-timeout-seconds: 600 + notifications: + email-enabled: false + dingtalk-enabled: false + + security: + file-guard: + enabled: true + allowed-paths: + - "${user.dir}/workspace" + denied-paths: + - "/etc" + - "${user.home}/.ssh" + + audit-log: + enabled: true + retention-days: 90 + + skill: + security-scan: + enabled: true + block-critical: true +``` + +--- + +## 下一步 + +- [工具系统](./tools)——工具细节和 Tool Guard 规则模式 +- [技能系统](./skills)——技能安全扫描细节 +- [工作空间](./workspaces)——工作空间隔离基元 +- [Agent 引擎](./agents)——审批如何挂起并恢复 Agent 回合 +- [配置说明](./config)——完整配置参考 diff --git a/mateclaw-server/src/main/resources/docs/zh/skills.md b/mateclaw-server/src/main/resources/docs/zh/skills.md new file mode 100644 index 00000000..88b8b992 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/skills.md @@ -0,0 +1,631 @@ +# 技能系统 + +**一个技能是一个"用句子思考"的工具。** + +工具是原子的——读一个文件、发一个 HTTP 请求、跑一个命令。技能是**组合**——"调研这个话题然后写一份简报"、"审这段代码并给出评论"、"把我的 git log 变成 standup 汇报"。一个技能是一份 `SKILL.md` 文件,把指令、参数、prompt 模板、可选脚本和需要的工具列表组合在一起。 + +如果工具是手,那技能就是**菜谱**。 + +--- + +## 五种技能 + +| 类型 | 来源 | 维护者 | +|------|------|--------| +| **`builtin`** | 跟着 MateClaw 一起发布 | 核心团队 | +| **`custom`** | 你通过 UI、API 或直接往工作空间扔文件创建 | 你自己 | +| **`dynamic`** | Agent 在工作中自动合成 | Agent + 你的审批 | +| **`mcp`** | 由 MCP 服务暴露的工具支撑(同名技能会被 `custom` 真技能覆盖) | MCP 服务作者 | +| **`acp`** | 桥接到外部 Agent 客户端协议端点(Claude Code、Codex 等) | 上游 Agent 服务 | + +全都走同一条运行时管道。唯一的区别是来源。 + +--- + +## SKILL.md 协议 + +每个技能是一份带 YAML frontmatter 的 Markdown 文件。**frontmatter 是契约,正文是 prompt。** + +```markdown +--- +name: web-researcher +title: 网页调研员 +description: 搜索网页并对给定话题做总结 +version: 1.0.0 +type: custom +author: your-name +tools: + - WebSearchTool + - ReadFileTool +tags: + - research + - search +parameters: + - name: topic + type: string + required: true + description: 调研的话题 + - name: depth + type: string + required: false + default: brief + description: 详细程度(brief, detailed, comprehensive) +--- + +# 网页调研员 + +你是一个网页调研助手。给你一个话题时,你应该: + +1. 用 WebSearchTool 搜索关于 {{topic}} 的相关信息 +2. 评估信息源的可信度 +3. 把发现整理成 {{depth}} 级别的总结 +4. 在回答里包含来源 URL + +## 输出格式 + +把你的发现按这个格式呈现: +- **总结**:2–3 句话概述 +- **关键事实**:要点列表 +- **来源**:带编号的 URL 列表 +``` + +注意两件事。第一,正文是一份 prompt——不是对 prompt 的**描述**。它是技能在运行时会对 Agent 说的话。第二,`tools:` 列表是一份契约:运行时保证这些工具在技能运行时可用。 + +### Frontmatter 字段 + +| 字段 | 必填 | 用途 | +|------|------|------| +| `name` | ✅ | 唯一标识符(kebab-case) | +| `title` | ✅ | 人类可读的显示名 | +| `description` | ✅ | 一句话总结 | +| `version` | ✅ | 语义化版本 | +| `type` | ✅ | `builtin`、`custom`、`mcp` | +| `author` | — | 作者 | +| `tools` | — | 技能需要的工具名列表 | +| `tags` | — | 分类标签 | +| `parameters` | — | 类型化的输入参数 | + +### 参数 schema + +| 字段 | 必填 | 用途 | +|------|------|------| +| `name` | ✅ | 参数名(`{{name}}` 插值时用) | +| `type` | ✅ | `string`、`number`、`boolean`、`array` | +| `required` | — | 是否必须提供(默认 false) | +| `default` | — | 调用者省略时的默认值 | +| `description` | ✅ | 这个参数控制什么 | + +### 脚本的类型化包装工具(v1.4 新增) + +SKILL.md 可以声明一个 `scripts:` 块,把每个脚本入口变成**独立的、带类型化 JSON Schema 的命名工具**。Agent 看到的不再是一个泛用的 `runSkillScript`,而是一个个 `skill__` 工具,直接填写 schema 描述的参数。 + +```yaml +scripts: + - id: summarize + path: scripts/dispatch.py + fixedArgs: ["summarize"] # 每次调用前原样拼在最前 + parameters: + - name: url + type: string + required: true + - id: translate + path: scripts/dispatch.py + fixedArgs: ["translate"] + parameters: + - name: lang + type: string + required: true +``` + +- **每个入口一个类型化工具**——模型拿到的是类型化参数,不是一串自由格式的 arg。 +- **`fixedArgs` 让一个 dispatcher 脚本支撑多个入口**——上面两条都调 `dispatch.py`,靠固定的首参区分,不用每个命令一个文件。 +- **包装工具随技能生命周期注册/注销**——技能上线时出现,禁用或归档时消失。路径穿越被拦死:只能触达技能自己 `scripts/` 目录下的脚本。纯数据库技能(没有目录)不暴露任何包装工具。 + +--- + +## 运行时管道 + +``` +1. RESOLVE 在 mate_skill 里按名字查技能 + │ + ▼ +2. VALIDATE 检查必填参数都提供了 + │ + ▼ +3. RENDER 替换 SKILL.md 正文里的 {{parameter}} 占位符 + │ + ▼ +4. INJECT 把渲染后的指令追加到 Agent 的 system prompt + │ + ▼ +5. BIND TOOLS 校验技能要求的工具都可用;缺任何一个就早失败 + │ + ▼ +6. EXECUTE Agent 用增强后的 prompt 和绑定的工具处理任务 +``` + +技能默认**不跑脚本**——它在调用期间**塑形 Agent 的行为**。例外是自带脚本的技能——`SkillScriptTool` 可以执行技能捆绑的脚本文件,走 Tool Guard。 + +### 模板渲染 + +技能正文支持 `{{parameterName}}` 占位符。参数 `{topic: "量子计算", depth: "detailed"}` 下: + +```markdown +用 {{depth}} 级别的详细程度调研话题 "{{topic}}"。 +``` + +…渲染成: + +```markdown +用 detailed 级别的详细程度调研话题 "量子计算"。 +``` + +缺失参数回退到默认值。未知占位符原样保留。 + +--- + +## 技能存储 + +数据库是真相源,文件系统是物化缓存。这条规则对 **SKILL.md** 一直成立,**v1.3 起对 scripts/ 和 references/ 也成立**。 + +### 数据库:`mate_skill` + `mate_skill_file` + +`mate_skill`——技能身份与正文: + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `name` | 唯一名 | +| `title` | 显示标题 | +| `description` | 一句话总结 | +| `type` | `builtin` / `custom` / `mcp` | +| `content` | 完整的 `SKILL.md` 内容 | +| `version` | 语义化版本 | +| `enabled` | 开关 | +| `tags` | JSON 数组 | +| `create_time` / `update_time` | 时间戳 | + +`mate_skill_file`(v1.3 新表,迁移 `V112`)——bundle 文件的**权威副本**: + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `skill_id` | 外键到 `mate_skill` | +| `file_path` | `scripts/run.py` 或 `references/cfg.md` 这种相对路径 | +| `content` | UTF-8 文本(单文件 ≤1 MB,bundle ≤50 MB) | +| `content_size` | 字节数(不用拉 blob 就能列) | +| `sha256` | 内容指纹,给同步器做幂等 diff | + +### 文件系统:技能工作空间 + +``` +~/.mateclaw/skills/ +├── translate/ +│ ├── SKILL.md # 技能定义 +│ ├── references/ # 参考资料 +│ └── scripts/ # 可选的可执行脚本 +├── code-review/ +│ ├── SKILL.md +│ └── ... +└── .archived/ # 归档的旧版本 + └── translate-20260401-143000/ +``` + +把它想成"**Maven 本地仓库,但用来存技能**"——区别是"本地仓库"现在能从数据库 hydrate 出来。 + +### 启动时自动同步 + +启动时跑两遍同步,保证每个节点拿到的都是最新的: + +1. `SkillWorkspaceBootstrapRunner` → `BundledSkillSyncer` 扫描 classpath 的 `skills/` 目录,把**捆绑技能**同步到工作空间根。**只在目标目录不存在时同步**,不会覆盖本地修改。 +2. `SkillFileSyncer` 比对 `mate_skill_file`(DB)与本地工作空间(FS),按 `sha256` 增量物化缺失或过期的文件。 + +**多实例部署的意义**:一个节点上传 zip,DB row 与 file rows 写入;其他节点重启或调一次 `POST /api/v1/skills/{id}/sync-files` 就能拿到完整 bundle,不用 NFS、不用脚本拷贝、桌面端跨机也能接力。 + +> 升级路径:v1.3 之前的安装在 FS 有文件但 DB 没 row。`SkillFileSyncer` 第一次启动会**从磁盘回填**到 `mate_skill_file`,之后两边保持同步。 + +### 鲁棒的 zip 安装 + +第三方打包者千奇百怪——有人把 `setup.sh` 直接放 zip 根,有人 `scripts/` 排在 `SKILL.md` 之前。`ZipSkillFetcher` v1.3 起: + +- **两遍扫描**——先把所有条目缓存(受 50 MB 上限保护),定位 `SKILL.md` 算出 wrapper 前缀,再分类。**条目顺序不再影响结果**。 +- **根目录扩展名兜底**——SKILL.md 同级的非约定文件按扩展名归类:`.sh / .py / .js / .rb / ...` → `scripts/`,`.md / .json / .yaml / .csv / ...` → `references/`,未识别扩展名落 `WARN` 日志。 +- **写后裁剪 + 空 bundle 守卫**——重装时**先写新文件再裁剪不在新 bundle 里的旧文件**。如果新 bundle 某个桶(`scripts/` 或 `references/`)一个条目都没有,**保留磁盘上的旧文件**——一个解析失败的损坏 zip 不会再把你的 skill 擦干净。要强制清空就传 `forcePrune=true`。 + +> 这道门管得住的实际场景:上次实测中腾讯会议 zip 的 `setup.sh` 在根(不在 `scripts/` 子目录),旧 extractor 静默丢弃;新 extractor 自动归到 `scripts/setup.sh`,安装完直接可跑。 + +### 配置 + +```yaml +mateclaw: + skill: + workspace: + root: ${user.home}/.mateclaw/skills + auto-init: true + delete-policy: archive + bundled-skills-path: skills +``` + +--- + +## 技能市场(以及 ClawHub) + +**技能市场** 页面(`/skills`)是你浏览、安装、编辑、管理技能的地方。三个来源: + +- **内置**——MateClaw 出厂带的技能 +- **你的自定义技能**——你创建或上传的 +- **ClawHub**——一个社区技能库,浏览上千个社区技能、预览、一键安装 + +ClawHub 是**可选**的——离线或不想用外部技能,就别碰那个 tab。 + +--- + +## 技能市场 API + +```bash +# 列出所有技能 +curl http://localhost:18088/api/v1/skills \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 创建一个自定义技能 +curl -X POST http://localhost:18088/api/v1/skills \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "code-reviewer", + "title": "代码审查员", + "description": "审查代码,找 bug、风格问题、改进点", + "type": "custom", + "content": "---\nname: code-reviewer\n...", + "tags": ["development", "review"] + }' + +# 启用 / 禁用 +curl -X PUT http://localhost:18088/api/v1/skills/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"enabled": true}' + +# 删除 +curl -X DELETE http://localhost:18088/api/v1/skills/1 \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +删除策略可配——默认把技能工作空间移到 `.archived/` 而不是清掉。 + +--- + +## 写一个自定义技能 —— 一步一步 + +1. **决定技能干什么。** 一句话。 +2. **列出它需要的工具。** 三个以内是个好目标。 +3. **写参数。** 必填的在前,可选的带默认值。 +4. **写正文。** 直接对 Agent 说话:*"你是 X。给你 Y 时,做 Z。"* +5. **上传**——通过技能市场 UI 或 API。 +6. **绑定**到一个或多个 Agent。 +7. **测试**——发一条应该触发这个技能的消息。 + +示例——"每日站会"技能: + +```markdown +--- +name: daily-standup +title: 每日站会生成器 +description: 根据最近的 git 活动生成站会汇报 +version: 1.0.0 +type: custom +tools: + - ShellExecuteTool +parameters: + - name: repo_path + type: string + required: true + description: git 仓库路径 +--- + +# 每日站会生成器 + +通过分析最近的 git 活动生成站会汇报。 + +## 步骤 + +1. 在 {{repo_path}} 目录下执行 `git log --oneline --since="yesterday" --author=$(git config user.name)` +2. 总结完成的工作 +3. 识别所有 work-in-progress 分支 +4. 按以下格式输出站会汇报: + - **昨天**:完成了什么 + - **今天**:根据未合并的分支计划做什么 + - **阻塞**:合并冲突或失败的测试 +``` + +--- + +## 工作空间隔离 + +每个工作空间都有自己的一份技能副本。给某个工作空间启用一个技能时,它的文件被 stage 到那个工作空间的目录下、技能的工具被 scope 到这个工作空间、技能写任何文件都在工作空间边界内。v1.4 起技能**目录与运行时也按工作空间隔离**,每个工作空间只看到、只运行属于自己的技能。见 [工作空间](./workspaces)。 + +--- + +## 自动技能合成 + +Agent 用得多了,会发现自己反复在做同样的事——某种特定的数据库查询方式、某种报表格式、SSH 到你机器的命令序列。Agent 能**主动把这些套路变成技能**。 + +工作流程: + +1. Agent 在任务执行中识别出一个可复用的模式 +2. Agent 提议创建一个新技能(创建 / 编辑 / 修补 / 删除) +3. 你在 ChatConsole 里审批——看内容、改名字、确认或拒绝 +4. 审批通过后,技能自动保存为 `dynamic` 类型,下次直接复用 + +**安全扫描在保存前自动执行**——危险模式(prompt 注入、脚本注入)会被阻断。技能可以跨 Agent 迁移,也可以打包成 ZIP 分享。 + +Agent 的记忆和你一起长大。不用再反复说"记住我喜欢按这个格式排表格"。 + +--- + +## 模板向导:从起步模板开始 + +不会写 SKILL.md?打开向导。 + +`技能 → 创作向导`: + +1. 选一个**起步模板**(8 个:调研员、代码审查员、写作助手、客户支持脚本、数据分析、Claude Code helper、Codex helper、空白模板) +2. 填变量——名字、参数、几句描述 +3. 上传任何辅助文件(脚本、参考资料、prompt 片段) +4. 设置密钥(API key 之类的)——**密钥进密钥库,不进 SKILL.md** +5. 保存 + +你得到的不是一份 SKILL.md,是一个**多文件 bundle**——SKILL.md、references/、scripts/、密钥引用,全在一起。 + +### `skill-authoring` 元技能(v1.4 新增) + +现在有一个内置的 `skill-authoring` 技能,启动时自动播种,教 Agent(或你)正确编写 SKILL.md。它涵盖: + +- **必填 frontmatter** 以及每个字段的含义 +- **校验器限制**——name 必须匹配 `^[a-z0-9][a-z0-9._-]{0,63}$`,内容 ≤ 100k 字符 +- **内置 vs 自定义**的编写流程 +- scripts/ 与 references/ 的**目录摆放** +- 会导致校验失败或静默出错的**常见坑** + +把它绑到一个 Agent 上,"给我写一个能……的技能"第一次就产出合法 bundle,不用来回校验三轮。 + +--- + +## 安装前的 Preflight 检查 + +技能装进来不一定能用——它可能需要某个 API key、某个 CLI 工具、某个 MateClaw 开关被打开。 + +之前你装完一跑才发现少东西。现在: + +**Pre-flight install 对话框**——技能在变活之前自动跑一遍前置检查: + +- 需要的工具在不在 +- 需要的 API key 配了没 +- 需要的 feature flag 开了没 +- 依赖的 MCP / ACP 端点连得上吗 + +少什么直接告诉你,提供一键 **`[Set Up]`** 按钮跳到对应配置页。**不再装完才报错让你自己 debug。** + +--- + +## LESSONS.md:让技能从经验里学习 + +每个技能可以带一份 `LESSONS.md`——技能在执行中学到的经验。 + +- 技能跑完一次,可以**主动写一行 lesson**:"上次用户在这种格式下不满意,下次别这样" +- 下次同名技能再被调用,LESSONS 自动注入到 prompt 上下文 +- 用得越多,技能越知道**什么时候该出场、什么时候该闭嘴** + +这是技能"自我进化"的第一版。技能从一组指令,变成有套路、有经验、能成长的东西。 + +LESSONS 在技能详情抽屉的 **Memory tab** 里查看和编辑。 + +--- + +## 密钥(Secrets):把 token 放对地方 + +很多技能要 API 凭证才能跑——腾讯会议要 `TENCENT_MEETING_TOKEN`、Slack 要 bot token、Linear 要 personal API key。这些东西**不能写进 SKILL.md**(会进 prompt,泄露给 LLM)、不能写进 scripts(commit 到代码就完了)、改 `~/.zshrc` 又要重启服务器、桌面端跨机更带不走。 + +v1.3 起,每个技能自带一个 **per-skill 密钥仓**。 + +### 在 UI 里管 + +技能详情抽屉 → **密钥** tab。一张表加一个表单: + +``` +键名 值 最近更新 操作 +TENCENT_MEETING_TOKEN sk••••ef 2026-05-12 [改] [删] + +[+ 新增密钥] +``` + +- **明文永不出后端**——列表只回 `preview`(`sk••••ef` 这种 mask),新增/修改弹窗的值字段从空开始,提交即覆写。 +- **客户端预校验**——key 必须 `^[A-Za-z_][A-Za-z0-9_]{0,127}$`,错的 key 在浏览器就拦掉。 +- **value 字段是 password 型 input + autocomplete=off**——肩窥、截屏、密码管理器都不会沾。 + +### 怎么落盘 / 怎么注入 + +| 阶段 | 怎么做 | +|---|---| +| 写入 | `POST /api/v1/skills/{id}/secrets` `{key, value}` → AES 加密 → `mate_skill_secret` | +| 读出 | 子进程启动前 `SkillScriptService.getDecrypted(skillId)` AES 解密 | +| 注入 | `ProcessBuilder.environment().putAll(...)`——**覆盖父进程同名环境变量** | + +注入语义是**密钥仓里有的覆盖 `.zshrc` 里的;密钥仓里没有的沿用 `.zshrc`**。多人 / 多机部署、桌面端用户、公司域账户互相不串库时,密钥仓是更靠谱的事实源。 + +### REST 接口 + +```bash +# 列(masked) +GET /api/v1/skills/{id}/secrets +# upsert(value 为空等同删除) +POST /api/v1/skills/{id}/secrets {"key":"...", "value":"..."} +# 删 +DELETE /api/v1/skills/{id}/secrets/{key} +``` + +### 一个完整例子:腾讯会议 + +``` +SkillMarket → tencent-meeting-mcp 卡片 → 详情抽屉 → 密钥 tab + → +新增密钥 → key=TENCENT_MEETING_TOKEN, value= + → 保存 + +之后 agent 跑 setup.sh 或 scripts/tencent_meeting.py 时: + ProcessBuilder env 里就有 $TENCENT_MEETING_TOKEN + → mcporter / Python 脚本走腾讯 API → 会议号回来 +``` + +不再需要改 `~/.zshrc`、不需要重启 mateclaw。 + +--- + +## 技能发现:装完就能被用上 + +之前装一个新技能,agent 经常找不到。三个原因,三个修复,v1.3 都补了。 + +### 1) 新技能在 prompt catalog 里**优先** + +agent 的 system prompt 里有一个紧凑的 Skills 表。每个模型按 max input tokens 给一个上限——qwen-turbo 这种 8192 上限的模型只塞 **8 个**。一个全新的技能没有使用记录,原本的 RECOMMENDED 排序会把它埋在 ~40 个老技能后面,进不了 top-8。 + +v1.3 起在排序链最前加一档「**最近 7 天安装的优先**」。装完到周一回来都还在第一屏——足够长,又不会无限期占位。Builtin 与虚拟 MCP/ACP 行不算(你不是"刚装"它们)。 + +### 2) `listAvailableSkills()` 教会 LLM 怎么搜更多 + +工具描述里现在明确写了: + +- 默认页 20 条;如果看到 `Showing: 20 of 47` 这种字样,**用 `keyword=<部分名>` 或 `limit=50` 重试** +- 如果用户报上来一个具体技能名,**别走目录**,直接 `readSkillFile(skillName="", filePath="SKILL.md")` 验证 + +返回结果末尾被截断时也带一行截断提示,小模型也能看见怎么续搜。 + +### 3) skill 名误调成 tool 时**自动重定向** + +LLM 偶尔会把 skill 名当 tool 调(`tencent-meeting-mcp({...})`)。旧版本会返回一段文字提示叫它去 `readSkillFile`——qwen-turbo 这种小模型经常理解不了,回一句"好的我去查"就把回合结束了,进死循环。 + +v1.3 起,`ToolExecutionExecutor` 检测到这种情况且 `readSkillFile` 已绑定到 agent,**透明地代它跑 readSkillFile**,把 SKILL.md 全文(带 `[auto-redirect]` 前缀 + 原始参数回显)作为工具结果返回。模型一看就有 SKILL.md 里的可执行示例可以照抄,下一步直接 `runSkillScript`,不会再卡。 + +> 这条修复对所有小模型都有效,对大模型也无害(它们本来就会看完提示直接 retry)。 + +--- + +## 渐进式技能披露(v1.4 新增) + +把每个技能完整的 SKILL.md 全塞进 system prompt 不可扩展——既炸 token 预算,又让 prompt 缓存每回合失效。v1.4 反过来:prompt 里只放一张紧凑目录,Agent **按需拉取**某个技能的指令。 + +**`load_skill(skillName, filePath?)`** 在 Agent 决定用某个技能的当下,加载它的 SKILL.md(或通过可选的 `filePath` 加载 bundle 里任意文件): + +- **经消息历史注入,不进 system prompt**——加载的内容作为一个会话回合到达,所以 system prompt(及其缓存)整个会话保持逐字节稳定。 +- **加载过的技能会被置顶**到后续回合的运行时目录顶端,Agent 一直看得见自己刚拉进来的东西。 +- 目录引导会告诉模型用技能前先 `load_skill(skillName=)`,用户点名某个具体技能时直接调它。 + +```yaml +mateclaw: + skill: + disclosure: + load-skill-tool: + enabled: true # 默认;设为 false 回退到旧的 readSkillFile 流程 +``` + +关闭时,目录引导改指向 `readSkillFile`,`load_skill` 不再注册。 + +--- + +## 技能生命周期管理员(v1.4 新增) + +会合成技能的 Agent 会攒下垃圾——三周前的一次性技能还在目录里占着位子。**管理员(curator)** 是一个每日扫描,把闲置的、**Agent 创建的**技能沿 `active → stale → archived` 老化,让它们退场而不删除任何东西。 + +- 闲置超过 `staleAfterDays`(默认 30 天)→ **stale**;闲置超过 `archiveAfterDays`(默认 90 天)→ **archived**(工作空间移到 `.archived/` 子目录)。`restore` 把归档技能拉回来。 +- **永不触碰**:内置、置顶、MCP/ACP/虚拟技能,以及任何以受保护前缀开头的名字(默认 `sys-`、`ops-`)。 + +### 设置 → 技能管理员 面板 + +- **预览(dry-run)**——在真正执行前,看清下一次扫描会移动哪些技能。 +- **暂停 / 恢复**整个扫描;**激活 / 停用**单个技能。 +- **上次运行 / 下次运行**时间戳,以及**各状态计数**(active / stale / archived)。 + +### 配置 + +```yaml +mateclaw: + skill: + curator: + enabled: true + cron: "0 0 2 * * *" # 每天 02:00 + staleAfterDays: 30 + archiveAfterDays: 90 + scope: AGENT_CREATED # AGENT_CREATED | ALL_DYNAMIC | OFF + protectPrefixes: ["sys-", "ops-"] +``` + +`scope: AGENT_CREATED` 只动有来源对话的技能;`ALL_DYNAMIC` 还会扫手动创建的 dynamic 技能;`OFF` 无视 `enabled` 直接关闭扫描。 + +### 技能市场里的生命周期 + +技能页接住了生命周期: + +- **生命周期标签页**——已启用 / Stale / 已归档。 +- 卡片显示**「最近使用」**徽章。 +- 详情抽屉新增**手动归档 / 恢复 / 置顶**。 +- 手动归档一个**仍被绑定**的技能会触发**二次确认握手**——不会在某个数字员工还在用它时悄悄把技能抽走。 + +--- + +## ACP 桥接:把外部编码 Agent 接进来 + +ACP(Agent Client Protocol)是一种把外部 Agent 客户端(Claude Code、Codex、其他兼容客户端)以技能身份接入 MateClaw 的协议。 + +接入之后: + +- ACP 端点**自动桥接成技能卡**——出现在技能页,自带一组包装工具 +- **可视化环境编辑器**——每个 endpoint 需要的 key、URL、CWD 都在 UI 上配 +- **会话级 cwd**——每个 ACP 会话自己的工作目录 +- **错误翻译**——上游 "Request not allowed" 这种话翻成你看得懂的 +- **OAuth 钥匙串劫持检测**——发现 OAuth token 被其他应用占了,提示你重新登录 + +模板:`claude-code-helper`、`codex-helper`——开箱可用。 + +数字员工调用 ACP 技能的方式,和调内置工具没区别。 + +### MCP/ACP 技能的虚拟 SKILL.md(v1.4 新增) + +MCP / ACP 衍生的技能过去是不透明的工具包,没有可读指令。v1.4 从每个 MCP/ACP 服务的元数据(transport、command、args、env、暴露的工具)**合成一份只读的虚拟 SKILL.md**,让这些集成在技能页里变成**可浏览的技能目录**。因为是合成的,虚拟 SKILL.md 每次列举调用都重建——没有过期的持久副本要维护——而且 `load_skill` 能像读真技能一样读它,让 Agent 在调用第一个工具前就拿到这个集成能干什么的说明。 + +--- + +## 详情抽屉:所有信息一处看 + +每个技能卡片点开是一个抽屉,八个 tab: + +- **概览**——身份字段、manifest 投影、来源、版本 +- **正文**——`SKILL.md` 编辑器(接管整个抽屉宽度) +- **工具**——这个技能用哪些工具(含 effective tool 展开) +- **特性**——能力矩阵 +- **安全**——内容扫描结果、Tool Guard 关联规则 +- **经验**——`LESSONS.md` 内容 +- **密钥**——env-var 形态的凭证(v1.3 新增,详见下方"密钥(Secrets)"小节) +- **记忆**——绑定到这个技能的数字员工 + +卡片本身瘦身——只 6 个字段、一个状态徽章。**清楚比全面重要。** + +--- + +## 安全 + +自定义技能在变活之前会过几道检查: + +- **内容扫描**——上传时 `SKILL.md` 会被扫描 prompt 注入和脚本注入 +- **工具需求检查**——`tools:` 列表只能引用存在的工具 +- **Tool Guard 合规**——列出危险工具的技能继承那些工具的 Tool Guard 规则 +- **MCP 技能约束**——MCP 支撑的技能继承它背后 MCP 服务的安全约束 + +完整的技能安全审核在 [安全与审批](./security) 里。 + +--- + +## 下一步 + +- [工具系统](./tools)——技能能用的工具 +- [Agent 引擎](./agents)——Agent 怎么在回合中调用技能 +- [MCP 协议](./mcp)——MCP 支撑的技能 +- [安全与审批](./security)——技能扫描的细节 diff --git a/mateclaw-server/src/main/resources/docs/zh/tools.md b/mateclaw-server/src/main/resources/docs/zh/tools.md new file mode 100644 index 00000000..41461f7e --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/tools.md @@ -0,0 +1,376 @@ +# 工具系统 + +**一个工具就是 Agent 能伸出去的一只手。** + +把语言模型单独放在那里,它只是一个包在文本里的模式匹配器。它不知道现在几点。它不知道你的文件里写了什么。它不能搜索网页、执行命令、看一份 PDF、把任务交给另一个 Agent、打开一个浏览器。它只能**谈论**做这些事。 + +工具是 MateClaw 解决这件事的方式。每一个工具是一个 Agent 被允许调用的具体操作——读文件、搜网页、执行 shell 命令、从 PDF 抽文字、把任务委托给另一个 Agent。Agent 判断需要某个工具时,发出一次**工具调用**,运行时执行它,结果作为**观察**回到 Agent 下一步推理里。 + +**二十个内置工具**开箱即用。无限多个可以通过 MCP 服务、自定义技能脚本、或者你自己写的 `@Tool` Spring bean 加进来。 + +--- + +## 一次工具调用实际发生了什么 + +``` +Agent 判断需要一个工具 + │ + ▼ + 发出工具调用:{"name": "WebSearchTool", "args": {"query": "..."}} + │ + ▼ + ┌─────────────────────┐ + │ 工具注册表 │ ← 按名字查工具 + └─────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Tool Guard │ ← 基于规则的检查:allow / deny / 审批 + └─────────────────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ + 允许 审批挂起 → 用户决定 → 允许 / 拒绝 + │ + ▼ + ┌─────────────────────┐ + │ 执行(带超时) │ ← 异步,按工具单独设超时 + └─────────────────────┘ + │ + ▼ + 结果 → 观察 → Agent 下一步推理 +``` + +Tool Guard 是守门员。超时是**每个工具独立**的(这样一个慢工具冻不了整个回合)。在一次 Action 阶段里,Agent 同时调多个独立工具时它们可以**并发执行**。 + +这整套对 Agent 的 prompt 是**不可见**的。 + +--- + +## 工具注册的三条路 + +**1. 内置工具。** MateClaw 出厂带的二十个工具,启动时自动注册到工具表里。 + +**2. MCP 服务。** 说 Model Context Protocol 的外部进程动态暴露工具。MateClaw 通过 `tools/list` 发现它们。见 [MCP 协议](./mcp)。 + +> **每 Agent 的 MCP 工具范围(1.4.0+,#117)**:当一个 Agent **没有勾选任何具体的 MCP 工具行**时,已启用的 MCP 工具会**自动并入**它的工具集;一旦它勾选了某些具体 MCP 工具,就**只限定在这个集合**内。只绑技能 / 内置工具的 Agent 仍保留对全部 MCP 工具的访问。 + +**3. 技能脚本。** 技能包可以带可执行脚本,运行时被包装成工具。见 [技能系统](./skills)。 + +工具发现是**黑名单式**的——默认所有可发现的工具都会被注册,需要排除哪个就显式排除。这样新加进来的工具不会因为白名单遗漏被默默忽略。 + +--- + +## 渐进式工具披露(1.4.0+) + +工具一多,系统 prompt 就会被几十个完整的工具 schema 撑大——哪怕这次任务只用得上一两个。**渐进式披露**把工具分成两层,让 prompt 跟着**任务**走,而不是跟着**工具总数**走。 + +| 层级 | 系统 prompt 里怎么呈现 | 能不能直接调 | +|------|------------------------|--------------| +| **核心层(CORE)** | 始终完整广播,带完整 schema | 开箱即用 | +| **扩展层(EXTENSION)** | 只列一份压缩目录——名字 + 来源 + 一行说明,完整 schema 隐藏 | 先用 `enable_tool` 激活才能调 | + +**默认分层**:生成类工具(`image_generate`、`music_generate`、`video_generate`、`model3d_generate`)和 `browser_use` 默认放进**扩展层**;其余全部是**核心层**。 + +- **页面控制**——Tools 页面分「核心 / 扩展」两栏,内置工具和渠道工具每行有一个层级开关;MCP / ACP 工具的层级是锁定的。 +- **持久化**——层级存在 `mate_tool.disclosure_tier` 和 `mate_mcp_server.disclosure_tier`。 +- **配置**——`mateclaw.tools.disclosure.mode`,默认 `progressive`;设成 `legacy` 则恢复"全部广播"的老行为。 + +**为什么这么做**:不让上下文被白白撑爆——系统 prompt 的体积应该跟当前任务的需要成正比,而不是跟你装了多少工具成正比。 + +--- + +## 二十个内置工具 + +| 工具 | 作用 | 危险 | +|------|------|------| +| `DateTimeTool` | 获取任意时区的当前日期时间 | — | +| `WebSearchTool` | 通过搜索引擎链搜索(Serper / Tavily / DuckDuckGo / SearXNG) | — | +| `ReadFileTool` | 读文件 | — | +| `WriteFileTool` | 写内容到文件 | ⚠️ | +| `EditFileTool` | 查找替换编辑 | ⚠️ | +| `ShellExecuteTool` | 执行 shell 命令 | ⚠️ | +| `FileTypeDetectorTool` | 检测 MIME 类型和编码 | — | +| `DocumentExtractTool` | 从 PDF / DOCX / XLSX 抽文字 | — | +| `WorkspaceMemoryTool` | 读写 Agent 的工作空间记忆 | — | +| `SkillFileTool` | 读取和管理 `SKILL.md` 文件 | — | +| `SkillScriptTool` | 执行技能脚本 | ⚠️ | +| `SkillManageTool` | 创建 / 编辑 / 删除技能包 | ⚠️ | +| `BrowserUseTool` | 驱动无头浏览器 | ⚠️ | +| `DelegateAgentTool` | 把任务委托给另一个 Agent(支持并行) | — | +| `MateClawDocTool` | 读取内置项目文档 | — | +| `ImageGenerateTool` | 文生图 / **图生图(1.3.0+)** | — | +| `VideoGenerateTool` | 文生视频 / 图生视频 | — | +| `DocxRenderTool` | **1.3.0+** Markdown → .docx(Word 文档) | — | +| `XlsxRenderTool` | **1.3.0+** Markdown 表格 → .xlsx(Excel) | — | +| `PptxRenderTool` | **1.3.0+** Markdown(Marp 风格 `---` 分页) → .pptx | — | +| `PdfRenderTool` | **1.3.0+** Markdown → 出版级 PDF(中文字体内嵌) | — | +| `CronJobTool` | 创建和管理定时任务 | ⚠️ | +| `DatasourceTool` | 管理外部数据源连接 | ⚠️ | +| `SqlQueryTool` | 对已连接数据源执行 SQL 查询 | ⚠️ | +| `send_file` | **1.4.0+** 把服务器上已有文件作为原生 IM 附件投递(#199) | — | +| `enable_tool` | **1.4.0+** 在本次会话里激活一个扩展层工具 | — | +| `load_skill` | **1.4.0+** 按需加载某个技能的 `SKILL.md` | — | + +此外还有 [多模态创作](./multimodal) 的音乐生成工具 `MusicGenerateTool`。以及 [LLM Wiki](./wiki) 的 14 个 Wiki 工具:`wiki_read_page`、`wiki_read_many`、`wiki_list_pages`、`wiki_search_pages`、`wiki_semantic_search`、`wiki_compile_page`、`wiki_trace_source`、`wiki_create_page`、`wiki_delete_page`、`wiki_archive_page`、`wiki_unarchive_page`、`wiki_related_pages`、`wiki_explain_relation`、`wiki_enrich_page`。 + +### DateTimeTool + +返回给定时区的当前日期时间。没有意外。 + +``` +输入:{"timezone": "America/New_York"} +输出:"2026-04-11T14:30:22" +``` + +### WebSearchTool + +通过**供应商链**搜索——DuckDuckGo 和 SearXNG 作为无 Key 的 fallback,Serper 和 Tavily 在你有 Key 时启用。在 `设置 → 系统设置 → 搜索服务` 里配置,**改完即生效,不需要重启**。 + +``` +输入:{"query": "Spring AI Alibaba 最新版本", "freshness": "month", "count": 5} +输出:"Spring AI Alibaba 1.1 发布..." +``` + +特性: + +- **供应商链**——主 provider 失败时链式 fallback 到下一个 +- **高级参数**——`freshness`、`language`、`count` +- **结果缓存**——近期查询被缓存 +- **安全包装**——结果返回前先净化 +- **原生搜索 + 工具搜索共存**——自带搜索的模型用原生搜索,工具搜索作为 fallback + +### ShellExecuteTool + +跨平台 shell 执行。Linux/macOS 用 `/bin/sh -c`,Windows 用 `cmd.exe /D /S /C`。**每一次调用都过 Tool Guard。** + +安全设计: + +- **超时**——默认 60 秒,硬上限 300 秒 +- **输出上限**——stdout 和 stderr 各自上限 10,000 字节 +- **文件支撑输出**——写到临时文件,不是管道 +- **结构化结果**——`{exitCode, stdout, stderr, timedOut}` +- **危险模式检测**——`find -delete`、`rm -rf /`、管道 bash 下载触发更高级别审批 + +### ReadFileTool / WriteFileTool / EditFileTool + +读是安全的。写和编辑都过 Tool Guard。 + +### DocumentExtractTool + +PDF、DOCX、XLSX 之类变成纯文本。扫描件在可用的地方降级到 OCR。 + +### Office 文档生成(1.3.0+) + +四个新工具,把 Markdown 直接渲染为可下载的 Office 文件——**不 fork 子进程,不依赖 npm**。生成的字节缓存在内存里,返回一个一次性下载链接: + +| 工具 | 适用 | 关键能力 | +|---|---|---| +| `DocxRenderTool.renderDocx` | 报告 / 备忘录 / 合同 / 简历 | 标题(# ## ###) / 加粗(**text**) / 列表 / 表格 / 图片(PNG/JPG/GIF/BMP/SVG → PNG) | +| `DocxRenderTool.renderDocxFromFile` | 同上,但 markdown 在工作区文件里 | 用于 LLM 不想把已经写好的大段 markdown 当 tool 参数再发一次 | +| `XlsxRenderTool.renderXlsx` | 财务表 / 数据导出 / 模板 | Markdown 表格语法 → 多 sheet(用 `## SheetName` 切分) | +| `PptxRenderTool.renderPptx` | 演讲稿 / 项目方案 / 简报 | Marp 风格 `---` 分页;`16:9`(默认)/ `4:3` 比例 | +| `PptxRenderTool.renderPptxFromFile` | 同上,markdown 在文件里 | 内容大于 5KB 时优选 | +| `PdfRenderTool.renderPdf` | 出版级文档 / 周报 / 制式文件 | 1in 边距 / 智能分页 / 页码 / 封面页 / 中英混排(CJK 字体内嵌) | + +::: tip 跟原有 `skills/docx` 的关系 +现成的 `skills/docx` skill **保留**——它擅长的是**编辑已有 .docx**(tracked changes / 复杂 XML 操作)和首次安装时跑 `npm install docx`。新四个工具专门处理"从零创建"路径,**没有 npm 启动延迟**。Agent 首选这四个 RenderTool;要修已有 .docx 才转回 skill。 +::: + +### ImageGenerateTool —— 1.3.0 起支持图像编辑 + +v1.2.0 时这个工具只能"文生图"。v1.3.0 起新增 `image` / `images` 两个参数,支持**多图输入的图像编辑**。详见 [多模态创作](./multimodal#image-edit)。 + +### WorkspaceMemoryTool + +让 Agent 读、写、编辑自己的工作空间记忆文件——`workspace/{agentId}/` 下面的任何 `.md`。见 [记忆系统](./memory)。 + +### BrowserUseTool + +驱动一个无头浏览器。每次调用都过 Tool Guard。 + +### DelegateAgentTool —— Agent 之间委托 + +一个 Agent 可以把子任务交给另一个 Agent: + +- **`delegateToAgent(agentName, task)`**——按名字调用指定 Agent,在隔离会话里执行任务 +- **`listAvailableAgents()`**——列出所有可用 Agent + +``` +用户:搜一下 Spring AI 的新闻,让 Writer 总结一下 +Agent A:[调 WebSearchTool] + [调 delegateToAgent(agentName="Writer", task="总结:...")] + [收到 Writer 的回复] + 合并后回复用户 +``` + +安全: + +- **递归上限**——委托最多嵌套 3 层 +- **隔离会话**——被委托的 Agent 跑在自己的会话里 +- **结果截断**——委托结果上限 4000 字符 + +### MateClawDocTool + +读取内置的 MateClaw 项目文档。让 Agent 回答"MateClaw 里 X 是怎么工作的"这种问题时,**去查真文档**而不是猜。 + +### enable_tool —— 激活扩展层工具(1.4.0+) + +`enable_tool(toolName)` 把一个**扩展层**工具激活,使它在**本次会话剩余的回合**里完整可调。 + +- **会校验**——只有在 Agent 的有效工具集里的工具才能激活。 +- **下一回合生效**——激活在同一个 ReAct 循环的**下一次推理**时生效(Agent 先看到完整 schema,再发真正的调用)。 +- **会话级,不持久化**——激活只对当前会话有效,不写库;新会话回到默认分层。 + +### load_skill —— 按需加载技能(1.4.0+) + +`load_skill(skillName, filePath?)` 在需要时才把某个技能的 `SKILL.md` 加载进来——不传 `filePath` 读主文件,传了就读技能包内的子文件。 + +- **走消息历史注入**——加载的内容是注入到**消息历史**里,而不是系统 prompt,这样 **prompt 缓存保持稳定**(系统 prompt 不变,缓存不失效)。 +- **后续回合保持**——已加载的技能在之后的回合里**钉住**,不用反复加载。 +- **配置**——`mateclaw.skill.disclosure.load-skill-tool.enabled`,默认开启。 + +详见 [技能系统](./skills)。 + +### send_file —— 把已有文件作为原生附件投递(1.4.0+,#199) + +`send_file(filePath, fileName?)` 读取服务器上**一个已经存在的文件**,把它作为**原生 IM 附件**投递——不是一条文本下载链接。 + +- **进生成文件缓存**——文件被放进生成文件缓存,渠道适配器(飞书 / 钉钉 / Telegram)**自动识别并投递**。 +- **任意常见文件类型**,上限 **20 MB**。 +- **跟 `ReadFileTool` 的区别**——`ReadFileTool` 把文件**抽成文本**喂给 Agent 推理;`send_file` 把文件**原样发给用户**。 + +### ReadFileTool —— 超长行分页(1.4.0+,#190) + +针对单行特别长的文件,`ReadFileTool` 新增可选的 `startColumn`(在 `startLine` 内的 1-based 字符偏移),用来**从一行的中间续读**它的尾部。 + +- 截断时**始终返回** `nextStartLine`; +- 当这一行还有剩余没读完时,**额外返回** `nextStartColumn`。 + +把两者回填到下一次调用,就能把一个巨大的单行文件分段读完。 + +--- + +## Tool Guard —— 权限层 + +Tool Guard 是 MateClaw 不让强工具干蠢事的机制。它是**基于规则的**,不是一个扁平的"危险 / 不危险"清单。每条规则说:*对这个工具,带这些参数,在这个上下文里,做 X*——X 是 `allow`、`deny`、或 `require_approval`。 + +核心几张表: + +- **`mate_tool_guard_rule`**——单条规则 +- **`mate_tool_guard_config`**——全局配置 +- **`mate_tool_guard_audit_log`**——每一次受守护的调用一条记录 + +示例规则:*`ShellExecuteTool`,命令以 `ls`、`cat`、`grep`、`find` 开头时允许。其他情况要求审批。* + +```yaml +mateclaw: + tool: + guard: + enabled: true + default-policy: require_approval + rules: + - tool: ShellExecuteTool + arg-pattern: "^(ls|cat|grep|find)\\s" + action: allow + - tool: WriteFileTool + action: require_approval +``` + +或者在 `设置 → 安全与审批` 里可视化管理。规则判定为需要审批时,运行时会在 `mate_tool_approval` 持久化一条记录并把 Agent 回合挂起。完整机制在 [安全与审批](./security)。 + +### 声明式 Hook 系统 + +Tool Guard 规则是一种更通用机制的特例——**声明式 Hook 系统**。5 个生命周期钩子覆盖工具调用和 LLM 调用的全部关键时刻: + +| Hook | 触发时机 | 典型用途 | +|------|----------|----------| +| `before_tool` | 工具执行前 | 参数脱敏、注入上下文、额外校验 | +| `after_tool` | 工具执行后 | 结果过滤、审计记录 | +| `before_llm` | LLM 调用前 | prompt 增强、缓存命中检查 | +| `after_llm` | LLM 返回后 | 输出过滤、token 统计 | +| `on_error` | 错误发生时 | 告警、降级策略 | + +Hook 在进程内执行,可以改参数、改结果、脱敏、加审计日志。你可以用 Hook 做 Tool Guard 之外的事——比如在每次 LLM 调用前注入安全策略,或者在工具返回后自动脱敏敏感字段。 + +--- + +## 执行:并发、隔离、有界 + +- **并发执行**——一个回合里独立的工具调用并发跑。Guard 检查是顺序的,执行在安全的地方并发。 +- **每工具超时**——每个工具有自己的超时。默认:快工具 30s,shell/browser 60s,生成类 300s。 +- **段隔离**——回合中间需要审批时,段在审批边界处分裂。 +- **观察截断**——结果超长会被自动截断。 +- **错误隔离**——单个工具失败不会中止整个回合。 + +--- + +## API 管理 + +```bash +# 列出所有工具 +curl http://localhost:18088/api/v1/tools \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# 启用 / 禁用 +curl -X PUT http://localhost:18088/api/v1/tools/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"enabled": false}' + +# 直接测试一个工具 +curl -X POST http://localhost:18088/api/v1/tools/WebSearchTool/test \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"query": "Spring AI"}' +``` + +每个依赖 provider 的工具在 Tools 页面都有测试按钮。 + +--- + +## 自定义工具 + +### 路线 1:`@Tool` Spring bean + +```java +@Component +public class FactorialTool { + + @Tool(description = "Calculate the factorial of a number") + public String factorial( + @ToolParam(description = "The number to compute factorial for") int n) { + long result = 1; + for (int i = 2; i <= n; i++) { + result *= i; + } + return String.valueOf(result); + } +} +``` + +- Spring `@Component` +- 每个 `@Tool` 方法变成一个可调用的工具 +- 每个参数上用 `@ToolParam`——这是 LLM 读的描述 +- 返回值就是 Agent 看到的观察 +- **工具做任何危险的事情时,为它加一条 Tool Guard 规则** + +重启后工具就活了。 + +### 路线 2:技能脚本 + +不想写 Java?把行为打包成一个技能包,带 `SKILL.md` 和脚本。见 [技能系统](./skills)。 + +### 路线 3:MCP 服务 + +能力已经以 MCP 服务形式存在?加个服务配置就行。见 [MCP 协议](./mcp)。 + +--- + +## 下一步 + +- [技能系统](./skills)——建立在工具之上的更高层能力 +- [MCP 协议](./mcp)——外部工具提供者 +- [安全与审批](./security)——Tool Guard 规则、审批流程、审计日志 +- [多模态创作](./multimodal)——生成类工具 diff --git a/mateclaw-server/src/main/resources/docs/zh/triggers.md b/mateclaw-server/src/main/resources/docs/zh/triggers.md new file mode 100644 index 00000000..a4bd4bdf --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/triggers.md @@ -0,0 +1,299 @@ +# 触发器(Triggers) + +::: tip 1.3.0 新增 +触发器系统自 v1.3.0 起提供。在 v1.2.0 及更早版本里,工作流和员工对话只能被手动调起。 +::: + +**触发器是什么**:把"系统里发生的事件"和"要执行的动作"连起来。事件可以是定时(cron)、是 webhook 来了、是某个渠道收到消息、是某个员工跑完了某次对话、是另一个工作流跑完了。动作可以是启动某个工作流,也可以是直接给某个员工发消息让它处理。 + +**触发器不是什么**: +- 不是 cron 任务管理器替代品——`mate_cron_job` 仍然存在并独立运作;触发器**复用**它的 ShedLock + 调度器底座,但**不写入** `mate_cron_job` +- 不是 IFTTT / n8n 风格的可拖拉自动化——触发器只负责"事件 → 动作"的路由;复杂逻辑放到 [工作流](./workflow.md) 里 +- 不是 webhook 的全功能 dispatcher——它只做去重 / 限流 / bot self-msg 过滤 / pattern 匹配,不替你解析复杂业务报文 + +::: warning v1.3.0 范围 +v0 = 6 种 pattern type + 2 种 dispatch target(agent / workflow)。安全治理(事件去重、per-trigger rate limit、循环保护、bot 自消息过滤)是默认开的。 +::: + +--- + +## 一分钟看懂 + +```jsonc +// 触发器:每天早上 9 点跑一次"晨报工作流" +{ + "name": "daily-morning-report", + "patternType": "cron", + "patternJson": { + "cronExpression": "0 0 9 * * *", + "timezone": "Asia/Shanghai" + }, + "targetType": "workflow", + "targetId": 12345, + "payloadTemplate": "{ \"date\": \"{{ now | date('yyyy-MM-dd') }}\" }", + "rateLimitPerMin": 10, + "dedupWindowSecs": 60, + "botSelfFilter": true, + "enabled": true +} +``` + +每天 9 点 → 后端通过 `CronDelegationPort` 抢到 ShedLock 锁 → 渲染 payload → 调起 workflow `12345` 异步运行。其它实例同一时刻被锁挡住,不会重复触发。 + +--- + +## 6 种 pattern type + +实现在 `TriggerPatternMatcher.java`。每个 pattern 对应 trigger 行的 `pattern_json` 列里一段 JSON。**未列出的字段表示 v0 不识别**——matcher 对未知字段直接忽略。 + +| Pattern | 触发时机 | `pattern_json` 字段 | 复用约束 | +|---|---|---|---| +| `cron` | 按 cron 表达式定时(**不进 ingest 管道**,由 scheduler 直跑) | `cronExpression`、`timezone` | 复用 `cron/` 模块的 ShedLock + Spring TaskScheduler;**不写 mate_cron_job 实体、不调 CronJobService** | +| `webhook` | 通用事件入口透传(**v0 不做更细过滤**——secret 校验在 channel 层;trigger 这边只看 `patternType=webhook` 命中) | (v0 无字段) | 通过 `POST /api/v1/triggers/events` 入口 + envelope wrap | +| `channel_message` | 渠道收到消息 | `channelType`(可选,按 envelope `data.channelType` 比对)、`senderEquals`(可选,按 sender id 精确比对) | 旁路 `ChannelWebhookController`,原路由不变 | +| `agent_lifecycle` | 员工生命周期事件 | `agentId`(可选)、`phase`(可选,取值 `spawned` / `terminated` / `crashed`) | 挂在 `ReActLifecycleListener` 上 | +| `content_match` | 内容包含 substring 才命中 | `substring`(**必填**,envelope 的 `data.content` 字段大小写不敏感包含匹配) | 通用过滤层,事件源由 envelope 决定 | +| `workflow_completion` | 工作流跑完进入终态 | `sourceWorkflowId`(可选)、`stateFilter`(可选,取值 `completed` / `failed` / `any`) | 监听 `WorkflowEngine` 终态事件;A→B→A 递归保护见下文 | + +> **未知 pattern type 默认 fail-closed**——typo 或将来加的 pattern 不会偷偷把 workspace 内所有 trigger 都点燃。 +> +> **不在 v1.3.0 里**:`schedule`(不带 cron 的定时如"30 分钟后")、外部 MQ 监听(Kafka / Pulsar / RocketMQ)、metrics / threshold 告警触发。 + +--- + +## 事件治理(默认开) + +### Bot self-msg 过滤(默认绑定为 noop) + +某些渠道(飞书 / 钉钉 / 企微)会把 bot 自己发的消息也回流为 `channel_message` 事件。**框架层**通过 trigger 行的 `bot_self_filter` 字段(默认 `true`)+ `BotSelfFilter` SPI 协作过滤。 + +::: warning v0 默认实现是 noop +开箱默认绑的是 `NoopBotSelfFilter`——`isBotSelf(...)` **永远返回 false**。这意味着 `bot_self_filter=true` 的 trigger 现在**不真过滤任何事件**。要让过滤真正生效,需要 channel 适配器侧注册一个真正能识别自己 bot id 的 `BotSelfFilter` Spring Bean(替换默认实现)。这是有意设计的——避免一个错误的 default 实现把所有合法的 bot 间通讯都误杀。 +::: + +要单独让一条 trigger 接受自己 bot 的消息(极少见,比如 bot 发特殊命令触发清理流程),把这条 trigger 的 `bot_self_filter` 设 `false`。 + +### 事件去重 + +事件经 `TriggerEventIngestService` 派发时,引擎在 `mate_trigger_event` 表上查 `dedup_key` 是否已经在 `dedupWindowSecs`(默认 60s)时窗内入过库。已经在 → **直接丢弃**,连 `fire_count` 都不++。 + +默认 `dedupWindowSecs = 60`。提高这个值可以扛更长时间的网关重投递;调到 `0` 关闭去重(**不推荐**)。 + +### Per-trigger rate limit + +每个 trigger 单独限速:1 分钟最多 `rateLimitPerMin` 次(默认 10)。命中限速的事件被丢弃,**不**重试,**不**写 `mate_trigger_event` 行;`mate_trigger.last_error` 字段会被刷成 `"rate-limited"` 便于运维查。 + +`channel_message` 类 trigger 通常要调高(瞬时群发);`workflow_completion` 类通常调低(防止 A→B→A 链路加速)。 + +### 递归循环保护 + +`workflow_completion` trigger 启动的 workflow 又触发另一个 `workflow_completion`……dispatch 链超过 5 层 → 引擎切断 + 告警。这是防止"A 写消息触发 B,B 写消息又触发 A"递归。 + +### Webhook ACK 时序 + +HTTP 入口(`POST /api/v1/triggers/events`)收到事件 → envelope wrap → dedup check → bot-self check → rate limit check → **立即 ACK 200** → 异步 dispatch。这意味着: + +- 上游网关(飞书 / 钉钉 等)拿到 200 就不再重投 +- 实际 dispatch 失败 → `mate_trigger.last_error` 被刷新;同 `dedup_key` 再来仍然被去重挡掉,**不重试** + +如果你需要"dispatch 成功才 ACK"语义,**目前没有**——v0 故意设计为 fire-and-forget 扛峰值。 + +--- + +## 在 UI 里管理触发器 + +::: tip 1.4.0 调整:合并进"调度中心" +v1.4.0 起,**定时任务**和**触发器**合并为单个**调度中心**页面(`设置 → 调度中心`,路由 `/settings/scheduler`),分三个 tab:**计划任务**(Scheduled Jobs)/ **事件触发器**(Event Triggers)/ **运行历史**(Run History)。每个 tab 标题旁带条目计数;右上角动作按钮随当前 tab 变化(计划任务 / 触发器 tab 是"新建",历史 tab 是"刷新");运行历史**横跨两者**,定时任务和触发器的执行记录都在这里看。 + +老路由会自动重定向:`/cron-jobs` 和 `/settings/triggers` 分别落到调度中心对应的 tab。 +::: + +### 入口 + +`设置 → 调度中心`(侧栏)→ **事件触发器** tab。触发器列表在 v1.4.0 里从原来的宽表格改版为**规则卡片**——每条 trigger 一张卡,pattern type / target / 启停状态一目了然。点 **+ 新建触发器** 打开抽屉。 + +### 创建 trigger + +抽屉里按 6 种 pattern type 各自结构化表单填字段——不需要手写 `pattern_json`: + +- 选 `cron` → cron 表达式输入框 + 时区下拉 + 下一次触发时间预览。表达式可手输,也可点输入框旁的编辑按钮打开**可视化 cron 编辑器**(见下) +- 选 `channel_message` → 渠道类型可选 + (可选)按 sender id 精确匹配 +- 选 `agent_lifecycle` → agent 可选 + phase(spawned / terminated / crashed)可选 +- 选 `content_match` → substring 输入(**必填**),匹配 envelope 的 `data.content` +- 选 `workflow_completion` → 上游 workflow 可选 + state filter(completed / failed / any)可选 +- 选 `webhook` → v0 没有额外字段(透传一切) + +填完保存 → trigger 入库;`enabled=true` 时立即注册到对应引擎(cron 注册到 ShedLock;其它走 envelope 路由)。 + +### 可视化 cron 编辑器(1.4.0 新增) + +cron 表达式不必手写。点表达式输入框旁的编辑按钮打开**分段编辑器**:分钟 / 小时 / 日 / 月 / 星期 各占一个 tab,每段可选"每个 / 指定值 / 区间 / 步进";上方一排**预设**(每分钟、整点、每天午夜、每周一……)一键填入;底部是**实时可读预览**,把当前表达式翻译成人话(例如"每天 09:00")。 + +这个编辑器是**计划任务和触发器共用**的同一个组件: + +- **计划任务**用 **5 段** cron(分 时 日 月 周) +- **触发器**用 **6 段** cron(带秒:秒 分 时 日 月 周)——多出最前面的秒字段 + +输入框本身也带一行可读预览,不打开编辑器也能确认你手输的表达式解析成了什么。 + +--- + +## 调度任务类型(task type) + +调度中心 **计划任务** tab 里的每条任务都有一个 `task_type`,决定它跑起来做什么。这是 cron 任务类型的权威清单(事件触发器的 6 种 pattern type 见上文): + +| task type | 行为 | 是否绑定员工 | 备注 | +|---|---|---|---| +| `text` / `agent` / `reminder` | 按 cron 调起一次员工对话 | **是**(必填 agent) | 经典定时对话;结果路由到对应会话 | +| `wiki_process` | 按 cron 离线处理某个知识库 | **否** | 1.4.0 新增——见下 | + +### `wiki_process`:错峰处理知识库(1.4.0 新增) + +`wiki_process` 让你把**知识库的处理**安排到业务低峰时段离线跑,而不是上传完就立刻占满处理队列。它**不绑定任何员工**——它是个系统任务,不开对话、不进聊天。 + +新建时只需要填: + +- **cron 表达式**(用上面的可视化编辑器,5 段) +- **知识库选择器**——这次任务要处理哪个 KB +- 可选的 **"强制重新处理"** 开关——开了就连已处理过的原始材料一起重跑(`force`) + +每次到点,任务把该 KB 的原始材料**异步入队**处理,并在运行历史里记一行结果,形如 `queued N raw material(s)`(开了强制会带 `(force)` 后缀)。**注意它不路由到任何对话**——它只是把活儿丢进处理队列,进度去 [LLM Wiki](./wiki.md) 页面看。 + +### Payload template + +`payload_template` 字段是 Pebble 模板字符串,渲染后作为 dispatch target(agent 对话或 workflow run)的输入。 + +```jsonc +"payload_template": "{ + \"date\": \"{{ now | date('yyyy-MM-dd') }}\", + \"trigger\": \"{{ trigger.name }}\", + \"sourceEvent\": {{ event | toJson }} +}" +``` + +模板可访问的变量: +- `now` —— 当前时间 +- `trigger.{name,id,workspaceId}` —— 当前触发器 +- `event` —— 当前事件 envelope(`workspaceId` / `senderId` / `data` JSON 等) + +### 查看触发历史 + +`mate_trigger_event` 表存的是**去重元数据**——一行记录含 `trigger_id` / `dedup_key` / `received_at` / `expires_at`,不存 envelope 副本本身。要审计具体一次事件的内容,查 `mate_trigger.last_error` + dispatch 日志。 + +`mate_trigger.fire_count` 诚实记录有效 dispatch 次数(不计被去重 / 限速过滤掉的);`mate_trigger.last_error` 记录最近一次失败原因。 + +--- + +## API 参考 + +所有 endpoint 在 `/api/v1/triggers/` 下。`v1.3.0` 实际暴露的就这些——RFC 里规划的 `/webhook/{slug}` / `/test-fire` / `/{id}/events` 暂未实装。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/api/v1/triggers` | 列当前 workspace 所有 trigger | +| `GET` | `/api/v1/triggers/{id}` | 获取详情 | +| `POST` | `/api/v1/triggers` | 新建 trigger;若 `enabled=true` 立即注册到 scheduler / 路由 | +| `PUT` | `/api/v1/triggers/{id}` | 更新(包括启用 / 禁用——改 `enabled` 字段即可);`pattern_json` 改动时 `pattern_version++`,跨实例自取消旧 future | +| `DELETE` | `/api/v1/triggers/{id}` | 软删(等同禁用) | +| `POST` | `/api/v1/triggers/events` | **统一事件入口**——任何 webhook / channel adapter / 内部模块送一份 envelope 进来;引擎做 dedup / bot-self / rate limit / pattern match / dispatch;返回 per-trigger 命中 / 丢弃汇总 | + +--- + +## 跟现有 cron 模块的关系 + +::: tip 不取代,只复用 +v1.3.0 之前 MateClaw 已经有一个独立的 cron 子系统(`mate_cron_job` 表 + `CronJobService`)。Trigger 系统**不取代它**—— +- 老的 cron 任务(task_type = `text` / `agent` / `reminder`)仍然在 `Cron Jobs` 页面管理 +- 新的 trigger cron 在 `Triggers` 页面管理 +- 两者**共享**底层 ShedLock 锁表 + Spring TaskScheduler 线程池 +- `mate_cron_job` 列表**不会**显示 trigger cron;反过来也是 +::: + +为什么不合并?因为 `mate_cron_job` 老表的 `task_type` / `agentId` 必填等字段不适合 workflow target。强行扩列会破坏既有 product 约束。`CronDelegationPort` 是 v0 的最小化解——共享调度底座,分离持久层。`mate_cron_job` 整体收敛到 trigger 是后续版本的工作。 + +--- + +## 跨实例一致性(多副本部署) + +`CronDelegationPort` 的所有方法是**进程局部**的——本地 ScheduledFuture 只在本 JVM 注册,不持久化 handle。跨实例靠: + +1. 每个实例启动时调 `syncFromDatabase()` 扫所有 enabled cron trigger 注册本地 +2. 修改 trigger 时 `pattern_version++` + 取消本地 future +3. 每次 fire 前重新读 trigger 行,`patternVersion` 不匹配则**本地短路自取消**(说明被别的实例改过) +4. ShedLock 锁名 = `"mate-trigger-{triggerId}"`,跨实例互斥 +5. 周期 `@Scheduled(fixedDelay=60s) syncFromDatabase()` 兜底收敛 + +实战意义:你正常 rolling-deploy 多副本不需要做任何额外动作——新实例起来自动接管,老实例本地 future 走完最后一轮就停。 + +--- + +## 数据模型 + +### `mate_trigger` —— 触发器配置 + +主要字段: + +| 字段 | 类型 | 用途 | +|---|---|---| +| `pattern_type` | varchar | 6 种 pattern 之一 | +| `pattern_json` | TEXT | 该 pattern 的过滤参数 JSON | +| `target_type` | varchar | `agent` 或 `workflow` | +| `target_id` | bigint | 对应 agent / workflow 主键 | +| `payload_template` | TEXT | Pebble 渲染模板 | +| `dedup_window_secs` | int | 去重窗口(秒) | +| `rate_limit_per_min` | int | 每分钟最大 fire 次数 | +| `bot_self_filter` | bool | 是否启用 bot self 过滤(默认 true,但默认实现是 noop) | +| `pattern_version` | bigint | 乐观并发 lamport 计数器,**每次 `pattern_json` 改动 +1**;跨实例 fire 前比对自取消 | +| `fire_count` | bigint | 有效 dispatch 次数(不计去重 / 限速过滤掉的) | +| `last_error` | varchar | 最近一次失败原因(含 `"rate-limited"` / 异常 message) | +| `enabled` | bool | 软启停开关 | +| `deleted` | int | 软删标志 | + +### `mate_trigger_event` —— 去重元数据 + +仅用于去重判定,**不存 envelope 副本本身**: + +| 字段 | 类型 | 用途 | +|---|---|---| +| `id` | bigint | 主键 | +| `trigger_id` | bigint | 关联 trigger | +| `dedup_key` | varchar | **唯一索引**,引擎按此 key 在 `dedup_window_secs` 时窗内做去重判定 | +| `received_at` | timestamp | 入库时间 | +| `expires_at` | timestamp | 去重窗口过期时间,超过此点同 key 可以重新入库 | + +::: tip 设计取舍 +v0 故意**不把 envelope 全文写进 `mate_trigger_event`**——大体量渠道事件全量持久化撑不住库。事件正文的审计依赖 channel 层日志 + agent / workflow 层的 run 记录。如果未来需要"事件回放"等能力,再加 envelope 持久化列。 +::: + +--- + +## 已知限制(v1.3.0) + +- **没有可视化 trigger → workflow 串联图**——多 trigger 投递到同 workflow 在 UI 上看是两个独立列表 +- **没有 trigger 间优先级 / 依赖**——同一事件命中多 trigger 时按数据库 id 升序串行 dispatch +- **Webhook 入口没鉴权 IP allowlist**——只有 secret header;如果你需要更强的 IP 限制,前置 nginx / 网关 +- **`agent_lifecycle` 不区分会话级和 step 级**——员工一次对话内多次 step 失败只会触发一次 `failed` +- **没有事件回放**——`mate_trigger_event` 是只读历史,没有"重新派发这条事件"的按钮(v1 加) + +--- + +## 故障排查 + +| 现象 | 排查 | +|---|---| +| Cron trigger 没触发 | 1) `enabled=true`? 2) cron 表达式 + 时区是否解析为下次时间?UI 编辑器有预览; 3) ShedLock 锁是否被另一实例长持?查 `shedlock` 表 | +| 事件 `POST /events` 返回 200 但 dispatch 没发生 | 返回体里有 per-trigger fire / drop 汇总——看是否被 `BOT_SELF` / `RATE_LIMITED` / `DEDUPED` / `PATTERN_MISMATCH` 标了原因 | +| `channel_message` 触发不起来 | 1) envelope 的 `data.channelType` 拼写大小写是否和 trigger 的 `pattern_json.channelType` 匹配?2) `bot_self_filter=true` 但有自定义 `BotSelfFilter` 实现把它过掉了?3) `content_match` 的 `substring` 是否真的出现在 envelope 的 `data.content` 里 | +| `agent_lifecycle` 没触发 | 检查 `pattern_json.phase` 是 `spawned` / `terminated` / `crashed` 之一(不是 `started` / `completed` / `failed`) | +| 重启后 cron trigger 不再触发 | 看启动日志 `syncFromDatabase()` 是否报错;常见是表损坏 / `pattern_json` 反序列化失败 | +| `mate_trigger.last_error` 是 `"rate-limited"` | 调高 `rate_limit_per_min` 或者把 trigger 拆成多条按 group 分流 | +| `bot_self_filter=true` 没起作用 | 确认 `BotSelfFilter` 是否真有非 noop 实现——默认 `NoopBotSelfFilter` 永远返回 false | + +--- + +## 相关链接 + +- [工作流(Workflow)](./workflow.md) —— `target_type=workflow` 时 dispatch 到这里 +- [数字员工](./agents.md) —— `target_type=agent` 时 dispatch 到这里 +- [多渠道接入](./channels.md) —— `channel_message` pattern 监听的事件来源 +- [审批与安全](./security.md) —— webhook secret + ACL 兜底 diff --git a/mateclaw-server/src/main/resources/docs/zh/user-guide.md b/mateclaw-server/src/main/resources/docs/zh/user-guide.md new file mode 100644 index 00000000..e5eb7c4d --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/user-guide.md @@ -0,0 +1,201 @@ +# 用户手册 + +你打开 MateClaw,是因为你想让 AI 替你干活。不是因为你想学一套新软件。 + +这份手册只做一件事:**让你在最短时间内,从「装好了」走到「它在帮我做事了」。** + +--- + +## 60 秒起跑线 + +| 步骤 | 做什么 | 花多久 | +|------|--------|--------| +| 1 | 双击打开,`admin` / `admin123` 登录 | 10 秒 | +| 2 | 设置 → 模型 → 添加供应商,**启用一个**,粘贴 Key | 30 秒 | +| 3 | 聊天 → 选 Agent → 说「你好」 | 10 秒 | +| 4 | 看到回复流出来 → **系统活了** | — | + +看到回复的那一刻,你已经在产品里了。接下来的一切,是让它**对你有用**。 + +--- + +## 模型:先接通一个 + +**新装的 MateClaw 模型列表是空的。这是故意的——你不需要看 16 个供应商,你只需要一个能跑的。** + +`设置 → 模型 → 添加供应商`——按钮打开一个抽屉,里面是完整目录。 + +| 你的情况 | 推荐 | +|---------|------| +| 什么都没有,想最快跑通 | **DashScope** — 阿里云控制台复制 Key,粘进来 | +| 有 OpenAI / Anthropic Key | 直接填 | +| 有 ChatGPT Plus / Pro 账号 | **ChatGPT OAuth** — 浏览器登录即用,不需要 API Key | +| 想数据不出本机 | **Ollama** — 自动发现 `localhost:11434` | + +在抽屉里**点一下"启用"**,然后填 base URL(已知供应商预填)+ 粘贴 API Key,保存。模型立刻出现在聊天页的模型选择器里。 + +::: tip 启用 / 禁用是两件事 +**启用**让供应商出现在所有看得到模型的地方;**禁用**让它消失但保留配置——临时切供应商不需要删配置。 +::: + +**一个就够。** 别在这里花时间配五家——先让系统跑起来,后面随时加。 + +--- + +## 聊天:产品的心脏 + +左侧点「聊天」。选一个 Agent。选模型。打字。回车。 + +这就是整个交互。没有别的入口。 + +### 三件事值得立刻试 + +**1. 问一个直接的问题** + +> 帮我解释一下 Java 的虚拟线程和平台线程有什么区别 + +Agent 直接回答,不调工具。你看到的是纯推理能力。 + +**2. 让它用工具** + +> 搜一下 Spring Boot 最新版本,总结 breaking changes + +Agent 自己去调搜索工具、读结果、组织答案。你看到「思考 → 行动 → 观察 → 总结」的完整循环——这就是 ReAct。 + +**3. 让它做多步任务** + +> 先查一下我们的 Wiki 里关于认证的设计决策,再对比 Spring Security 6 的最佳实践,给我一份差异分析 + +Agent 会自动拆分成步骤、逐步执行、最后汇总。你能看到计划和每一步的进度。 + +如果三件事都跑通了,**你已经理解了 90% 的产品**。 + +--- + +## Agent:决定 AI 怎么工作 + +`Agent 管理 → 新建 Agent` + +一个 Agent 只定义五件事: + +| 配置 | 一句话 | +|------|--------| +| **系统指令** | 它是谁、怎么说话、什么态度 | +| **模型** | 用哪个模型 | +| **工具** | 能用哪些工具 | +| **技能** | 能调用哪些技能包 | +| **知识库** | 能读哪些 Wiki | + +从模板开始。模板是开箱即用的——改个名字、调调 system prompt、勾选工具,保存。30 秒一个新 Agent。 + +::: tip 什么时候该建新 Agent +当你发现自己每次都要重复同样的前提说明时——那就是该建一个专用 Agent 的信号。把那些前提放进 system prompt,以后不用再说。 +::: + +--- + +## 记忆:让它记住你 + +MateClaw 的记忆不需要你手动管理。聊完天,系统自动提取关键信息写入记忆。下次对话,Agent 会带着这些记忆工作。 + +你能做的: + +- **PROFILE.md** — 你是谁、你的偏好、你的工作方式 +- **MEMORY.md** — 长期积累的事实和笔记 +- **每日记忆** — 系统自动生成的对话摘要 + +记忆在所有渠道共享。你在桌面端聊的内容,钉钉里的 Agent 也记得。 + +--- + +## Wiki:让它读懂你的文档 + +`Wiki → 新建知识库` + +把 PDF、DOCX、TXT 或整个文件夹扔进去。等消化完——每条素材都有进度条,不用干等。 + +消化完之后: + +1. 把知识库绑到一个 Agent +2. 问里面的内容 +3. Agent 会自动检索相关页面,带着知识回答 + +::: tip +Wiki 不是全文搜索。它是**语义检索**——问「我们关于认证做了什么决定」,返回的是决策,不是包含「认证」两个字的所有页面。 +::: + +--- + +## 技能和 MCP:扩展能力边界 + +**技能** — `Agents → 选一个 Agent → 技能`。从技能市场安装,或手写 `SKILL.md`。 + +**MCP** — `设置 → MCP 服务`。接入外部工具服务器(文件系统、数据库、自定义 API)。MCP 工具自动出现在工具列表里,Agent 不知道也不需要知道它们是外部的。 + +当默认的 20 个内置工具不够时,从这两个入口扩展。 + +--- + +## 渠道:在你待的地方找到它 + +`渠道 → 选一个平台 → 贴凭证` + +支持 8 个渠道:钉钉、飞书、企业微信、微信个人、Telegram、Discord、QQ、Slack。 + +::: tip 钉钉和飞书:扫码即可(v1.1.0+) +不需要去开放平台"创建应用 → 复制 ID 和 Secret"。在新建渠道的表单里直接点**扫码绑定**,用钉钉 / 飞书 App 扫码授权,**client_id / app_id 和密钥自动回填**。整个过程不到 30 秒。 +::: + +同一个 Agent,同一份记忆,在所有渠道里工作。 + +--- + +## 安全:能力强,但不失控 + +`安全` 页面里你能做三件事: + +1. **工具守卫** — 哪些工具需要你批准才能执行(Shell、SQL、文件写入) +2. **文件守卫** — 哪些目录 Agent 不能碰 +3. **审计日志** — 看 Agent 做过什么 + +默认配置已经够安全。如果你在生产环境里用,建议进去收紧一下 Shell 和 SQL 的审批规则。 + +--- + +## 三种推荐配置 + +### A. 个人助手(最快起步) + +配一个模型 → 用默认 Agent → 开始聊。记忆系统自动积累。 + +### B. 知识助手 + +建一个 Agent → 建一个 Wiki 知识库 → 导入文档 → 把 Wiki 绑到 Agent。 + +### C. 自动化 Worker + +建一个专用 Agent → 安装技能 → 接 MCP 服务 → 在安全页配审批规则。 + +--- + +## 出了问题 + +| 症状 | 最可能的原因 | +|------|------------| +| 后端起不来 | 18088 端口被占。看 `~/.mateclaw/logs/app.log` | +| 模型调用报错 | API Key 错了,或者网络不通。回设置里检查 | +| 界面白屏 | Ctrl+Shift+R 强刷 | +| Ollama 报 "does not support tools" | 换一个支持 function calling 的模型(qwen3、llama3.1:8b+) | +| 还是不行 | [GitHub Issues](https://github.com/matevip/mateclaw/issues),贴 `app.log` 尾巴 | + +--- + +## 下一步 + +| 你想做什么 | 去哪里 | +|-----------|--------| +| 理解产品为什么这么设计 | [项目介绍](./intro) | +| 看技术架构 | [架构说明](./architecture) | +| 配置更多选项 | [配置参考](./config) | +| 接更多渠道 | [多渠道接入](./channels) | +| 深入 Agent 引擎 | [Agent 引擎](./agents) | diff --git a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md new file mode 100644 index 00000000..e466b3ea --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md @@ -0,0 +1,419 @@ +# 企业微信深度优化 + +**让一个真正能被企业内部群里几十号人用起来的 bot,远不止"接通就行"。** + +[多渠道接入 → 企业微信](./channels#企业微信) 那一节是把 bot 跑起来;这一篇是把 bot **跑稳**——所有 MateClaw 在企业微信适配层做过的非显然优化、踩过的平台边角,以及为什么这么处理。 + +阅读对象: + +- 已经把企业微信渠道连通、想理解"为什么我的群聊体验是这样"的运维 / 一线 +- 想加新功能但需要先知道平台限制的开发者 +- 想把 bot 推给真实业务团队前做技术评估的负责人 + +--- + +## 平台一句话总结 + +**企业微信 AI Bot 是个"看起来像聊天 SDK,本质是个事件回调"的平台。** + +它给你三种能力: + +1. **接收事件** —— 用户在群里 @ bot,平台通过长连接(WebSocket)或 webhook 把消息推过来 +2. **回复**(同一会话内)—— 用 `aibot_respond_msg` 把答案"贴"到对应的 frame 上 +3. **主动推**(不限于回复)—— 用 `aibot_send_msg` 但**仅限单聊** + +**最关键的隐藏规则**:第 2、3 条在群聊里是不一样的,单聊里也不一样。下面的所有优化都围绕这个矩阵展开。 + +--- + +## 群聊多用户协作 + +### 平台默认行为 + +群聊里 A、B、C 三个人都在 @ bot,平台会把每个人的消息当一条独立 frame 推过来,但都打到**同一个 chatId** 上。 + +如果你直接按 chatId 分会话(这是最自然的做法),后果是: + +- 持久化的对话历史里全是 `user: ...` 没有发送人前缀,模型读历史看到的是一锅粥 +- 防抖窗口(500ms / 2.5s 自适应)会把 A 和 B 的连发消息合并成一条 +- A 问"我想查 X",B 接着问"我想查 Y",bot 看到的是"用户问了 X 和 Y 两个不相关的事" + +### MateClaw 的处理 + +**两层修复**: + +**1. 防抖按 sender 切边界。** 同一会话内连续两条消息进来时,先看 senderId: + +- 同一个人 → 合并(典型场景:粘贴长文被 IM 客户端切片) +- 不同人 → 立即 flush 已有 pending,给新发送人开新窗口 + +代码层面是 [`ChannelMessageRouter.isSameSender`](https://github.com/anthropics/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java)。null 防御:任一 senderId 缺失都不合并,宁可多 flush 一次也不要错串归属。 + +**2. 持久化 + Prompt 都带 `[@sender]` 前缀。** 群聊(`chatId != null`)的每条 user 消息在落库时和送给 LLM 之前都会被 `applyGroupTag(message, content)` 包一层: + +``` +[@XuZhanFu] @迈特云的机器人 我想查 X +[@xuzf] @迈特云的机器人 我想查 Y +``` + +这样: + +- 第 30 条历史消息也能让模型知道是谁说的 +- 持久化的对话时间线读起来像 `[@A] ...; [@B] ...; [@A] ...`,模型能正确处理跟问、引用回复、互相纠错 +- 单聊(`chatId == null`)零开销,行为不变 + +senderName 优先于 senderId(友好),都没有时返回 null(避免 `[@null]` 这种垃圾标签)。 + +### 你能观察到什么 + +``` +[wecom] Sender boundary in conversation wecom:{chatId}: flushing pending from sender=A, accepting new sender=B +``` + +DB 里 `mate_message.content` 列直接看 `[@xxx]` 前缀。 + +--- + +## 上传约束矩阵 + +企业微信平台对 bot 上传的媒体有**硬性大小限制**,超限的请求在 **chunk-finish 阶段**被拒(已经传完所有字节才报错),用户体验是"传了三分钟然后什么都没发出来"。 + +### 限制 + +| 类型 | 大小上限 | 格式要求 | +|------|---------|---------| +| 文件 | **20 MB** | 任意 | +| 图片 | **10 MB** | 任意常见格式 | +| 视频 | **10 MB** | 任意常见格式 | +| 语音 | **2 MB** | **必须 AMR**(其他格式平台拒收) | +| 全局 | **20 MB** | 兜底硬上限 | + +### MateClaw 的处理 + +**客户端预检**,避免无效上传。`applyWeComUploadLimits(fileSize, mediaType, contentType)` 在上传前判定结果: + +- 文件 > 20 MB → 拒绝,告诉用户"超过 20MB 上限" +- 图片 > 10 MB → 降级为文件上传(用户在群里能看到附件,只是不再是缩略图) +- 视频 > 10 MB → 降级为文件上传 +- 语音 > 2 MB **或** mime 不是 `audio/amr` → 降级为文件上传 +- 文件 + 任何类型 > 20 MB → 直接拒绝(绝对硬上限) + +降级时附带一段说明文字("图片超过 10MB,已转为文件附件发送"),用户立刻知道发生了什么,不会以为 bot 抽风。 + +### 智能识别没有 filename 的文件 + +WeCom 群里转发的文件经常**没有 filename 字段**。落地存成 `file.bin` 的话,下游所有按扩展名 dispatch 的工具(PDF 阅读、DOCX 解析等)会全部失效。 + +修复:通过 magic-byte 嗅探还原扩展名: + +- `%PDF` → `.pdf` +- `PK\x03\x04` 是 ZIP 容器;进一步 peek 内部条目区分 `.docx` / `.xlsx` / `.pptx` / `.odt` / `.epub` / `.jar` +- 其他常见格式(PNG / JPEG / MP4 / MP3 / WAV)都能正确识别 +- 实在认不出 → 保留 `.bin`,至少不假装是其他格式 + +实现在 `WeComChannelAdapter.sniffMagic()` + `refineZipKind()`。 + +--- + +## 引用消息(quote) + +WeCom 用户引用前一条消息(图片、文件、文本、语音、小程序)然后追加问题,是**最常见的群聊交互模式**。 + +### 支持的引用类型 + +| 引用类型 | bot 看到的 | 是否能进一步处理 | +|----------|------------|------------------| +| 引用文本 | `[引用消息: 之前的文本内容]\n用户的新问题` | ✅ 文本一并送给模型 | +| 引用语音 | `[引用消息: [语音] ASR 转文字]\n用户的新问题` | ✅ 语音 ASR 结果作为上下文 | +| 引用图片 | `[引用消息: [图片]]\n用户的新问题` + 图片 attached part | ✅ 视觉模型 sidecar 看图 | +| 引用文件 | `[引用消息: [文件: report.pdf]]\n用户的新问题` + 文件 attached part | ✅ 文件 tool 可读 | +| 引用混合 | 各子类按上面规则展开 | ✅ | + +### 实现要点 + +- **媒体一并下载**:引用的图片 / 文件不只是个标记字符串,会真的下载、AES-256-CBC 解密、落到 `data/chat-uploads/{conversationId}/...`,然后作为 MessageContentPart 给 agent +- **路径一致**:媒体落盘的 conversationId **必须**等于 `mate_conversation` 表里的 conversationId,否则下游 `/api/v1/chat/files/{convId}/{name}` 会因 `isConversationOwner` 查不到行直接 403,前端 `` 显示图裂 + +历史 bug:早期版本 `inboundConversationId()` 给群聊路径加了 `wecom:group:` 中缀,但 router 持久化时是 `wecom:{chatId}` 没中缀,两边一对不上整批群聊引用图片全部图裂。已修。 + +--- + +## appmsg 消息类型 + +`msgtype=appmsg` 是 WeCom 给富媒体卡片留的扩展点,常见四种子变体: + +| 变体 | 实际是什么 | bot 怎么处理 | +|------|-----------|--------------| +| `appmsg.file` | 转发的文件(PDF / Word / Excel) | 走完整下载 pipeline,等同 `msgtype=file` | +| `appmsg.image` | 图片卡片 | 走完整下载 pipeline,等同 `msgtype=image` | +| `appmsg.url` | **公众号文章 / 外链** | 见下一节 | +| `appmsg.miniprogram` | 小程序 | 把 title 暴露给模型,附件无法获取 | + +未知子类型 fallback 成 `[appmsg: title]` 标记,至少模型知道"用户分享了某种富媒体"。 + +### 公众号文章 + +mp.weixin.qq.com 的文章页是**带 captcha-gated SSR 的**,任何 LLM 工具都抓不到正文。如果 bot 假装能读,模型会**凭标题瞎编内容**(生产里观察到:"本文讲了三个要点……" 完全是幻觉)。 + +MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追加一段提示: + +> (提示:该链接为公众号文章,正文需要用户在微信内打开后复制粘贴,请优先请用户粘贴正文,不要凭标题猜测内容。) + +效果:模型不再编造,主动让用户粘贴正文。其他正常网址(github、维基、随便一个外链)**不**触发提示,因为它们的 body 是普通工具能 fetch 的。 + +--- + +## 群聊主动推送(aibot_send_msg vs aibot_respond_msg) + +### 平台规则 + +``` +单聊:aibot_send_msg ✓ aibot_respond_msg ✓ +群聊:aibot_send_msg ✗ aibot_respond_msg ✓ (必须绑定一个 inbound frame 的 reqId) +``` + +群聊里 bot 任何主动消息(cron 推送、异步任务回推、图像生成完成)都必须**搭一辆顺风车**——绑到一个之前用户 inbound 的 frameReqId 上,否则平台拒收。 + +### MateClaw 的处理 + +**LRU 缓存最近 inbound reqId**。`lastChatReqIds: ConcurrentHashMap` 在每条群聊 inbound 进来时被更新,上限 1000 个 chat。 + +**统一出口 `sendOutboundFrame(chatId, body)`**: + +- 缓存命中 → `aibot_respond_msg` + 缓存的 reqId +- 缓存未命中 → 降级 `aibot_send_msg`(单聊或新 chat) + +这样: + +- cron 定时摘要 → 群聊有人说过话 → 走 respond 推送成功;从来没说过话 → 降级 send_msg 失败,但至少不会一刀切都失败 +- 异步任务(图像 / 音乐 / 视频生成)完成后 → `AsyncTaskMediaDispatcher` 调用统一出口 +- 同一条 LLM 回复跨多个 chunk → 同一个 reqId 复用 + +### 你能观察到什么 + +``` +[wecom] Group send via aibot_respond_msg: chatId=..., reqId=... +``` + +--- + +## 异步任务回推 + +图像生成 (`image_generate`) / 音乐生成 (`music_generate`) / 视频生成 (`video_generate`) / 3D 模型生成 (`model3d_generate`) 都是**异步任务**——agent 拿到 task id 立刻返回,真正的产物 30 秒~几分钟后才出来。 + +历史问题:产物只出现在 Web 控制台的会话历史里,**WeCom 群里看不到**。 + +修复:`AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`—— + +- 任务完成后,从 `ChannelSessionStore` 反查 conversationId 绑的渠道 +- 跳过 `web` / `webchat`(SSE 已经覆盖) +- 调对应渠道适配器的 `sendContentParts(targetId, parts)` +- WeCom:image / audio / video / file 全部支持,走原生附件 +- Slack:通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack)) +- 不支持 `sendContentParts` 的渠道(QQ 等):catch UnsupportedOperationException + log,不让一个不支持的渠道卡住整批分发 + +文件路径在 `data/chat-uploads/{conversationId}/`,serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 + +--- + +## 模型行为:假装调用工具 + +观察:**qwen3.6-plus** 在长上下文 + 工具调用密集的场景下偶发地"懒"——它会用 Markdown 代码块**伪装**自己调了工具,但实际 `toolCallCount=0`: + +```` +🎵 《在熟悉的路口》 重新创作任务已提交! +⏳ 生成约需 1-2 分钟,完成后音频会自动推送到对话中... + +```json +{ "prompt": "...", "lyrics": "..." } +``` +```` + +后端没拿到 tool_call → 永远不会真的发起音乐生成 → 用户永远收不到歌。 + +**目前的应对**:换更稳定执行 tool_calls 的模型(kimi-for-coding、claude-sonnet-4.5、deepseek-r1)。在 [模型配置](./models) 里把 agent 的默认模型改掉即可。 + +未来可能加:服务端检测"任务已提交 + toolCallCount=0"模式 → 自动注入纠正提示重试一次。 + +--- + +## 模型行为:自循环输出 + +另一种偶发故障:模型陷入"思考-输出"自循环,重复同一段中文回答几十次直到耗尽 max_tokens(16384)。生产上观察到的模式: + +``` +"Wait, I should X." → 写中文答案 → "Done." → 写同一份中文答案 → "Wait, Y." → 同一份答案 → ... +``` + +用户全程看 "生成中..." 等几十秒到几分钟,最后收到一坨重复文本。 + +### MateClaw 的处理 + +**两层守卫**: + +1. **检测**:[`hasRepeatingSuffix`](https://github.com/anthropics/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java) 探测 buffer 尾部是否被同一个 24~240 字符的 unit 连续重复 4 次以上 → 立即 dispose 上游订阅 +2. **去重 + 标记**:`dedupTrailingRepeats` 把已累积的 buffer 尾部 N 份拷贝缩成 1 份;ReasoningNode 把 finishReason 设为 `INCOMPLETE`,前端展示截断卡 + "重新生成"按钮 + +为什么不无脑发警告就算了:用户已经在 SSE 流里看到那坨重复文本(SSE 单向 push 没法 unsend),但 **DB 持久化** 和 **WeCom 回推** 用的都是 `finalAnswer`——所以 IM 群里只看到一份干净的回答 + INCOMPLETE 提示。 + +阈值选得**特别窄**(4 次 verbatim 连续)就是为了不误伤合法的"TL;DR / body / TL;DR 三段式"输出。 + +--- + +## 网络层稳定性 + +### TLS / Socket 瞬时错误重试 + +DashScope / OpenAI / 各家 LLM 网关在公网传输中偶发产生: + +- `bad_record_mac`(TLS RFC 5246 §7.2.2 fatal alert 20) +- `SSLHandshakeException` +- `SocketException: Connection reset by peer` +- `Premature close` / `Broken pipe` + +之前这些一旦发生直接给 agent 抛 `LLM 调用失败` 红字,没有重试。 + +修复:把这些都归类成 `SERVER_ERROR`,走现有的指数退避重试链:3s → 6s → 12s(带 jitter)最多 5 次。详见 [agents 引擎](./agents#错误恢复)。 + +### keepalive + +群聊回复用 `aibot_respond_msg` 时,平台对**单条流**有 60 秒 TTL——超过 60 秒不发新数据,平台会丢弃这个 stream slot,后续真正的 reply 静默失败。 + +agent 处理复杂任务(多次工具调用 + LLM 推理)经常超过 60 秒。`WeComKeepaliveScheduler` 每 30 秒往 stream 上发一个 noop "正在处理..." 心跳,slot 永不过期。180 秒兜底强制 finish,避免任务真挂了 keepalive 一直续命。 + +### 重连 + 指数退避 + +WeCom 长连接断开时(NAT 超时、网络抖动),适配器自动重连:2s → 4s → 8s → 16s → 30s 封顶。**永远不会放弃**——只要进程还活着,下次能连上就立刻恢复消息接收。 + +控制台健康视图能看到当前重连次数,运维心里有数。 + +--- + +## 平台级约束(不是 bug,是限制) + +这些是**企业微信平台本身**的约束,没法在代码层绕过,只能配置层规避: + +### 数据权限锁 + +API 模式 bot 在企业微信管理后台勾选**任何一项数据使用权限**(如"读取消息"、"获取群信息"),bot 会**自动锁定为仅创建者可用**。其他成员发消息 bot 不响应。 + +**解决**:在管理后台**取消勾选**全部 7 项数据权限,bot 即可对所有授权成员可见。MateClaw 通过 webhook 拿消息,不需要这些数据权限。 + +### 可见范围 + 数据权限二维矩阵 + +| 可见范围 | 数据权限 | 实际效果 | +|---------|---------|---------| +| 全员 | 全部勾选 | **仅创建者**可用(数据权限锁覆盖可见范围) | +| 全员 | 全部取消 | 全员可用(推荐) | +| 指定部门 | 全部取消 | 指定部门成员可用 | +| 指定人员 | 全部取消 | 指定人列表内可用 | + +### 群聊里 @bot 才会触发 + +WeCom 群的 bot 必须被 `@` 才收到消息。私聊不需要 `@`。这是平台行为,没办法绕过。MateClaw 不会在群里 broadcast 监听所有消息(也做不到)。 + +--- + +## 调试技巧 + +### 看群聊归属是否生效 + +```sql +SELECT content FROM mate_message +WHERE conversation_id = 'wecom:{chatId}' AND role = 'user' +ORDER BY id DESC LIMIT 5; +``` + +期望:每条 user 消息都以 `[@username]` 开头。 + +### 看媒体落盘路径 + +```bash +ls data/chat-uploads/wecom:{chatId}/ +``` + +**不应该**有 `wecom:group:{chatId}` 这种带 `group:` 中缀的目录(早期 bug 残留可以手动清理)。 + +### 看群聊回推路径 + +后端日志里: + +``` +[wecom] Group send via aibot_respond_msg: chatId=..., reqId=... +``` + +如果群聊里 bot 没回复,但日志里看到这行 + reqId 不为空,说明回推到了平台但平台拒收(一般是 reqId 已被消费过、或 bot 已被踢出群)。 + +### 看 keepalive 状态 + +```bash +grep "wecom-keepalive" logs/mateclaw.log | tail +``` + +期望看到周期性的 "Heartbeat sent" + "Heartbeat ACK received" / 偶尔的 "force-finished stream" 强制完成。 + +--- + +## 已知 corner case + +| 场景 | 当前行为 | 后续可能 | +|------|---------|---------| +| 群里第一条消息就是 cron 推送(chat 还没人说过话) | 缓存里没有 reqId,降级 `aibot_send_msg` 被平台拒 | 加 ring buffer 缓存多条历史 reqId(仅修复有限场景,不上) | +| 模型在长会话里"懒"得调工具 | 用户重发 / 换模型 | 加服务端检测注入纠正提示 | +| 同一群同时来 3 条不同 sender 的消息 | 串行处理,每个用户独立窗口(生效) | — | +| 公众号文章用户拒绝粘贴正文 | bot 礼貌引导用户复制 | — | +| OOXML 文档 magic-byte 误判(极小概率) | 退回到 `.zip` | 已通过 ZIP 内部条目 peek 解决 90% 场景 | + +--- + +## 一图概括 + +``` + ┌─────────────────────┐ + │ 企业微信群里的用户 │ + └──────────┬──────────┘ + │ inbound (含 chatId) + ▼ + ┌────────────────────────────────────────┐ + │ WeComChannelAdapter │ + │ ├─ chunk upload pre-check (4 类限制) │ + │ ├─ magic-byte sniff (OOXML peek) │ + │ ├─ AES 解密 + 落 chat-uploads/{convId}/ │ + │ ├─ quote 引用解析(5 子类型) │ + │ ├─ appmsg 解析(4 子类型 + 公众号提示) │ + │ └─ 缓存 lastChatReqIds[chatId] │ + └──────────────┬─────────────────────────┘ + │ ChannelMessage(content="[@xxx] ...") + ▼ + ┌────────────────────────────────────────┐ + │ ChannelMessageRouter │ + │ ├─ 自适应 debounce (500ms / 2.5s) │ + │ ├─ sender boundary 切断(群聊关键) │ + │ ├─ applyGroupTag 落库 + 送 LLM │ + │ └─ 队列 + sessionLock 串行 │ + └──────────────┬─────────────────────────┘ + │ + ▼ + ┌──────────┐ + │ Agent │ ← StateGraph + ReAct + └─────┬────┘ + │ finalAnswer / tool_calls + ▼ + ┌────────────────────────────────────────┐ + │ sendOutboundFrame(chatId, body) │ + │ ├─ 缓存命中 → aibot_respond_msg │ + │ ├─ 缓存未命中 → aibot_send_msg │ + │ ├─ keepalive scheduler (60s TTL 续命) │ + │ └─ 重连退避(NAT / 抖动自愈) │ + └────────────────────────────────────────┘ +``` + +--- + +## 相关阅读 + +- [多渠道接入](./channels) — 9 个渠道的总览 + 设置 +- [Agent 引擎](./agents) — TLS 重试、错误分类、自循环检测 +- [模型配置](./models) — 怎么换默认模型、failover chain +- [安全与审批](./security) — 群里执行高风险工具的审批流 +- [Doctor 健康检查](./doctor) — 怎么用诊断命令排查渠道问题 diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md new file mode 100644 index 00000000..0498b38a --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md @@ -0,0 +1,482 @@ +--- +title: LLM Wiki 知识库 — 结构化知识引擎,不是向量检索 +description: LLM Wiki 把原始文档消化成结构化知识页面,带双向链接、摘要和溯源。Agent 自动注入知识。支持 lazy 入库(先入索引、按需出页面)和 eager 入库(上传即生成完整 Wiki)。 +head: + - - meta + - name: keywords + content: LLM Wiki,知识库,知识引擎,双向链接,结构化知识,RAG替代,知识图谱,lazy ingest,按需编译,语义搜索 +--- + +# LLM Wiki 知识库 + +知识库不是一个让你搜索的地方,是一个让你**读**的地方。 + +市面上大多数 AI 知识系统只做一件事:把文件切块、向量化、查询时返回片段。你拿到的是碎片。你看不到它的全貌。你问它之前,你永远不知道它到底"知道"什么。没有任何东西是**完成**的。 + +MateClaw 的 LLM Wiki 做的事不一样。你把原始材料扔进知识库,系统会把它读一遍,消化一遍,然后写出结构化的 Wiki 页面——每一页有摘要、反向链接、通往原文段落的来源指针。你可以打开任何一页直接读。你可以编辑。Agent 自动读摘要,按需取全文。 + +**是一本书,不是一个向量库。** + +::: tip 它和那些「LLM Wiki」开源仿品有什么不一样 +2026 年 4 月,Andrej Karpathy 用一个 GitHub Gist 把 "LLM Wiki" 这个想法推到台面:扔给 AI 的资料应该被读一遍、写成可读的 wiki,而不是当成查询时才翻一遍的向量碎片。一个月内,GitHub 上冒出至少 9 个 `llm-wiki` 单文件实现——好用、本地、个人级。 + +MateClaw 的 LLM Wiki **是同一个想法长成的产品**: + +- 不是一个人的笔记本,是**团队共享**的知识库——多用户、权限、审计、归档 +- 不是跑完就完的脚本,是 **Agent 一直在用**的能力——记忆、检索、引用全打通 +- 不是只有 eager 模式,**lazy 模式按需出页面**——量大时省 90%+ 的 LLM 调用 +- 不是裸文件输出,是**带溯源 / 双向链接 / 人工编辑保护 / 归档恢复**的页面层 +- 不是孤立工具,是 **MateClaw Agent 操作系统的知识层**——和记忆、Agent、渠道交付串成一根链 + +> 他们做了克隆。我们做了一个家。 +::: + +--- + +## 三层模型 + +一个知识库是三层结构叠起来的: + +1. **原始材料层**——你扔进去的文件。PDF、Word、Excel、PowerPoint、HTML、Markdown、纯文本(含 CSV),或者桌面端扫描整个本地目录。系统保留原文不动;Wiki 里的任何一句话都能回溯到它出自哪段原文。 +2. **Wiki 页面层**——AI 从原始材料里写出的结构化文章。每一页有标题、摘要、正文、指向相关页面的双向链接(`[[像这样]]`,也支持 `[[target|展示文字]]` alias 形式)、以及通往原文的来源指针。 +3. **Agent 表层**——Agent 调用 wiki 工具时,系统会把相关页面的摘要自动注入 prompt,正文按需读取。Agent **不读原文**,它读这本书。 + +这事很重要,是因为 Agent 的上下文窗口再也不用浪费在反复读原文上了。Token 都花在思考上,不是重复阅读上。 + +--- + +## 建一个知识库 + +`Wiki → 新建知识库`。起名按"里面装的是什么",不是"属于谁"。"产品规格"比"Alpha 组的 KB"好。 + +建好之后加材料: + +- **上传文件**——把 PDF、Word、Excel、PowerPoint、HTML、Markdown、纯文本(含 CSV)拖进上传区。每个文件成为一条 raw material。 +- **扫描本地目录**——桌面端专属。指一个文件夹,MateClaw 递归走完整个树,尊重 `.gitignore`,把能读出文本的全都导入。 +- **粘贴文本**——适合短片段或对话记录。 + +材料一进来,系统就开始入库。每条 raw material 上都有状态:`pending → processing → completed`。如果中间几块失败了,状态会变 `partial`——你拿到的是"除了坏块之外都成功了",不是"一块坏了整份全挂"。 + +--- + +## 两种入库模式:要不要让 AI 立刻写页面 + +`Wiki → 配置 → 入库模式` 二选一: + +- **Eager(立即生成页面)**——上传后立刻跑完整流水线,把材料消化成结构化 Wiki 页面。适合"我就是要一份现成可读的 Wiki"。代价:每次上传烧 N 次 LLM 调用,慢、贵。 +- **Lazy(先入索引)**——上传只做抽取、清洗、切片、向量化。**0 次页面生成 LLM 调用**。立刻可搜,页面在有人真要读时按需生成。适合"先把资料倒进去,回头按需取"。 + +旧 KB 默认走 eager 不变,新建的 KB 想省钱就在配置页切到 lazy。两种模式可以混着用——同一个 KB 的旧页面继续在,再上传的材料按当前模式处理。 + +> Lazy 下"页面数 = 0"是**完成**态,不再被标记 failed。这条改动专门解决之前抽不出文本的材料一律变红的尴尬。 + +--- + +## 消化到底在干嘛 + +### Eager 模式:完整流水线 + +对每一份原始材料,按顺序: + +1. **切块**——把原文切成带重叠的段落,附带 chunk 级元数据:页码(PDF)、标题路径(Markdown / HTML 经过 jsoup 清洗)、章节标识、token 估算。 +2. **抽取概念**——让 LLM 在每一块里识别出实体、决策、事实、未解问题。 +3. **聚类成稿**——把相关的抽取聚在一起,生成一批候选 Wiki 页面的结构化草稿。 +4. **建链**——在页面之间找双向引用(`[[concept]]` 和 `[[concept|展示文字]]`),计算反向链接。 +5. **落库**——把页面写进 `mate_wiki_page`,引文(citation)指回原文段落。 + +入库是幂等的。同一份材料再跑一遍,系统更新已有页面而不是复制一份。人工编辑过的内容会被保护——`locked` 标记告诉 digester"这段是人写的,别动";要让 AI 重写就显式解锁。 + +#### 两阶段消化 + +eager 模式分两阶段,速度提了一个数量级: + +- **阶段 A(路由)**——抽取元信息和概念路由,决定每段原文会流向哪些页面。 +- **阶段 B(合并)**——按页并行生成,60+ 页同时跑。每条原始素材有自己的**独立进度条**——不再盯着"处理中…"猜进度。 + +**可恢复**:中途断了?点"重新处理",只重跑未完成的页面,已生成的不动。超过模型上下文限制的文档,系统自动做 mean-pool 子段切分——你不用管。 + +### Lazy 模式:先入索引,按需出页面 + +链路缩成四步: + +1. **抽取** → 拿到原始文本(PDF/DOCX/...)。 +2. **预处理** → jsoup 清掉 HTML 噪声,识别 markdown 标题层级和 PDF `--- Page N ---` 标记。 +3. **切片 + 元数据** → 每个 chunk 自带 `page_number`、`header_breadcrumb`(如 `Intro / Setup / Linux`)、`source_section`、`token_count`。 +4. **向量化** → embedding 异步入库,立刻可搜。 + +页面什么时候出?不出。等你或者 agent 真的需要的时候,按需编译一次——只取检索到的 evidence chunks,引文也只绑到这几个 chunk 上。 + +--- + +## 系统页:overview 和 log + +每个 KB 自动有两条**系统页**: + +- `slug=overview` —— 知识库的门面。范围、最近更新、覆盖率统计的摘要。 +- `slug=log` —— 入库 / 编译 / 编辑活动的可审计记录。 + +两者 `page_type=system, locked=1`: + +- 删除(单条 / 批量 / 重处理时的旧页清理)**删不掉**——会拿到清晰的拒绝理由。 +- 列表、关键词搜索、语义搜索、关联推荐**默认过滤掉**它们,避免污染检索结果和上下文窗口。 +- 但 agent 直接按 slug 读(`wiki_read_page("overview")`)仍然可以——想看就显式读。 + +> 普通用户也可以给手写的页面打上 `locked=1`,AI 工具就不会动它,逻辑跟 `lastUpdatedBy="manual"` 是叠加的。 + +--- + +## 加工器(Transformations):让知识库可编程 + +::: tip 1.3.0 新增 +Transformations 引擎自 v1.3.0 起提供。v1.2.0 及更早版本里,Wiki 只能被动检索——把原料切块、向量化、等召回;这一版起 Wiki **学会主动加工**:用户自定义模板、跨原料聚合、reverse-citation、JSON 输出、对页面跑模板、cancel/re-run 等能力全部到位。详细 release 故事见 [v1.3.0 release notes](./releases/1.3.0)。 +::: + +Wiki 默认把原始材料消化成它认为重要的页面 —— 但"重要"是它定义的,不是你定义的。**加工器**翻转了这件事:你写 prompt 模板,告诉系统"我想从材料里抽什么",引擎替你跑、落库、维护。 + +`Wiki → [任一知识库] → 加工器` 进入面板。每个模板由这几样组成: + +- **标识名** —— 短的小写 slug,Agent 调用时用它指名(如 `contract-risk-extract`) +- **显示名 / 描述** —— 给人看的 +- **提示词模板** —— 你的指令,支持 `{input_text}` 和 `{title}` 占位符 +- **模型** —— 默认走 KB 默认 chat 模型;也可以把单个模板钉到一个特定模型上 +- **默认运行** —— 勾上 → 每次新材料处理完,自动跑这个模板 +- **输出去向** —— `不保存`(只留运行历史) / `保存为 Wiki 页面`(自动产生 synthesis 页) +- **输出格式** —— `Markdown` 或 `JSON`(带可选 Schema 校验) + +### 开箱即用的 7 个企业模板 + +新建 KB 直接看到,覆盖典型企业场景: + +| 模板 | 用途 | +|---|---| +| `contract-risk-extract` | 合同条款级风险提取(高 / 中 / 低)+ AI 建议改写 | +| `meeting-action-items` | 会议纪要 → 决议 + 行动项(owner / 截止日 / 验收标准)| +| `customer-profile` | 客户邮件 / CRM 记录 → 结构化客户画像 | +| `competitor-update` | 公开信号 → 竞品动态简报 | +| `resume-structured-extract` | 简历 → 标准档案(教育 / 工作 / 技能 / 亮点)| +| `incident-postmortem` | 事故报告 → 5-Why 链 + 整改清单 + 相似事故 | +| `paper-imrad` | 论文 → IMRaD 摘要 + 关键术语 | + +### 四种触发方式 + +| 触发 | 怎么发起 | 用在哪 | +|---|---|---| +| **手动** | UI 选材料 + 点「运行」 | 调 prompt、单次试运行 | +| **默认运行** | 模板开关 + 上传新材料 | "每份新合同都自动跑一次" | +| **Agent 工具** | 数字员工调 `wiki_apply_transformation(name, rawId)` | Agent 自己决定要跑哪个 | +| **跨原料聚合** | 卡片上的「聚合所有运行」按钮 / `wiki_aggregate_transformation` | 把 N 份材料的 per-source 输出 map-reduce 成一份 KB 级合成页 | + +### 输入:原始材料 / 现有页面 + +模板不止能跑在 raw material 上,也能直接对现有 wiki 页面运行(Agent 端工具:`wiki_apply_transformation_to_page(name, slug)`)。这让你把模板串起来 —— 先用 A 把原料做成 synthesis 页,再用 B 对那个页面跑出新的视图。 + +### 输出去哪:留在历史还是变成 Wiki 页面 + +- **不保存** —— 运行结果只在「加工器」tab 的运行历史里可查,不进 Wiki。适合一次性临时输出。 +- **保存为 Wiki 页面** —— 每次成功运行 → 自动 upsert 到一个固定 slug 的 synthesis 页(`<模板名>-<材料标题>`)。重跑只更新这页,不复制。**而且**: + - 自动建 page-level embedding,进语义搜索 + - 反向解析输出里的「第 N 题 / 第 X 页」标记 → 回写 chunk-级 citation,绑回原文 + - 进关系图、热缓存、Agent 直接可读 + +### JSON 输出 + Schema 校验 + +输出格式选 JSON 时: + +1. 注入严格 system prompt("只返回 JSON 对象,前后无文字") +2. 解析失败 → 自动重试一次,retry 时附上具体错误提示 +3. 模板上可选填一个 JSON Schema,executor 在解析后检查 required 字段是否齐 +4. 仍然失败 → run 标记 failed,错误写进历史 + +成功时 JSON 以 fenced ```json 块的形式存进 page,下游程序可直接 grep + parse。 + +### 跨原料聚合 + +KB 里 10 份合同每份都跑了 `contract-risk-extract`,怎么看整体?卡片上点「聚合所有运行」: + +- 系统读取该模板对该 KB 的所有 completed run +- 按 source 去重(每份原料只取最新一次) +- LLM 做 merge + dedupe:去重相同条款类型、合并 source 引用、保留分歧 +- 产出 KB 级合成页 `<模板名>-aggregate` +- 自动嵌入语义搜索 + +这是把 "per-source extract" 升级成 "KB-level synthesis" 的关键 —— 不用一份份对比,AI 帮你看全局。 + +### 运行历史 + 可观测性 + +每条 run 都记录: + +- 状态:`pending / running / completed / failed / cancelled` +- 耗时、模型、触发方式(manual / apply_default / agent_tool / aggregate) +- 上行 / 下行 token 消耗(`8.2k↑ / 1.1k↓`),跨重试累加 +- 关联输出页面(如果 output_target=page) +- 完整输出 / 错误信息 + +UI 上能做: + +- **取消** —— 标记一个正在跑的 run 为 cancelled(LLM 调用仍在 provider 端走完,但执行器会丢弃结果) +- **重试** —— 失败 / 完成的 run 一键再跑一次(同输入) +- **对比** —— 勾两条 completed run → 「对比所选」→ side-by-side 模态框,左旧右新,调 prompt 时看差异最方便 + +### 典型场景 + +| 场景 | 配方 | +|---|---| +| 法务自动化 | `contract-risk-extract` + 默认运行 + 保存为页 → 每份合同自动生成风险报告页 | +| 销售情报 | `customer-profile` + 默认运行 → 每份客户材料合成画像页 | +| 研发记忆 | `meeting-action-items` + `incident-postmortem` 一起用 → 决策史和事故知识沉淀 | +| 研究综述 | `paper-imrad` 跑一批论文 → 「聚合所有运行」生成主题综述 | +| 程序化下游 | JSON 输出 + Schema → 把 wiki 当成结构化数据源 | + +### REST 端点(基础路径 `/api/v1/wiki/transformations`) + +| Method | Path | 作用 | +|---|---|---| +| `GET` / `POST` / `PUT` / `DELETE` | `/`、`/{id}` | 模板 CRUD | +| `POST` | `/{id}/apply?sync=true` | 跑一次(body 给 `rawId` 或 `pageId` 二选一)| +| `POST` | `/{id}/aggregate?kbId=X` | 跨原料聚合 | +| `GET` | `/runs?rawId=` 或 `?kbId=` 或 `?transformationId=` | 查运行历史 | +| `POST` | `/runs/{runId}/save-as-page` | 手动把一条 run 落成 wiki 页面 | +| `POST` | `/runs/{runId}/cancel` | 标记 cancelled | + +--- + +## Agent 怎么用 Wiki + +在 `Agents → 某个 Agent → 知识库` 里绑定一个知识库。从那一刻起: + +- Agent 的 system prompt 里自动注入这个 KB 顶层页面的压缩摘要。 +- Agent 的工具箱里多了这些 wiki 工具: + +| 工具 | 用途 | +|---|---| +| `wiki_search_pages` | 混合检索(关键词 + 语义),页面级 | +| `wiki_semantic_search` | chunk 级语义搜索,命中带 `pageNumber` 和 `section` 字段 | +| `wiki_read_page` | 读单页,可按 section 或字符上限截取 | +| `wiki_read_many` | **新**:一次拿多个 slug 的内容,最多 10 个,每页可设上限。替代多轮 `wiki_read_page` | +| `wiki_compile_page` | **新**:lazy 模式下按主题生成单页。引文只绑搜到的 evidence chunks | +| `wiki_trace_source` | 跟踪某个 wiki 页面来自哪些原文 | +| `wiki_related_pages` | 关联页面(共享 chunk / 共享原文 / 双向链 / 语义近邻) | +| `wiki_explain_relation` | 详细拆解两页之间的关联强度和原因 | +| `wiki_create_page` / `wiki_delete_page` | 直接维护页面(删除受 locked / system 保护) | +| `wiki_archive_page` / `wiki_unarchive_page` | 软归档:从默认 list/search/related 隐藏,但保留页面与引文,可恢复。系统页不能归档。 | +| `wiki_list_transformations` | 列出当前 KB 可用的加工器模板(名称、用途、是否默认运行)| +| `wiki_apply_transformation` | 对一份**原始材料**运行一个模板,返回输出(runId / output / 落页信息)| +| `wiki_apply_transformation_to_page` | 对一个**现有 wiki 页面**运行模板(接 slug,无需数字 ID)| +| `wiki_aggregate_transformation` | 跨 KB 内所有 raw 的同模板 run,合成一份 KB 级 synthesis 页 | + +`kbId` 参数根据绑定自动解析——Agent 不需要猜。 + +一个典型的 Agent 回合: + +> **用户**:"上个季度我们关于重试策略是怎么决定的?" +> +> **Agent**:*(读到注入的摘要里有"重试策略"页,直接打开"重试策略"页,返回决策内容和原文来源链接。)* + +那不是一次向量查询。是字面意义上的"打开这一页"——因为**这一页真实存在**。 + +### 热缓存:每次系统提示里都有一份"最近活跃"快照 + +绑定的 KB 不光给 Agent 注入摘要,还会注入一份**热缓存**——可以理解为 Agent 每一轮开局都先翻一遍的那一页: + +- **最近更新**——最近一次入库 / 页面编辑 +- **关键近期事实**——重建器筛出的高信号要点 +- **最近变更**——上次重建以来新生成 / 重新编译的页面 +- **悬而未决的话题**——开放问题和未结论的决策 + +重建在每次会话结束(`ConversationCompletedEvent`)异步触发,配合一个可配置的去抖窗口(默认约 30 秒),短轮次密集发生时不会把 LLM 打爆。Admin 也可以手动触发重建——手动路径会绕开去抖。 + +注入受 `wiki.hot_cache.enabled` 特性开关控制(关闭 → 注入空字符串),并按 KB 优先级最多挑前两个,避免系统提示被撑爆。 + +#### 在 KB 详情抽屉里管理 + +`Wiki → [你的 KB] → 热缓存` 面板里: + +- **重新生成**——异步手动重建;点完面板会在几秒后轮询并刷新 +- **重置**——软删除当前行,下一次 `ConversationCompletedEvent` 时重建 +- 元数据栅格:上次更新时间、更新原因(`AUTO` / `MANUAL` / `EVENT`)、重建次数、上次耗时(毫秒) +- 上次重建失败时显示错误条 +- 渲染后的 Markdown 内容预览 + +#### 运维端点 + +基础路径 `/api/v1/wiki/hot-cache`: + +| Method | Path | 作用 | +|---|---|---| +| `GET` | `/{kbId}` | 拿当前快照 + 元数据 | +| `POST` | `/{kbId}/regenerate` | 手动重建(异步,跳过去抖) | +| `DELETE` | `/{kbId}` | 软删除;下次事件触发重建 | + +热缓存数据落在 `mate_wiki_hot_cache`——具体列见下面的 **底层数据** 一节。 + +### Lazy 上传后的典型流程 + +``` +用户上传 product-manual.pdf (lazy 模式:0 次页面生成 LLM 调用) + ↓ +Agent: wiki_semantic_search("error code 500 retry") + → 命中 chunk #1234,page=12,section "Error Handling / Retries" + ↓ +Agent: wiki_compile_page(topic="500 retry policy", maxEvidenceChunks=5) + → 生成 slug=500-retry-policy,引文绑到这 5 个 chunk + ↓ +Agent: wiki_read_page("500-retry-policy") + → 返回结构化页面 + 来源 chunk 列表 +``` + +整条路径只在最后一步烧了一次 LLM 调用,且只针对真正相关的 5 个 chunk。 + +--- + +## 页面的阅读和编辑 + +每一个生成出来的页面都是一等公民的文档,你可以在 Wiki 视图里直接打开: + +- Markdown 渲染带语法高亮 +- 侧边栏列出反向链接——看看有哪些别的页面引用了它 +- 每个说法上都有"来源"按钮,点一下跳到 Wiki 所依据的原文段落 +- 编辑模式下可以直接重写页面的文字 +- system / locked 页面的删除按钮是禁用状态 + +AI 写错了就改。你的修改在下一次入库时会被保留——`locked` 标记告诉 digester 别碰这段人写的内容。要让 AI 重新起草就显式解锁。 + +--- + +## 搜索、来源追溯、语义检索 + +- **语义搜索**——问"我们关于 auth 决定了什么?",直接返回那个决策,不是一堆包含"auth"的页面。chunk 级嵌入 + cosine 检索,**理解你问的是什么意思**。命中现在自带 `pageNumber` 和 `section`,agent 可以引用 "page 12, Setup / Linux" 而不是粘一段没头没尾的片段。 +- **混合检索**——同时走全文匹配和语义匹配,取两者的交集优势。 +- **全文搜索**——搜标题、摘要、正文、概念抽取。横跨你有权访问的所有 KB。 +- **来源追溯**——任何页面的任何一句话都可以点回原文段落。Agent 也能做这件事。 +- **反向链接**——每一页都显示有哪些别的页面引用了它。`[[concept|展示文字]]` 形式的 alias 链接现在解析正确了——只把 `concept` 当 slug,`展示文字` 只用于显示。 +- **关联推荐**——跨四种信号(共享 chunk、共享原文、双向链、语义近邻)找相关页面,扩展时**不会**把 overview / log 拉进来当种子。 +- **人工编辑保护**——locked / 编辑过的页面不会在再次入库时被覆盖;要重写就显式解锁。 + +--- + +## 视觉管线:图片也能被读出来 + +读不了图的 wiki 是半瞎的。PDF 尤其严重——一半的真信息往往就在那些图里。 + +打开 `wiki.ocr.enabled` 特性开关之后,MateClaw 会把每一张上传图片——以及**嵌在 PDF 页面里**的每一张图——都过一遍视觉管线,提取**配字描述**和**图中可见文字**,作为一等公民的 chunk 和上下文文字一起入库。检索找得到、Agent 引用得到,搜索结果里图片也会作为缩略图就地显示,点一下放大成灯箱。 + +### 工作流程 + +1. **哈希**图像字节(SHA-256)——缓存按内容寻址,所以同一张图换个 KB 重传,零成本。 +2. **查 `mate_wiki_image_caption_cache`**——命中直接复用配字、`hit_count` 加 1。 +3. 未命中就**按顺序走配置好的视觉 provider**,第一个返回非空 caption 的获胜。 +4. **写回缓存**:caption + visible text + provider id + model + 耗时(race-tolerant insert,并发上传同样 OK)。 +5. `VisionResult` 回到 chunker,作为该图所在页的额外内容。 + +### 支持的视觉 Provider + +| Provider id | 模型 | 备注 | +|---|---|---| +| `dashscope-vision` | `qwen-vl-max` | DashScope 兼容模式,复用 UI 里配好的 DashScope provider | +| `zhipu-vision` | `glm-5v-turbo` | 智谱 BigModel,OpenAI 兼容 | +| `volcano-doubao-vision` | 可配置 | 字节跳动火山豆包视觉 | + +Provider 按 order 自动选用。Key / base URL 都在 `Settings → 模型` 里像普通 provider 那样配,视觉管线会从那里取凭证。 + +### 切换开关 + +`Settings → Feature Flags → wiki.ocr.enabled`。轻量部署默认关;至少配好一个视觉 provider 之后再打开。 + +开关**关闭**时管线会短路——上传仍然成功,只是图像 chunk 没有 caption。这些图的 `extracted_text` 缓存是**延后**而不是被污染,所以你下次重新打开开关,新上传的图会自动配字,老图也不用强制重建。 + +### UI 上看得到的变化 + +- 命中含图片证据的搜索结果会内联显示缩略图,点开就是全分辨率灯箱。 +- 原始材料详情抽屉里每张抽出来的图片旁边显示 caption,方便你对照模型到底"看到了"什么。 + +--- + +## 健康感知的 LLM 降级 + +Wiki 入库本身就很烧 LLM 调用,过去任何一个 provider 卡住都会拖死整批。现在每一个 wiki 步骤(`route` / `create_page` / `merge_page` / `enrich` …)都走**健康感知**的降级链:主模型报错或超时,就把 KB `fallback` 列表上的下一个模型试一次。每个 provider 的健康度(成功 / 失败 / 延迟)会被独立追踪,抖动严重的 provider 会被自动降权,等它恢复再回来。 + +降级链在 `Wiki → 配置 → 模型策略` 里、按步选模型旁边配。 + +--- + +## 模型策略真生效 + +`Wiki → 配置 → 模型策略` 里给每个步骤选不同模型: + +```text +heavy_ingest.route → 便宜的小模型,做路由 +heavy_ingest.create_page → 强模型,写完整页面 +heavy_ingest.merge_page → 强模型,合并已有页面 +light_enrich.enrich → 便宜的小模型,标 wikilink +``` + +回退顺序: + +```text +stepModels[step] → wikiDefaultModelId → 系统默认模型 +``` + +之前 UI 上的这套配置在 Java 端只是摆样子(字段没承接),现在每一次 LLM 调用真的按它选——route / create / merge / retry / repair / 文档分析 都是。 + +--- + +## 底层数据(如果你好奇) + +九张表: + +| 表名 | 用途 | +|------|------| +| `mate_wiki_knowledge_base` | 每个 KB 一行。owner、名字、描述、配置 JSON(含 `ingestMode` / `wikiDefaultModelId` / `stepModels` 等)。 | +| `mate_wiki_raw_material` | 每份上传一行。状态、byte hash、来源路径、上次成功处理时的 hash。 | +| `mate_wiki_page` | 每个生成页面一行。标题、摘要、正文、`source_raw_ids`(回指原文)、`page_type`、`locked`、版本号,外加 `embedding` / `embedding_model` / `embedding_text_version` 让 synthesis 页直接进语义搜索。 | +| `mate_wiki_chunk` | 每个 chunk 一行。content + hash + 偏移 + embedding,外加 `page_number` / `header_breadcrumb` / `source_section` / `token_count`。 | +| `mate_wiki_relation` | 缓存的页对页边(共享 chunk / 共享原文 / 直接链接 / 语义近邻),用于检索时的 1 跳关系 boost 和关联推荐工具。 | +| `mate_wiki_hot_cache` | 每个 KB 一行。渲染后的 Markdown 快照 + `last_updated` / `update_reason` / `rebuild_count` / `last_rebuild_duration_ms` / `last_rebuild_error`。 | +| `mate_wiki_image_caption_cache` | 视觉管线提取出的 caption 缓存,按 SHA-256 索引。`caption` / `visible_text` / `mime_type` / `capture_model` / `provider_id` / `duration_ms` / `hit_count`。 | +| `mate_wiki_transformation` | 每个加工器模板一行。`name` / `title` / `description` / `prompt_template` / `model_id` / `apply_default` / `output_target` / `output_format` / `output_schema`。`kb_id=NULL` = 工作区全局可用。 | +| `mate_wiki_transformation_run` | 每次模板运行一行。`status` / `output` / `error` / `duration_ms` / `model_id` / `triggered_by` / `input_tokens` / `output_tokens` / `total_tokens` / `output_page_id`。 | + +`mate_wiki_page` 还带两个保护字段: + +- `locked`(V40)—— 1 = 禁止 AI 工具 / 批量操作 / 重处理清理删改本页。系统页 `overview`/`log` 默认 `locked=1`,但用户也可以给任意手写页面打上。 +- `archived`(V41)—— 1 = 软归档,从默认 list / search / related 结果消失,但页面、引文、反向链全保留。可恢复。 + +### 几个运维 endpoint + +不靠 cron / 事件钩子兜底,想立刻刷一下的时候用: + +| Endpoint | 作用 | +|---|---| +| `POST /api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | 立即按当前数据重写 overview marker 区域 | +| `POST /api/v1/wiki/admin/backfill-tokens` | 立即跑一批 token_count 回填,返回 `pendingBefore/pendingAfter/filledThisBatch` | + +`application.yml` 的 `mate.wiki` 配置块控制切块大小、并发度、auto-process 等全局参数;具体到每个 KB 的入库模式 / 模型策略 / 备选模型链,写在 KB 的 `configContent` JSON 里——前端配置页直接编辑。 + +> 旧 chunk 的 `token_count` 列允许 NULL;后台有个低频 cron `WikiChunkTokenBackfillJob` 用 `ceil(charCount / 4)` 按批回填,不影响主链路。 + +--- + +## 什么时候该用它 + +用 Wiki KB 当你有: + +- 同一主题下不止几份文档 +- 希望人也能读能改、不只是被检索的内容 +- 需要跨 Agent、跨会话持续存在的信息 +- "这个说法从哪来的"这件事很重要的资料 + +如果只是想把一份 PDF 扔进一次对话,在聊天里直接附件就好。Wiki 是给值得一个书架的材料准备的。 + +模式选择经验: + +- 你**马上**要拿 Wiki 来人读 / 演示 / 分享:eager。 +- 你只是先把资料倒进来,等 agent 用到才考虑要不要固化成页面:lazy + 按需编译。 +- 量大、模型贵、不确定每篇都需要完整页面:lazy 默认更省。 + +--- + +## 下一步 + +- [Agent 引擎](./agents) —— 把 Agent 绑定到 KB +- [记忆系统](./memory) —— Wiki 和记忆的区别(提示:Wiki 是刻意的,记忆是被动的) +- [API 参考](./api) —— Wiki 的 REST 接口 diff --git a/mateclaw-server/src/main/resources/docs/zh/workflow.md b/mateclaw-server/src/main/resources/docs/zh/workflow.md new file mode 100644 index 00000000..382db51b --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/workflow.md @@ -0,0 +1,340 @@ +# 工作流(Workflow) + +::: tip 1.3.0 新增 +工作流编排自 v1.3.0 起提供。在 v1.2.0 及更早版本里没有这个能力。 +::: + +**工作流是什么**:把多个数字员工 + 系统操作(审批 / 渠道分发 / 写记忆)按线性 step 编排成一条业务流程。每一步可以被前一步的输出条件控制,可以并行扇出,可以等待人工审批,可以把结果写进员工的 MEMORY.md。 + +**工作流不是什么**: +- 不是 ReAct / Plan-and-Execute 的替代品——单 agent 的多轮推理仍然在那两条引擎里 +- 不是可视化拖拉框框生成 if/else 的低代码工具——v0 是 **JSON 优先**(v1 才上画布) +- 不是 30+ 节点的 Dify-style 编排——MateClaw 工作流刻意保持极简:**线性 step 数组 + 一个 mode 字段表达控制流** + +::: warning v1.3.0 范围 +v0 = internal alpha。**7 种 step mode + 6 种 trigger pattern**。`loop` / `invoke_skill` 留给后续版本。生产场景请先在标杆账号 / 内部 workspace 跑通再推广。 +::: + +--- + +## 一分钟看懂 + +```json +{ + "schemaVersion": "1.0", + "inputs": [ + { "name": "customer", "type": "json" } + ], + "steps": [ + { + "name": "enrich", + "agentName": "data-analyst", + "promptTemplate": "Enrich and return strict JSON: {{ inputs.customer | toJson }}", + "mode": { "type": "sequential" }, + "outputVar": "enriched", + "outputContentType": "json" + }, + { + "name": "vip-route", + "agentName": "enterprise-sales", + "promptTemplate": "VIP onboarding for {{ outputs.enriched.name }}", + "mode": { + "type": "conditional", + "expression": "{{ outputs.enriched.tier == 'enterprise' }}" + } + }, + { + "name": "notify-feishu", + "agentName": "ops-bot", + "promptTemplate": "Notify feishu: {{ outputs.enriched }}", + "mode": { "type": "fan_out" } + }, + { + "name": "notify-email", + "agentName": "ops-bot", + "promptTemplate": "Notify email: {{ outputs.enriched }}", + "mode": { "type": "fan_out" } + }, + { + "name": "wait-acks", + "mode": { "type": "collect" } + }, + { + "name": "record", + "promptTemplate": "Onboarded {{ inputs.customer.name }}", + "mode": { + "type": "write_memory", + "employeeId": "{{ outputs.enriched.assignedEmployeeId }}", + "file": "MEMORY.md", + "mergeStrategy": "append" + } + } + ] +} +``` + +读法: +1. `enrich` 让数据员工把客户信息结构化为 JSON +2. 如果客户层级是 `enterprise`,让企业销售员工跑 VIP onboarding +3. 同时(fan_out)扇出到飞书通知 + 邮件通知 step +4. `collect` 等两个通知都返回再继续 +5. 把结果追加写到员工的 `MEMORY.md` + +--- + +## 核心概念 + +### Step 七种 mode(v1.3.0) + +| Mode | 行为 | 必填字段 | 关键语义 | +|---|---|---|---| +| `sequential` | 顺序执行;上一步 output → `{{input}}` | — | 默认 mode | +| `fan_out` | 与连续后续 fan_out 并行;都收到同一 `{{input}}` | — | 边界由编译期检测:从此 step 开始,遇到第一个非 fan_out / 非 collect 的 step 即停 | +| `collect` | 把前面**最近一组** fan_out 的输出按 `\n\n---\n\n` 拼接为 `{{input}}` | — | 前面必须存在至少 2 个连续 fan_out;编译期校验 | +| `conditional` | Pebble 表达式 true 才执行 | `expression` | false 时跳过;`{{input}}` 不变(保留上一步) | +| `await_approval` | 暂停 run,发审批 | `approvalKind`、`approverChannels[]` | resume 后继续下一步;超时按 workspace 政策处理 | +| `dispatch_channel` | 把 `{{input}}` 多渠道分发 | `channels[]` | 单渠道失败按 errorMode 处理 | +| `write_memory` | 写员工记忆文件 | `employeeId`、`file`、`mergeStrategy` | 4 种策略:`append` / `replace_section` / `upsert_kv` / `overwrite` | + +> **不在 v1.3.0 里**:`loop`(迭代 N 次或对数组逐项处理)、`invoke_skill`(直接调用 skill 不经过员工)。等用户反馈再加。 + +### 表达式:Pebble 子集 + +工作流**不**用全功能模板引擎——它支持的是 Kestra 同款 Pebble 子集,只够做条件判断和变量引用,不能跑代码。 + +| 类别 | 语法 | +|---|---| +| 变量引用 | `inputs.X` / `outputs.varname.field` / `vars.X` / `now` / `flow.id` | +| 操作符 | `==` `!=` `<` `<=` `>` `>=` `and` `or` `not` `+` `-` | +| 内建过滤器 | `length` / `lower` / `upper` / `default('x')` / `toJson` / `fromJson` / `date(format)` | +| JSONPath | `\| jq('.field.subfield')` | +| 字符串测试 | `\| contains('x')` / `\| startsWith('x')` / `\| matches('regex')` | + +**不支持**(编译期拒): +- 自定义函数 / 宏定义 +- include / extends +- 文件 I/O / 网络 I/O +- 任何副作用操作 + +### 输出类型:text vs json + +每一步的 `outputContentType` 决定下游怎么访问它: + +| outputContentType | 默认 | Pebble 访问规则 | +|---|---|---| +| `text` | ✅ | `outputs.X` 是字符串;`outputs.X.field` **编译期错**;`\| jq(...)` **运行时错** | +| `json` | — | 运行时 `JSON.parse` 解析;失败按 `errorMode` 处理;字段访问 / `jq(...)` 合法 | + +**Agent step 默认 `outputContentType=text`**——LLM 自然语言输出本来就不是 JSON。要做条件分支或字段访问,必须: +1. 在 `promptTemplate` 里**明确**让 LLM 输出严格 JSON("return strict JSON: {...}") +2. 把这一步的 `outputContentType` 设为 `json` + +### 编译期非法组合(发布时拒) + +| 组合 | 拒原因 | +|---|---| +| 连续多个 `fan_out` 后没 `collect` | `{{input}}` 进入下一步歧义 | +| `collect` 没有前置 `fan_out` | 没东西可 collect | +| `fan_out` 组里混入 `await_approval` | 多审批并发同时触发,没法聚合 | +| `agentName` 指向员工不存在 / 已禁用 / 跨 workspace | ACL 失败 | +| Pebble 表达式引用未声明的变量 | 编译期 | +| `outputs.X.field` 但 step X 是 `text` 类型 | 编译期类型错 | +| `dispatch_channel` 引用的 channel 不在 workspace allowlist | ACL | +| `write_memory` 引用的 employeeId 跨 workspace | ACL | +| step 数 > 200(默认上限) | 防失控配置 | + +发布时跑 `WorkflowCompiler.validate(graphJson) → List`,每个错误指到 step name + 字段路径,UI 上 Monaco 编辑器直接标红。 + +--- + +## 在 UI 里用工作流 + +### 入口 + +`Workflows`(侧栏)→ 列表 → 点 **+ 新建**。 + +::: tip +新装的实例 Workflows 列表是空的。这是故意的——v0 不内置模板,由用户和标杆客户共建。 +::: + +### 编辑器(v1.3.0 = JSON only) + +- **Monaco 编辑器**:JSON schema 校验 + 自动补全 + Pebble 表达式静态检查 +- **模板下拉**:从 `GET /api/v1/workflows/draft/templates` 拉的内置骨架 +- **预编译**:`POST /api/v1/workflows/{id}/compile` 拿 compile diagnostics,**不写 revision、不真跑** +- **发布**:跑编译 → ACL 校验 → 写一个新的 `mate_workflow_revision` 行(整数版本号 +1) + +::: warning v1 才上画布 +`@vue-flow/core` 画布在 v1.3.0 里**已经有 UI 雏形**,但它把 step 数组渲染为节点链,**不是**可拖拉编辑——双击节点弹出对应字段表单,主编辑路径仍是 JSON。完整的可视化拖拉编辑推到 v1.4+。 +::: + +### 自然语言 → 工作流草稿(v1.3.0) + +`POST /api/v1/workflows/draft/generate` 接受一段自然语言描述("我要一条客户工单分流流程,飞书入口,按客户层级路由 enterprise / pro / standard 分别走不同处理人"),让一个内置 agent 生成对应 graph_json,并**立即编译返回**——附带 compile diagnostics。 + +适用场景: +- 不熟 JSON DSL 的用户先生成一稿可发布的草稿,再去 Monaco 里调 +- 老 SOP 文档批量灌进生成器,快速得到候选工作流模板 +- 客户共建时,把"做完这件事我希望它如何运转"的口语描述快速可视化 + +返回结构: +```json +{ + "graphJson": "...", // 可直接 PUT 进 draft 的 JSON + "compileErrors": [...], // 同 /compile 一致的诊断 + "modelUsed": "qwen-plus", + "tokenUsage": { ... } +} +``` + +::: tip 不替代 Monaco 编辑 +生成器**不会**直接发布——它只生成草稿(`saveDraft`),仍要走人工审阅 → 编译 → 发布。生成的 JSON 会带编译错误也不奇怪,作者修完再发布。 +::: + +### 运行历史 + +每条 run 都持久化为 `mate_workflow_run` + `mate_workflow_run_step`。详情页: +- 每个 step 的 input / output(payload URI 引用) +- 每步耗时 + token 消耗 +- 跨 step 失败链路高亮 +- await_approval 暂停时显示等谁审批 + 等了多久 + +### 触发方式 + +工作流的实际启动只能通过 [触发器(Triggers)](./triggers.md) 或 `await_approval` 恢复——v0 没有"立即手动跑一次"的 endpoint。详见上方 API 参考。 + +::: tip 1.4.0:触发器现在在调度中心里 +v1.4.0 起,**定时任务**和**触发器**合并为单个**调度中心**页面(`设置 → 调度中心`,路由 `/settings/scheduler`),分**计划任务 / 事件触发器 / 运行历史**三个 tab。要给工作流挂触发器,去调度中心的**事件触发器** tab 新建一条 `target_type=workflow` 的规则。详见 [触发器](./triggers.md)。 +::: + +--- + +## API 参考 + +所有 endpoint 都在 `/api/v1/workflows/` 下,请求要带 `X-Workspace-Id` header。 + +### CRUD + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/api/v1/workflows` | 列出当前 workspace 的所有工作流 | +| `POST` | `/api/v1/workflows` | 新建工作流(草稿初始为空) | +| `GET` | `/api/v1/workflows/{id}` | 获取工作流元信息 + 内联草稿 | +| `PUT` | `/api/v1/workflows/{id}` | 更新工作流元数据(name / description / enabled) | +| `PUT` | `/api/v1/workflows/{id}/draft` | 保存内联草稿 graph_json(不编译) | +| `DELETE` | `/api/v1/workflows/{id}` | 软删工作流 | + +### 编译 / 发布 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `POST` | `/api/v1/workflows/{id}/compile` | 编译当前草稿,返回 compile diagnostics,**不写 revision** | +| `POST` | `/api/v1/workflows/{id}/publish` | 编译 + 写新 revision;自动指向 `latest_revision_id` | + +### 草稿生成器(v1.3.0 内置) + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/api/v1/workflows/draft/templates` | 列出内置草稿模板 | +| `POST` | `/api/v1/workflows/draft/preview-compile` | 任意 graph_json 试编译——不需要先建 workflow 行就能拿到诊断 | +| `POST` | `/api/v1/workflows/draft/generate` | **自然语言 → 工作流草稿**——你描述需求,agent 生成 graph_json + 编译诊断 | + +### 运行查询 / 恢复 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `GET` | `/api/v1/workflows/{id}/runs?limit=...` | 列某 workflow 最近 run(默认 50 条) | +| `GET` | `/api/v1/workflows/runs/paused?limit=...` | 列当前 workspace **所有 paused run**(运维入口) | +| `GET` | `/api/v1/workflows/runs/{runId}` | 单个 run 详情 + 全部 step 行(input / output / duration) | +| `POST` | `/api/v1/workflows/runs/{runId}/resume` | 从 `await_approval` 暂停恢复(系统在审批通过时自动调用,不需要前端手动调) | + +::: warning v0 没有"启动 run"的独立 endpoint +工作流 run 的实际启动路径只有两条: + +1. **通过 trigger**——在 [Triggers](./triggers.md) 配一条 trigger 指向这个 workflow(`target_type=workflow`),事件到了引擎自动启动 run +2. **通过 `await_approval` resume**——`/runs/{runId}/resume` 把 paused run 推下去 + +v0 **没有** `POST /api/v1/workflows/{id}/runs` 这种"立即手动跑一次"的 API。要做"试运行",用 `/draft/preview-compile` 拿编译结果(**只编译、不入库、不真跑**),或者临时挂一条 webhook trigger 触发。手动启动 run 在 RFC 里已规划,会在后续版本加。 +::: + +--- + +## 安全模型 + +### 三层 ACL 角色 + +| 角色 | 能干什么 | +|---|---| +| `workflow:author` | 编辑 draft、读自己的 run | +| `workflow:publisher` | 发布 revision;发布期跑静态 ACL 检查 | +| `workflow:operator` | 启停 trigger、cancel run、查看其它人的 run | + +### Step 执行身份 + +每个 step 在 ExecutionContext 携带: +- `workspaceId`:必须等于 workflow 的 workspace +- `actingAgentId`:sequential / 3 个 MateClaw mode → 该 step 的 agent;其它 mode → 发布者作为 fallback +- `triggeredBy` / `workflowId` / `revisionId` / `runId`:审计追溯 + +### 跨 workspace 隔离 + +发布期跑 `WorkflowAclValidator.checkAll(graphJson)`: +- `agentName` 引用的员工必须在当前 workspace 内 +- `dispatch_channel` 的 channel 必须在 workspace allowlist 内 +- `write_memory` 的 employeeId 必须在当前 workspace 内 + +任一不通过 → 发布失败,事务回滚,**不写 revision、不更新 latest_revision_id**。 + +### 与 [MCP 每 agent 工具绑定](./mcp.md) 的关系 + +工作流**不能**给员工额外的工具。Agent step 调工具时会跑 `AgentBindingService.getEffectiveToolNames(agentId)` 算出的同一套 ACL——员工在工作流里能用什么工具,跟它在普通对话里能用什么工具完全一致。 + +--- + +## 内置存储 URI(payload) + +工作流的输入 / 输出 / 中间产物如果超过 4KB 默认阈值,会被自动写入 `mate_workflow_payload` 表(v1.3.0:单库存储)或本地文件系统兜底,并在 graph 里以 `payload://` URI 替代。这避免了大上下文撑爆 message column 的问题——参考 commit `9c81dba0 feat(workflow): payload fs fallback for medium-size payloads`。 + +```text +payload://run/abc123/step/enrich/output → 实际存储位置由后端解析 +``` + +UI 渲染时按需 lazy-load。 + +--- + +## 数据模型 + +工作流系统涉及 8 张表: + +| 表 | 用途 | +|---|---| +| `mate_workflow` | 工作流主体(id / name / workspace) | +| `mate_workflow_revision` | 已发布版本(整数 revision;graph_json 整体快照;不可变) | +| `mate_workflow_run` | 一次执行(runId / triggerSource / status / startedAt / endedAt) | +| `mate_workflow_run_step` | run 内每一步的 input/output/duration | +| `mate_workflow_run_pause` | await_approval 持久化暂停状态(重启后能恢复) | +| `mate_workflow_payload` | 大 payload 内置存储(payload URI 解析目标) | +| `mate_trigger` | 触发器配置(含 cron pattern_version) | +| `mate_trigger_event` | 事件去重 + rate limit 历史 | + +--- + +## 已知限制(v1.3.0) + +- **没有可视化拖拉编辑**——画布是只读的链式渲染,主编辑路径是 JSON +- **没有 loop step**——做不了"对数组逐项处理"或"重试 N 次"。变通:用 `fan_out` 数个固定分支,或上层调度多 run +- **没有 invoke_skill step**——skill 必须挂在 agent 上由 agent 调用 +- **没有跨 workspace 共享**——同一份工作流模板要复用到多个 workspace 需要复制 +- **没有实时协作编辑**——同时间多人编辑同一草稿,**后写覆盖** +- **没有 step 级 retry policy**——`errorMode.retry` 是 step-wide 的;细粒度 retry 推到后续 + +--- + +## 相关链接 + +- [触发器(Triggers)](./triggers.md) —— 工作流的事件入口 +- [审批与安全](./security.md) —— `await_approval` 走的就是这条审批通道 +- [数字员工](./agents.md) —— Step 里 `agentName` 引用的就是它们 +- [多渠道接入](./channels.md) —— `dispatch_channel` 能投递到哪些渠道 +- [记忆系统](./memory.md) —— `write_memory` 写到员工的哪份文件 diff --git a/mateclaw-server/src/main/resources/docs/zh/workspaces.md b/mateclaw-server/src/main/resources/docs/zh/workspaces.md new file mode 100644 index 00000000..1e94a9ac --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/workspaces.md @@ -0,0 +1,308 @@ +# 工作空间 + +**一个工作空间就是一个团队所有东西外面的一个盒子。** + +MateClaw 在单次部署里支持多个团队的方式,是把每一种资源——Agent、技能、Wiki 知识库、会话、记忆文件、Tool Guard 规则、渠道——组织进**工作空间**。你登录时看到的是你所属的工作空间、其他什么都看不到。切换工作空间时,整个 UI 重新 scope:不同的 Agent、不同的技能、不同的知识、不同的渠道。 + +重点是**一个 MateClaw 部署可以同时服务一个产品组、一个工程组、一个研究组**,他们的数据、Agent、对话不会互相渗透。 + +--- + +## 什么属于工作空间 + +几乎所有东西。被 scope 的资源: + +| 资源 | 怎么 scope 的 | +|------|---------------| +| **Agent** | 每一行 Agent 都有 `workspace_id` 外键 | +| **技能** | 自定义和 MCP 技能按工作空间 scope;内置技能是全局的 | +| **Wiki 知识库** | 每个 KB 属于且只属于一个工作空间 | +| **会话和消息** | scope 到 Agent 所在的工作空间 | +| **工作空间记忆文件** | `workspace/{workspaceId}/{agentId}/...` | +| **渠道** | 每个渠道绑一个 Agent,所以传递地绑一个工作空间 | +| **Tool Guard 规则** | 规则可以是全局或 scope 到特定工作空间 | +| **File Guard 路径** | 允许/拒绝路径可以按工作空间 | +| **Cron 任务** | scope 到它触发的 Agent 所在的工作空间 | +| **数据源** | 外部 DB 连接,按工作空间 scope | +| **审计事件** | 每个审计事件记录它的 `workspace_id` | + +**不**被 scope 的(即全局的): + +- JWT secret 和认证配置 +- 模型供应商和 API Key(全局,但用量按工作空间追踪) +- MCP 服务定义(全局连接;工作空间访问由权限控制) +- `mate_system_setting` 里的系统级设置 +- 内置技能 + +Agent、技能(catalog + 运行时)、会话、工作空间文件全部按工作空间 ID 隔离;**跨工作空间访问一律返回 403**。 + +--- + +## 工作空间角色 + +每个用户在工作空间里被分配四种角色之一。权限**叠加**——高角色继承低角色的全部能力: + +| 角色 | 能力(继承下层后新增) | +|------|------------------------| +| **Viewer** | `chat`、`view:wiki`。只读。为了让聊天能跑通,Viewer 还能读取当前激活模型、读取员工的工作空间文件。 | +| **Member** | Viewer + `view:memory`、`view:dashboard`、`manage:wiki`、`manage:agents` | +| **Admin** | Member + `manage:skills`、`manage:channels`、`manage:models`、`manage:security`、`manage:settings` | +| **Owner** | 与 Admin 相同,外加 owner 专属:删除工作空间、转移所有权 | + +一个用户可以属于多个工作空间、**在不同工作空间有不同角色**。切换工作空间时,有效权限跟着切换。 + +### 全局管理员 vs 工作空间角色 + +二者是两套独立的权限: + +- **全局管理员**——`mate_user.role='admin'`,系统级。管理用户、创建工作空间,以 owner 等同的权限横跨**所有**工作空间(即便它不是某工作空间的成员)。 +- **工作空间角色**——`mate_workspace_member.role`,每工作空间一份,就是上表那四种。 + +系统级端点(模型 / provider / OAuth / 数据源、用户管理、创建工作空间)要求全局管理员(`@RequireGlobalAdmin`);工作空间级端点(技能 / 工具 / 插件)要求工作空间角色——读需要 Member、写需要 Admin。 + +### 能力的 scope —— 后端是唯一真相源 + +角色控制 **UI 可见性**和 **API 访问**,而**后端是能力的唯一真相源**:后端维护一份 `RoleCapabilities` 映射,前端从不本地推导。切换工作空间后、或遇到与权限相关的 403 时,前端调用 `GET /api/v1/workspaces/{id}/access`,拿回 `memberRole`、`isGlobalAdmin`、`effectiveRole`、`capabilities`。 + +前端据此 gating:路由声明所需能力;侧栏按能力过滤(加载完成前不会闪现菜单);Viewer 登录后落在 `/chat`;侧栏还会显示通知角标(待审批、卡住的员工)。后端在每个 API 端点上执行同样的规则,所以能力不足的请求返回 `403 Forbidden`。 + +--- + +## 创建一个工作空间 + +`设置 → 工作空间 → 新建工作空间`。 + +1. 按"**团队在做什么**"起名,不是"团队叫什么"("产品调研"比"Alpha 组"好) +2. 可选描述 +3. 保存 + +**只有全局管理员能创建工作空间。** 创建者自动成为这个工作空间的 **Owner**,现在可以添加成员了。 + +### 走 API + +```bash +curl -X POST http://localhost:18088/api/v1/workspaces \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "产品调研", + "description": "竞品调研和产品规格" + }' +``` + +--- + +## 成员与角色 + +`设置 → 成员`。所有成员管理操作都需要 **Admin 及以上**。 + +### 添加成员 + +输入用户名,选角色(默认 `member`),保存。 + +- 用户名**不存在**时,会顺手**创建账号**——此时必须提供密码。 +- 用户名**已存在**且你又填了密码,则**重置该用户的密码**(管理员把人移除后用新密码重新加回来时很有用)。 +- 昵称可选。 + +成员**下次页面加载时**立刻在工作空间切换器里看到这个工作空间。没有邀请邮件,没有接受流程。 + +```bash +# 用 username 添加;不存在则按提供的密码建号 +curl -X POST http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "alice", + "password": "init-pass-123", + "nickname": "Alice", + "role": "member" + }' +``` + +### 更新成员角色(Admin+,不能改 Owner) + +```bash +curl -X PUT http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "admin"}' +``` + +> 路径是 `/members/{memberId}`,**不是** `/members/{memberId}/role`。 + +### 移除成员(Admin+,不能移除 Owner) + +```bash +curl -X DELETE http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " +``` + +### 列出成员 + +```bash +curl http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " +``` + +--- + +## 切换工作空间 + +管理控制台左上角。点工作空间名字打开切换器,选另一个切。整个 UI 重新 scope: + +- 侧栏菜单按新工作空间的角色重新渲染 +- Agent 列表刷新显示这个工作空间里的 Agent +- Wiki 列表、技能列表、渠道列表等全部改变 +- 活跃的对话**保持打开**(它们属于自己的工作空间) + +当前工作空间 ID 以**字符串**形式存在浏览器 localStorage 里(Snowflake ID 安全,不会被 `Number` 截断)。任何时候都有一个**默认工作空间兜底**——即便本地没有记录,你也总会落到一个可用的工作空间。 + +`GET /api/v1/workspaces` 返回的每个工作空间都带 `memberRole` / `effectiveRole` / `isGlobalAdmin`,前端据此渲染切换器和侧栏。 + +--- + +## 沿工作空间边界生效的安全基元 + +这是工作空间隔离真正起作用的地方。 + +### File Guard + +File Guard 的默认 allowed-path 列表是 `workspace/{workspaceId}/...`。工作空间 A 里一个 Agent 发出的工具调用**读不到也写不到**属于工作空间 B 的文件,不管它怎么用路径穿越的 trick——符号链接检查和路径规范化会逮住它。 + +### Tool Guard 规则 + +规则可以 scope 到特定工作空间。你可以有: + +- 一条**全局**规则说 `ShellExecuteTool` 需要审批 +- 一条**工作空间特定**的规则说命令匹配一个狭窄的只读模式时 `ShellExecuteTool` 被允许 + +**只有第二条规则在那个工作空间里生效。** 其他工作空间只看到全局规则。 + +### Wiki 知识库 + +Wiki KB 的数据**永远不会离开它的工作空间**。工作空间 B 里的 Agent 读不到属于工作空间 A 的 KB,**即使它尝试**。Wiki 检索和读取工具从绑定 Agent 的工作空间解析知识库 ID;跨工作空间读在 API 层被拒绝。 + +### 记忆文件 + +工作空间记忆文件(PROFILE.md、MEMORY.md、每日笔记)住在 `workspace/{workspaceId}/{agentId}/` 下面。File Guard 执行工作空间边界;记忆工具的 list/read/write 操作被限定到调用者的工作空间内。 + +### 渠道 + +每个渠道绑一个 Agent,传递地绑一个工作空间。工作空间 A 里配置的一个钉钉机器人和工作空间 B 里配置的一个钉钉机器人**完全独立**,即使它们被配置成连接同一个钉钉应用(你大概率不想这样,但技术上允许)。 + +--- + +## 工作空间隔离**不**覆盖的 + +- **共享的全局配置**——JWT secret、模型供应商 API Key、MCP 服务定义是全局的。工作空间管理员改不了。 +- **审计日志的跨工作空间访问**——带正确权限的安全管理员可以跨所有工作空间查询审计事件。这是**刻意的**——你想看到可疑活动,不管它发生在哪个工作空间。 +- **Token 用量报告**——全局聚合,在仪表盘里按工作空间、按 Agent、按模型细分。 +- **模型供应商成本**——全局层面每个 provider 一个计费关系;按工作空间的配额在[路线图](./roadmap)上。 + +--- + +## 在工作空间之间移动资源 + +**不直接支持。** 你有两个选项: + +1. **导出导入**——一些资源有 JSON 导出(Agent 走 API、Wiki KB 走 API)。在目标工作空间重新创建。 +2. **改所有权**——admin 或 owner 可以直接在数据库里更新简单资源的 `workspace_id` 列。这不是官方支持的;**自担风险而且一定要带备份**。 + +我们希望在未来版本里支持一等公民的移动。需要这个就在 [GitHub issue](https://github.com/matevip/mateclaw/issues) 上留言。 + +--- + +## 删除一个工作空间 + +**只有 Owner 能删工作空间。** `设置 → 工作空间 → [工作空间] → 删除`。如果工作空间下还**拥有 Wiki 知识库**,删除会失败——先迁移或删掉这些 KB 再删工作空间。 + +删工作空间会: + +- 软删除它下面的每一个资源——Agent、技能、KB、会话、记忆文件、渠道 +- 移除所有成员关联 +- 记录一个审计事件 + +**软删除**意味着数据不被物理移除——它被标记为 `deleted = 1`、从查询里隐藏。误删的话数据库管理员可以通过翻转标记恢复。配置的保留期过后,删除的数据可能被清理任务永久清除。 + +--- + +## 工作空间管理 API + +```bash +# 列出你所属的工作空间 +curl http://localhost:18088/api/v1/workspaces \ + -H "Authorization: Bearer " + +# 获取单个工作空间详情 +curl http://localhost:18088/api/v1/workspaces/1 \ + -H "Authorization: Bearer " + +# 创建 +curl -X POST http://localhost:18088/api/v1/workspaces \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "产品调研"}' + +# 更新 +curl -X PUT http://localhost:18088/api/v1/workspaces/1 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"description": "更新后的描述"}' + +# 删除(仅 owner) +curl -X DELETE http://localhost:18088/api/v1/workspaces/1 \ + -H "Authorization: Bearer " + +# 成员管理 +curl http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " + +curl -X POST http://localhost:18088/api/v1/workspaces/1/members \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"username": "alice", "password": "init-pass-123", "role": "member"}' + +curl -X DELETE http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " + +# 更新角色:路径是 /members/{memberId},不是 /members/{memberId}/role +curl -X PUT http://localhost:18088/api/v1/workspaces/1/members/42 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "admin"}' +``` + +--- + +## 数据模型 + +**`mate_workspace`** + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `name` | 工作空间名 | +| `description` | 简短描述 | +| `owner_id` | Owner 的用户 ID | +| `create_time` / `update_time` | 时间戳 | +| `deleted` | 逻辑删除标志 | + +**`mate_workspace_member`** + +| 列 | 用途 | +|----|------| +| `id` | 主键 | +| `workspace_id` | 外键到 `mate_workspace` | +| `user_id` | 外键到 `mate_user` | +| `role` | `owner` / `admin` / `member` / `viewer` | +| `joined_at` | 用户加入这个工作空间的时间 | +| `create_time` / `update_time` | 时间戳 | + +--- + +## 下一步 + +- [控制台](./console)——工作空间切换器和 UI +- [安全与审批](./security)——工作空间隔离和 Tool Guard、File Guard 的交互 +- [LLM Wiki](./wiki)——工作空间 scope 的知识库 +- [记忆系统](./memory)——工作空间记忆文件 diff --git a/mateclaw-server/src/main/resources/logback-spring.xml b/mateclaw-server/src/main/resources/logback-spring.xml index 6922ce98..9710add5 100644 --- a/mateclaw-server/src/main/resources/logback-spring.xml +++ b/mateclaw-server/src/main/resources/logback-spring.xml @@ -114,6 +114,9 @@ + + + diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index fe18b3b4..e38c3353 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -59,6 +59,9 @@ tool.read_file.error.not_readable=\u6587\u4ef6\u4e0d\u53ef\u8bfb: {0} tool.read_file.error.start_exceeds=\u8d77\u59cb\u884c {0} \u8d85\u51fa\u6587\u4ef6\u603b\u884c\u6570 {1} tool.read_file.error.start_gt_end=\u8d77\u59cb\u884c {0} \u5927\u4e8e\u7ed3\u675f\u884c {1} tool.read_file.error.read_exception=\u8bfb\u53d6\u6587\u4ef6\u5f02\u5e38: {0} +tool.read_file.truncated=\u8f93\u51fa\u5df2\u622a\u65ad\uff08\u6700\u591a {0} \u884c / {1}KB\uff09\u3002\u4f7f\u7528 startLine={2} \u7ee7\u7eed\u8bfb\u53d6\u3002 +tool.read_file.line_truncated_marker= ...[\u672c\u884c\u8fc7\u957f\uff0c\u5df2\u622a\u65ad] +tool.read_file.line_truncated=\u7b2c {0} \u884c\u957f\u5ea6\u8d85\u8fc7\u5355\u6b21\u8f93\u51fa\u4e0a\u9650\uff08{1}KB\uff09\u3002\u8981\u7ee7\u7eed\u8bfb\u53d6\u8be5\u884c\u5269\u4f59\u5185\u5bb9\uff0c\u8bf7\u4f7f\u7528 startLine={2}\u3001startColumn={3}\uff1b\u6216\u4f7f\u7528 startLine={4} \u8df3\u5230\u4e0b\u4e00\u884c\u3002\u4e0d\u8981\u63a8\u65ad\u6216\u865a\u6784\u88ab\u622a\u65ad\u7684\u5185\u5bb9\u3002 tool.write_file.error.path_empty=\u6587\u4ef6\u8def\u5f84\u4e0d\u80fd\u4e3a\u7a7a tool.write_file.error.is_directory=\u8def\u5f84\u662f\u4e00\u4e2a\u5df2\u6709\u76ee\u5f55\uff0c\u65e0\u6cd5\u4f5c\u4e3a\u6587\u4ef6\u5199\u5165: {0} tool.write_file.error.write_exception=\u5199\u5165\u6587\u4ef6\u5f02\u5e38: {0} @@ -79,6 +82,22 @@ tool.video_generate.desc=\u751f\u6210\u89c6\u9891\u3002\u652f\u6301\u6587\u751f\ tool.music_generate.desc=\u751f\u6210\u97f3\u4e50\u6216\u6b4c\u66f2\u3002\u652f\u6301\u6587\u5b57\u63cf\u8ff0\u751f\u6210\u97f3\u4e50\u3001\u6b4c\u8bcd\u8c31\u66f2\u3001\u7eaf\u97f3\u4e50\u7b49\u6a21\u5f0f\uff0c\u652f\u6301 Google Lyria \u548c MiniMax Music \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-3 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u97f3\u9891\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002 tool.model3d_generate.desc=\u751f\u6210 3D \u6a21\u578b (.glb)\u3002\u652f\u6301\u6587\u751f 3D \u4e0e\u56fe\u751f 3D \u4e24\u79cd\u6a21\u5f0f\uff0c\u76ee\u524d Provider \u4e3a\u817e\u8baf\u6df7\u5143 3D Rapid\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-3 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u6a21\u578b\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff08\u5e26 model-viewer \u9884\u89c8\uff09\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002 +# --- Document rendering tools --- +tool.renderPdf.desc=\u4ece Markdown \u751f\u6210\u65b0\u7684 PDF \u6587\u4ef6\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002\u5f53\u7528\u6237\u660e\u786e\u8981\u6c42 PDF\uff08\u5bfc\u51fa PDF / \u751f\u6210 pdf / \u53e6\u5b58\u4e3a PDF\uff09\u65f6\u5fc5\u987b\u4f7f\u7528\u672c\u5de5\u5177\uff0c\u4e0d\u8981\u7528 renderDocx \u66ff\u4ee3\uff0c\u4e5f\u4e0d\u8981\u901a\u8fc7 chrome --headless\u3001wkhtmltopdf \u7b49\u65b9\u5f0f\u7ed5\u8fc7\u3002\u9002\u5408\u62a5\u544a\u3001\u767d\u76ae\u4e66\u3001\u5408\u540c\u7b49\u6700\u7ec8\u4ea4\u4ed8\u7269\u3002\u53ef\u9009\u7684 YAML frontmatter \u53ef\u9a71\u52a8\u5c01\u9762\u9875\u4e0e\u9875\u7709\u9875\u811a\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u6539\u7528 renderPdfFromFile\u3002 +tool.renderPdfFromFile.desc=\u4ece\u78c1\u76d8\u4e0a\u7684 markdown \u6587\u4ef6\u751f\u6210 PDF \u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002\u5f53\u7528\u6237\u8981\u6c42 PDF \u4e14 markdown \u6b63\u6587\u5df2\u5199\u5165\u6587\u4ef6\u65f6\u4f7f\u7528\u672c\u5de5\u5177\uff0c\u907f\u514d LLM \u628a\u81ea\u5df1\u7684\u5185\u5bb9\u4f5c\u4e3a\u53c2\u6570\u91cd\u590d\u8f93\u51fa\u3002\u652f\u6301\u4e0e renderPdf \u76f8\u540c\u7684 markdown \u5b50\u96c6\u4e0e frontmatter \u7ea6\u5b9a\u3002 +tool.renderDocx.desc=\u4ece Markdown \u751f\u6210\u65b0\u7684\u53ef\u7f16\u8f91 .docx\uff08Word\uff09\u6587\u4ef6\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002\u9002\u5408\u7528\u6237\u540e\u7eed\u4ecd\u9700\u4fee\u6539\u7684\u6587\u6863\u2014\u2014\u62a5\u544a\u3001\u5907\u5fd8\u5f55\u3001\u5408\u540c\u3001\u4fe1\u51fd\u3001\u7b80\u5386\u3002\u652f\u6301\u6807\u9898\u3001\u52a0\u7c97\u3001\u6709\u5e8f/\u65e0\u5e8f\u5217\u8868\u3001\u8868\u683c\u3001\u56fe\u7247\uff08SVG \u81ea\u52a8\u6805\u683c\u5316\u4e3a PNG\uff09\u3002\u7528\u6237\u8981\u6c42 PDF \u65f6\u6539\u7528 renderPdf\uff0c\u8981\u8868\u683c\u5de5\u4f5c\u7c3f\u7528 renderXlsx\uff0c\u8981\u5e7b\u706f\u7247\u7528 renderPptx\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u6539\u7528 renderDocxFromFile\u3002 +tool.renderDocxFromFile.desc=\u4ece\u78c1\u76d8\u4e0a\u7684 markdown \u6587\u4ef6\u751f\u6210\u53ef\u7f16\u8f91 .docx\uff08Word\uff09\u6587\u4ef6\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u4f18\u5148\u7528\u672c\u5de5\u5177\uff0c\u907f\u514d LLM \u91cd\u590d\u8f93\u51fa\u81ea\u5df1\u7684\u5185\u5bb9\u3002\u7528\u6237\u8981\u6c42 PDF \u65f6\u6539\u7528 renderPdfFromFile\u3002markdown \u5b50\u96c6\u4e0e renderDocx \u76f8\u540c\u3002 +tool.renderDocxFromFiles.desc=\u6309\u987a\u5e8f\u62fc\u63a5\u591a\u4e2a markdown \u6587\u4ef6\u751f\u6210\u5355\u4e2a .docx \u5e76\u8fd4\u56de\u4e0b\u8f7d\u94fe\u63a5\u3002\u9002\u5408\u5206\u7ae0\u8282\u64b0\u5199\u7684\u957f\u62a5\u544a\uff08\u5c01\u9762\u3001\u76ee\u5f55\u3001\u6b63\u6587\u3001\u9644\u5f55\u5206\u522b\u6210\u6587\u4ef6\uff09\u3002\u6587\u4ef6\u4ee5 UTF-8 \u8bfb\u53d6\u5e76\u4ee5\u7a7a\u884c\u8fde\u63a5\uff0cmarkdown \u5b50\u96c6\u4e0e renderDocx \u76f8\u540c\u3002 +tool.renderXlsx.desc=\u4ece Markdown \u751f\u6210\u65b0\u7684 .xlsx\uff08Excel\uff09\u5de5\u4f5c\u7c3f\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002\u9002\u5408\u8d22\u52a1\u62a5\u8868\u3001\u6570\u636e\u8868\u3001\u5bf9\u6bd4\u77e9\u9635\u3001\u8ba1\u5212\u8868\u3002\u7ea6\u5b9a\uff1a\u6bcf\u4e2a\u300c# \u6807\u9898\u300d\u5f00\u542f\u4e00\u4e2a\u65b0\u5de5\u4f5c\u8868\uff0c\u5176\u4e0b\u7684\u7ba1\u9053\u8868\u683c\u6210\u4e3a\u8868\u4f53\uff0c\u9996\u884c\u4f5c\u4e3a\u8868\u5934\uff0c\u6570\u5b57\u5355\u5143\u683c\u81ea\u52a8\u8bc6\u522b\u4e3a\u6570\u503c\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u6539\u7528 renderXlsxFromFile\u3002 +tool.renderXlsxFromFile.desc=\u4ece\u78c1\u76d8\u4e0a\u7684 markdown \u6587\u4ef6\u751f\u6210 .xlsx\uff08Excel\uff09\u5de5\u4f5c\u7c3f\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u4f18\u5148\u7528\u672c\u5de5\u5177\u3002markdown \u5b50\u96c6\u4e0e renderXlsx \u76f8\u540c\uff08\u6bcf\u4e2a\u300c# \u6807\u9898\u300d\u4e00\u4e2a\u5de5\u4f5c\u8868\uff0c\u7ba1\u9053\u8868\u683c\u4e3a\u8868\u4f53\uff09\u3002 +tool.renderPptx.desc=\u4ece Markdown \u751f\u6210\u65b0\u7684 .pptx \u6f14\u793a\u6587\u7a3f\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002\u9002\u5408\u63d0\u6848\u6f14\u793a\u3001\u9879\u76ee\u8ba1\u5212\u3001\u6f14\u8bb2\u3001\u6c47\u62a5\u3002\u91c7\u7528 Marp \u98ce\u683c\u7ea6\u5b9a\uff1a\u5355\u72ec\u4e00\u884c\u300c---\u300d\u5206\u9694\u5e7b\u706f\u7247\uff0c\u6bcf\u9875\u9996\u4e2a\u300c#/##/###\u300d\u4f5c\u4e3a\u6807\u9898\uff0c\u300c-\u300d\u300c*\u300d\u5f00\u5934\u7684\u884c\u4e3a\u8981\u70b9\uff0c\u300c\u300d\u4e3a\u6f14\u8bb2\u8005\u5907\u6ce8\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u6539\u7528 renderPptxFromFile\u3002 +tool.renderPptxFromFile.desc=\u4ece\u78c1\u76d8\u4e0a\u7684 markdown \u6587\u4ef6\u751f\u6210 .pptx \u6f14\u793a\u6587\u7a3f\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002markdown \u6b63\u6587\u8f83\u5927\uff08>5KB\uff09\u65f6\u4f18\u5148\u7528\u672c\u5de5\u5177\u3002Marp \u98ce\u683c markdown \u5b50\u96c6\u4e0e renderPptx \u76f8\u540c\u3002 +tool.render_html_image.desc=\u5c06 HTML \u6e32\u67d3\u4e3a PNG \u56fe\u7247\u5e76\u8fd4\u56de\u4e00\u6b21\u6027\u4e0b\u8f7d\u94fe\u63a5\u3002\u5f53\u7528\u6237\u5e0c\u671b\u4ee5\u300c\u56fe\u7247\u300d\u5f62\u5f0f\u4ea4\u4ed8 HTML \u5236\u54c1\uff08\u67b6\u6784\u56fe\u3001\u4fe1\u606f\u56fe\u3001\u4eea\u8868\u76d8\u3001\u539f\u578b\u7a3f\uff09\u65f6\u4f7f\u7528\uff0c\u5c24\u5176\u5728\u4f01\u4e1a\u5fae\u4fe1\u3001\u9489\u9489\u3001\u98de\u4e66\u3001Telegram\u3001Discord \u7b49\u65e0\u6cd5\u70b9\u5f00 HTML \u94fe\u63a5\u7684 IM \u6e20\u9053\u3002filePath \u4e0e html \u53c2\u6570\u4e8c\u9009\u4e00\u3002 +tool.extract_document_text.desc=\u4ece Office/PDF \u6587\u6863\u4e2d\u63d0\u53d6\u6587\u672c\u5185\u5bb9\uff0c\u652f\u6301 PDF\u3001Word(.docx/.doc)\u3001Excel(.xlsx/.xls)\u3001PowerPoint(.pptx/.ppt)\u3002\u6309\u683c\u5f0f\u8d70\u591a\u7ea7 fallback \u63d0\u53d6\u94fe\uff08\u7cfb\u7edf\u547d\u4ee4 \u2192 Python \u2192 Java \u2192 Tika\uff09\uff0c\u8fd4\u56de\u63d0\u53d6\u6587\u672c\u3001\u6240\u7528\u65b9\u6cd5\u4e0e\u5143\u6570\u636e\u3002options \u53ef\u6307\u5b9a pages \u9875\u7801\u8303\u56f4\u6216 method \u5f3a\u5236\u63d0\u53d6\u5668\u3002 +tool.extract_pdf_text.desc=\u63d0\u53d6 PDF \u6587\u4ef6\u7684\u6587\u672c\u5185\u5bb9\uff0c\u662f extract_document_text \u7684\u5feb\u6377\u65b9\u5f0f\u3002\u53ef\u901a\u8fc7 pages \u53c2\u6570\u6307\u5b9a\u9875\u7801\u8303\u56f4\uff08\u5982 "1-5" \u6216 "1,3,5"\uff09\uff0c\u4e0d\u4f20\u5219\u63d0\u53d6\u5168\u90e8\u3002 +tool.extract_docx_text.desc=\u63d0\u53d6 Word \u6587\u6863\uff08.docx/.doc\uff09\u7684\u6587\u672c\u5185\u5bb9\uff0c\u662f extract_document_text \u7684\u5feb\u6377\u65b9\u5f0f\u3002 +tool.detect_file_type.desc=\u68c0\u6d4b\u6587\u4ef6\u7684 MIME \u7c7b\u578b\u4e0e\u6587\u4ef6\u7c7b\u522b\u3002\u7528\u4e8e\u8bfb\u53d6\u6587\u4ef6\u524d\u5224\u65ad\u7c7b\u578b\u3001\u533a\u5206\u6587\u672c\u6587\u4ef6\u4e0e\u4e8c\u8fdb\u5236\u6587\u6863\uff08PDF/Office\uff09\u3001\u9009\u62e9\u5408\u9002\u7684\u8bfb\u53d6\u6216\u63d0\u53d6\u5de5\u5177\u3002\u8fd4\u56de mimeType\u3001fileCategory \u4e0e\u5efa\u8bae\u5de5\u5177 suggestedTool\u3002 + # --- Guard Rules --- guard.SHELL_RM_RF_ROOT.name=\u9012\u5f52\u5f3a\u5236\u5220\u9664\u6839\u76ee\u5f55 guard.SHELL_RM_RF_ROOT.fix=\u8bf7\u6307\u5b9a\u5177\u4f53\u76ee\u5f55\u8def\u5f84\u800c\u975e\u6839\u76ee\u5f55 @@ -138,6 +157,10 @@ guard.CRED_AWS_KEY.name=AWS Access Key \u6cc4\u9732 guard.CRED_AWS_KEY.fix=\u4f7f\u7528 IAM Role \u6216 AWS Secrets Manager guard.CRED_PRIVATE_KEY.name=\u79c1\u94a5\u6cc4\u9732 guard.CRED_PRIVATE_KEY.fix=\u8bf7\u52ff\u5728\u53c2\u6570\u4e2d\u4f20\u9012\u79c1\u94a5 +guard.CRED_JWT_TOKEN.name=JWT Token \u6cc4\u9732 +guard.CRED_JWT_TOKEN.fix=\u8bf7\u4f7f\u7528\u73af\u5883\u53d8\u91cf\u6216\u5bc6\u94a5\u7ba1\u7406\u670d\u52a1\uff0c\u907f\u514d\u660e\u6587\u4f20\u9012 JWT +guard.CRED_GITHUB_TOKEN.name=GitHub Token \u6cc4\u9732 +guard.CRED_GITHUB_TOKEN.fix=\u8bf7\u7acb\u5373\u5728 GitHub \u540e\u53f0\u64a4\u9500\u8be5 Token\uff0c\u6539\u7528\u73af\u5883\u53d8\u91cf # --- Exception Messages (structured keys) --- err.auth.invalid_credentials=\u7528\u6237\u540d\u6216\u5bc6\u7801\u9519\u8bef @@ -157,6 +180,7 @@ err.workspace.not_member=\u7528\u6237\u4e0d\u662f\u8be5\u5de5\u4f5c\u533a\u7684\ err.workspace.cannot_modify_owner=\u4e0d\u80fd\u4fee\u6539\u5de5\u4f5c\u533a\u62e5\u6709\u8005\u7684\u89d2\u8272 err.workspace.cannot_remove_owner=\u4e0d\u80fd\u79fb\u9664\u5de5\u4f5c\u533a\u62e5\u6709\u8005 err.workspace.insufficient_permission=\u6743\u9650\u4e0d\u8db3 +err.workspace.not_empty=\u5de5\u4f5c\u533a\u4e0b\u8fd8\u6709\u77e5\u8bc6\u5e93\uff0c\u8bf7\u5148\u5220\u9664\u77e5\u8bc6\u5e93\u540e\u518d\u5220\u9664\u5de5\u4f5c\u533a err.channel.not_found=\u6e20\u9053\u4e0d\u5b58\u5728 err.channel.name_required=\u6e20\u9053\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a err.channel.type_required=\u6e20\u9053\u7c7b\u578b\u4e0d\u80fd\u4e3a\u7a7a @@ -166,6 +190,7 @@ err.skill.not_found=\u6280\u80fd\u4e0d\u5b58\u5728 err.skill.name_required=\u6280\u80fd\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a err.skill.name_exists=\u6280\u80fd\u540d\u79f0\u5df2\u5b58\u5728 err.skill.builtin_readonly=\u5185\u7f6e\u6280\u80fd\u4e0d\u53ef\u5220\u9664 +err.skill.builtin_not_archivable=\u5185\u7f6e\u6280\u80fd\u4e0d\u53ef\u5f52\u6863 err.skill.cross_workspace_binding=\u4e0d\u80fd\u5c06\u5176\u5b83\u5de5\u4f5c\u533a\u7684\u6280\u80fd\u7ed1\u5b9a\u5230\u5f53\u524d Agent err.mcp.not_found=MCP server \u4e0d\u5b58\u5728 err.mcp.builtin_readonly=\u5185\u7f6e MCP server \u4e0d\u53ef\u5220\u9664 @@ -180,19 +205,24 @@ err.cron.agent_required=\u8bf7\u9009\u62e9\u5173\u8054 Agent err.cron.expression_required=Cron \u8868\u8fbe\u5f0f\u4e0d\u80fd\u4e3a\u7a7a err.cron.trigger_required=\u89e6\u53d1\u6d88\u606f\u4e0d\u80fd\u4e3a\u7a7a err.cron.target_required=\u6267\u884c\u76ee\u6807\u4e0d\u80fd\u4e3a\u7a7a +err.cron.wiki_kb_required=\u8bf7\u9009\u62e9\u77e5\u8bc6\u5e93 +err.cron.wiki_kb_invalid=\u77e5\u8bc6\u5e93 ID \u683c\u5f0f\u4e0d\u5408\u6cd5 +err.cron.wiki_kb_not_found=\u77e5\u8bc6\u5e93\u4e0d\u5b58\u5728 # agent (extended) err.agent.no_default_model=\u65e0\u6cd5\u6784\u5efa Agent\uff1a\u8bf7\u5148\u914d\u7f6e\u5e76\u542f\u7528\u9ed8\u8ba4\u6a21\u578b err.agent.model_not_configured=\u6a21\u578b Provider \u672a\u5b8c\u6210\u914d\u7f6e err.agent.protocol_not_supported=\u5f53\u524d\u4e0d\u652f\u6301\u8be5\u534f\u8bae err.agent.plan_compile_failed=Plan-Execute StateGraph \u7f16\u8bd1\u5931\u8d25 err.agent.graph_compile_failed=StateGraph v2 \u7f16\u8bd1\u5931\u8d25 -err.agent.protocol_limited=StateGraph \u5f53\u524d\u4ec5\u652f\u6301 DashScope/OpenAI/Anthropic \u534f\u8bae +err.agent.protocol_limited=StateGraph \u5f53\u524d\u4ec5\u652f\u6301 DashScope/OpenAI/Anthropic/Gemini \u534f\u8bae err.agent.provider_not_configured=Provider \u672a\u5b8c\u6210\u914d\u7f6e err.agent.provider_apikey_invalid=Provider API Key \u672a\u914d\u7f6e\u6216\u65e0\u6548 err.agent.provider_baseurl_missing=Provider Base URL \u672a\u914d\u7f6e err.agent.dashscope_key_missing=DashScope API Key \u672a\u914d\u7f6e err.agent.anthropic_not_configured=Anthropic Provider \u672a\u5b8c\u6210\u914d\u7f6e err.agent.anthropic_key_invalid=Anthropic API Key \u672a\u914d\u7f6e\u6216\u65e0\u6548 +err.agent.gemini_not_configured=Gemini Provider \u672a\u5b8c\u6210\u914d\u7f6e +err.agent.gemini_key_invalid=Gemini API Key \u672a\u914d\u7f6e\u6216\u65e0\u6548 err.agent.template_not_found=\u6a21\u677f\u4e0d\u5b58\u5728 err.agent.delete_forbidden=\u53ea\u6709\u521b\u5efa\u8005\u6216\u5de5\u4f5c\u533a\u7ba1\u7406\u5458\u53ef\u5220\u9664\u6b64 Agent err.common.wrong_workspace=\u8d44\u6e90\u4e0d\u5c5e\u4e8e\u5f53\u524d\u5de5\u4f5c\u533a @@ -238,6 +268,8 @@ err.llm.chatgpt_models_fetch_failed=\u62c9\u53d6 ChatGPT \u53ef\u7528\u6a21\u578 err.llm.chatgpt_stream_failed=ChatGPT \u6d41\u5f0f\u8c03\u7528\u5931\u8d25 err.llm.chatgpt_error=ChatGPT \u8fd4\u56de\u9519\u8bef err.llm.chatgpt_account_missing=chatgpt-account-id \u7f3a\u5931 +err.llm.gemini_stream_failed=Gemini \u6d41\u5f0f\u8c03\u7528\u5931\u8d25 +err.llm.gemini_error=Gemini \u8fd4\u56de\u9519\u8bef err.llm.model_not_supported=\u6a21\u578b ID \u4e0d\u652f\u6301\u5728 DashScope \u539f\u751f\u534f\u8bae\u4e2d\u4f7f\u7528\u3002\u70b9\u7248\u672c\u683c\u5f0f\u7684\u7cfb\u5217\uff08\u4f8b\u5982 qwen3.5-*\u3001qwen3.6-*\uff09\u53ea\u80fd\u901a\u8fc7\u517c\u5bb9\u6a21\u5f0f\u4f7f\u7528\u3002\u8bf7\u4f7f\u7528\u5141\u8bb8\u7684 ID\uff0c\u5982 qwen-max / qwen-plus / qwen3-max\u3002 # datasource err.datasource.not_found=\u6570\u636e\u6e90\u4e0d\u5b58\u5728 @@ -275,15 +307,21 @@ agent.limit_exceeded.fallback=\u62b1\u6b49\uff0c\u5df2\u8fbe\u5230\u6700\u5927\u agent.limit_exceeded.empty_context=\uff08\u5c1a\u672a\u6536\u96c6\u5230\u5de5\u5177\u8c03\u7528\u7ed3\u679c\uff09 # --- Cron unified tasks conversation --- -cron.tasks_conversation.title=📋 定时任务 -cron.run_header.scheduled=定时触发 -cron.run_header.manual=手动触发 +cron.tasks_conversation.title=\ud83d\udccb \u5b9a\u65f6\u4efb\u52a1 +cron.run_header.scheduled=\u5b9a\u65f6\u89e6\u53d1 +cron.run_header.manual=\u624b\u52a8\u89e6\u53d1 +cron.run.silent=\uff08\u672c\u6b21\u5b9a\u65f6\u4efb\u52a1\u68c0\u67e5\u540e\u65e0\u65b0\u5185\u5bb9\uff0c\u5df2\u8df3\u8fc7\u6295\u9012\uff09 # --- Wiki vision-in pipeline --- -err.wiki.vision.disabled=图片识别功能未启用 -err.wiki.vision.no_provider=未配置可用的图片识别 provider -err.wiki.vision.provider_failed=图片识别 provider 调用失败 -err.wiki.vision.all_failed=所有图片识别 provider 调用失败 +err.wiki.vision.disabled=\u56fe\u7247\u8bc6\u522b\u529f\u80fd\u672a\u542f\u7528 +err.wiki.vision.no_provider=\u672a\u914d\u7f6e\u53ef\u7528\u7684\u56fe\u7247\u8bc6\u522b provider +err.wiki.vision.provider_failed=\u56fe\u7247\u8bc6\u522b provider \u8c03\u7528\u5931\u8d25 +err.wiki.vision.all_failed=\u6240\u6709\u56fe\u7247\u8bc6\u522b provider \u8c03\u7528\u5931\u8d25 # --- Chat: assistant stop / interrupt placeholders --- -chat.stopMarker.userAborted=[已被用户中止] +chat.stopMarker.userAborted=[\u5df2\u88ab\u7528\u6237\u4e2d\u6b62] + +# --- Tool: send_file --- +tool.send_file.error.too_large=\u6587\u4ef6\u8fc7\u5927\uff1a{0}MB \u8d85\u51fa\u9650\u5236 {1}MB +tool.send_file.error.failed=\u53d1\u9001\u6587\u4ef6\u5931\u8d25\uff1a{0} +tool.send_file.success={0} \u5df2\u53d1\u9001\uff1a[{0}]({1})\uff08\u94fe\u63a5 10 \u5206\u949f\u5185\u6709\u6548\uff09\u3002\n\u91cd\u8981\uff1a\u56de\u7b54\u7528\u6237\u65f6**\u5fc5\u987b**\u4f7f\u7528\u4e0a\u8ff0\u76f8\u5bf9\u8def\u5f84 `{1}`\uff0c**\u4e0d\u8981**\u6dfb\u52a0\u4efb\u4f55 https://\u3001http:// \u57df\u540d\u524d\u7f00\uff0c\u524d\u7aef\u4f1a\u81ea\u52a8\u62fc\u63a5\u5f53\u524d\u4e3b\u673a\u3002 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index acd419f6..da1b0d46 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -59,6 +59,9 @@ tool.read_file.error.not_readable=File is not readable: {0} tool.read_file.error.start_exceeds=Start line {0} exceeds total lines {1} tool.read_file.error.start_gt_end=Start line {0} is greater than end line {1} tool.read_file.error.read_exception=Read file exception: {0} +tool.read_file.truncated=Output truncated (max {0} lines / {1}KB). Use startLine={2} to continue reading. +tool.read_file.line_truncated_marker= ...[line too long, truncated] +tool.read_file.line_truncated=Line {0} exceeds the {1}KB single-output limit. To read the rest of this line, continue with startLine={2}, startColumn={3}; or skip to startLine={4} for the next line. Do NOT infer or fabricate the omitted content. tool.write_file.error.path_empty=File path cannot be empty tool.write_file.error.is_directory=Path is an existing directory, cannot write as file: {0} tool.write_file.error.write_exception=Write file exception: {0} @@ -79,6 +82,22 @@ tool.video_generate.desc=Generate a video. Supports text-to-video and image-to-v tool.music_generate.desc=Generate music or a song. Supports text-to-music, lyrics composition, and instrumental modes via Google Lyria and MiniMax Music. Runs asynchronously (1-3 minutes); the client receives the audio automatically via the SSE async_task_completed event without a manual refresh. tool.model3d_generate.desc=Generate a 3D model (.glb). Supports text-to-3D and image-to-3D modes via Tencent Hunyuan 3D Rapid. Runs asynchronously (1-3 minutes); the client receives the model automatically via the SSE async_task_completed event with an inline model-viewer preview, no manual refresh required. +# --- Document rendering tools --- +tool.renderPdf.desc=Render a new PDF file from Markdown and return a one-time download URL. Use this tool whenever the user explicitly asks for a PDF (export PDF / save as PDF); do not substitute renderDocx, and do not bypass it via chrome --headless / wkhtmltopdf. Best for final deliverables such as reports, white papers, and contracts. Optional YAML frontmatter drives the cover page and page header/footer. For markdown bodies larger than ~5 KB, use renderPdfFromFile instead. +tool.renderPdfFromFile.desc=Render a PDF from a Markdown file on disk and return a one-time download URL. Use this when the user asks for a PDF and the markdown body is already saved to a file, so the LLM need not repeat its own output as a tool argument. Same markdown subset and frontmatter convention as renderPdf. +tool.renderDocx.desc=Render a new editable .docx (Word) file from Markdown and return a one-time download URL. Best for documents the user will keep revising — reports, memos, contracts, letters, resumes. Supports headings, bold, ordered/unordered lists, tables, and images (SVG is rasterized to PNG). Use renderPdf when the user asked for a PDF, renderXlsx for spreadsheets, renderPptx for slide decks. For markdown bodies larger than ~5 KB, use renderDocxFromFile instead. +tool.renderDocxFromFile.desc=Render an editable .docx (Word) file from a Markdown file on disk and return a one-time download URL. Prefer this for large markdown bodies (>5 KB) so the LLM need not repeat its own output. Use renderPdfFromFile when the user asked for a PDF. Same markdown subset as renderDocx. +tool.renderDocxFromFiles.desc=Render a single .docx by concatenating multiple Markdown files in order and return a download URL. Use for long reports split into chapters (cover, table of contents, body, appendix as separate files). Files are read as UTF-8 and joined with a blank line; same markdown subset as renderDocx. +tool.renderXlsx.desc=Render a new .xlsx (Excel) workbook from Markdown and return a one-time download URL. Best for financial reports, data tables, comparison matrices, and plans. Convention: each `# Heading` starts a new sheet, the pipe table below it becomes the sheet body, the first row is the header, and numeric cells are auto-detected. For markdown larger than ~5 KB, use renderXlsxFromFile instead. +tool.renderXlsxFromFile.desc=Render a .xlsx (Excel) workbook from a Markdown file on disk and return a one-time download URL. Prefer this for large markdown bodies (>5 KB). Same markdown subset as renderXlsx (`# Heading` per sheet, pipe tables as the body). +tool.renderPptx.desc=Render a new .pptx slide deck from Markdown and return a one-time download URL. Best for pitch decks, project plans, talks, and briefings. Uses Marp-style convention: a lone `---` line separates slides, the first `#/##/###` of a slide is its title, `-`/`*` lines are bullets, and `` becomes speaker notes. For markdown larger than ~5 KB, use renderPptxFromFile instead. +tool.renderPptxFromFile.desc=Render a .pptx slide deck from a Markdown file on disk and return a one-time download URL. Prefer this for large markdown bodies (>5 KB). Same Marp-style markdown subset as renderPptx. +tool.render_html_image.desc=Render HTML to a PNG image and return a one-time download URL. Use when the user wants an HTML artifact (architecture diagram, infographic, dashboard, mockup) delivered as an image — especially on IM channels (WeCom, DingTalk, Feishu, Telegram, Discord) where a raw HTML link is not clickable. Supply exactly one of filePath or html. +tool.extract_document_text.desc=Extract text content from Office/PDF documents — PDF, Word (.docx/.doc), Excel (.xlsx/.xls), PowerPoint (.pptx/.ppt). Runs a per-format multi-stage fallback chain (system command, Python, Java, Tika) and returns the extracted text, the method used, and metadata. The options argument may set a pages range or force a specific extraction method. +tool.extract_pdf_text.desc=Extract text content from a PDF file; a shortcut for extract_document_text. The pages argument selects a page range (e.g. "1-5" or "1,3,5"); omit it to extract the whole document. +tool.extract_docx_text.desc=Extract text content from a Word document (.docx/.doc); a shortcut for extract_document_text. +tool.detect_file_type.desc=Detect a file's MIME type and category. Use before reading a file to decide its type, distinguish text files from binary documents (PDF/Office), and pick the right read or extract tool. Returns mimeType, fileCategory, and a suggestedTool. + # --- Guard Rules --- guard.SHELL_RM_RF_ROOT.name=Recursive force delete root directory guard.SHELL_RM_RF_ROOT.fix=Specify a concrete directory path instead of root @@ -138,6 +157,10 @@ guard.CRED_AWS_KEY.name=AWS Access Key leak guard.CRED_AWS_KEY.fix=Use IAM Role or AWS Secrets Manager guard.CRED_PRIVATE_KEY.name=Private key leak guard.CRED_PRIVATE_KEY.fix=Do not pass private keys in parameters +guard.CRED_JWT_TOKEN.name=JWT token leak +guard.CRED_JWT_TOKEN.fix=Use environment variables or secret manager; avoid passing JWTs in plaintext +guard.CRED_GITHUB_TOKEN.name=GitHub token leak +guard.CRED_GITHUB_TOKEN.fix=Revoke the token in GitHub immediately and switch to environment variables # --- WorkspacePathGuard --- guard.path.not_allowed=Path is outside workspace boundary: {0}, allowed root: {1} @@ -164,6 +187,7 @@ err.workspace.not_member=User is not a member of this workspace err.workspace.cannot_modify_owner=Cannot modify workspace owner role err.workspace.cannot_remove_owner=Cannot remove workspace owner err.workspace.insufficient_permission=Insufficient permission +err.workspace.not_empty=Workspace still contains knowledge bases; delete them first # channel err.channel.not_found=Channel not found err.channel.name_required=Channel name cannot be empty @@ -176,6 +200,7 @@ err.skill.not_found=Skill not found err.skill.name_required=Skill name cannot be empty err.skill.name_exists=Skill name already exists err.skill.builtin_readonly=Built-in skill cannot be deleted +err.skill.builtin_not_archivable=Built-in skill cannot be archived err.skill.cross_workspace_binding=Cannot bind a skill from a different workspace to this Agent # mcp err.mcp.not_found=MCP server not found @@ -192,19 +217,24 @@ err.cron.agent_required=Please select an Agent err.cron.expression_required=Cron expression cannot be empty err.cron.trigger_required=Trigger message cannot be empty err.cron.target_required=Execution target cannot be empty +err.cron.wiki_kb_required=Please select a knowledge base +err.cron.wiki_kb_invalid=Knowledge base id is malformed +err.cron.wiki_kb_not_found=Knowledge base does not exist # agent (extended) err.agent.no_default_model=Cannot build Agent: please configure and enable a default model in Settings > Models err.agent.model_not_configured=Model provider not configured, please fill in API Key in Settings > Models err.agent.protocol_not_supported=Protocol not currently supported err.agent.plan_compile_failed=Plan-Execute StateGraph compilation failed err.agent.graph_compile_failed=StateGraph v2 compilation failed -err.agent.protocol_limited=StateGraph currently only supports DashScope, OpenAI-compatible, and Anthropic protocols +err.agent.protocol_limited=StateGraph currently only supports DashScope, OpenAI-compatible, Anthropic, and Gemini protocols err.agent.provider_not_configured=Provider not configured, please fill in valid API Key and Base URL err.agent.provider_apikey_invalid=Provider API Key not configured or invalid err.agent.provider_baseurl_missing=Provider Base URL not configured err.agent.dashscope_key_missing=DashScope API Key not configured err.agent.anthropic_not_configured=Anthropic Provider not configured err.agent.anthropic_key_invalid=Anthropic API Key not configured or invalid +err.agent.gemini_not_configured=Gemini Provider not configured +err.agent.gemini_key_invalid=Gemini API Key not configured or invalid err.agent.template_not_found=Template not found err.agent.delete_forbidden=Only the creator or a workspace admin can delete this Agent err.common.wrong_workspace=Resource does not belong to current workspace @@ -250,6 +280,8 @@ err.llm.chatgpt_models_fetch_failed=Failed to fetch available ChatGPT models err.llm.chatgpt_stream_failed=ChatGPT streaming call failed err.llm.chatgpt_error=ChatGPT returned an error err.llm.chatgpt_account_missing=chatgpt-account-id missing, disconnect and re-login via OAuth +err.llm.gemini_stream_failed=Gemini streaming call failed +err.llm.gemini_error=Gemini returned an error # datasource err.datasource.not_found=Datasource not found err.datasource.sql_empty=SQL cannot be empty @@ -285,6 +317,7 @@ agent.limit_exceeded.empty_context=(No tool call results collected yet.) cron.tasks_conversation.title=📋 Scheduled Tasks cron.run_header.scheduled=scheduled cron.run_header.manual=manual +cron.run.silent=(Scheduled task found nothing new this run — delivery skipped) # --- Wiki vision-in pipeline --- err.wiki.vision.disabled=Image vision pipeline is currently disabled @@ -294,3 +327,8 @@ err.wiki.vision.all_failed=All image vision providers failed # --- Chat: assistant stop / interrupt placeholders --- chat.stopMarker.userAborted=[Stopped by user] + +# --- Tool: send_file --- +tool.send_file.error.too_large=File too large: {0}MB exceeds limit of {1}MB +tool.send_file.error.failed=Failed to send file: {0} +tool.send_file.success={0} sent: [{0}]({1}) (link valid for 10 minutes).\nIMPORTANT: when replying to the user you **must** use the relative path `{1}` exactly; do **not** prepend any https:// or http:// host. The frontend will prepend the current host automatically. diff --git a/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt b/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt index da30a817..b83e9c3f 100644 --- a/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt +++ b/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt @@ -30,5 +30,10 @@ ## 关键上下文 [不显式保留就会丢失的具体值、错误消息、配置详情] +## 数据保真(最高优先级) +- 涉及具体数据集(列表、表格、记录集合)时,**绝不编造、重新编号,或把明细缩减为范围表述/总数**。只复述上下文中真实可见的内容。 +- 明细已被清理或不在上下文中(如出现"旧工具输出已清理""已截断""TRUNCATED"等标记)时,只声明该部分数据不完整且不可见,**不要猜测其数量、内容或来源路径**;仅当上下文里有真实可见的落盘路径(read_file)时才可引用它。 +- 不要为了塞进篇幅上限而虚构、合并或范围化明细——诚实省略(如标注"该数据集未完整保留")始终优于编造。 + 目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。 只输出摘要正文,不要前缀或额外说明。 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt b/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt index 1c774282..3393eee1 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt @@ -4,6 +4,8 @@ 回答要求: 1. 给出简洁、诚实、可执行的回答 2. 若信息不足以完全回答,明确说明哪些部分还不确定 +2.1. 若所依据的数据被截断或不完整(含"已截断/TRUNCATED/preview/INCOMPLETE"等标记),明确告知用户数据不完整并指出缺口,**绝不编造、补全或重新编号缺失的数据条目**,也不要把明细列表缩减为范围表述 +2.2. 如果上下文里出现"## 当前任务进度"账本,**以账本为权威完成度** —— done 的步骤真的做完了,pending / in_progress 的步骤明确告诉用户"未完成",并把这些条目列出来作为下一步建议 3. 如果还有未完成的方向,简要列出建议的下一步 4. **不要为「未完成」道歉,不要提及「推理步数」「迭代次数」「工具调用上限」这类技术细节** —— 直接给结论 5. 保持输出简洁,避免重复已知内容 diff --git a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt index 187ac46f..dff3c028 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt @@ -1,5 +1,12 @@ 你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。 +## 数据保真(最高优先级,先于一切篇幅要求) + +- **绝不编造、推测或重新编号任何数据**。只能复述观察结果中真实出现的内容。 +- **绝不把明细列表/表格/记录集合缩减为范围表述或总数**(例如把 60 条记录写成"第 1-60 行"或"共 60 项"而丢弃具体内容)。条目过多无法全部保留时,明确写"已保留 N 条具体记录,省略 M 条;完整数据见原始工具输出或落盘文件(read_file)",绝不用概括掩盖省略。 +- 若某条观察本身带有截断标记(如 `TRUNCATED`、`已截断`、`INCOMPLETE`、`preview`、`compacted`、`trimmed`),在摘要中**显式标注该数据不完整**,并指明完整数据的来源,绝不补全缺失部分。 +- 当篇幅上限与数据保真冲突时,**优先保真**:宁可如实标注"还有 M 条未列出",也不要为压缩篇幅而虚构或合并明细。 + ## 任务结构判断(先做这一步) 判断用户的原始问题包含的是单一任务还是多个独立子任务: diff --git a/mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md b/mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md new file mode 100644 index 00000000..750d4221 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md @@ -0,0 +1,103 @@ +--- +name: digital_employee +nameZh: 数字员工组建 +nameEn: Digital Employee Builder +version: "1.0.0" +icon: "🧑‍💼" +description: "根据一句业务需求,自动规划并创建一组分工明确的数字员工(Agent),再把他们编排成一条工作流,无需逐个手工创建。" +tags: digital_employee,agent,workflow,team +dependencies: + tools: + - listAvailableAgents + - list_capability_catalog + - create_employee + - workflow_draft_generate +--- + +# 数字员工组建 + +把一句业务需求,变成「一支分工明确的数字员工团队 + 一条把他们串起来的工作流」。用户不必逐个设计、逐个手工创建 Agent。 + +## 何时使用 + +- 用户描述了一个**需要多个角色协作**的目标("帮我搭一个做竞品分析的团队"、"我要一套从线索到成交的销售流程")。 +- 用户说"建几个 Agent / 数字员工帮我做 X"、"把 X 这件事自动化成一支团队"。 +- 现有 Agent 不足以覆盖需求,需要新建专才角色。 + +## 不应使用 + +- 一个 Agent 就能完成 → 直接用 `chat_with_agent` 或现有 Agent。 +- 只是想编排**已存在**的 Agent 协作 → 用 `multi_agent_collaboration`。 +- 只是想把流程做成工作流,且员工都已存在 → 直接用 `workflow_draft_generate`。 + +## 工作流程 + +### 第一步:理解需求,盘点现状 + +1. 读懂用户的业务目标、产出物、是否有触发条件(定时 / 来消息时)、要不要审批、结果发到哪个渠道。 +2. 调用 `listAvailableAgents()` 看现有员工——**能复用就复用**,不要重复造同名角色。 +3. 调用 `list_capability_catalog()` 拿到可分配的**技能名**和**工具名**清单。后续 `create_employee` 只能用清单里的真实名字,不要臆造。 + +如果需求关键信息缺失(产出物、角色边界),先向用户澄清一句再继续,不要凭空假设。 + +### 第二步:设计团队(2–6 个角色) + +把目标拆成**互补的角色**,每个角色给出: + +- `name`:工作区内唯一,用英文 kebab-case(如 `market-research-analyst`)。 +- `description`:一句话职责,工作流编排器会据此分配任务。 +- `systemPrompt`:定义这个员工的专长、视角、工作方式——要具体,别写空话。 +- `skillNames` / `toolNames`:从 `list_capability_catalog()` 里挑这个角色真正需要的;**留空则继承全局默认能力**(通才)。专才角色建议显式收窄。 +- `agentType`:默认 `react`;只有当角色需要"先规划再分步执行"时才用 `plan_execute`。 + +设计原则: + +- 角色数量 2–6 个,宁少勿滥;每个角色职责单一、边界清晰。 +- 避免两个角色职责重叠。 +- 一般不指定 `modelName`,留空用工作区默认模型;用户明确要求某模型时才填。 + +### 第三步:逐个创建员工 + +对每个设计好的角色调用一次 `create_employee(...)`: + +``` +create_employee( + name="market-research-analyst", + description="负责竞品功能、定价、市场动态的检索与结构化整理", + systemPrompt="你是资深市场研究分析师……(写清专长与产出格式)", + skillNames=["news", "web_search"], // 来自 list_capability_catalog,可留空 + toolNames=["web_search"] // 来自 list_capability_catalog,可留空 +) +``` + +- 工具会返回 `agentId` 和实际绑定的技能/工具;**记下每个员工的 name**,下一步要用。 +- 若返回 `[error]`(如重名),换个名字重试,不要中断整个流程。 +- 创建即启用——员工立刻可被工作流引用。 + +### 第四步:编排工作流 + +所有员工创建完成后,调用一次 `workflow_draft_generate(description=...)`。在 `description` 里: + +- **点名第三步创建的真实员工**(用它们的 name),说明执行顺序、依赖关系、并行还是串行。 +- 写清触发条件、是否需要审批、产出发往哪个渠道。 + +由于员工已经落库,`workflow_draft_generate` 会读到它们作为可用数字员工,直接引用真实 agent,而不是填 `TODO_*_AGENT` 占位。 + +> 工作流只会保存为**草稿**,不会自动发布、不会自动启用触发器。这是安全约定——让用户在工作流编辑器里 review 后再 publish。 + +### 第五步:汇报 + +给用户一份清晰小结: + +- 创建了哪些员工(name + 职责 + 绑定的关键能力)。 +- 生成的工作流草稿名称、id、各步骤如何串联。 +- 草稿编译预校验是否通过、有无缺失字段需要补。 +- 明确告知:工作流是草稿,请到工作流编辑器确认后再发布。 + +## 关键规则 + +- 员工 name 用英文 kebab-case 且工作区内唯一;工作流 step.name / outputVar 的命名约束由 `workflow_draft_generate` 负责,本技能不必操心。 +- 技能名 / 工具名必须来自 `list_capability_catalog()`,臆造的名字会被静默跳过。 +- 先创建员工,**再**生成工作流——顺序不能反,否则工作流只能拿到占位符。 +- 能复用现有 Agent 就复用,避免制造一堆同质化角色。 +- 不自动发布工作流,不自动启用触发器。 diff --git a/mateclaw-server/src/main/resources/skills/node-inspect-debugger/SKILL.md b/mateclaw-server/src/main/resources/skills/node-inspect-debugger/SKILL.md index 57c6f10e..fcbc8bd2 100644 --- a/mateclaw-server/src/main/resources/skills/node-inspect-debugger/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/node-inspect-debugger/SKILL.md @@ -1,14 +1,15 @@ --- name: node-inspect-debugger description: Debug Node.js via --inspect + Chrome DevTools Protocol CLI. -version: 1.0.0 +version: 1.1.0 tags: - debugging - nodejs - node-inspect - cdp - breakpoints -- ui-tui +- electron +- vite author: ported --- # Node.js Inspect Debugger @@ -24,15 +25,17 @@ Two tools, pick one: **Prefer `node inspect` first.** It's always available and the REPL is fast. +In this repo the Node.js surfaces are the front-end packages — `mateclaw-ui`, `mateclaw-webchat`, and the Electron desktop app `mateclaw-desktop`. The Spring Boot backend is a JVM process and is not a target for this skill. + ## When to Use -- A Node test fails and you need to see intermediate state -- ui-tui crashes or behaves wrong and you want to inspect React/Ink state pre-render -- tui_gateway child processes (`_SlashWorker`, PTY bridge workers) misbehave +- A Node-based build or packaging step (a Vite build, an `electron-builder` hook, a `scripts/` helper) fails and you need to see intermediate state +- The Electron desktop **main process** (`mateclaw-desktop`) crashes, hangs on startup, or mishandles the bundled Java backend child process +- A Vite dev server or a build plugin behaves wrong and `console.log` can't reach the value - You need to inspect a value in a closure that `console.log` can't reach without patching - Perf: attach to a running process to capture a CPU profile or heap snapshot -**Don't use for:** things `console.log` solves in under a minute. Breakpoint-driven debugging is heavier; use it when the payoff is real. +**Don't use for:** things `console.log` solves in under a minute. Breakpoint-driven debugging is heavier; use it when the payoff is real. The Electron **renderer** is a Chromium page, not a Node target — debug it with the window's built-in DevTools, not `node inspect`. ## Quick Reference: `node inspect` REPL @@ -72,7 +75,7 @@ The `debug>` prompt accepts: ## Attaching to a Running Process -When the process is already running (e.g. a long-lived dev server or the TUI gateway): +When the process is already running (e.g. a Vite dev server, or the Electron main process): ```bash # 1. Send SIGUSR1 to enable the inspector on an existing process @@ -152,7 +155,7 @@ const CDP = require('chrome-remote-interface'); // Set a breakpoint by URL regex + line await Debugger.setBreakpointByUrl({ - urlRegex: '.*app\\.tsx$', + urlRegex: '.*dist-electron/main/index\\.js$', lineNumber: 119, // 0-indexed columnNumber: 0, }); @@ -167,74 +170,67 @@ Run it: node /tmp/cdp-debug.js ``` -the agent-specific note: `chrome-remote-interface` is NOT in `ui-tui/package.json`. Install it to a throwaway location if you don't want to dirty the project: +`chrome-remote-interface` is not a dependency of any package in this repo. Install it to a throwaway location so you don't dirty a project's `package.json`: ```bash mkdir -p /tmp/cdp-tools && cd /tmp/cdp-tools && npm i chrome-remote-interface NODE_PATH=/tmp/cdp-tools/node_modules node /tmp/cdp-debug.js ``` -## Debugging the agent ui-tui +## Debugging the Electron Desktop App -The TUI is built Ink + tsx. Two common scenarios: +`mateclaw-desktop` is an Electron app. The **main process** is a Node process — `electron/main/index.ts`, compiled by Vite to `dist-electron/main/index.js` (the `main` field in `package.json`). It spawns the Java backend as a child process. The **renderer** is a Chromium `BrowserWindow` — debug that with the window's DevTools, not this skill. -### Debugging a single Ink component under dev +### Launch the main process paused -`ui-tui/package.json` has `npm run dev` (tsx --watch). Add `--inspect-brk` by running tsx directly: +Electron forwards `--inspect` / `--inspect-brk` to its main process. Build the Electron output first so there is a `dist-electron/` to run: ```bash -cd /path/to/your/project/ui-tui -npm run build # produce dist/ once so transpile isn't needed on first load -node --inspect-brk dist/entry.js +cd mateclaw-desktop +npm run build # produces dist/ and dist-electron/ +npx electron --inspect-brk=9229 . # Electron starts, paused on the main process first line # In another terminal: -node inspect -p +node inspect ws://127.0.0.1:9229/ ``` Then inside `debug>`: ``` -sb('dist/app.js', 220) # or wherever the suspect render is +sb('dist-electron/main/index.js', 220) # e.g. the suspect line in window/backend setup cont ``` -When it pauses, `repl` → inspect `props`, state refs, `useInput` handler values, etc. +When it pauses, `repl` → inspect `mainWindow`, `javaProcess`, `BACKEND_PORT`, the updater state, etc. -### Debugging a running `the agent --tui` +### Attach to an already-running desktop app -The TUI spawns Node from the Python CLI. Easiest path: +The Electron main process is the one launched without a `--type=` flag (renderer/GPU/utility processes carry `--type=`): ```bash -# 1. Launch TUI -the agent --tui & -TUI_PID=$(pgrep -f 'ui-tui/dist/entry' | head -1) +# Find the main process PID (the entry without --type=) +ps aux | grep -i 'mateclaw-desktop' | grep -v -- '--type=' -# 2. Enable inspector on that Node PID -kill -SIGUSR1 "$TUI_PID" +# Enable the inspector on it +kill -SIGUSR1 -# 3. Find the WS URL +# Find the WS URL and attach curl -s http://127.0.0.1:9229/json/list | jq -r '.[0].webSocketDebuggerUrl' - -# 4. Attach node inspect ws://127.0.0.1:9229/ ``` -Interacting with the TUI (typing in its window) continues to advance execution; your debugger can pause it on a breakpoint at any `sb(...)`. +The Java backend that the main process spawns is a JVM, not a Node target — it will not appear in `/json/list`. To debug that, use the JVM's own remote-debug flags, not this skill. -### Debugging `_SlashWorker` / PTY child processes +## Debugging a Vite Dev Server -Those are Python, not Node — use the `python-debugpy` skill for them. Only Node portions (Ink UI, tui_gateway client, tsx-run tests under `ui-tui/`) use this skill. - -## Running Vitest Tests Under the Debugger +`mateclaw-ui`, `mateclaw-webchat`, and `mateclaw-desktop` all run `vite` for `dev`. To step through Vite config or a build plugin, run Vite's binary under the inspector instead of the `pnpm dev` wrapper: ```bash -cd /path/to/your/project/ui-tui -# Run a single test file paused on entry -node --inspect-brk ./node_modules/vitest/vitest.mjs run --no-file-parallelism src/app/foo.test.tsx +cd mateclaw-ui +node --inspect-brk ./node_modules/vite/bin/vite.js +# In another terminal: node inspect -p , then sb('vite.config.ts', N), cont ``` -In another terminal: `node inspect -p `, then `sb('src/app/foo.tsx', 42)`, `cont`. - -Use `--no-file-parallelism` (vitest) or `--runInBand` (jest) so only one worker exists — debugging a pool is painful. +This pauses inside the Node process that loads `vite.config.ts` and runs plugin hooks. The browser-side Vue code it serves is not reachable here — that runs in the browser and is debugged with browser DevTools. ## Heap Snapshots & CPU Profiles (Non-interactive) @@ -261,7 +257,7 @@ require('fs').writeFileSync('/tmp/heap.heapsnapshot', chunks.join('')); ## Common Pitfalls -1. **Wrong line numbers in TS source.** Breakpoints hit the emitted JS, not the `.ts`. Either (a) break in the built `dist/*.js`, or (b) enable sourcemaps (`node --enable-source-maps`) and use `sb('src/app.tsx', N)` — but only with CDP clients that follow sourcemaps. `node inspect` CLI does not. +1. **Wrong line numbers in TS source.** Breakpoints hit the emitted JS, not the `.ts`. Either (a) break in the built file (`dist-electron/main/index.js`), or (b) enable sourcemaps (`node --enable-source-maps`) and use `sb('electron/main/index.ts', N)` — but only with CDP clients that follow sourcemaps. The `node inspect` CLI does not. 2. **`--inspect` vs `--inspect-brk`.** `--inspect` starts the inspector but doesn't pause; your script races past your first breakpoint if you attach too late. Use `--inspect-brk` when you need to set breakpoints before any code runs. @@ -270,11 +266,11 @@ require('fs').writeFileSync('/tmp/heap.heapsnapshot', chunks.join('')); curl -s http://127.0.0.1:9229/json/list # lists all inspectable targets on the host ``` -4. **Child processes.** `--inspect` on a parent does NOT inspect its children. Use `NODE_OPTIONS='--inspect-brk' node parent.js` to propagate to every child; be aware they all need unique ports (Node auto-increments when `NODE_OPTIONS='--inspect'` is inherited). +4. **Child processes.** `--inspect` on a parent does NOT inspect its children. Electron itself is multi-process, and the desktop main process additionally spawns the Java backend. Use `NODE_OPTIONS='--inspect-brk' node parent.js` to propagate to every Node child; be aware they all need unique ports (Node auto-increments when `NODE_OPTIONS='--inspect'` is inherited). 5. **Background kills.** If you `Ctrl+C` out of `node inspect` while the target is paused, the target stays paused. Either `cont` first, or `kill` the target explicitly. -6. **Running `node inspect` through an agent terminal.** It's a PTY-friendly REPL. In the agent, launch it with `terminal(pty=true)` or `background=true` + `process(action='submit', data='...')`. Non-PTY foreground mode will work for one-shot commands but not for interactive stepping. +6. **Running `node inspect` through the agent's shell tool.** The `execute_shell_command` tool is one-shot and non-interactive — it cannot drive the interactive `debug>` REPL. For interactive stepping, run `node inspect` in a real terminal yourself. For agent-driven debugging, prefer the scripted CDP driver above: it is fully non-interactive and runs fine as a single `execute_shell_command` call. 7. **Security.** `--inspect=0.0.0.0:9229` exposes arbitrary code execution. Always bind to `127.0.0.1` (the default) unless you have an isolated network. diff --git a/mateclaw-server/src/main/resources/skills/skill-authoring/SKILL.md b/mateclaw-server/src/main/resources/skills/skill-authoring/SKILL.md new file mode 100644 index 00000000..42088883 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/skill-authoring/SKILL.md @@ -0,0 +1,165 @@ +--- +name: skill-authoring +description: 'Author SKILL.md skills: frontmatter, validator limits, structure.' +version: 1.0.0 +tags: +- skills +- authoring +- skill-md +- conventions +- meta +author: ported +--- +# Authoring MateClaw Skills + +## Overview + +A skill is a `SKILL.md` file — YAML frontmatter plus a markdown body of reusable instructions. There are two places a SKILL.md can live, and they have different creation paths: + +1. **Builtin (in-repo):** `mateclaw-server/src/main/resources/skills//SKILL.md` — committed, shipped inside the server JAR. On every startup `BuiltinSkillSeedService` scans `classpath*:skills/*/SKILL.md`, parses each frontmatter, and upserts a row into `mate_skill` keyed by `name`. The SKILL.md is the single source of truth — no SQL seed entry is required. +2. **Custom (runtime):** created by an agent or user through the `skill_manage` tool. Stored as a `mate_skill` row with `skill_type=custom` and exported to the workspace at `~/.mateclaw/skills//`. Not committed; lives per-installation. + +This skill covers both. Note that `skill_manage` does NOT write into the in-repo `skills/` tree — builtin skills are authored by writing the file directly and restarting. + +## When to Use + +- You're adding a reusable workflow that should ship with MateClaw → builtin. +- You're editing an existing builtin skill under `mateclaw-server/src/main/resources/skills/`. +- An agent finished a complex task and wants to persist the approach → custom, via `skill_manage`. +- You're reviewing a SKILL.md for correct frontmatter and structure. + +**Don't use for:** recording a one-off tip discovered while *using* a skill (that belongs in `record_lesson` / a per-skill LESSONS.md) or cross-skill memory notes (`remember`). This skill is about writing the skill document itself. + +## Required Frontmatter + +The frontmatter is parsed by `SkillFrontmatterParser`: a regex (`^---\s*\n(.*?)\n---\s*\n(.*)$`) splits the fenced block, then SnakeYAML loads it as a mapping. Hard requirements: + +- Starts with `---` as the **first bytes** — no leading blank line, no BOM. +- A closing `---` line follows, then the body. The body must be non-empty. +- The block between the fences parses as a YAML mapping. +- `name` is present — it is the upsert key. `BuiltinSkillSeedService` skips any SKILL.md with no `name`. +- `description` is present — a single line. + +If the frontmatter regex fails to match, the parser treats the whole file as body with an empty `name`, and a builtin skill is silently skipped at seed time. A loadable skill ALWAYS has well-formed frontmatter. + +## Size & Naming Limits + +- **Skill content:** ≤ 100,000 chars (`MAX_CONTENT_CHARS`, ~25k tokens) — enforced by `skill_manage` for custom skills. Builtin skills aren't hard-checked but should obey the same ceiling. +- **Name:** must match `^[a-z0-9][a-z0-9._-]{0,63}$` — lowercase letters and digits plus `-` `_` `.`, starting with a letter or digit, ≤ 64 chars. `skill_manage` lowercases the name before validating. +- **Description:** keep it to one line. Peer skills run 40-70 chars — a tight trigger phrase, not a paragraph. +- **Peer skills** in `resources/skills/` sit at 6-15k chars. Aim for that range; past ~20k, split detail into `references/*.md`. + +## Peer-Matched Frontmatter + +Every shipped skill follows this shape: + +```yaml +--- +name: my-skill-name +description: 'One line: what it does and when it fires.' +version: 1.0.0 +tags: +- short +- descriptive +- tags +author: ported +--- +``` + +Fields `BuiltinSkillSeedService` projects onto the `mate_skill` row: + +| Field | Effect | Default if absent | +|---|---|---| +| `name` | upsert key, skill identity | — (required) | +| `description` | shown in skill lists | empty | +| `version` | `mate_skill.version` | `1.0.0` | +| `icon` | emoji, or a `/skill-assets/...` path | `🛠️` | +| `author` | attribution | `MateClaw` | +| `tags` | YAML list or CSV string | skill name | +| `nameZh` / `nameEn` | bilingual display names | none | +| `optional: true` | seeds the skill **disabled** — user opts in from the Skills page | `false` (enabled) | +| `dependencies.tools` | required tool ids → `config_json.requiredTools` | none | +| `platforms` | e.g. `[linux, macos, windows]` | none | + +`version` / `author` / `tags` are not validator-enforced, but every peer carries them — omitting makes the skill look half-finished. Use `optional: true` for heavyweight skills (paid CLI dependencies, external OAuth, niche integrations) so they ship dark and the user activates them deliberately. + +## Skill Structure + +Shipped skills follow roughly: + +``` +# + +## Overview — one or two paragraphs: what and why. +## When to Use — bulleted triggers, plus a "Don't use for:" counter-trigger. +## <Topic sections> — quick-reference tables, exact commands, concrete recipes + (mvn test, paths under mateclaw-server/, etc.). +## Common Pitfalls — numbered mistakes paired with their fixes. +## Verification Checklist — checkbox list of post-action checks. +``` + +Not every section is mandatory, but `Overview` + `When to Use` + an actionable body + `Common Pitfalls` is the minimum for the skill to read like a peer. + +## Directory Placement + +``` +mateclaw-server/src/main/resources/skills/<skill-name>/SKILL.md +``` + +The `skills/` tree is **flat** — no category subdirectories. The seed glob `classpath*:skills/*/SKILL.md` matches exactly one level deep, so a skill nested under a category directory would never be scanned. The directory name SHOULD equal the frontmatter `name`. Supporting files go in `references/` and `scripts/` subdirectories (see below). + +## Builtin Workflow (in-repo) + +1. **Survey peers:** `ls mateclaw-server/src/main/resources/skills/` and read 2-3 SKILL.md files close to your topic — match tone and structure. +2. **Create** `skills/<name>/SKILL.md` with the file tools. +3. **Validate** that the frontmatter parses — see the checklist below. +4. **Restart the server.** `BuiltinSkillSeedService` seeds the new row only at startup; a running server will not see it. The service also skips re-seeding when no SKILL.md's size/mtime changed, so rebuilding the JAR is what makes a change land. +5. **Commit** the new `skills/<name>/` directory. No SQL seed change is needed — the SKILL.md is the source of truth and obsoletes per-skill `INSERT INTO mate_skill`. + +## Custom Workflow (skill_manage) + +Agents and users create runtime skills with the `skill_manage` tool — actions `create | edit | patch | delete`: + +- `create` — a new skill from full SKILL.md content. Rejects a duplicate name. +- `edit` — a full-content rewrite of a custom skill. +- `patch` — find-and-replace one section (`oldText` → `newText`). +- `delete` — uninstall (logical delete plus workspace archive). + +Notes: + +- Every write is **security-scanned** (`SkillSecurityService`) before saving — dangerous patterns are rejected with the reason. Builtin SKILL.md files are NOT scanned; they are trusted committed source. +- `edit` / `patch` / `delete` **refuse builtin skills** ("cannot edit builtin skill"). To change a builtin skill, edit the resource file and restart. +- A custom skill is live immediately — the tool re-runs the resolver pipeline — so no restart is needed. + +## Supporting Files + +Beyond `SKILL.md`, a skill directory may carry: + +- `references/*.md` — long-form material the body links to. Use this to keep SKILL.md under ~20k chars. +- `scripts/*` — executable helpers a skill invokes. +- `templates/`, `assets/` — used by some bundled skills (HTML templates, images, etc.). + +`SkillFileAccessPolicy` only resolves runtime paths under `references/` and `scripts/`, and rejects `..` traversal or absolute paths — keep runtime-read files in those two directories. + +## Common Pitfalls + +1. **Leading whitespace before `---`.** The frontmatter regex anchors on `^---`; a blank line or BOM makes the whole file parse as body with an empty `name`, and a builtin skill is silently skipped. +2. **Expecting a running server to see a new builtin skill.** `BuiltinSkillSeedService` seeds only at startup. Restart — or, for a quick iteration, create a custom skill via `skill_manage`, which is live immediately. +3. **Trying to `skill_manage edit` a builtin skill.** It is refused. Builtin skills are committed source — edit the file and restart. +4. **Adding an `INSERT INTO mate_skill` for a new builtin skill.** Unnecessary and discouraged — the SKILL.md is the source of truth and the seed service upserts by `name`. +5. **Generic description.** "Debug things" is weak. A peer description names the *trigger* — "4-phase root cause debugging: understand bugs before fixing." beats "Debug things." +6. **Naming an external project or internal RFC in the skill body.** Describe the function objectively. Shipped content states *what* it does, not where the idea came from — `author: ported` is the neutral attribution for an adapted skill. +7. **Skill content over 100k chars.** `skill_manage` rejects it outright; split detail into `references/`. +8. **Mismatched directory and `name`.** The upsert keys on the frontmatter `name`, but a directory that disagrees confuses everyone reading the tree. Keep them equal. + +## Verification Checklist + +- [ ] File at `mateclaw-server/src/main/resources/skills/<name>/SKILL.md` (builtin); the directory name equals the frontmatter `name` +- [ ] Frontmatter starts at byte 0 with `---`, closes with a `---` line, and the body is non-empty +- [ ] `name` matches `^[a-z0-9][a-z0-9._-]{0,63}$`; `description` is a single line +- [ ] `version`, `tags`, `author` present (peer-matched shape) +- [ ] Total file ≤ 100,000 chars (aim 6-15k; split into `references/` past ~20k) +- [ ] Structure: `# Title` → `## Overview` → `## When to Use` → actionable body → `## Common Pitfalls` → `## Verification Checklist` +- [ ] No external project names or RFC numbers in the body +- [ ] Builtin: server restarted so `BuiltinSkillSeedService` seeds the row; the new `skills/<name>/` directory is committed +- [ ] Custom: created via `skill_manage`, security scan reported PASSED diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java new file mode 100644 index 00000000..efafc53e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java @@ -0,0 +1,134 @@ +package vip.mate.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.model.AgentEntity; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +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; + +/** + * Exercises the agent authoring tool against a real Spring context so the + * create-then-bind sequence runs through {@link AgentService} and + * {@link AgentBindingService} exactly as it would at chat time. Builtin + * skills are seeded on startup, so skill-name resolution hits real rows. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:agent_authoring_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class AgentAuthoringToolTest { + + private static final AtomicLong WS_SEQ = new AtomicLong(70_000L); + + @Autowired + private AgentAuthoringTool tool; + @Autowired + private AgentService agentService; + @Autowired + private AgentBindingService bindingService; + + private final ObjectMapper mapper = new ObjectMapper(); + + private long workspaceId; + + @BeforeEach + void setUp() { + workspaceId = WS_SEQ.getAndIncrement(); + } + + private ToolContext ctxFor(long ws) { + return ChatOrigin.web("conv-" + ws, "123", ws, null).toToolContext(); + } + + @Test + @DisplayName("create_employee 在 ChatOrigin 的 workspace 内创建 Agent,无能力名时继承全局默认") + void createsGeneralistInOriginWorkspace() throws Exception { + String json = tool.create_employee( + "generalist-helper", "general assistant", "You help with anything.", + null, null, null, null, ctxFor(workspaceId)); + + JsonNode node = mapper.readTree(json); + long agentId = Long.parseLong(node.get("agentId").asText()); + + AgentEntity created = agentService.getAgent(agentId); + assertEquals("generalist-helper", created.getName()); + assertEquals(workspaceId, created.getWorkspaceId()); + assertEquals("react", created.getAgentType()); + // Creator attribution parsed from the numeric requesterId. + assertEquals(123L, created.getCreatorUserId()); + // No bindings declared → inherits global defaults (null sentinel). + assertNull(bindingService.getBoundSkillIds(agentId)); + assertNull(bindingService.getBoundToolNames(agentId)); + } + + @Test + @DisplayName("create_employee 绑定指定的内置技能(按名解析为 id)") + void bindsRequestedBuiltinSkill() throws Exception { + String json = tool.create_employee( + "planner-employee", "planning specialist", "You break goals into plans.", + "react", null, "[\"make_plan\"]", null, ctxFor(workspaceId)); + + JsonNode node = mapper.readTree(json); + long agentId = Long.parseLong(node.get("agentId").asText()); + + Set<Long> boundSkills = bindingService.getBoundSkillIds(agentId); + assertNotNull(boundSkills, "declaring a skill must create a binding set"); + assertEquals(1, boundSkills.size()); + // The summary echoes the bound skill name. + assertTrue(node.get("skillsBound").toString().contains("make_plan")); + } + + @Test + @DisplayName("create_employee 缺少 workspace 上下文时拒绝执行") + void rejectsWithoutWorkspace() { + String result = tool.create_employee( + "no-ws", "x", "y", null, null, null, null, ChatOrigin.EMPTY.toToolContext()); + assertTrue(result.startsWith("[error]")); + } + + @Test + @DisplayName("create_employee 重名返回友好错误而非抛出") + void duplicateNameReturnsFriendlyError() { + ToolContext ctx = ctxFor(workspaceId); + tool.create_employee("dup-employee", "first", "p", null, null, null, null, ctx); + String second = tool.create_employee("dup-employee", "second", "p", null, null, null, null, ctx); + assertTrue(second.startsWith("[error]"), "duplicate name should surface as a friendly error"); + } + + @Test + @DisplayName("list_capability_catalog 返回技能与工具清单") + void catalogReturnsSkillsAndTools() throws Exception { + String json = tool.list_capability_catalog(ctxFor(workspaceId)); + JsonNode node = mapper.readTree(json); + assertTrue(node.has("skills")); + assertTrue(node.has("tools")); + assertTrue(node.get("skills").isArray()); + // make_plan is a seeded builtin skill, so the catalog must surface it. + boolean hasMakePlan = false; + for (JsonNode s : node.get("skills")) { + if ("make_plan".equals(s.path("name").asText())) { hasMakePlan = true; break; } + } + assertTrue(hasMakePlan, "seeded builtin skill make_plan should appear in the catalog"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java new file mode 100644 index 00000000..bdc88a6a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java @@ -0,0 +1,161 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #142 regression: a scheduled-job run must see only its own task in + * the LLM prompt — never the prior or concurrent runs that share its + * {@code tasks_<wsId>} (web-origin) or {@code cron_<id>} (per-job) conversation. + * + * <p>A cron run's instruction is passed explicitly through the call chain, so + * the runtime never reconstructs it from the shared conversation: history is + * empty and the current message is the explicit argument. This holds even + * when concurrent runs interleave their rows into that conversation. A normal + * Web / channel turn is unaffected — it still loads full history. + */ +class BaseAgentCronIsolationTest { + + @AfterEach + void clearOrigin() { + ChatOriginHolder.clear(); + } + + @Test + @DisplayName("cron run: empty LLM history even when the conversation holds interleaved concurrent-run rows") + void cronRun_emptyHistory_evenWithInterleavedRows() { + ConversationService conv = mock(ConversationService.class); + // The shared tasks_1 after three concurrent runs: headers, user rows + // and assistant rows each cluster together — NOT adjacent per run, + // because each startRun commits in its own interleaving transaction. + List<MessageEntity> contaminated = List.of( + sys("📋 job-A · 定时触发"), sys("📋 job-B · 定时触发"), sys("📋 job-C · 定时触发"), + user("job A task"), user("job B task"), user("job C task"), + assistant("A result"), assistant("B result"), assistant("C result")); + when(conv.countMessages(any())).thenReturn((long) contaminated.size()); + when(conv.listMessages(any())).thenReturn(contaminated); + stubRender(conv); + + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.cron("tasks_1", 1L, null, null, null)); + + List<Message> history = agent.history("tasks_1", "job A task"); + + assertTrue(history.isEmpty(), + "a cron run must replay no conversation history at all"); + } + + @Test + @DisplayName("cron run: current message is the explicit argument, never a conversation-guessed last user row") + void cronRun_currentMessage_usesExplicitArgument() { + ConversationService conv = mock(ConversationService.class); + // The conversation's LAST user row belongs to a DIFFERENT concurrent + // run — the pre-fix code reconstructed the current message from it. + when(conv.listMessages(any())).thenReturn(List.of( + sys("📋 job-A"), sys("📋 job-B"), + user("job A task"), user("job B task — WRONG for this run"))); + stubRender(conv); + + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.cron("tasks_1", 1L, null, null, null)); + + String current = agent.currentMessage("tasks_1", "job A task — the real one"); + + assertEquals("job A task — the real one", current, + "a cron run must use its own explicit task text, not the conversation's last user row"); + } + + @Test + @DisplayName("normal turn: full history kept even when the conversation holds scheduled-job records") + void nonCronTurn_keepsFullHistory() { + ConversationService conv = mock(ConversationService.class); + List<MessageEntity> stored = List.of( + user("早上好"), assistant("你好,有什么可以帮你"), user("现在几点")); + when(conv.countMessages("conv_x")).thenReturn((long) stored.size()); + when(conv.listMessages("conv_x")).thenReturn(stored); + stubRender(conv); + + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.web("conv_x", "u1", 1L, null)); + + List<Message> history = agent.history("conv_x", "现在几点"); + + assertEquals(2, history.size(), + "a normal turn keeps prior history (the trailing current user row is de-duplicated)"); + } + + // ---------- scaffold ---------- + + private static TestAgent newAgent(ConversationService conv) { + TestAgent agent = new TestAgent(conv); + agent.agentName = "test-agent"; + agent.modelName = "test-model"; + return agent; + } + + private static void stubRender(ConversationService conv) { + when(conv.renderMessageContent(any())).thenAnswer( + inv -> ((MessageEntity) inv.getArgument(0)).getContent()); + } + + private static MessageEntity row(String role, String content) { + MessageEntity m = new MessageEntity(); + m.setRole(role); + m.setContent(content); + return m; + } + + private static MessageEntity sys(String content) { + return row("system", content); + } + + private static MessageEntity user(String content) { + return row("user", content); + } + + private static MessageEntity assistant(String content) { + return row("assistant", content); + } + + /** Minimal concrete BaseAgent fixture exposing the protected builders. */ + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conv) { + super(null, conv); + } + + List<Message> history(String conversationId, String currentUserMessage) { + return buildConversationHistory(conversationId, currentUserMessage); + } + + String currentMessage(String conversationId, String userMessageText) { + return buildCurrentUserMessageWithRouting(conversationId, userMessageText) + .userMessage().getText(); + } + + @Override public String chat(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override public String execute(String goal, String conversationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java index d5136b1f..73f1d5aa 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java @@ -12,6 +12,8 @@ import vip.mate.MateClawApplication; import vip.mate.agent.binding.model.AgentToolBinding; import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.exception.MateClawException; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.service.AvailableToolService; import java.util.List; import java.util.Set; @@ -41,6 +43,9 @@ class AgentBindingServiceTest { @Autowired private AgentBindingService bindingService; + @Autowired + private AvailableToolService availableToolService; + @Autowired private JdbcTemplate jdbcTemplate; @@ -220,6 +225,29 @@ class AgentBindingServiceTest { assertEquals(0, count, "拒绝时不能写入绑定行"); } + @Test + @DisplayName("bindSkill 允许跨 workspace 的 builtin skill(builtin 为全局能力,不做 tenancy 校验)") + void bindSkillAllowsBuiltinSkillCrossWorkspace() { + // Builtin skills are seeded once into the default workspace but are + // global — an agent in any workspace must be able to bind them. Seed + // a builtin row whose workspace_id deliberately differs from the + // agent's (=1) to prove the builtin exemption, not a workspace match, + // is what lets the binding through. + long builtinSkillId = 7_777_350L; + jdbcTemplate.update( + "MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, 'builtin', '1.0.0', TRUE, TRUE, 2, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + builtinSkillId, "binding-test-builtin-" + builtinSkillId); + + assertDoesNotThrow(() -> bindingService.bindSkill(agentId, builtinSkillId)); + + Set<Long> ids = bindingService.getBoundSkillIds(agentId); + assertNotNull(ids); + assertTrue(ids.contains(builtinSkillId), "builtin skill 应当可跨 workspace 绑定"); + } + @Test @DisplayName("bindSkill 允许 MCP 虚拟 skill(McpServerEntity 无 workspace,全局共享)") void bindSkillAllowsVirtualMcpSkill() { @@ -334,6 +362,62 @@ class AgentBindingServiceTest { + "否则 AgentToolSet.withAllowedToolsOnly 会变成空集禁掉所有工具"); } + @Test + @DisplayName("Issue #117: agent 显式勾选某个 MCP 工具后,只有该工具进入 allowlist,其它 MCP 工具不再自动并入") + void mcpToolsScopedWhenAgentPicksSpecificMcpTool() { + // Enterprise scenario: a role should be limited to a fixed subset + // of MCP tools. Two enabled MCP servers exist; the operator ticks + // only server A's tool. Server B's tool must NOT leak into the + // allowlist just because its server is enabled at the system level. + seedMcpServerWithOneTool(8_888_101L, "issue117-server-a", "alpha_probe"); + seedMcpServerWithOneTool(8_888_102L, "issue117-server-b", "beta_probe"); + + String mcpA = mcpToolNameForServer(8_888_101L); + String mcpB = mcpToolNameForServer(8_888_102L); + assertNotNull(mcpA, "server A 的 MCP 工具应出现在 picker 中"); + assertNotNull(mcpB, "server B 的 MCP 工具应出现在 picker 中"); + + bindingService.setToolBindings(agentId, List.of(mcpA)); + + Set<String> effective = bindingService.getEffectiveToolNames(agentId); + assertNotNull(effective, "binding 非空时应返回 allowlist(非 null)"); + assertTrue(effective.contains(mcpA), "显式勾选的 MCP 工具必须在 allowlist 中"); + assertFalse(effective.contains(mcpB), + "未勾选的其它 MCP 工具不得自动并入 —— 这正是 issue #117 要求的按岗位限定 MCP 范围。" + + "实际 allowlist: " + effective); + } + + /** Picker name the UI would save for the (only) MCP tool of {@code serverId}. */ + private String mcpToolNameForServer(long serverId) { + return availableToolService.listAvailable().stream() + .filter(t -> "mcp".equals(t.getSource())) + .filter(t -> t.getProviderId() != null && serverId == t.getProviderId()) + .map(AvailableToolDTO::getName) + .findFirst() + .orElse(null); + } + + @Test + @DisplayName("Issue #143: 绑定任意工具后,wiki 知识库工具仍留在 effective allowlist(可读写知识库)") + void wikiToolsSurviveSkillBindingAllowlist() { + // Reproduce issue #143: once an agent has any binding, the effective + // allowlist turns on. Wiki tools live on the WikiTool bean and are + // never declared by a skill manifest, so before the fix they were + // filtered out — the agent could chat but lost its KB read/write + // tools and reported "no permission" when asked to save a result. + seedBuiltinTool("builtin_probe"); + bindingService.setToolBindings(agentId, List.of("builtin_probe")); + + Set<String> effective = bindingService.getEffectiveToolNames(agentId); + assertNotNull(effective, "binding 非空时应返回 allowlist(非 null)"); + assertTrue(effective.contains("wiki_create_page"), + "wiki_create_page 必须留在 allowlist —— 否则 AI 无法把结果写入知识库(issue #143)。" + + "实际 allowlist: " + effective); + assertTrue(effective.contains("wiki_read_page"), + "wiki_read_page 必须留在 allowlist —— 否则 agent 无法读取自己的知识库。" + + "实际 allowlist: " + effective); + } + @Test @DisplayName("unbindTool 后 DB 里真的没行(物理 delete,不是软删留 deleted=1)") void unbindPhysicallyRemovesRow() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingSkillRemovalListenerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingSkillRemovalListenerTest.java new file mode 100644 index 00000000..36a7cbf2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingSkillRemovalListenerTest.java @@ -0,0 +1,49 @@ +package vip.mate.agent.binding; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.binding.service.AgentBindingSkillRemovalListener; +import vip.mate.skill.event.SkillRemovedEvent; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Issue #127 regression — deleting a skill from the skill management page + * left orphan rows in {@code mate_agent_skill}, so the agent edit modal kept + * the old binding count and the user couldn't clear it. This listener drops + * those rows in response to {@link SkillRemovedEvent}. + */ +class AgentBindingSkillRemovalListenerTest { + + @Test + @DisplayName("event triggers a delete on mate_agent_skill scoped to the removed skill id") + void removalDropsBindingRows() { + AgentSkillBindingMapper mapper = mock(AgentSkillBindingMapper.class); + when(mapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2); + + AgentBindingSkillRemovalListener listener = new AgentBindingSkillRemovalListener(mapper); + listener.onSkillRemoved(new SkillRemovedEvent(77L, "pdf")); + + verify(mapper, times(1)).delete(any(LambdaQueryWrapper.class)); + } + + @Test + @DisplayName("null event or null skillId is a no-op — defensive guard") + void nullEventDoesNothing() { + AgentSkillBindingMapper mapper = mock(AgentSkillBindingMapper.class); + AgentBindingSkillRemovalListener listener = new AgentBindingSkillRemovalListener(mapper); + + listener.onSkillRemoved(null); + listener.onSkillRemoved(new SkillRemovedEvent(null, "dangling")); + + verify(mapper, never()).delete(any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java new file mode 100644 index 00000000..750bf133 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java @@ -0,0 +1,153 @@ +package vip.mate.agent.binding.service; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.lifecycle.BlockedByBindingRow; +import vip.mate.skill.lifecycle.ConfirmRequiredException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.tool.service.AvailableToolService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Covers the lifecycle-curator support queries on {@link AgentBindingService}: + * the binding hard guard and the manual-archive agent lookup. + */ +@ExtendWith(MockitoExtension.class) +class AgentBindingServiceCuratorTest { + + @Mock + private AgentSkillBindingMapper skillBindingMapper; + @Mock + private AgentToolBindingMapper toolBindingMapper; + @Mock + private AgentProviderPreferenceMapper providerPreferenceMapper; + @Mock + private SkillRuntimeService skillRuntimeService; + @Mock + private AvailableToolService availableToolService; + @Mock + private AgentMapper agentMapper; + @Mock + private SkillMapper skillMapper; + @Mock + private AcpSkillBridge acpSkillBridge; + + private AgentBindingService service; + + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeAll + static void initTableInfo() { + // Lambda wrappers resolve column names from MyBatis-Plus's static + // TableInfo cache; trigger it manually for this plain unit test. + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), ""); + TableInfoHelper.initTableInfo(assistant, AgentEntity.class); + TableInfoHelper.initTableInfo(assistant, AgentSkillBinding.class); + TableInfoHelper.initTableInfo(assistant, SkillEntity.class); + } + + @BeforeEach + void setUp() { + service = new AgentBindingService(skillBindingMapper, toolBindingMapper, providerPreferenceMapper, + skillRuntimeService, availableToolService, agentMapper, skillMapper, acpSkillBridge); + } + + private AgentEntity agent(long id, String name) { + AgentEntity a = new AgentEntity(); + a.setId(id); + a.setName(name); + a.setEnabled(true); + return a; + } + + private AgentSkillBinding binding(long skillId, long agentId) { + AgentSkillBinding b = new AgentSkillBinding(); + b.setSkillId(skillId); + b.setAgentId(agentId); + b.setEnabled(true); + return b; + } + + private SkillEntity skill(long id, String name, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(name); + s.setSkillType("dynamic"); + s.setBuiltin(false); + s.setPinned(false); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + @Test + void onlyBindingsToEnabledAgentsCountAsProtected() { + when(agentMapper.selectList(any())).thenReturn(List.of(agent(1L, "Alpha"), agent(2L, "Beta"))); + // skill 10 bound to enabled agent 1; skill 20 bound to a non-enabled agent 99. + when(skillBindingMapper.selectList(any())) + .thenReturn(List.of(binding(10L, 1L), binding(20L, 99L))); + + Set<Long> protectedIds = service.skillIdsBoundToEnabledAgents(); + + assertEquals(Set.of(10L), protectedIds); + } + + @Test + void blockedByBindingCandidatesCarrySkillDetailAndDaysIdle() { + when(agentMapper.selectList(any())).thenReturn(List.of(agent(1L, "Alpha"))); + when(skillBindingMapper.selectList(any())).thenReturn(List.of(binding(10L, 1L))); + when(skillMapper.selectBatchIds(any())) + .thenReturn(List.of(skill(10L, "weekly-report", now.minusDays(50)))); + + List<BlockedByBindingRow> rows = service.blockedByBindingCandidates(now); + + assertEquals(1, rows.size()); + assertEquals(10L, rows.get(0).skillId()); + assertEquals("weekly-report", rows.get(0).name()); + assertEquals(50L, rows.get(0).daysIdle()); + assertTrue(rows.get(0).agentIds().contains(1L)); + } + + @Test + void enabledAgentsBoundToSkillListsTheAffectedAgents() { + when(skillBindingMapper.selectList(any())).thenReturn(List.of(binding(10L, 1L))); + when(agentMapper.selectList(any())).thenReturn(List.of(agent(1L, "Alpha"))); + + List<ConfirmRequiredException.AgentRow> agents = service.enabledAgentsBoundToSkill(10L); + + assertEquals(1, agents.size()); + assertEquals(1L, agents.get(0).id()); + assertEquals("Alpha", agents.get(0).name()); + } + + @Test + void noEnabledAgentsMeansNothingIsProtected() { + when(agentMapper.selectList(any())).thenReturn(List.of()); + + assertTrue(service.skillIdsBoundToEnabledAgents().isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java deleted file mode 100644 index d8e90124..00000000 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java +++ /dev/null @@ -1,71 +0,0 @@ -package vip.mate.agent.chatmodel; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * RFC-001 (Claude 4.7 contract): {@link AgentAnthropicChatModelBuilder#isClaude47} - * must correctly classify the model variants we'll see in production. - * - * <p>Reference: hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}. - * Claude 4.7 forbids temperature / top_p / top_k entirely — the builder relies - * on this detector to skip those fields rather than letting Anthropic 400. - */ -class AgentAnthropicChatModelBuilderClaude47Test { - - @Test - @DisplayName("isClaude47 detects hyphenated direct-API model names") - void detect_hyphenated() { - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7")); - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7")); - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-haiku-4-7")); - } - - @Test - @DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)") - void detect_dotted() { - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4.7")); - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7")); - } - - @Test - @DisplayName("isClaude47 detects OpenRouter-style prefixed model ids") - void detect_openrouterPrefix() { - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7")); - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7")); - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7")); - } - - @Test - @DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names") - void detect_negatives() { - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-6")); - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5")); - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"), - "3.7 must not match 4.7"); - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet")); - // The "claude" prefix guard prevents non-Anthropic models from spuriously - // matching even if they contain "4-7" / "4.7" substrings. - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("gpt-4-7"), - "Non-Claude models must NOT match — claude prefix guard active"); - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct")); - } - - @Test - @DisplayName("isClaude47 null-safe") - void detect_nullSafe() { - assertFalse(AgentAnthropicChatModelBuilder.isClaude47(null)); - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("")); - } - - @Test - @DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*") - void detect_3_7_vs_4_7() { - // Both contain "-7" but only the second contains "4-7" as a substring. - assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219")); - assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"), - "Date-stamped 4-7 variants must still match"); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java new file mode 100644 index 00000000..48c31e74 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java @@ -0,0 +1,103 @@ +package vip.mate.agent.context; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Cover the three new {@link ChatOrigin} sender fields (senderName, + * channelType, chatId), the {@link ChatOrigin#withSender} wither, and + * the JSON round-trip path that {@code ApprovalReplayContinuityTest} + * already exercises for the rest of the record. + * + * <p>Carry the field-evolution guarantees pinned in {@link ChatOrigin}'s + * doc: existing fields preserved by every wither, new fields default + * to null when not supplied (e.g. via {@code web()} / {@code cron()} + * factories). + */ +class ChatOriginSenderFieldsTest { + + @Test + @DisplayName("withSender returns a new instance with the three fields populated") + void withSenderUpdatesFields() { + ChatOrigin base = ChatOrigin.EMPTY; + ChatOrigin enriched = base.withSender("Alice", "feishu", "oc_42"); + + assertEquals("Alice", enriched.senderName()); + assertEquals("feishu", enriched.channelType()); + assertEquals("oc_42", enriched.chatId()); + + // Original untouched (record immutability + wither contract) + assertNull(base.senderName()); + assertNull(base.channelType()); + assertNull(base.chatId()); + } + + @Test + @DisplayName("withSender preserves every pre-existing field") + void withSenderPreservesOtherFields() { + ChatOrigin original = new ChatOrigin( + 7L, "conv-1", "u123", 5L, "/ws", 9L, null, false, + null, null, null); + ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1"); + + // All non-sender fields unchanged + assertEquals(original.agentId(), enriched.agentId()); + assertEquals(original.conversationId(), enriched.conversationId()); + assertEquals(original.requesterId(), enriched.requesterId()); + assertEquals(original.workspaceId(), enriched.workspaceId()); + assertEquals(original.workspaceBasePath(), enriched.workspaceBasePath()); + assertEquals(original.channelId(), enriched.channelId()); + assertEquals(original.channelTarget(), enriched.channelTarget()); + assertEquals(original.cronOrigin(), enriched.cronOrigin()); + } + + @Test + @DisplayName("web() factory sets channelType to 'web' and leaves sender / chat null") + void webFactoryDefaults() { + ChatOrigin web = ChatOrigin.web("conv_1", "user-1", 5L, "/ws"); + assertEquals("web", web.channelType()); + assertNull(web.senderName()); + assertNull(web.chatId()); + } + + @Test + @DisplayName("cron() factory leaves all three sender fields null") + void cronFactoryDefaults() { + ChatOrigin cron = ChatOrigin.cron("cron_1", 1L, null, 9L, null); + assertNull(cron.senderName()); + assertNull(cron.channelType()); + assertNull(cron.chatId()); + } + + @Test + @DisplayName("JSON round-trip preserves the new sender fields") + void jsonRoundTripPreservesFields() throws Exception { + ObjectMapper om = new ObjectMapper(); + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, + "Alice", "feishu", "oc_42"); + + String json = om.writeValueAsString(origin); + ChatOrigin restored = om.readValue(json, ChatOrigin.class); + + assertEquals(origin, restored); + assertEquals("Alice", restored.senderName()); + assertEquals("feishu", restored.channelType()); + assertEquals("oc_42", restored.chatId()); + } + + @Test + @DisplayName("withAgent / withWorkspace / withConversationId preserve sender fields") + void existingWithersPreserveSenderFields() { + ChatOrigin origin = ChatOrigin.EMPTY.withSender("Alice", "feishu", "oc_42"); + + assertEquals("Alice", origin.withAgent(99L).senderName()); + assertEquals("feishu", origin.withWorkspace(7L, "/ws").channelType()); + assertEquals("oc_42", origin.withConversationId("new").chatId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index d84fde7b..a51dd4d9 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -28,7 +28,7 @@ class ChatOriginTest { void roundTripThroughToolContext_preservesAllFields() { ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, target); + "/data/ws/5", 9L, target, false, null, null, null); ToolContext ctx = original.toToolContext(); ChatOrigin restored = ChatOrigin.from(ctx); @@ -56,11 +56,26 @@ class ChatOriginTest { assertNull(origin.agentId(), "agentId is enriched later by BaseAgent"); } + @Test + void cronOriginFlag_setByFactoryAndPreservedByWithers() { + ChatOrigin cron = ChatOrigin.cron("cron_7", 1L, null, 3L, null); + assertTrue(cron.cronOrigin(), "cron() factory must flag the origin as a cron run"); + assertTrue(cron.withAgent(9L).cronOrigin(), "withAgent must preserve cronOrigin"); + assertTrue(cron.withConversationId("tasks_1").cronOrigin(), + "withConversationId must preserve cronOrigin"); + assertTrue(cron.withWorkspace(2L, "/ws").cronOrigin(), + "withWorkspace must preserve cronOrigin"); + + assertFalse(ChatOrigin.web("conv_1", "u1", 1L, null).cronOrigin(), + "web() origin must not be flagged as a cron run"); + assertFalse(ChatOrigin.EMPTY.cronOrigin(), "EMPTY must not be flagged as a cron run"); + } + @Test void jsonSerialization_isStableAndForwardCompatible() throws Exception { ObjectMapper om = new ObjectMapper(); ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001")); + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java new file mode 100644 index 00000000..bb13a159 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java @@ -0,0 +1,158 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.graph.executor.ToolResultStorage; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ConversationWindowManager#compactAgedToolResponses} — the + * age-based pass that drops bodies of tool responses older than the K most + * recent into a placeholder while preserving the toolCallId / tool name so + * the model still sees "I called X earlier" in history. Complements (does + * not replace) the existing size/dedup-based prune pass. + */ +class CompactAgedToolResponsesTest { + + private static final ConversationWindowManager MANAGER = new ConversationWindowManager( + null, null, null); + + private static ToolResponseMessage toolResp(String id, String name, String body) { + return ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse(id, name, body))) + .build(); + } + + private static String spillBody(String tool, String path) { + return ToolResultStorage.SPILL_MARKER_PREFIX + " tool=" + tool + " full_chars=12345 path=" + + path + + "\n[Preview — first 800 of 12345 chars. The preview is INCOMPLETE: use read_file]\n" + + "<preview content>\n…[truncated]"; + } + + @Test + @DisplayName("keepRecentN=0 or negative is a no-op (returns same list reference).") + void zeroKeepIsNoop() { + List<Message> in = List.of(toolResp("t1", "search", "a body".repeat(50))); + assertSame(in, MANAGER.compactAgedToolResponses(in, 0)); + assertSame(in, MANAGER.compactAgedToolResponses(in, -1)); + } + + @Test + @DisplayName("Latest K tool responses are kept verbatim; older ones get the placeholder.") + void keepsLatestKVerbatim() { + String body = "abcdef".repeat(50); // 300 chars — guaranteed larger than placeholder + List<Message> in = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + in.add(toolResp("call-" + i, "search", "result-" + i + " " + body)); + } + List<Message> out = MANAGER.compactAgedToolResponses(in, 2); + // out[3] and out[4] are the two newest — verbatim. + assertTrue(((ToolResponseMessage) out.get(3)).getResponses().get(0).responseData().contains("result-3")); + assertTrue(((ToolResponseMessage) out.get(4)).getResponses().get(0).responseData().contains("result-4")); + // out[0..2] are older — compacted. + for (int i = 0; i <= 2; i++) { + String compactedBody = ((ToolResponseMessage) out.get(i)).getResponses().get(0).responseData(); + assertTrue(compactedBody.startsWith("[Old tool output cleared"), + "expected placeholder at index " + i + ", got: " + compactedBody); + } + } + + @Test + @DisplayName("Tool name and toolCallId survive the rewrite so the assistant/tool pair stays valid.") + void preservesIdAndName() { + String big = "a".repeat(500); + List<Message> in = List.of( + toolResp("call-old", "search", big), + toolResp("call-mid", "search", big), + toolResp("call-new", "search", big)); + List<Message> out = MANAGER.compactAgedToolResponses(in, 1); + ToolResponseMessage.ToolResponse old = ((ToolResponseMessage) out.get(0)).getResponses().get(0); + assertEquals("call-old", old.id()); + assertEquals("search", old.name()); + assertNotEquals(big, old.responseData()); + // Latest stays verbatim. + assertEquals(big, ((ToolResponseMessage) out.get(2)).getResponses().get(0).responseData()); + } + + @Test + @DisplayName("Spill-marker bodies keep their on-disk path inside the placeholder for read_file recovery.") + void spillPathPreserved() { + String body = spillBody("browser_use", "/tmp/mateclaw/tool-results/conv-1/tool_abc.txt"); + List<Message> in = List.of( + toolResp("call-old", "browser_use", body), + toolResp("call-new", "search", "a".repeat(500))); + List<Message> out = MANAGER.compactAgedToolResponses(in, 1); + String compacted = ((ToolResponseMessage) out.get(0)).getResponses().get(0).responseData(); + assertTrue(compacted.contains("/tmp/mateclaw/tool-results/conv-1/tool_abc.txt"), + "spill path should be preserved in placeholder: " + compacted); + assertTrue(compacted.contains("read_file"), compacted); + } + + @Test + @DisplayName("Exempt tools (delegateToAgent / delegateParallel) skip compaction entirely.") + void exemptToolsSkipped() { + String big = "x".repeat(500); + List<Message> in = List.of( + toolResp("d-old", "delegateToAgent", big), + toolResp("s-old", "search", big), + toolResp("s-new", "search", big)); + List<Message> out = MANAGER.compactAgedToolResponses(in, 1); + assertEquals(big, ((ToolResponseMessage) out.get(0)).getResponses().get(0).responseData()); // exempt + assertTrue(((ToolResponseMessage) out.get(1)).getResponses().get(0).responseData() + .startsWith("[Old tool output cleared")); // non-exempt aged → compacted + assertEquals(big, ((ToolResponseMessage) out.get(2)).getResponses().get(0).responseData()); // newest kept + } + + @Test + @DisplayName("A body shorter than the placeholder itself is kept verbatim — no negative savings.") + void tinyBodyNotInflated() { + List<Message> in = List.of( + toolResp("old", "ping", "ok"), + toolResp("new", "ping", "ok")); + List<Message> out = MANAGER.compactAgedToolResponses(in, 1); + assertEquals("ok", ((ToolResponseMessage) out.get(0)).getResponses().get(0).responseData()); + } + + @Test + @DisplayName("Non-tool messages (user / assistant) are passed through untouched.") + void nonToolMessagesUntouched() { + Message user = new UserMessage("hello"); + Message assistant = new AssistantMessage("hi"); + List<Message> in = List.of(user, assistant, + toolResp("old", "search", "x".repeat(500)), + toolResp("new", "search", "y".repeat(500))); + List<Message> out = MANAGER.compactAgedToolResponses(in, 1); + assertSame(user, out.get(0)); + assertSame(assistant, out.get(1)); + } + + @Test + @DisplayName("buildAgedPlaceholder spill-path extraction handles trailing newline boundary.") + void buildPlaceholderSpillPath() { + String body = spillBody("browser_use", "/a/b/c.txt"); + String out = ConversationWindowManager.buildAgedPlaceholder("browser_use", body); + assertNotNull(out); + assertTrue(out.contains("/a/b/c.txt"), out); + } + + @Test + @DisplayName("buildAgedPlaceholder falls back to plain text when no spill path is present.") + void buildPlaceholderPlainBody() { + String out = ConversationWindowManager.buildAgedPlaceholder("search", "regular result body"); + assertTrue(out.contains("'search'"), out); + assertTrue(out.contains("can be called again"), out); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerPtlTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerPtlTest.java new file mode 100644 index 00000000..bc7dae2a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerPtlTest.java @@ -0,0 +1,230 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.config.ConversationWindowProperties; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Contract for the structured PTL (Prompt Too Long) recovery path. + * <p> + * Verifies: + * <ol> + * <li>A PTL hit runs the full {@link ConversationWindowManager#compactMessages} + * pipeline (anchor + summary + tail) under a forced-tight budget, not + * the legacy tail-only drop.</li> + * <li>Tool-call clusters are kept intact across the cut so the retry + * prompt doesn't 400 the provider a second time.</li> + * <li>The persisted boundary row carries + * {@code metadata.trigger = "prompt_too_long"} so the summary is + * retrievable distinctly from a normal token-threshold compaction.</li> + * <li>A second PTL within the 60s cooldown falls back to tail-only and + * does NOT invoke the summary LLM (avoids compaction storms).</li> + * </ol> + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConversationWindowManagerPtlTest { + + @Mock private ChatModel chatModel; + @Mock private ConversationService conversationService; + + private ConversationWindowProperties properties; + private ConversationWindowManager manager; + + @BeforeEach + void setUp() { + properties = new ConversationWindowProperties(); + properties.setFirstUserAnchorEnabled(true); + properties.setFirstUserAnchorMaxTokens(400); + // memoryManager is null — onPreCompress hook is a no-op. + manager = new ConversationWindowManager(properties, null, conversationService); + + // ChatModel always returns a short, deterministic summary so the + // pipeline can persist a boundary row and we can assert on its + // metadata. + when(chatModel.call(any(Prompt.class))).thenAnswer(inv -> + makeChatResponse("STRUCTURED_SUMMARY_FROM_LLM")); + when(conversationService.saveCompressionSummaryReturningId( + anyString(), anyString(), anyInt(), any())).thenReturn(42L); + } + + @Test + @DisplayName("PTL structured pass: returns summary + anchor + tail, no broken tool-call pair, trigger=prompt_too_long") + void structuredCompactionLandsAnchorSummaryAndTail() { + List<Message> history = buildHistoryWithToolPairs(); + int sizeBefore = history.size(); + + List<Message> compacted = manager.compactForRetry(history, chatModel, "conv-ptl-1", 1L); + + // ---- shape: not null, smaller than input ---- + assertThat(compacted).isNotNull(); + assertThat(compacted.size()).isLessThan(sizeBefore); + + // ---- contains the structured summary marker + anchor ---- + boolean hasStructuredSummary = compacted.stream() + .filter(m -> m instanceof UserMessage) + .map(Message::getText) + .anyMatch(t -> t != null + && t.startsWith(ConversationWindowManager.SUMMARY_PREFIX) + && t.contains("STRUCTURED_SUMMARY_FROM_LLM")); + assertThat(hasStructuredSummary) + .as("compacted history must include the LLM summary wrapped with SUMMARY_PREFIX") + .isTrue(); + boolean hasAnchor = compacted.stream() + .filter(m -> m instanceof UserMessage) + .map(Message::getText) + .anyMatch(t -> t != null && t.startsWith(ConversationWindowManager.ANCHOR_PREFIX)); + assertThat(hasAnchor) + .as("anchor of the original user goal must be present") + .isTrue(); + + // ---- pair safety: every AssistantMessage with tool_calls keeps its + // matching ToolResponseMessages adjacent ---- + assertNoBrokenToolPair(compacted); + + // ---- the boundary persistence path tags trigger = "prompt_too_long" ---- + @SuppressWarnings("unchecked") + ArgumentCaptor<Map<String, Object>> metaCaptor = ArgumentCaptor.forClass(Map.class); + verify(conversationService).saveCompressionSummaryReturningId( + org.mockito.ArgumentMatchers.eq("conv-ptl-1"), + anyString(), anyInt(), metaCaptor.capture()); + assertThat(metaCaptor.getValue()) + .containsEntry("trigger", "prompt_too_long") + .containsKey("preTokens") + .containsKey("postTokens"); + } + + @Test + @DisplayName("PTL cooldown: second call within 60s falls back to tail-only, no extra ChatModel.call") + void secondPtlWithinCooldownFallsBackToTailOnly() { + List<Message> history = buildHistoryWithToolPairs(); + + // First call exercises the structured path → ChatModel.call invoked + // for summary generation. + manager.compactForRetry(history, chatModel, "conv-cooldown", 1L); + verify(chatModel, times(1)).call(any(Prompt.class)); + + // Second call within cooldown — tail-only fallback, no further LLM call. + List<Message> second = manager.compactForRetry(history, chatModel, "conv-cooldown", 1L); + assertThat(second).isNotNull(); + // Tail-only path drops summary + anchor — no SUMMARY_PREFIX in the + // second result (this is what differentiates it from the structured + // path even when both happen to return ≤ 4 messages). + assertThat(second.stream().anyMatch(m -> { + String t = m.getText(); + return t != null && t.startsWith(ConversationWindowManager.SUMMARY_PREFIX); + })).isFalse(); + // Critical: the summary LLM was NOT called a second time. + verify(chatModel, times(1)).call(any(Prompt.class)); + } + + @Test + @DisplayName("Tiny history (≤ 2 messages) returns null without touching ChatModel") + void tinyHistoryReturnsNull() { + List<Message> tiny = List.of(new UserMessage("hi"), new AssistantMessage("hello")); + List<Message> result = manager.compactForRetry(tiny, chatModel, "conv-tiny", 1L); + assertThat(result).isNull(); + verify(chatModel, never()).call(any(Prompt.class)); + } + + // ---------- helpers ---------- + + /** + * Build a 50-message history: alternating user/assistant turns plus five + * intact assistant.tool_calls → ToolResponseMessage clusters scattered + * through it. Each filler message carries ~300 chars so the total token + * count is comfortably large enough to push the structured budget into + * the "needs LLM summary" range. + */ + private static List<Message> buildHistoryWithToolPairs() { + List<Message> msgs = new ArrayList<>(); + String filler = "x".repeat(300); + msgs.add(new UserMessage("ORIGINAL_USER_GOAL: investigate the bug in module X")); + for (int i = 0; i < 22; i++) { + msgs.add(new AssistantMessage("assistant turn " + i + " " + filler)); + msgs.add(new UserMessage("user turn " + i + " " + filler)); + } + // Append five tool-call clusters at the tail half so the pair-safe cut + // has work to do (the cut may walk through them). + for (int i = 0; i < 5; i++) { + String callId = "call-" + i; + AssistantMessage call = AssistantMessage.builder() + .content("calling tool " + i) + .toolCalls(List.of(new AssistantMessage.ToolCall( + callId, "function", "search", "{\"q\":\"q" + i + "\"}"))) + .build(); + ToolResponseMessage response = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse( + callId, "search", "result body " + i + " " + filler))) + .build(); + msgs.add(call); + msgs.add(response); + } + return msgs; + } + + /** + * Assert that every {@link AssistantMessage} carrying a non-empty + * {@code tool_calls} block in {@code messages} is immediately followed + * by at least one matching {@link ToolResponseMessage}, with one + * response per call id. Catches the "boundary split through a tool + * pair" failure mode the structured PTL path is specifically built to + * avoid. + */ + private static void assertNoBrokenToolPair(List<Message> messages) { + for (int i = 0; i < messages.size(); i++) { + if (messages.get(i) instanceof AssistantMessage am + && am.getToolCalls() != null && !am.getToolCalls().isEmpty()) { + // Every call id must appear in subsequent ToolResponseMessage(s) + // before any other AssistantMessage shows up. + java.util.Set<String> outstanding = new java.util.LinkedHashSet<>(); + for (var c : am.getToolCalls()) outstanding.add(c.id()); + for (int j = i + 1; j < messages.size() && !outstanding.isEmpty(); j++) { + Message next = messages.get(j); + if (next instanceof ToolResponseMessage trm) { + for (var r : trm.getResponses()) outstanding.remove(r.id()); + } else if (next instanceof AssistantMessage) { + break; + } + } + assertThat(outstanding) + .as("AssistantMessage at index %d has unmatched tool_call ids", i) + .isEmpty(); + } + } + } + + private static ChatResponse makeChatResponse(String text) { + AssistantMessage am = new AssistantMessage(text); + return new ChatResponse(List.of(new Generation(am))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java new file mode 100644 index 00000000..e4e6c666 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java @@ -0,0 +1,111 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin {@link RuntimeContextInjector}'s sender-block inclusion rules. + * + * <p>The sender block is the user-visible payoff of Batch 3 — the LLM + * sees who's talking, what channel they came in on, and whether it's a + * 1:1 or group. Test the inclusion / exclusion matrix here so that any + * regression on the gate (channelType blank / web / cron) is loud. + * + * <p>We deliberately do NOT assert on the time line — it changes every + * second and existing eval baselines already cover it. + */ +class RuntimeContextInjectorSenderTest { + + @Test + @DisplayName("IM origin with sender + chat → block includes channel, sender, chat lines") + void imOriginIncludesAllSenderLines() { + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:oc_abc", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, + /* senderName */ "Alice", + /* channelType */ "feishu", + /* chatId */ "oc_abc"); + + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); + + assertTrue(ctx.contains("Channel: feishu"), "channel line missing: " + ctx); + assertTrue(ctx.contains("Sender: Alice"), "sender name missing: " + ctx); + assertTrue(ctx.contains("id=ou_xyz"), "sender id missing: " + ctx); + assertTrue(ctx.contains("Chat: oc_abc"), "chat line missing: " + ctx); + assertTrue(ctx.contains("group conversation"), "group hint missing: " + ctx); + } + + @Test + @DisplayName("IM origin without chatId → no chat line, no group hint") + void privateChatOmitsChatLine() { + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, + "Alice", "feishu", null); + + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); + + assertTrue(ctx.contains("Channel: feishu")); + assertTrue(ctx.contains("Sender: Alice")); + assertFalse(ctx.contains("Chat:"), "private chat must not emit Chat line"); + assertFalse(ctx.contains("group conversation")); + } + + @Test + @DisplayName("web origin → no sender block (preserves existing prompt cache + eval baseline)") + void webOriginSuppressesSenderBlock() { + ChatOrigin origin = ChatOrigin.web("conv_1", "user-1", 5L, "/data/ws/5"); + + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); + + assertFalse(ctx.contains("Channel:"), "web origin must NOT emit Channel line: " + ctx); + assertFalse(ctx.contains("Sender:")); + assertFalse(ctx.contains("Chat:")); + } + + @Test + @DisplayName("cron origin → no sender block (system-triggered, no human sender)") + void cronOriginSuppressesSenderBlock() { + ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 9L, null); + + String ctx = RuntimeContextInjector.buildContextMessage("", null, origin); + + assertFalse(ctx.contains("Channel:"), "cron origin must NOT emit Channel line: " + ctx); + assertFalse(ctx.contains("Sender:")); + } + + @Test + @DisplayName("null origin → no sender block (matches the no-arg legacy overload exactly)") + void nullOriginNoSenderBlock() { + String withNull = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, null); + String legacy = RuntimeContextInjector.buildContextMessage("/data/ws/5"); + + // Both omit the sender block — and stay byte-identical so the + // legacy overload remains a no-op proxy to the new path. + assertFalse(withNull.contains("Channel:")); + assertFalse(legacy.contains("Channel:")); + } + + @Test + @DisplayName("EMPTY origin → no sender block") + void emptyOriginNoSenderBlock() { + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, ChatOrigin.EMPTY); + assertFalse(ctx.contains("Channel:")); + } + + @Test + @DisplayName("IM origin with blank senderName → still emits Channel line, omits Sender line") + void blankSenderName() { + ChatOrigin origin = new ChatOrigin( + 7L, null, "ou_xyz", null, null, null, null, false, + /* senderName */ " ", "feishu", null); + + String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin); + + assertTrue(ctx.contains("Channel: feishu")); + assertFalse(ctx.contains("Sender:"), "blank senderName must skip Sender line: " + ctx); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java new file mode 100644 index 00000000..fe75725f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java @@ -0,0 +1,138 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link StructuredTruncator} snaps JSON cut points to structural + * boundaries (never mid-token / mid-string) and degrades to plain cuts for + * non-JSON input, all while staying within the requested budget. + */ +class StructuredTruncatorTest { + + /** A 60-element array of uniform objects — the asset-inventory shape from the bug report. */ + private static String jsonArray(int rows) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < rows; i++) { + if (i > 0) { + sb.append(","); + } + sb.append("{\"id\":").append(i) + .append(",\"name\":\"server-").append(i) + .append("\",\"cpu\":8,\"mem\":\"64GB\",\"note\":\"comma,inside,string\"}"); + } + return sb.append("]").toString(); + } + + private static final String MARKER = "...[TRUNCATED]..."; + + @Test + @DisplayName("short input is returned unchanged") + void shortInputUnchanged() { + String s = jsonArray(2); + assertEquals(s, StructuredTruncator.truncate(s, 10_000, 10_000, MARKER)); + } + + @Test + @DisplayName("JSON head ends on a structural boundary, never mid-token") + void headSnapsToBoundary() { + String json = jsonArray(60); + String out = StructuredTruncator.truncate(json, 200, 200, MARKER); + + String head = out.substring(0, out.indexOf(MARKER)); + // The kept head must end right after a complete element/structure char. + char last = head.charAt(head.length() - 1); + assertTrue(last == ',' || last == '}' || last == ']', + "head must end on a JSON boundary, got: ..." + head.substring(Math.max(0, head.length() - 12))); + // And it must be balanced enough that no quote is left dangling open. + assertTrue(quotesBalancedIgnoringEscapes(head), + "head must not end inside a string literal: " + head); + } + + @Test + @DisplayName("JSON tail begins on a structural boundary, never mid-token") + void tailSnapsToBoundary() { + String json = jsonArray(60); + String out = StructuredTruncator.truncate(json, 200, 200, MARKER); + + String tail = out.substring(out.indexOf(MARKER) + MARKER.length()); + assertTrue(quotesBalancedIgnoringEscapes(tail), + "tail must not start inside a string literal: " + tail); + } + + @Test + @DisplayName("result never exceeds head + marker + tail budget") + void staysWithinBudget() { + String json = jsonArray(200); + String out = StructuredTruncator.truncate(json, 800, 800, MARKER); + assertTrue(out.length() <= 800 + MARKER.length() + 800, + "result length " + out.length() + " exceeded budget"); + assertTrue(out.length() < json.length(), "should actually have truncated"); + } + + @Test + @DisplayName("commas inside string values are not treated as boundaries") + void commasInStringsAreNotBoundaries() { + // A single object whose only comma-bearing content is inside a string. + String json = "{\"a\":\"x,y,z,looooooooooooooooooooooooooong,value\",\"b\":1}"; + String out = StructuredTruncator.truncate(json, 8, 8, MARKER); + String head = out.substring(0, out.indexOf(MARKER)); + // The head budget (8) lands inside the quoted value; since the only commas + // are inside the string, no cheap boundary exists → plain cut, but it must + // not have falsely split on an in-string comma earlier than budget. + assertTrue(head.length() <= 8, "head must respect budget when no real boundary exists"); + } + + @Test + @DisplayName("non-JSON text falls back to plain head+tail cut") + void nonJsonPlainCut() { + String text = "x".repeat(5000); + String out = StructuredTruncator.truncate(text, 100, 100, MARKER); + assertEquals("x".repeat(100) + MARKER + "x".repeat(100), out); + } + + @Test + @DisplayName("headSlice snaps a JSON preview to a complete element") + void headSliceSnaps() { + String json = jsonArray(60); + String preview = StructuredTruncator.headSlice(json, 200); + assertTrue(preview.length() <= 200); + char last = preview.charAt(preview.length() - 1); + assertTrue(last == ',' || last == '}' || last == ']', + "preview must end on a JSON boundary, got: " + preview); + assertFalse(preview.equals(json)); + } + + @Test + @DisplayName("null input is tolerated") + void nullSafe() { + assertEquals(null, StructuredTruncator.truncate(null, 10, 10, MARKER)); + assertEquals(null, StructuredTruncator.headSlice(null, 10)); + } + + /** True when double-quotes (ignoring backslash-escaped ones) are balanced, i.e. the + * fragment does not end while still inside a string literal. */ + private static boolean quotesBalancedIgnoringEscapes(String s) { + boolean inString = false; + boolean escaped = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + } else if (c == '"') { + inString = true; + } + } + return !inString; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java index 5b749dc4..e00454cc 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java @@ -169,7 +169,7 @@ class LaneDPerformanceFixesTest { } @Test - @DisplayName("SERVER_ERROR keeps full MAX_RETRIES=5 (not capped like RATE_LIMIT)") + @DisplayName("SERVER_ERROR keeps full MAX_RETRIES (not capped like RATE_LIMIT)") void serverErrorKeepsFullRetries() { AtomicInteger callCount = new AtomicInteger(0); ChatModel model = mock(ChatModel.class); @@ -181,12 +181,17 @@ class LaneDPerformanceFixesTest { var helper = helper(model); var result = helper.streamCall(model, smallPrompt(), "conv-d2b", "reasoning"); - // SERVER_ERROR should use the full MAX_RETRIES=5 (6 total calls: attempt 0-5), - // NOT the reduced MAX_RETRIES_RATE_LIMIT=2. - assertTrue(callCount.get() > 3, - "SERVER_ERROR should retry more than RATE_LIMIT (>3 calls), but got " + callCount.get()); - assertEquals(6, callCount.get(), - "SERVER_ERROR should try 6 times total (attempt 0 through 5)"); + // SERVER_ERROR should use the full MAX_RETRIES budget, NOT the reduced + // MAX_RETRIES_RATE_LIMIT=2. Total calls = MAX_RETRIES + 1 (attempt 0 + + // MAX_RETRIES retries). Reading the constant directly keeps this test + // in sync if MAX_RETRIES changes again — last bumped 5 -> 10 in + // commit 1dd99b68 to ride out provider flaps under wiki batch load. + int expectedCalls = NodeStreamingChatHelper.MAX_RETRIES + 1; + assertTrue(callCount.get() > NodeStreamingChatHelper.MAX_RETRIES_RATE_LIMIT + 1, + "SERVER_ERROR should retry more than RATE_LIMIT, but got " + callCount.get()); + assertEquals(expectedCalls, callCount.get(), + "SERVER_ERROR should try " + expectedCalls + " times total " + + "(attempt 0 through " + NodeStreamingChatHelper.MAX_RETRIES + ")"); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java index 226fa2c6..71723b93 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java @@ -166,6 +166,36 @@ class NodeStreamingChatHelperFailoverTest { verify(fallback, times(1)).stream(any(Prompt.class)); } + // ============================================================ + // C5 regression: RATE_LIMIT (429) must fall back, not surface + // ============================================================ + + /** + * Prior to this fix a rate-limited primary exhausted its 2 same-model + * retries and then {@code return}ed the 429 error result directly, + * skipping the fallback chain entirely — the 429 surfaced as the + * conversation's answer even though other providers were healthy. + * After the fix RATE_LIMIT breaks out to the chain walker, mirroring + * AUTH_ERROR / BILLING. + * + * <p>Note: this test waits out two real retry backoffs (~3s + ~6s) on + * the primary before the hand-off, so it runs for ~10s by design.</p> + */ + @Test + @DisplayName("C5 (regression): primary RATE_LIMIT (429) hands off to the fallback chain") + void rateLimitFallsBack() { + ChatModel primary = errorModel(new RuntimeException("429 Too Many Requests")); + ChatModel fallback = successModel("recovered after rate limit"); + var helper = helper(primary, List.of(new FallbackEntry("dashscope", fallback)), "zhipu-cn"); + + var result = helper.streamCall(primary, smallPrompt(), "conv-c5", "reasoning"); + + assertEquals("recovered after rate limit", result.text(), + "a rate-limited primary must fail over instead of surfacing the 429"); + verify(primary, atLeast(2)).stream(any(Prompt.class)); + verify(fallback, times(1)).stream(any(Prompt.class)); + } + // ============================================================ // Bonus: confirm no infinite loop / regression on success path // ============================================================ diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java index f5ef3903..b195081c 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java @@ -179,19 +179,26 @@ class NodeStreamingChatHelperPoolTest { } @Test - @DisplayName("Primary MODEL_NOT_FOUND HARD-removes with MODEL_NOT_FOUND source") - void primaryModelNotFoundEvictsWithCorrectSource() { + @DisplayName("Primary MODEL_NOT_FOUND keeps the provider in the pool (model-scoped, not provider-wide)") + void primaryModelNotFoundKeepsProviderInPool() { pool.add("openai"); pool.add("dashscope"); + // One model id is rejected — the provider's other models are still fine, + // so the provider must stay usable for them. ChatModel primary = errorModel(new RuntimeException("404 model_not_found: gpt-99")); ChatModel fallback = successModel("ok"); var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai"); - helper.streamCall(primary, smallPrompt(), "conv-h3c", "reasoning"); + var result = helper.streamCall(primary, smallPrompt(), "conv-h3c", "reasoning"); - assertFalse(pool.contains("openai")); - assertEquals(RemovalSource.MODEL_NOT_FOUND, pool.snapshot().get("openai").source()); + assertEquals("ok", result.text(), "request still succeeds via the fallback chain"); + assertTrue(pool.contains("openai"), + "MODEL_NOT_FOUND rejects one model id — the provider's sibling models stay usable"); + assertNull(pool.snapshot().get("openai"), + "a model-scoped error must not record a provider removal reason"); + assertNull(healthTracker.snapshot().get("openai"), + "MODEL_NOT_FOUND must not nudge the provider toward cooldown"); } // ============================================================ diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java new file mode 100644 index 00000000..39925131 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java @@ -0,0 +1,76 @@ +package vip.mate.agent.graph.edge; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the dual-target dispatcher: instances configured for ReAct + * route to REASONING_NODE on followup, instances configured for + * Plan-Execute route to PLAN_GENERATION_NODE on followup, and both + * route to END otherwise. + */ +class GoalEvaluationDispatcherTest { + + private OverAllState stateWith(boolean followup) { + return stateWith(followup, false); + } + + private OverAllState stateWith(boolean followup, boolean terminal) { + OverAllState s = mock(OverAllState.class); + lenient().when(s.value("goal_followup_injected", false)).thenReturn(followup); + lenient().when(s.value("goal_evaluated_this_run", false)).thenReturn(terminal); + return s; + } + + @Test + void reactInstance_routesFollowupToReasoning() throws Exception { + GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__"); + assertEquals("reasoning", d.apply(stateWith(true))); + } + + @Test + void reactInstance_routesTerminalToEnd() throws Exception { + GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__"); + assertEquals("__END__", d.apply(stateWith(false))); + } + + @Test + void planExecuteInstance_routesFollowupToPlanGeneration() throws Exception { + GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__"); + assertEquals("plan_generation", d.apply(stateWith(true))); + } + + @Test + void planExecuteInstance_routesTerminalToEnd() throws Exception { + GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__"); + assertEquals("__END__", d.apply(stateWith(false))); + } + + // ===== Run-to-completion loop guard ===== + + @Test + void followupOnNonTerminalPass_reentersLoop() throws Exception { + // The self-continuation loop: followup injected, not a terminal pass. + GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__"); + assertEquals("reasoning", d.apply(stateWith(true, false))); + } + + @Test + void followupFlagLingeringOnTerminalPass_routesToEnd() throws Exception { + // GOAL_FOLLOWUP_INJECTED uses REPLACE and is never cleared, so after a + // run-to-completion loop it can still be true on the final (completed / + // exhausted) pass. goalEvaluatedThisRun == true must win and END the run, + // otherwise the graph loops forever. + GoalEvaluationDispatcher react = new GoalEvaluationDispatcher("reasoning", "__END__"); + assertEquals("__END__", react.apply(stateWith(true, true))); + GoalEvaluationDispatcher plan = new GoalEvaluationDispatcher("plan_generation", "__END__"); + assertEquals("__END__", plan.apply(stateWith(true, true))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java new file mode 100644 index 00000000..4f5560be --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java @@ -0,0 +1,95 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link ActionNode#extractLoadedSkillNames} — the load_skill + * detection that feeds the {@code LOADED_SKILLS} catalog pin. + */ +class ActionNodeLoadSkillTest { + + private static AssistantMessage.ToolCall call(String name, String args) { + return new AssistantMessage.ToolCall("id-" + name, "function", name, args); + } + + @Test + @DisplayName("empty / null batch yields no names") + void emptyBatch() { + assertTrue(ActionNode.extractLoadedSkillNames(null).isEmpty()); + assertTrue(ActionNode.extractLoadedSkillNames(List.of()).isEmpty()); + } + + @Test + @DisplayName("non-load_skill calls are ignored") + void nonLoadSkillIgnored() { + List<AssistantMessage.ToolCall> calls = List.of( + call("web_search", "{\"query\":\"x\"}"), + call("read_file", "{\"path\":\"/tmp/a\"}")); + assertTrue(ActionNode.extractLoadedSkillNames(calls).isEmpty()); + } + + @Test + @DisplayName("load_skill skillName arg is extracted") + void extractsSkillName() { + List<AssistantMessage.ToolCall> calls = List.of( + call("load_skill", "{\"skillName\":\"pdf\"}")); + assertEquals(Set.of("pdf"), ActionNode.extractLoadedSkillNames(calls)); + } + + @Test + @DisplayName("multiple load_skill calls collect every name, order preserved") + void multipleLoads() { + List<AssistantMessage.ToolCall> calls = List.of( + call("load_skill", "{\"skillName\":\"pdf\"}"), + call("web_search", "{\"query\":\"x\"}"), + call("load_skill", "{\"skillName\":\"docx\",\"filePath\":\"references/a.md\"}")); + assertEquals(Set.of("pdf", "docx"), ActionNode.extractLoadedSkillNames(calls)); + } + + @Test + @DisplayName("alternate arg keys skill_name / name are accepted") + void alternateKeys() { + assertEquals(Set.of("alpha"), + ActionNode.extractLoadedSkillNames(List.of(call("load_skill", "{\"skill_name\":\"alpha\"}")))); + assertEquals(Set.of("beta"), + ActionNode.extractLoadedSkillNames(List.of(call("load_skill", "{\"name\":\"beta\"}")))); + } + + @Test + @DisplayName("malformed or empty args are skipped without throwing") + void malformedArgsSkipped() { + List<AssistantMessage.ToolCall> calls = List.of( + call("load_skill", "not-json"), + call("load_skill", ""), + call("load_skill", "{\"skillName\":\"\"}"), + call("load_skill", "{\"other\":\"y\"}")); + assertTrue(ActionNode.extractLoadedSkillNames(calls).isEmpty()); + } + + @Test + @DisplayName("enable_tool toolName arg is extracted; non-enable_tool ignored") + void extractsEnabledToolNames() { + List<AssistantMessage.ToolCall> calls = List.of( + call("enable_tool", "{\"toolName\":\"image_generate\"}"), + call("web_search", "{\"query\":\"x\"}"), + call("enable_tool", "{\"tool_name\":\"music_generate\"}")); + assertEquals(Set.of("image_generate", "music_generate"), + ActionNode.extractEnabledToolNames(calls)); + } + + @Test + @DisplayName("enable_tool detection ignores empty batch and load_skill calls") + void enableToolEmptyAndCrossTalk() { + assertTrue(ActionNode.extractEnabledToolNames(null).isEmpty()); + assertTrue(ActionNode.extractEnabledToolNames( + List.of(call("load_skill", "{\"skillName\":\"pdf\"}"))).isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeEmptyCompletionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeEmptyCompletionTest.java new file mode 100644 index 00000000..34ce9462 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeEmptyCompletionTest.java @@ -0,0 +1,68 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.NodeStreamingChatHelper.ErrorType; +import vip.mate.agent.graph.NodeStreamingChatHelper.StreamResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ReasoningNode#isEmptyCompletion} — the predicate that decides + * whether a model turn is a blank no-op worth re-prompting (vs a real answer, a + * tool call, or a failure handled by another branch). A blank turn must NOT be + * accepted as a final answer; that is what made a long multi-step task quit + * mid-way. + */ +class ReasoningNodeEmptyCompletionTest { + + private static StreamResult turn(String text, String thinking, boolean hasToolCalls) { + return new StreamResult(text, thinking, null, List.of(), hasToolCalls, 0, 0); + } + + @Test + @DisplayName("No tool call + blank text + blank thinking → empty (re-prompt).") + void blankTurnIsEmpty() { + assertTrue(ReasoningNode.isEmptyCompletion(turn("", "", false))); + assertTrue(ReasoningNode.isEmptyCompletion(turn(" ", " ", false))); + assertTrue(ReasoningNode.isEmptyCompletion(turn(null, null, false))); + } + + @Test + @DisplayName("Any content or thinking → not empty.") + void contentOrThinkingNotEmpty() { + assertFalse(ReasoningNode.isEmptyCompletion(turn("here is the answer", "", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "let me reason", false))); + } + + @Test + @DisplayName("A tool call is real progress → not empty.") + void toolCallNotEmpty() { + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "", true))); + } + + @Test + @DisplayName("null result → not empty (nothing to re-prompt).") + void nullNotEmpty() { + assertFalse(ReasoningNode.isEmptyCompletion(null)); + } + + @Test + @DisplayName("Fatal / prompt-too-long / partial belong to other branches, not 'empty'.") + void otherFailuresNotEmpty() { + StreamResult fatal = new StreamResult("", "", null, List.of(), false, 0, 0, + false, "upstream boom", ErrorType.SERVER_ERROR); + assertFalse(ReasoningNode.isEmptyCompletion(fatal)); + + StreamResult promptTooLong = new StreamResult("", "", null, List.of(), false, 0, 0, + false, null, ErrorType.PROMPT_TOO_LONG); + assertFalse(ReasoningNode.isEmptyCompletion(promptTooLong)); + + StreamResult partial = new StreamResult("", "", null, List.of(), false, 0, 0, + true, null, ErrorType.NONE); + assertFalse(ReasoningNode.isEmptyCompletion(partial)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java new file mode 100644 index 00000000..34679910 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java @@ -0,0 +1,156 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.AgentToolSet; +import vip.mate.wiki.service.WikiContextService; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression guard: wiki / runtime-context content must survive a + * {@code prompt_too_long} retry. + * <p> + * An earlier PTL retry path in {@code ReasoningNode} reassembled the prompt + * with only the system prompt + workspace runtime context — the wiki + * relevant snippet that the initial assembly injected was silently dropped, + * so the retried prompt asked the model the same question with strictly + * less context. {@code buildNonHistoryPrefix(...)} now centralises the + * three-layer prefix so the initial assembly and the retry path consume the + * same returned list. This test pins that contract: the prefix list + * contains all three layers and the wiki layer reflects what + * {@link WikiContextService#buildRelevantContext} returned. + */ +class ReasoningNodePtlPromptTest { + + private static final String WIKI_RELEVANT_TEXT = + "[Wiki Relevant Pages]\n- module-X.md (matches 'investigate'): ..."; + + @Test + void prefixIncludesSystemRuntimeAndWikiSegments() { + WikiContextService wikiContextService = mock(WikiContextService.class); + when(wikiContextService.buildRelevantContext(eq(42L), anyString())) + .thenReturn(WIKI_RELEVANT_TEXT); + + ReasoningNode node = newNode(wikiContextService); + + List<Message> prefix = node.buildNonHistoryPrefix( + "you are a helpful assistant", + "/workspace/active", + "42", + "investigate the bug in module X", + vip.mate.agent.context.ChatOrigin.EMPTY); + + // Three layers: System, runtime-context UserMessage, wiki UserMessage. + assertThat(prefix).hasSize(3); + assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class); + assertThat(prefix.get(0).getText()).contains("you are a helpful assistant"); + assertThat(prefix.get(1)).isInstanceOf(UserMessage.class); + assertThat(prefix.get(2)).isInstanceOf(UserMessage.class); + assertThat(prefix.get(2).getText()).isEqualTo(WIKI_RELEVANT_TEXT); + } + + @Test + void buildIsDeterministicAcrossCalls_soInitialAndRetryShareIdenticalLayout() { + // Critical regression invariant: both Prompt assemblies (initial and + // PTL retry) consume the SAME list reference, so the wiki segment + // can never diverge between them. Belt-and-suspenders, also verify + // that two independent calls with the same inputs produce + // structurally identical output. + WikiContextService wikiContextService = mock(WikiContextService.class); + when(wikiContextService.buildRelevantContext(eq(42L), anyString())) + .thenReturn(WIKI_RELEVANT_TEXT); + + ReasoningNode node = newNode(wikiContextService); + + List<Message> a = node.buildNonHistoryPrefix( + "sys", "/workspace", "42", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); + List<Message> b = node.buildNonHistoryPrefix( + "sys", "/workspace", "42", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); + + assertThat(a).hasSameSizeAs(b); + for (int i = 0; i < a.size(); i++) { + assertThat(a.get(i).getClass()).isEqualTo(b.get(i).getClass()); + assertThat(a.get(i).getText()).isEqualTo(b.get(i).getText()); + } + } + + @Test + void noWikiServiceWiredSkipsWikiSegment() { + // wikiContextService is optional — when null (e.g. minimal config or + // a test rig), the prefix should still be valid: just system + + // runtime context, no wiki layer. + ReasoningNode node = newNode(null); + + List<Message> prefix = node.buildNonHistoryPrefix( + "you are a helpful assistant", + "/workspace/active", + "42", + "investigate the bug in module X", + vip.mate.agent.context.ChatOrigin.EMPTY); + + assertThat(prefix).hasSize(2); + assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class); + assertThat(prefix.get(1)).isInstanceOf(UserMessage.class); + } + + @Test + void nonNumericAgentIdSkipsWikiSegment() { + WikiContextService wikiContextService = mock(WikiContextService.class); + ReasoningNode node = newNode(wikiContextService); + + List<Message> prefix = node.buildNonHistoryPrefix( + "sys", "/workspace", "not-a-number", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); + + // Non-numeric agentId is the contract carried over from the + // pre-refactor codebase — skip wiki injection rather than throwing. + assertThat(prefix).hasSize(2); + verify(wikiContextService, + org.mockito.Mockito.never()).buildRelevantContext( + org.mockito.ArgumentMatchers.anyLong(), anyString()); + } + + @Test + void blankWikiResultSkipsWikiSegment() { + WikiContextService wikiContextService = mock(WikiContextService.class); + when(wikiContextService.buildRelevantContext(eq(42L), anyString())) + .thenReturn(" "); // blank → drop the layer + + ReasoningNode node = newNode(wikiContextService); + + List<Message> prefix = node.buildNonHistoryPrefix( + "sys", "/workspace", "42", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); + + assertThat(prefix).hasSize(2); + } + + private static ReasoningNode newNode(WikiContextService wikiContextService) { + // 9-arg constructor — explicit supportsReasoningEffort + empty + // tool set, nulls for the streaming / conversation-window deps we + // don't exercise here. + AgentToolSet emptyTools = AgentToolSet.fromCallbacks(List.of(), List.of()); + return new ReasoningNode( + /* chatModel */ null, + /* toolSet */ emptyTools, + /* reasoningEffort */ null, + /* supportsReasoningEffort */ false, + /* streamingHelper */ null, + /* conversationWindowManager */ null, + /* streamTracker */ null, + /* maxOutputTokens */ 1024, + wikiContextService); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java new file mode 100644 index 00000000..f65c99fc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java @@ -0,0 +1,33 @@ +package vip.mate.agent.graph.plan.state; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.state.MateClawStateKeys; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlanStateAccessorUsageTest { + + @Test + void mergeUsageAlsoIncrementsSharedLlmCallCount() { + Map<String, Object> state = new HashMap<>(); + state.put(MateClawStateKeys.PROMPT_TOKENS, 10); + state.put(MateClawStateKeys.COMPLETION_TOKENS, 20); + state.put(MateClawStateKeys.LLM_CALL_COUNT, 2); + NodeStreamingChatHelper.StreamResult result = + new NodeStreamingChatHelper.StreamResult("ok", "", null, List.of(), false, 3, 4); + + Map<String, Object> output = PlanStateAccessor.output() + .mergeUsage(new OverAllState(state), result) + .build(); + + assertEquals(13, output.get(MateClawStateKeys.PROMPT_TOKENS)); + assertEquals(24, output.get(MateClawStateKeys.COMPLETION_TOKENS)); + assertEquals(3, output.get(MateClawStateKeys.LLM_CALL_COUNT)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/MateClawStateAccessorTerminalAnswerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/MateClawStateAccessorTerminalAnswerTest.java new file mode 100644 index 00000000..b58588b7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/MateClawStateAccessorTerminalAnswerTest.java @@ -0,0 +1,62 @@ +package vip.mate.agent.graph.state; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pins the bridge that lets GoalEvaluationNode work uniformly across + * graph flavors: + * <ul> + * <li>ReAct path: FinalAnswerNode writes FINAL_ANSWER.</li> + * <li>Plan-Execute long path: PlanSummaryNode writes FINAL_SUMMARY.</li> + * <li>Plan-Execute short path: DirectAnswerNode writes DIRECT_ANSWER.</li> + * </ul> + */ +class MateClawStateAccessorTerminalAnswerTest { + + private OverAllState mockState(String finalAnswer, String finalSummary, String directAnswer) { + OverAllState s = mock(OverAllState.class); + lenient().when(s.value(eq("final_answer"), eq(""))).thenReturn(finalAnswer); + lenient().when(s.value(eq("final_summary"), eq(""))).thenReturn(finalSummary); + lenient().when(s.value(eq("direct_answer"), eq(""))).thenReturn(directAnswer); + return s; + } + + @Test + void reactPath_returnsFinalAnswer() { + OverAllState s = mockState("ReAct answer", "", ""); + assertEquals("ReAct answer", new MateClawStateAccessor(s).terminalAnswer()); + } + + @Test + void planExecuteLongPath_returnsFinalSummary() { + OverAllState s = mockState("", "Plan summary text", ""); + assertEquals("Plan summary text", new MateClawStateAccessor(s).terminalAnswer()); + } + + @Test + void planExecuteShortPath_returnsDirectAnswer() { + OverAllState s = mockState("", "", "Direct quick answer"); + assertEquals("Direct quick answer", new MateClawStateAccessor(s).terminalAnswer()); + } + + @Test + void allEmpty_returnsEmptyString() { + OverAllState s = mockState("", "", ""); + assertEquals("", new MateClawStateAccessor(s).terminalAnswer()); + } + + @Test + void finalAnswerWins_overFinalSummary() { + // Defensive: if both happen to be populated (shouldn't, but state + // is shared across graphs in tests), FINAL_ANSWER takes priority. + OverAllState s = mockState("ReAct answer", "stale summary", ""); + assertEquals("ReAct answer", new MateClawStateAccessor(s).terminalAnswer()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerServiceConcurrencyTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerServiceConcurrencyTest.java new file mode 100644 index 00000000..53a0cfc8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerServiceConcurrencyTest.java @@ -0,0 +1,146 @@ +package vip.mate.agent.progress; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the per-conversation mutex inside + * {@link ProgressLedgerService#upsert} — the load-mutate-save sequence + * must serialise per conversation, otherwise N parallel + * {@code progress_update} tool calls on the same conversation collapse + * to last-writer-wins and silently drop entries. + * + * <p>Repro: in round-3 of the LLM-review test, the model pre-registered + * 12 entries in a single batch of parallel tool calls; only 7-8 survived + * to the DB, the rest were lost, and the agent later re-did completed + * work because the snapshot it saw was missing the pending entries. + * + * <p>Uses an in-memory subclass of the service rather than mocking + * Mybatis-Plus: the JSON I/O methods are protected for exactly this + * purpose. + */ +class ProgressLedgerServiceConcurrencyTest { + + /** + * Test double — overrides the two protected DB methods to read/write a + * thread-safe in-memory map. The {@code upsert} logic (including the + * per-conversation mutex under test) inherits unchanged from the + * parent. + */ + private static final class InMemoryProgressLedgerService extends ProgressLedgerService { + private final Map<String, String> store = new ConcurrentHashMap<>(); + + InMemoryProgressLedgerService() { + super(null, new ObjectMapper().registerModule(new JavaTimeModule())); + } + + @Override + protected String loadLedgerJson(String conversationId) { + return store.get(conversationId); + } + + @Override + protected void saveLedgerJson(String conversationId, String json) { + store.put(conversationId, json); + } + } + + @Test + @DisplayName("12 parallel upserts on one conversation all survive — no last-writer-wins drops.") + void parallelUpsertsAllSurvive() throws Exception { + ProgressLedgerService service = new InMemoryProgressLedgerService(); + String conv = "conv-race-1"; + + int n = 12; + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(n); + AtomicInteger failures = new AtomicInteger(); + + for (int i = 0; i < n; i++) { + final int idx = i; + pool.submit(() -> { + try { + start.await(); + service.upsert(conv, "step_" + idx, "Step " + idx, ProgressStatus.PENDING, null); + } catch (Exception e) { + failures.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(10, TimeUnit.SECONDS), "all upsert threads must finish within 10s"); + pool.shutdown(); + + assertEquals(0, failures.get(), "no thread should fail"); + ProgressLedger finalLedger = service.load(conv); + assertEquals(n, finalLedger.size(), + "all " + n + " parallel entries must survive; got " + finalLedger.size() + + " — keys=" + finalLedger.asMap().keySet()); + for (int i = 0; i < n; i++) { + assertTrue(finalLedger.asMap().containsKey("step_" + i), + "expected key step_" + i + " in final ledger"); + } + } + + @Test + @DisplayName("Parallel upserts on DIFFERENT conversations do not contend.") + void differentConversationsAreIndependent() throws Exception { + ProgressLedgerService service = new InMemoryProgressLedgerService(); + + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch done = new CountDownLatch(2); + + pool.submit(() -> { + for (int i = 0; i < 5; i++) { + service.upsert("conv-A", "a_" + i, "A " + i, ProgressStatus.DONE, null); + } + done.countDown(); + }); + pool.submit(() -> { + for (int i = 0; i < 5; i++) { + service.upsert("conv-B", "b_" + i, "B " + i, ProgressStatus.DONE, null); + } + done.countDown(); + }); + assertTrue(done.await(5, TimeUnit.SECONDS)); + pool.shutdown(); + + assertEquals(5, service.load("conv-A").size()); + assertEquals(5, service.load("conv-B").size()); + } + + @Test + @DisplayName("Sequential updates on the same key advance status in order.") + void sequentialStatusTransitions() { + ProgressLedgerService service = new InMemoryProgressLedgerService(); + String conv = "conv-X"; + + service.upsert(conv, "step_a", "Step A", ProgressStatus.PENDING, null); + service.upsert(conv, "step_a", null, ProgressStatus.IN_PROGRESS, "working"); + service.upsert(conv, "step_a", null, ProgressStatus.DONE, "finished"); + + ProgressLedger ledger = service.load(conv); + assertEquals(1, ledger.size()); + ProgressEntry e = ledger.asMap().get("step_a"); + assertEquals(ProgressStatus.DONE, e.getStatus()); + // Label survives the null-label updates by falling back to existing value. + assertEquals("Step A", e.getLabel()); + assertEquals("finished", e.getNote()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java new file mode 100644 index 00000000..b53d766f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java @@ -0,0 +1,103 @@ +package vip.mate.agent.progress; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ProgressLedger#renderSnapshot} — the exact string the runtime + * splices into the system prompt before each LLM call. Order of the buckets + * and the per-entry shape are part of the contract; the agent is going to + * parse this text every turn. + */ +class ProgressLedgerSnapshotTest { + + @Test + @DisplayName("Empty ledger renders null so the runtime can skip injection.") + void emptyRendersNull() { + assertNull(ProgressLedger.empty().renderSnapshot()); + assertNull(new ProgressLedger(new LinkedHashMap<>()).renderSnapshot()); + } + + @Test + @DisplayName("Buckets ordered done → in-progress → pending → blocked, with stable status icons.") + void bucketOrdering() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("a", entry("a", "Step A", ProgressStatus.PENDING, null)); + entries.put("b", entry("b", "Step B", ProgressStatus.DONE, null)); + entries.put("c", entry("c", "Step C", ProgressStatus.IN_PROGRESS, null)); + entries.put("d", entry("d", "Step D", ProgressStatus.BLOCKED, "missing dep")); + + String out = new ProgressLedger(entries).renderSnapshot(); + assertNotNull(out); + + int done = out.indexOf("✅"); + int inProg = out.indexOf("🔄"); + int pending = out.indexOf("⏳"); + int blocked = out.indexOf("⛔"); + assertTrue(done >= 0 && inProg > done && pending > inProg && blocked > pending, + "Bucket order should be done → in-progress → pending → blocked: " + out); + } + + @Test + @DisplayName("Empty buckets are suppressed — no \"0 entries\" placeholder noise.") + void emptyBucketsAreOmitted() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("only", entry("only", "Only step", ProgressStatus.DONE, null)); + String out = new ProgressLedger(entries).renderSnapshot(); + assertNotNull(out); + assertTrue(out.contains("✅")); + assertFalse(out.contains("🔄")); + assertFalse(out.contains("⏳")); + assertFalse(out.contains("⛔")); + } + + @Test + @DisplayName("Each entry shows label + bracketed key + optional note suffix.") + void entryShape() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("step_pptx", entry("step_pptx", "Generate PPTX", ProgressStatus.IN_PROGRESS, + "currently on slide 4")); + String out = new ProgressLedger(entries).renderSnapshot(); + assertNotNull(out); + assertTrue(out.contains("Generate PPTX"), out); + assertTrue(out.contains("[`step_pptx`]"), out); + assertTrue(out.contains("— currently on slide 4"), out); + } + + @Test + @DisplayName("A very long note is truncated to the preview cap with an ellipsis.") + void longNoteTruncated() { + String huge = "x".repeat(500); + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("k", entry("k", "K", ProgressStatus.DONE, huge)); + String out = new ProgressLedger(entries).renderSnapshot(); + assertNotNull(out); + assertTrue(out.endsWith("\n请基于此进度继续推进;已完成的步骤不要重复执行。完成新步骤后调用 `progress_update` 工具更新本账本。") + || out.contains("…"), "expected ellipsis when note exceeds preview cap"); + // Snapshot must be far smaller than the raw 500-char note. + assertTrue(out.length() < 500, "snapshot length=" + out.length()); + } + + @Test + @DisplayName("Missing label falls back to the key so the bullet is never blank.") + void missingLabelFallsBackToKey() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("only_key", entry("only_key", null, ProgressStatus.PENDING, null)); + String out = new ProgressLedger(entries).renderSnapshot(); + assertNotNull(out); + assertTrue(out.contains("only_key"), out); + } + + private static ProgressEntry entry(String key, String label, ProgressStatus status, String note) { + return new ProgressEntry(key, label, status, note, Instant.now()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java new file mode 100644 index 00000000..b997979a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java @@ -0,0 +1,96 @@ +package vip.mate.agent.progress; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ProgressLedger#renderStaleReminder} — the heuristic that + * decides whether to inject a "the model is forgetting its ledger" warning + * into the next reasoning step. Triggers were calibrated against the + * round-4 failure mode where the model called progress_update 3 times in + * the first 30s and then never again across the remaining 27 minutes. + */ +class ProgressLedgerStaleReminderTest { + + private static final Instant NOW = Instant.parse("2026-05-24T19:30:00Z"); + + @Test + @DisplayName("Iteration < 10 → no reminder regardless of ledger state.") + void warmupPeriodNoReminder() { + assertNull(ProgressLedger.empty().renderStaleReminder(0, NOW)); + assertNull(ProgressLedger.empty().renderStaleReminder(5, NOW)); + assertNull(ProgressLedger.empty().renderStaleReminder(9, NOW)); + } + + @Test + @DisplayName("Empty ledger between iter 10 and 14 → still no reminder.") + void emptyLedgerBelowNudgeThreshold() { + assertNull(ProgressLedger.empty().renderStaleReminder(10, NOW)); + assertNull(ProgressLedger.empty().renderStaleReminder(14, NOW)); + } + + @Test + @DisplayName("Empty ledger at iter ≥ 15 → emit empty-ledger reminder.") + void emptyLedgerTriggersReminder() { + String out = ProgressLedger.empty().renderStaleReminder(15, NOW); + assertNotNull(out); + assertTrue(out.contains("进度账本是空的"), out); + assertTrue(out.contains("15 轮"), out); + assertTrue(out.contains("progress_update"), out); + } + + @Test + @DisplayName("Non-empty ledger with fresh update → no reminder.") + void freshUpdateNoReminder() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.IN_PROGRESS, null, + NOW.minusSeconds(30))); // 30s ago — well within threshold + assertNull(new ProgressLedger(entries).renderStaleReminder(20, NOW)); + } + + @Test + @DisplayName("Non-empty ledger with last update ≥ 90s ago → emit stale reminder.") + void staleUpdateTriggersReminder() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.DONE, null, + NOW.minusSeconds(180))); // 3 min ago + entries.put("b", new ProgressEntry("b", "B", ProgressStatus.PENDING, null, + NOW.minusSeconds(200))); + String out = new ProgressLedger(entries).renderStaleReminder(40, NOW); + assertNotNull(out); + assertTrue(out.contains("180 秒"), "expected gap in reminder: " + out); + assertTrue(out.contains("1 done"), "expected done count: " + out); + assertTrue(out.contains("1 pending"), "expected pending count: " + out); + assertTrue(out.contains("progress_update"), out); + } + + @Test + @DisplayName("Iteration < warm-up overrides stale-gap trigger.") + void warmupBeatsStaleGap() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.DONE, null, + NOW.minusSeconds(600))); + assertNull(new ProgressLedger(entries).renderStaleReminder(5, NOW)); + } + + @Test + @DisplayName("mostRecentUpdate returns the latest updatedAt across entries.") + void mostRecentUpdate() { + Map<String, ProgressEntry> entries = new LinkedHashMap<>(); + Instant t1 = NOW.minusSeconds(300); + Instant t2 = NOW.minusSeconds(100); + Instant t3 = NOW.minusSeconds(200); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.DONE, null, t1)); + entries.put("b", new ProgressEntry("b", "B", ProgressStatus.DONE, null, t2)); + entries.put("c", new ProgressEntry("c", "C", ProgressStatus.DONE, null, t3)); + assertTrue(new ProgressLedger(entries).mostRecentUpdate().orElseThrow().equals(t2)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java new file mode 100644 index 00000000..7d969f05 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java @@ -0,0 +1,49 @@ +package vip.mate.agent.progress; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Pins {@link ProgressStatus#parse} — the only entry the LLM controls. + * The parser must tolerate the variants a model naturally produces (case, + * hyphens, spaces) so a status like "In Progress" doesn't kick the tool + * into a structured-error path purely over formatting. + */ +class ProgressStatusTest { + + @Test + @DisplayName("Wire values round-trip through parse/wireValue.") + void wireValuesRoundtrip() { + for (ProgressStatus s : ProgressStatus.values()) { + assertEquals(s, ProgressStatus.parse(s.wireValue())); + } + } + + @Test + @DisplayName("Mixed case input parses to the same enum.") + void caseInsensitive() { + assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse("In_Progress")); + assertEquals(ProgressStatus.DONE, ProgressStatus.parse("DONE")); + assertEquals(ProgressStatus.PENDING, ProgressStatus.parse("pending")); + } + + @Test + @DisplayName("Hyphen or space variants — \"in-progress\" / \"in progress\" — map to IN_PROGRESS.") + void hyphensAndSpaces() { + assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse("in-progress")); + assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse("in progress")); + assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse(" In Progress ")); + } + + @Test + @DisplayName("Unknown or null inputs return null so the tool can return a structured error.") + void unknownReturnsNull() { + assertNull(ProgressStatus.parse(null)); + assertNull(ProgressStatus.parse("")); + assertNull(ProgressStatus.parse("ready")); + assertNull(ProgressStatus.parse("finished")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java index 19d99525..2411b4fe 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -40,7 +40,11 @@ class ApprovalReplayContinuityTest { /* workspaceId */ 5L, /* workspaceBasePath */ "/data/ws/5", /* channelId */ 9L, - /* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001")); + /* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001"), + /* cronOrigin */ false, + /* senderName */ "Alice", + /* channelType */ "wecom", + /* chatId */ "group-a"); String json = objectMapper.writeValueAsString(original); ChatOrigin restored = workflow.restoreChatOrigin(json); diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java new file mode 100644 index 00000000..71fc80e1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java @@ -0,0 +1,82 @@ +package vip.mate.architecture; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Strict double-registration check for the persistent-goal state keys. + * + * <p>{@link StateKeyRegistrationCoverageTest} only verifies that a key + * appears somewhere in {@code AgentGraphBuilder.java}; it cannot tell + * apart the ReAct and Plan-Execute {@code KeyStrategyFactory} blocks. + * History (the {@code CHAT_ORIGIN} regression) shows that "registered + * once" is not enough — multi-node merges silently drop keys when one + * graph's factory leaves them out. + * + * <p>Each of the five Goal state keys must therefore appear at least + * twice in the builder source: once per graph. This test pins that + * invariant so future regressions get caught at PR time. + */ +class GoalStateKeyDoubleRegistrationTest { + + private static final String[] GOAL_KEYS = { + "ACTIVE_GOAL", + "GOAL_EVALUATION_RESULT", + "GOAL_FOLLOWUP_INJECTED", + "GOAL_FOLLOWUP_PROMPT", + "GOAL_EVALUATED_THIS_RUN", + }; + + @Test + void everyGoalKeyMustAppearAtLeastTwiceInAddStrategyCalls() throws Exception { + Path src = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java") + .toAbsolutePath(); + if (!Files.exists(src)) { + fail("Cannot find AgentGraphBuilder.java at " + src); + } + String content = Files.readString(src); + + for (String key : GOAL_KEYS) { + Pattern p = Pattern.compile( + "\\.addStrategy\\(\\s*MateClawStateKeys\\." + key + "\\b"); + Matcher m = p.matcher(content); + int count = 0; + while (m.find()) count++; + if (count < 2) { + fail("Goal state key " + key + " must be registered in BOTH the " + + "ReAct and Plan-Execute KeyStrategyFactory blocks " + + "(found " + count + " addStrategy occurrence(s) in " + + "AgentGraphBuilder.java). The architecture coverage " + + "test only checks 'appears somewhere'; this test " + + "is the strict double-registration guard documented " + + "in RFC 48 §3.2 v2."); + } + assertTrue(count >= 2, + "Sanity: " + key + " should have >=2 addStrategy calls"); + } + } + + @Test + void goalEvaluationNodeIdentifierAppearsExactlyOncePerGraph() throws Exception { + Path src = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java") + .toAbsolutePath(); + String content = Files.readString(src); + Pattern p = Pattern.compile( + "\\.addNode\\(\\s*MateClawStateKeys\\.GOAL_EVALUATION_NODE\\b"); + Matcher m = p.matcher(content); + int count = 0; + while (m.find()) count++; + assertEquals(2, count, + "GOAL_EVALUATION_NODE must be added as a node in BOTH graphs " + + "(ReAct + Plan-Execute). Found " + count + " addNode calls."); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/SkillStateKeyDoubleRegistrationTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/SkillStateKeyDoubleRegistrationTest.java new file mode 100644 index 00000000..b5a85a17 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/architecture/SkillStateKeyDoubleRegistrationTest.java @@ -0,0 +1,62 @@ +package vip.mate.architecture; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Strict double-registration check for the skill progressive-disclosure state + * keys. + * + * <p>{@link StateKeyRegistrationCoverageTest} only verifies that a key appears + * somewhere in {@code AgentGraphBuilder.java}; it cannot tell apart the ReAct + * and Plan-Execute {@code KeyStrategyFactory} blocks. The {@code CHAT_ORIGIN} + * regression showed that "registered once" is not enough — multi-node merges + * silently drop keys when one graph's factory leaves them out. + * + * <p>{@code LOADED_SKILLS} is written via read-merge-write in ActionNode, so a + * dropped key would silently disable the load_skill catalog pin. It must appear + * in BOTH factory blocks. + */ +class SkillStateKeyDoubleRegistrationTest { + + private static final String[] SKILL_KEYS = { + "LOADED_SKILLS", + "ENABLED_EXTENSION_TOOLS", + }; + + @Test + void everySkillDisclosureKeyMustAppearAtLeastTwiceInAddStrategyCalls() throws Exception { + Path src = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java") + .toAbsolutePath(); + if (!Files.exists(src)) { + fail("Cannot find AgentGraphBuilder.java at " + src); + } + String content = Files.readString(src); + + for (String key : SKILL_KEYS) { + Pattern p = Pattern.compile( + "\\.addStrategy\\(\\s*MateClawStateKeys\\." + key + "\\b"); + Matcher m = p.matcher(content); + int count = 0; + while (m.find()) count++; + if (count < 2) { + fail("Skill disclosure state key " + key + " must be registered in BOTH the " + + "ReAct and Plan-Execute KeyStrategyFactory blocks " + + "(found " + count + " addStrategy occurrence(s) in " + + "AgentGraphBuilder.java). Without double registration the " + + "spring-ai-alibaba-graph merge can drop the key, silently " + + "disabling the load_skill catalog pin."); + } + assertTrue(count >= 2, + "Sanity: " + key + " should have >=2 addStrategy calls"); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index 3ede4388..ab929f22 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -56,6 +56,12 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.notification.ApprovalNotificationService.class), mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class), mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class), + mock(vip.mate.channel.feishu.FeishuMediaUploader.class), + mock(vip.mate.channel.media.GeneratedFileScrubber.class), + mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class), + mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class), + mock(vip.mate.channel.feishu.FeishuClientFactory.class), + mock(vip.mate.stt.SttService.class), election); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java new file mode 100644 index 00000000..ccc7d50b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java @@ -0,0 +1,67 @@ +package vip.mate.channel; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.service.ChannelService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.memory.event.ConversationCompletionPublisher; +import vip.mate.tts.TtsService; +import vip.mate.workspace.conversation.ConversationService; + +import java.lang.reflect.Method; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ChannelMessageRouterApprovalDenyTest { + + @Test + void denyAlreadyResolvedDoesNotRewriteConversationOrBroadcastDeniedHint() throws Exception { + AgentService agentService = mock(AgentService.class); + ConversationService conversationService = mock(ConversationService.class); + ChannelService channelService = mock(ChannelService.class); + ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class); + ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class); + ApprovalNotificationService approvalNotificationService = mock(ApprovalNotificationService.class); + ConversationCompletionPublisher completionPublisher = mock(ConversationCompletionPublisher.class); + TtsService ttsService = mock(TtsService.class); + ChatStreamTracker streamTracker = mock(ChatStreamTracker.class); + ChannelChatOriginFactory chatOriginFactory = mock(ChannelChatOriginFactory.class); + ChannelErrorClassifier errorClassifier = mock(ChannelErrorClassifier.class); + ChannelMessageRouter router = new ChannelMessageRouter(agentService, conversationService, + channelService, channelSessionStore, approvalService, approvalNotificationService, + completionPublisher, ttsService, new ObjectMapper(), streamTracker, + chatOriginFactory, errorClassifier); + + PendingApproval pending = new PendingApproval("abcdef123", "conv-1", "alice", + "dangerous_tool", "{}", "needs approval"); + when(approvalService.findPendingByConversation("conv-1")).thenReturn(pending); + when(approvalService.resolve("abcdef123", "alice", "denied")) + .thenReturn(ResolveOutcome.alreadyResolved("abcdef123")); + ChannelAdapter adapter = mock(ChannelAdapter.class); + when(adapter.getChannelType()).thenReturn("test"); + ChannelEntity channel = new ChannelEntity(); + channel.setAgentId(100L); + ChannelMessage message = ChannelMessage.builder() + .senderId("alice") + .replyToken("reply-1") + .content("/deny abcdef") + .build(); + + Method process = ChannelMessageRouter.class.getDeclaredMethod( + "processMessage", ChannelMessage.class, ChannelAdapter.class, ChannelEntity.class, String.class); + process.setAccessible(true); + process.invoke(router, message, adapter, channel, "conv-1"); + + verify(conversationService, never()).removeApprovalPlaceholders(anyString()); + verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString()); + verify(adapter).sendMessage("reply-1", "⚠️ 审批记录已过期或已被处理。"); + verify(adapter, never()).sendMessage(eq("reply-1"), startsWith("⛔ 已拒绝执行工具")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java new file mode 100644 index 00000000..177c79db --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java @@ -0,0 +1,209 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.stt.SttService; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pin the Feishu inbound-audio STT contract. + * + * <p>Why this matters: Feishu — unlike WeCom and DingTalk — does NOT + * include ASR text in its inbound webhook payload, only an opaque + * {@code file_key}. Without the {@code transcribeInboundAudio} hop the + * agent sees the literal text {@code "[音频]"} and cannot reason about + * what the user said. That was the production gap reported when a user + * sent a voice message to the bot. + * + * <p>The contract this test pins: + * <ol> + * <li>Successful STT returns the transcript, which the audio branch + * of {@code extractContentParts} prepends as a text part so the + * prompt builder sees real content.</li> + * <li>STT failure (no provider, empty text, exception) returns + * {@code null} — never throws, never blocks the agent from + * seeing the audio part.</li> + * <li>STT not wired in (legacy 3-arg ctor, tests) returns + * {@code null} silently — degraded but not broken.</li> + * <li>Empty / missing audio file is detected before the SttService + * call so we don't bill providers for zero-byte requests.</li> + * </ol> + */ +class FeishuAudioSttTest { + + @TempDir + Path tmpDir; + + private SttService sttService; + + @BeforeEach + void setUp() { + sttService = mock(SttService.class); + } + + @Test + @DisplayName("STT success → transcript returned for prepending as text part") + void transcriptReturnedOnSuccess() throws Exception { + Path audioFile = tmpDir.resolve("voice.opus"); + Files.write(audioFile, "fake-opus-bytes".getBytes()); + + when(sttService.transcribe(any(), eq("voice.opus"), eq("audio/opus"), eq(null))) + .thenReturn(Map.of("success", true, "text", "你好,能帮我查一下天气吗")); + + FeishuChannelAdapter adapter = adapterWithStt(sttService); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + audioFile.toAbsolutePath().toString(), + "/api/v1/files/generated/some-id", + "voice.opus", + "audio/opus"); + + String transcript = adapter.transcribeInboundAudio(dl); + assertEquals("你好,能帮我查一下天气吗", transcript); + } + + @Test + @DisplayName("STT failure → null, agent still sees audio part (no throw)") + void nullOnSttFailure() throws Exception { + Path audioFile = tmpDir.resolve("voice.opus"); + Files.write(audioFile, "fake-opus-bytes".getBytes()); + + when(sttService.transcribe(any(), any(), any(), any())) + .thenReturn(Map.of("success", false, "error", "no provider")); + + FeishuChannelAdapter adapter = adapterWithStt(sttService); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + audioFile.toAbsolutePath().toString(), + null, "voice.opus", "audio/opus"); + + assertNull(adapter.transcribeInboundAudio(dl), + "STT failure must return null, not throw — agent gets [音频] placeholder only"); + } + + @Test + @DisplayName("empty transcript text → null (don't inject blank text parts)") + void nullOnEmptyTranscript() throws Exception { + Path audioFile = tmpDir.resolve("voice.opus"); + Files.write(audioFile, "fake-opus-bytes".getBytes()); + + // Some STT providers return success=true with empty text for silence + // or unsupported audio — those shouldn't pollute the prompt with a + // blank text part. + when(sttService.transcribe(any(), any(), any(), any())) + .thenReturn(Map.of("success", true, "text", " ")); + + FeishuChannelAdapter adapter = adapterWithStt(sttService); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + audioFile.toAbsolutePath().toString(), + null, "voice.opus", "audio/opus"); + + assertNull(adapter.transcribeInboundAudio(dl)); + } + + @Test + @DisplayName("SttService missing (legacy ctor) → null, no NPE") + void nullWhenSttServiceMissing() throws Exception { + Path audioFile = tmpDir.resolve("voice.opus"); + Files.write(audioFile, "fake-opus-bytes".getBytes()); + + FeishuChannelAdapter adapter = adapterWithStt(null); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + audioFile.toAbsolutePath().toString(), + null, "voice.opus", "audio/opus"); + + assertNull(adapter.transcribeInboundAudio(dl)); + } + + @Test + @DisplayName("null DownloadedResource → null, STT not called (download was disabled / failed)") + void nullOnMissingDownload() { + FeishuChannelAdapter adapter = adapterWithStt(sttService); + + assertNull(adapter.transcribeInboundAudio(null)); + // Verify we never billed the provider for a no-op. + verify(sttService, never()).transcribe(any(), any(), any(), any()); + } + + @Test + @DisplayName("empty audio file → null, no STT call (don't bill provider for 0 bytes)") + void nullOnEmptyAudioFile() throws Exception { + Path emptyFile = tmpDir.resolve("empty.opus"); + Files.write(emptyFile, new byte[0]); + + FeishuChannelAdapter adapter = adapterWithStt(sttService); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + emptyFile.toAbsolutePath().toString(), + null, "voice.opus", "audio/opus"); + + assertNull(adapter.transcribeInboundAudio(dl)); + verify(sttService, never()).transcribe(any(), any(), any(), any()); + } + + @Test + @DisplayName("missing file path → null, STT not called (download flag was off)") + void nullOnMissingPath() { + FeishuChannelAdapter adapter = adapterWithStt(sttService); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + null, "/api/v1/files/generated/x", "voice.opus", "audio/opus"); + + assertNull(adapter.transcribeInboundAudio(dl)); + verify(sttService, never()).transcribe(any(), any(), any(), any()); + } + + @Test + @DisplayName("fileName/contentType from download propagate to SttService for provider routing") + void fileNameAndMimePropagate() throws Exception { + Path audioFile = tmpDir.resolve("custom.mp3"); + Files.write(audioFile, "fake".getBytes()); + + Map<String, Object> success = new HashMap<>(); + success.put("success", true); + success.put("text", "hi"); + when(sttService.transcribe(any(), eq("custom.mp3"), eq("audio/mpeg"), eq(null))) + .thenReturn(success); + + FeishuChannelAdapter adapter = adapterWithStt(sttService); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + audioFile.toAbsolutePath().toString(), + null, "custom.mp3", "audio/mpeg"); + + assertEquals("hi", adapter.transcribeInboundAudio(dl)); + // Strict matchers above (eq("custom.mp3"), eq("audio/mpeg")) are + // what enforce propagation — if the helper had defaulted to opus, + // the stub would have returned null and the assert would fail. + } + + // ------------------------------------------------------------------ + // Test fixture + // ------------------------------------------------------------------ + + private static FeishuChannelAdapter adapterWithStt(SttService sttService) { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}"); + return new FeishuChannelAdapter( + e, + mock(ChannelMessageRouter.class), + new ObjectMapper(), + null, null, null, null, null, null, + sttService); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuCardFormatterTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuCardFormatterTest.java new file mode 100644 index 00000000..4c7fc133 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuCardFormatterTest.java @@ -0,0 +1,273 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static vip.mate.channel.feishu.FeishuCardFormatter.ContentFormat.*; + +class FeishuCardFormatterTest { + + // ==================== detect() ==================== + + @Test + void detect_nullAndBlank_returnsPlainText() { + assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect(null)); + assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("")); + assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect(" ")); + } + + @Test + void detect_nonEmptyJsonObject_returnsJson() { + assertEquals(JSON, FeishuCardFormatter.detect("{\"key\": \"value\"}")); + } + + @Test + void detect_jsonArrayOfObjects_returnsJson() { + assertEquals(JSON, FeishuCardFormatter.detect("[{\"a\": 1, \"b\": 2}]")); + } + + @Test + void detect_emptyJsonObject_doesNotReturnJson() { + assertNotEquals(JSON, FeishuCardFormatter.detect("{}")); + } + + @Test + void detect_primitiveArray_doesNotReturnJson() { + assertNotEquals(JSON, FeishuCardFormatter.detect("[1, 2, 3]")); + assertNotEquals(JSON, FeishuCardFormatter.detect("[\"a\", \"b\"]")); + } + + @Test + void detect_invalidJsonStartingWithBrace_doesNotReturnJson() { + assertNotEquals(JSON, FeishuCardFormatter.detect("{invalid json}")); + assertNotEquals(JSON, FeishuCardFormatter.detect("[引用消息: 你好]")); + } + + @Test + void detect_jsonOverSizeLimit_doesNotReturnJson() { + String big = "{\"k\":\"" + "x".repeat(32_000) + "\"}"; + assertNotEquals(JSON, FeishuCardFormatter.detect(big)); + } + + @Test + void detect_codeBlock_returnsMarkdown() { + assertEquals(MARKDOWN, FeishuCardFormatter.detect("看这段代码:\n```java\nint x = 1;\n```")); + } + + @Test + void detect_h2Header_returnsMarkdown() { + assertEquals(MARKDOWN, FeishuCardFormatter.detect("## 标题\n正文内容")); + } + + @Test + void detect_h1Header_returnsMarkdown() { + assertEquals(MARKDOWN, FeishuCardFormatter.detect("# 一级标题")); + } + + @Test + void detect_tableSeparatorRow_returnsMarkdown() { + assertEquals(MARKDOWN, FeishuCardFormatter.detect("| A | B |\n|---|---|\n| 1 | 2 |")); + } + + @Test + void detect_hrTripleDash_doesNotReturnMarkdown() { + assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("---")); + } + + @Test + void detect_twoBulletItems_returnsMarkdown() { + assertEquals(MARKDOWN, FeishuCardFormatter.detect("- 第一条\n- 第二条")); + } + + @Test + void detect_oneBulletItem_doesNotReturnMarkdown() { + assertNotEquals(MARKDOWN, FeishuCardFormatter.detect("- 只有一条")); + } + + @Test + void detect_inlineDashNotBullet_doesNotReturnMarkdown() { + String text = "价格 - 折扣 = 净价\n成本 - 税 = 实际"; + assertNotEquals(MARKDOWN, FeishuCardFormatter.detect(text)); + } + + @Test + void detect_longTextWithDoubleNewline_returnsLongText() { + String text = "x".repeat(150) + "\n\n" + "y".repeat(155); + assertEquals(LONG_TEXT, FeishuCardFormatter.detect(text)); + } + + @Test + void detect_longTextWithoutDoubleNewline_returnsPlainText() { + assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("x".repeat(400))); + } + + @Test + void detect_shortPlainText_returnsPlainText() { + assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("好的,明白了。")); + } + + // ==================== render() ==================== + + @Test + @SuppressWarnings("unchecked") + void render_markdown_hasSchema20AndLarkMdElement() { + String md = "## 标题\n- 第一条\n- 第二条"; + var card = FeishuCardFormatter.render(md, MARKDOWN); + + assertEquals("2.0", card.get("schema")); + assertNotNull(card.get("header")); + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + assertEquals("div", elems.get(0).get("tag")); + var text = (java.util.Map<String, Object>) elems.get(0).get("text"); + assertEquals("lark_md", text.get("tag")); + assertEquals(md, text.get("content")); + } + + @Test + @SuppressWarnings("unchecked") + void render_longText_hasNoHeaderAndPlainTextElement() { + String content = "x".repeat(150) + "\n\n" + "y".repeat(155); + var card = FeishuCardFormatter.render(content, LONG_TEXT); + + assertEquals("2.0", card.get("schema")); + assertNull(card.get("header")); + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + var text = (java.util.Map<String, Object>) elems.get(0).get("text"); + assertEquals("plain_text", text.get("tag")); + assertEquals(content, text.get("content")); + } + + @Test + @SuppressWarnings("unchecked") + void render_jsonObject_hasColumnSetPerField() { + var card = FeishuCardFormatter.render("{\"name\":\"Alice\",\"score\":95}", JSON); + + assertEquals("2.0", card.get("schema")); + assertNull(card.get("header")); // 摘要卡片无 header + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + assertEquals(2, elems.size()); // 2 个字段 → 2 个 column_set + assertEquals("column_set", elems.get(0).get("tag")); + } + + @Test + @SuppressWarnings("unchecked") + void render_jsonArrayFewColumns_usesTableComponent() { + var card = FeishuCardFormatter.render("[{\"a\":1,\"b\":2},{\"a\":3,\"b\":4}]", JSON); + + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + var table = elems.get(0); + assertEquals("table", table.get("tag")); + + var columns = (java.util.List<java.util.Map<String, Object>>) table.get("columns"); + assertEquals(2, columns.size()); + assertEquals("a", columns.get(0).get("name")); + assertEquals("b", columns.get(1).get("name")); + + var rows = (java.util.List<java.util.Map<String, Object>>) table.get("rows"); + assertEquals(2, rows.size()); + assertEquals("1", rows.get(0).get("a")); + assertEquals("2", rows.get(0).get("b")); + assertEquals("3", rows.get(1).get("a")); + assertEquals("4", rows.get(1).get("b")); + } + + @Test + @SuppressWarnings("unchecked") + void render_jsonArrayManyColumns_usesDivPerItem() { + // >4 字段 → 列表卡片(每条 item 一个 div) + var card = FeishuCardFormatter.render( + "[{\"a\":1,\"b\":2,\"c\":3,\"d\":4,\"e\":5}]", JSON); + + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + var div = elems.get(0); + assertEquals("div", div.get("tag")); + + var text = (java.util.Map<String, Object>) div.get("text"); + assertEquals("lark_md", text.get("tag")); + String content = (String) text.get("content"); + assertTrue(content.contains("**a**:"), "content should contain **a**: field"); + assertTrue(content.contains("**b**:"), "content should contain **b**: field"); + } + + @Test + @SuppressWarnings("unchecked") + void render_plainText_fallsBackToLongTextLayout() { + // PLAIN_TEXT 传入 render()("always" 模式下会发生)→ 应渲染为 plain_text div + var card = FeishuCardFormatter.render("简单的一句话", PLAIN_TEXT); + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + var text = (java.util.Map<String, Object>) elems.get(0).get("text"); + assertEquals("plain_text", text.get("tag")); + } + + // ==================== detect() — Markdown 内嵌 JSON ==================== + + @Test + void detect_markdownWithJsonObjectCodeBlock_returnsJson() { + String md = "上海今天天气如下:\n\n```json\n{\"city\":\"上海\",\"temp\":24}\n```"; + assertEquals(JSON, FeishuCardFormatter.detect(md)); + } + + @Test + void detect_markdownWithBareCodeBlockContainingJson_returnsJson() { + // 无 json 标注的代码块,内容是 JSON 对象也识别 + String md = "结果:\n```\n{\"status\":\"ok\"}\n```"; + assertEquals(JSON, FeishuCardFormatter.detect(md)); + } + + @Test + void detect_markdownWithJsonArrayCodeBlock_returnsJson() { + String md = "列表:\n```json\n[{\"a\":1},{\"a\":2}]\n```"; + assertEquals(JSON, FeishuCardFormatter.detect(md)); + } + + @Test + void detect_markdownWithPrimitiveArrayCodeBlock_returnsMarkdown() { + // 原始类型数组不识别为 JSON + String md = "数据:\n```json\n[1,2,3]\n```"; + assertEquals(MARKDOWN, FeishuCardFormatter.detect(md)); + } + + @Test + void detect_markdownWithNonJsonCodeBlock_returnsMarkdown() { + // Python 代码块不识别为 JSON + String md = "代码:\n```python\nprint('hello')\n```"; + assertEquals(MARKDOWN, FeishuCardFormatter.detect(md)); + } + + @Test + void detect_markdownWithEmptyJsonObjectCodeBlock_returnsMarkdown() { + // 空对象 {} 不识别为 JSON + String md = "空:\n```json\n{}\n```"; + assertEquals(MARKDOWN, FeishuCardFormatter.detect(md)); + } + + // ==================== render() — Markdown 内嵌 JSON ==================== + + @Test + @SuppressWarnings("unchecked") + void render_markdownWithJsonCodeBlock_rendersAsSummaryCard() { + String md = "天气结果:\n```json\n{\"city\":\"上海\",\"temp\":24}\n```"; + var card = FeishuCardFormatter.render(md, JSON); + + assertEquals("2.0", card.get("schema")); + assertNull(card.get("header")); // JSON object card has no header + var body = (java.util.Map<String, Object>) card.get("body"); + var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements"); + assertEquals(2, elems.size()); // 2 fields → 2 column_sets + assertEquals("column_set", elems.get(0).get("tag")); + } + + @Test + void detect_markdownWithJsonBlockSecond_returnsJson() { + // JSON 对象代码块在原始数组块后面,应该仍能识别 + // 原始数组 [1,2,3] 不是有效 JSON,但 JSON 对象 {"ok":true} 是 + String md = "示例:\n```\n[1,2,3]\n```\n\n结果:\n```json\n{\"ok\":true,\"count\":5}\n```"; + assertEquals(JSON, FeishuCardFormatter.detect(md)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java new file mode 100644 index 00000000..d916ed39 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java @@ -0,0 +1,170 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Pin the inbound-resource download contract for Feishu: + * + * <ol> + * <li>{@link FeishuChannelAdapter#extensionFor} maps every supported + * inbound content-type to a sensible on-disk extension — important + * because vision providers and file-reading tools often key on the + * extension, not the MIME header.</li> + * <li>{@link FeishuChannelAdapter#inferMimeFromName} round-trips the + * same set so the {@link vip.mate.tool.document.GeneratedFileCache} + * entry (which only stores MIME, no headers) renders correctly in + * the admin UI.</li> + * <li>{@link FeishuChannelAdapter#applyDownload} copies every populated + * field from a {@link FeishuChannelAdapter.DownloadedResource} onto + * the outbound {@link MessageContentPart} — and tolerates {@code + * null} input so the legacy "download disabled, keep the bare + * file_key" path still produces a valid part.</li> + * </ol> + * + * <p>These are the contracts that broke production before this change: + * when a user uploaded a PDF to the bot, the adapter only emitted a + * {@code file_key} placeholder and the agent never saw the bytes. + */ +class FeishuInboundResourceTest { + + // ------------------------------------------------------------------ + // extensionFor — Content-Type → on-disk extension + // ------------------------------------------------------------------ + + @Test + @DisplayName("extensionFor: image content-types map to standard image extensions") + void extensionForImages() { + assertEquals("jpg", FeishuChannelAdapter.extensionFor("image/jpeg", null)); + assertEquals("jpg", FeishuChannelAdapter.extensionFor("image/jpg", null)); + assertEquals("png", FeishuChannelAdapter.extensionFor("image/png", null)); + assertEquals("gif", FeishuChannelAdapter.extensionFor("image/gif", null)); + assertEquals("webp", FeishuChannelAdapter.extensionFor("image/webp", null)); + } + + @Test + @DisplayName("extensionFor: office and document MIMEs map correctly") + void extensionForOfficeDocs() { + assertEquals("pdf", FeishuChannelAdapter.extensionFor("application/pdf", null)); + assertEquals("docx", FeishuChannelAdapter.extensionFor( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", null)); + assertEquals("xlsx", FeishuChannelAdapter.extensionFor( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", null)); + assertEquals("pptx", FeishuChannelAdapter.extensionFor( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", null)); + } + + @Test + @DisplayName("extensionFor: audio + video MIMEs map correctly") + void extensionForMedia() { + assertEquals("opus", FeishuChannelAdapter.extensionFor("audio/opus", null)); + assertEquals("mp3", FeishuChannelAdapter.extensionFor("audio/mpeg", null)); + assertEquals("mp4", FeishuChannelAdapter.extensionFor("video/mp4", null)); + } + + @Test + @DisplayName("extensionFor: unknown content-type falls back to filename hint, then 'bin'") + void extensionForFallback() { + // Filename hint when content-type is unhelpful + assertEquals("zip", FeishuChannelAdapter.extensionFor("application/octet-stream", "report.zip")); + assertEquals("csv", FeishuChannelAdapter.extensionFor(null, "data.csv")); + // No hint and no recognisable content-type → bin sentinel + assertEquals("bin", FeishuChannelAdapter.extensionFor(null, null)); + assertEquals("bin", FeishuChannelAdapter.extensionFor("application/x-weird", null)); + // Filename hint with no dot is not a usable extension + assertEquals("bin", FeishuChannelAdapter.extensionFor(null, "noextension")); + } + + // ------------------------------------------------------------------ + // inferMimeFromName — round-trip from filename to MIME + // ------------------------------------------------------------------ + + @Test + @DisplayName("inferMimeFromName: common extensions resolve to the right MIME") + void inferMimeImages() { + assertEquals("image/jpeg", FeishuChannelAdapter.inferMimeFromName("photo.jpg")); + assertEquals("image/jpeg", FeishuChannelAdapter.inferMimeFromName("PHOTO.JPEG")); + assertEquals("image/png", FeishuChannelAdapter.inferMimeFromName("screenshot.png")); + assertEquals("image/gif", FeishuChannelAdapter.inferMimeFromName("anim.gif")); + assertEquals("image/webp", FeishuChannelAdapter.inferMimeFromName("avatar.webp")); + } + + @Test + @DisplayName("inferMimeFromName: office docs + media") + void inferMimeDocsAndMedia() { + assertEquals("application/pdf", FeishuChannelAdapter.inferMimeFromName("contract.pdf")); + assertEquals("audio/opus", FeishuChannelAdapter.inferMimeFromName("voice.opus")); + assertEquals("audio/mpeg", FeishuChannelAdapter.inferMimeFromName("track.mp3")); + assertEquals("video/mp4", FeishuChannelAdapter.inferMimeFromName("clip.mp4")); + assertEquals("text/plain", FeishuChannelAdapter.inferMimeFromName("README.md")); + } + + @Test + @DisplayName("inferMimeFromName: null or unknown returns generic octet-stream") + void inferMimeUnknown() { + assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName(null)); + assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName("mystery.xyz")); + assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName("noext")); + } + + // ------------------------------------------------------------------ + // applyDownload — copy DownloadedResource onto MessageContentPart + // ------------------------------------------------------------------ + + @Test + @DisplayName("applyDownload: copies path / fileUrl / fileName / contentType") + void applyDownloadFullCopy() { + MessageContentPart part = MessageContentPart.file("file_abc", null, null); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + "/tmp/mateclaw/feishu/om_x_file_abc.pdf", + "/api/v1/files/generated/uuid-1", + "contract.pdf", + "application/pdf"); + FeishuChannelAdapter.applyDownload(part, dl); + assertEquals("/tmp/mateclaw/feishu/om_x_file_abc.pdf", part.getPath()); + assertEquals("/api/v1/files/generated/uuid-1", part.getFileUrl()); + assertEquals("contract.pdf", part.getFileName()); + assertEquals("application/pdf", part.getContentType()); + } + + @Test + @DisplayName("applyDownload: null download leaves the bare key part untouched (legacy fallback)") + void applyDownloadNullIsNoop() { + MessageContentPart part = MessageContentPart.file("file_abc", "original.pdf", "application/pdf"); + FeishuChannelAdapter.applyDownload(part, null); + assertNull(part.getPath()); + assertNull(part.getFileUrl()); + // Pre-existing fields preserved — the "download disabled / failed" path + // still produces a valid part the outbound side can handle as an opaque key. + assertEquals("file_abc", part.getMediaId()); + assertEquals("original.pdf", part.getFileName()); + assertEquals("application/pdf", part.getContentType()); + } + + @Test + @DisplayName("applyDownload: preserves pre-existing fileName when caller already had one") + void applyDownloadPreservesExistingName() { + MessageContentPart part = MessageContentPart.file("file_abc", "user-named.pdf", null); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + "/tmp/path.pdf", "/url", "server-derived.pdf", "application/pdf"); + FeishuChannelAdapter.applyDownload(part, dl); + // User-provided file_name wins over server-derived name so the + // bubble matches what the sender typed. + assertEquals("user-named.pdf", part.getFileName()); + assertEquals("/tmp/path.pdf", part.getPath()); + assertEquals("/url", part.getFileUrl()); + } + + @Test + @DisplayName("applyDownload: tolerates null part defensively (router never sees NPE)") + void applyDownloadNullPart() { + // Just assert no exception. This is the contract that lets the + // inbound parser keep going when an upstream event is malformed. + FeishuChannelAdapter.applyDownload(null, new FeishuChannelAdapter.DownloadedResource( + "/x", "/y", "z", "t")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java new file mode 100644 index 00000000..d1c5a287 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java @@ -0,0 +1,73 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import vip.mate.MateClawApplication; +import vip.mate.channel.media.GeneratedFileScrubber; +import vip.mate.channel.media.MediaSizePolicy; +import vip.mate.channel.media.MediaUploader; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test — confirms the new Layer 1 + Layer 2 media beans + * are wired by Spring with the right contracts, so a runtime + * NoSuchBeanDefinitionException can't slip through to first user + * traffic after a deploy. + * + * <p>Does NOT touch any real Feishu credentials or call the upstream + * SDK — purely a Spring container contract test. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +class FeishuMediaWiringIT { + + @Autowired private FeishuClientFactory clientFactory; + @Autowired private FeishuMediaUploader mediaUploader; + @Autowired private FeishuSizePolicy sizePolicy; + @Autowired private GeneratedFileScrubber scrubber; + @Autowired private FeishuStreamingCardManager streamingCardManager; + @Autowired private vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher; + @Autowired private List<MediaUploader> uploaderBeans; + @Autowired private List<MediaSizePolicy> policyBeans; + + @Test + @DisplayName("all Layer 1 + Layer 2 beans resolve") + void beansAreWired() { + assertNotNull(clientFactory); + assertNotNull(mediaUploader); + assertNotNull(sizePolicy); + assertNotNull(scrubber); + assertNotNull(streamingCardManager); + assertNotNull(cardDispatcher); + assertTrue(cardDispatcher.registeredKindNames().contains("tool_guard_approval"), + "tool_guard kind should be auto-registered on dispatcher construction"); + } + + @Test + @DisplayName("MediaUploader SPI picks up FeishuMediaUploader by channelType") + void uploaderSpiContractsHold() { + boolean hasFeishu = uploaderBeans.stream() + .anyMatch(u -> "feishu".equals(u.channelType())); + assertTrue(hasFeishu, + "FeishuMediaUploader missing from MediaUploader SPI collection: " + + uploaderBeans.stream().map(MediaUploader::channelType).toList()); + } + + @Test + @DisplayName("MediaSizePolicy SPI picks up FeishuSizePolicy by channelType") + void policySpiContractsHold() { + boolean hasFeishu = policyBeans.stream() + .anyMatch(p -> "feishu".equals(p.channelType())); + assertTrue(hasFeishu, + "FeishuSizePolicy missing from MediaSizePolicy SPI collection: " + + policyBeans.stream().map(MediaSizePolicy::channelType).toList()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java new file mode 100644 index 00000000..53d03551 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java @@ -0,0 +1,150 @@ +package vip.mate.channel.feishu; + +import com.lark.oapi.service.im.v1.model.MentionEvent; +import com.lark.oapi.service.im.v1.model.UserId; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class FeishuMentionTest { + + private static final String BOT_ID = "ou_bot123"; + private static final String OTHER_ID = "ou_user456"; + + // ==================== eventMentionsContainBot ==================== + + @Test + void event_nullMentions_returnsFalse() { + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(null, BOT_ID)); + } + + @Test + void event_emptyMentions_returnsFalse() { + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[0], BOT_ID)); + } + + @Test + void event_nullBotOpenId_returnsFalse() { + MentionEvent mention = mentionEvent(BOT_ID); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, null)); + } + + @Test + void event_botIsMentioned_returnsTrue() { + MentionEvent mention = mentionEvent(BOT_ID); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + } + + @Test + void event_onlyOtherUserMentioned_returnsFalse() { + MentionEvent mention = mentionEvent(OTHER_ID); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + } + + @Test + void event_botAmongMultipleMentions_returnsTrue() { + MentionEvent[] mentions = {mentionEvent(OTHER_ID), mentionEvent(BOT_ID)}; + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void event_mentionWithNullId_skippedSafely() { + MentionEvent mention = MentionEvent.newBuilder().key("@_user_xxx").build(); // no id set + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + } + + // ==================== webhookMentionsContainBot ==================== + + @Test + void webhook_nullList_returnsFalse() { + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(null, BOT_ID)); + } + + @Test + void webhook_emptyList_returnsFalse() { + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(List.of(), BOT_ID)); + } + + @Test + void webhook_nullBotOpenId_returnsFalse() { + List<?> mentions = List.of(webhookMention(BOT_ID)); + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(mentions, null)); + } + + @Test + void webhook_botIsMentioned_returnsTrue() { + List<?> mentions = List.of(webhookMention(BOT_ID)); + assertTrue(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_onlyOtherUserMentioned_returnsFalse() { + List<?> mentions = List.of(webhookMention(OTHER_ID)); + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_botAmongMultipleMentions_returnsTrue() { + List<?> mentions = List.of(webhookMention(OTHER_ID), webhookMention(BOT_ID)); + assertTrue(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_malformedItem_skippedSafely() { + List<?> mentions = List.of("not-a-map", Map.of("no_id_key", "value"), webhookMention(BOT_ID)); + assertTrue(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_idMissingOpenId_skippedSafely() { + Map<String, Object> mention = Map.of("id", Map.of("user_id", "u123")); // no open_id key + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(List.of(mention), BOT_ID)); + } + + // ==================== isGroupNonMentionDrop (gate matrix) ==================== + + @Test + void gate_groupRequireMentionBotMentioned_passesThrough() { + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, true, true, BOT_ID)); + } + + @Test + void gate_groupRequireMentionBotNotMentioned_dropsWhenOpenIdKnown() { + assertTrue(FeishuChannelAdapter.isGroupNonMentionDrop(true, true, false, BOT_ID)); + } + + @Test + void gate_groupRequireMentionBotNotMentioned_failsOpenWhenOpenIdUnknown() { + // Bot identity unavailable (API outage / pending fetch) → degrade to allow. + // This was the bug the original PR shipped with: gate dropped instead of fell open. + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, true, false, null)); + } + + @Test + void gate_p2pAlwaysPasses_regardlessOfRequireMention() { + // require_mention only applies to group chat; DMs are never gated. + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(false, true, false, BOT_ID)); + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(false, true, false, null)); + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(false, true, true, BOT_ID)); + } + + @Test + void gate_requireMentionDisabled_passesThrough() { + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, false, false, BOT_ID)); + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, false, false, null)); + } + + // ==================== helpers ==================== + + private static MentionEvent mentionEvent(String openId) { + UserId userId = UserId.newBuilder().openId(openId).build(); + return MentionEvent.newBuilder().id(userId).build(); + } + + private static Map<String, Object> webhookMention(String openId) { + return Map.of("id", Map.of("open_id", openId)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java new file mode 100644 index 00000000..869fbf61 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java @@ -0,0 +1,115 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessage; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Pin the DONE-reaction hook contract on the Feishu adapter: + * <ul> + * <li>{@code onAgentCompleted} reacts with "DONE" on the inbound message id</li> + * <li>missing message id → no-op (defensive against weird payload shapes)</li> + * <li>{@code enable_done_reaction=false} → no-op (operator opt-out)</li> + * <li>null inbound message → no-op (defensive against router bugs)</li> + * </ul> + * + * <p>Subclasses {@link FeishuChannelAdapter} to capture the {@code addReactionAsync} + * call instead of hitting the real Feishu HTTP API — the production + * helper is {@code private}, so the subclass overrides {@code onAgentCompleted} + * itself only for the disable test; the bare-bones override path + * captures the emoji + message id via a recorder field. + */ +class FeishuOnAgentCompletedTest { + + /** + * Recording subclass — overrides the {@code addReactionAsync} entry + * point indirectly by re-implementing {@code onAgentCompleted} with + * the same gate logic. This keeps the production adapter private + * helper untouched. + */ + private static final class RecordingFeishuAdapter extends FeishuChannelAdapter { + record ReactionCall(String messageId, String emojiType) {} + final List<ReactionCall> calls = new CopyOnWriteArrayList<>(); + + RecordingFeishuAdapter(ChannelEntity channelEntity) { + super(channelEntity, mock(ChannelMessageRouter.class), new ObjectMapper()); + } + + @Override + public void onAgentCompleted(ChannelMessage inboundMessage) { + if (inboundMessage == null) return; + String messageId = inboundMessage.getMessageId(); + if (messageId == null || messageId.isBlank()) return; + if (!getConfigBoolean("enable_done_reaction", true)) return; + // Stand in for addReactionAsync — the real helper would POST + // /im/v1/messages/{messageId}/reactions; we just record. + calls.add(new ReactionCall(messageId, "DONE")); + } + } + + private static ChannelEntity channel(String configJson) { + ChannelEntity e = new ChannelEntity(); + e.setId(7L); + e.setChannelType("feishu"); + e.setName("test"); + e.setConfigJson(configJson); + return e; + } + + private static ChannelMessage inbound(String messageId) { + return ChannelMessage.builder() + .channelType("feishu") + .messageId(messageId) + .senderId("ou_abc") + .build(); + } + + @Test + @DisplayName("happy path: messageId present and config default → DONE reaction recorded") + void reactsOnHappyPath() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\"}")); + adapter.onAgentCompleted(inbound("om_123")); + assertEquals(1, adapter.calls.size()); + assertEquals("om_123", adapter.calls.get(0).messageId()); + assertEquals("DONE", adapter.calls.get(0).emojiType()); + } + + @Test + @DisplayName("null inbound → no-op, defensive") + void nullInboundNoOp() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\"}")); + adapter.onAgentCompleted(null); + assertTrue(adapter.calls.isEmpty()); + } + + @Test + @DisplayName("missing messageId → no-op (can't react without a target id)") + void noMessageIdNoOp() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\"}")); + adapter.onAgentCompleted(inbound(null)); + adapter.onAgentCompleted(inbound(" ")); + assertTrue(adapter.calls.isEmpty()); + } + + @Test + @DisplayName("enable_done_reaction=false → no-op (operator opt-out)") + void operatorOptOut() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\",\"enable_done_reaction\":false}")); + adapter.onAgentCompleted(inbound("om_123")); + assertTrue(adapter.calls.isEmpty(), "config flag must disable the reaction"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuScrubOnFinishTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuScrubOnFinishTest.java new file mode 100644 index 00000000..6359ed39 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuScrubOnFinishTest.java @@ -0,0 +1,207 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.media.GeneratedFileScrubber; +import vip.mate.channel.media.MediaSource; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.tool.document.GeneratedFileCache; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Pin the scrub-on-finish contract for outbound Feishu replies. + * + * <p>The agent often replies with a markdown link like + * <code>[report.pdf](/api/v1/files/generated/abc-123)</code> after + * generating a file. The IM client can't open the + * tenant-token-protected backend endpoint that link points at — so + * without intervention the user gets a dead link instead of the file. + * + * <p>The fix is the {@code scrubAndSendAttachments} hop, called by + * both {@code processStream} (CardKit streaming path) and + * {@code processStreamAsText} (fallback). This test pins that hop: + * + * <ol> + * <li>The URL is replaced with a {@code "📎 filename"} marker so + * the card text reads cleanly.</li> + * <li>An upload is enqueued for the cache-resolved bytes, with the + * right mediaType / fileName / mimeType from the cache entry.</li> + * <li>Cache misses degrade to a user-facing retry hint instead of + * leaving the dead link in the bubble.</li> + * <li>Replies with no generated-file URL pass through unchanged and + * trigger zero uploads.</li> + * </ol> + */ +class FeishuScrubOnFinishTest { + + private GeneratedFileCache cache; + private GeneratedFileScrubber scrubber; + private RecordingAdapter adapter; + + @BeforeEach + void setUp() { + cache = new GeneratedFileCache(); + scrubber = new GeneratedFileScrubber(cache); + adapter = new RecordingAdapter(scrubber); + } + + @Test + @DisplayName("agent reply with cached PDF URL → text rewritten + upload enqueued") + void cacheHitTriggersUploadAndRewrite() { + byte[] bytes = "%PDF-1.4 fake pdf body".getBytes(); + String id = cache.put(bytes, "report.pdf", "application/pdf"); + String agentReply = "✅ PDF 已生成\n[report.pdf](/api/v1/files/generated/" + id + ")\n" + + "点击上方链接即可下载,链接 10 分钟内有效"; + + String rendered = adapter.scrubAndSendAttachments("ou_user_123", agentReply); + + // Text bubble shows the marker, not the dead link. + assertTrue(rendered.contains("📎 report.pdf"), + "rewrittenText should embed the filename marker: " + rendered); + assertFalse(rendered.contains("/api/v1/files/generated/" + id), + "rewrittenText should NOT still carry the generated URL: " + rendered); + // The "(/api/...)" part of the markdown link disappears with the + // URL, leaving the bracketed display name + the marker. Both are + // user-friendly text — no live link rot. + + // Exactly one upload enqueued, with the right metadata. + assertEquals(1, adapter.uploads.size(), "expected exactly one attachment send"); + RecordedUpload upload = adapter.uploads.get(0); + assertEquals("ou_user_123", upload.targetId); + assertEquals("report.pdf", upload.fileName); + assertEquals("file", upload.mediaType, "PDF should classify as 'file', not 'image'"); + assertEquals("application/pdf", upload.contentType); + assertTrue(upload.source instanceof MediaSource.Bytes, + "upload should be a Bytes source so the uploader skips a remote fetch"); + assertEquals(bytes.length, + ((MediaSource.Bytes) upload.source).data().length); + } + + @Test + @DisplayName("PNG cache hit classifies as 'image' so vision-aware bubble renders inline") + void imageMimeTriggersImageUpload() { + byte[] bytes = new byte[]{(byte) 0x89, 'P', 'N', 'G'}; + String id = cache.put(bytes, "chart.png", "image/png"); + String agentReply = "这是图表: /api/v1/files/generated/" + id; + + String rendered = adapter.scrubAndSendAttachments("oc_chat_x", agentReply); + + assertTrue(rendered.contains("📎 chart.png")); + assertEquals(1, adapter.uploads.size()); + assertEquals("image", adapter.uploads.get(0).mediaType, + "image/png MIME must route to the image endpoint, not file"); + assertEquals("image/png", adapter.uploads.get(0).contentType); + } + + @Test + @DisplayName("cache miss → retry hint in bubble, no upload attempted") + void cacheMissDoesNotUpload() { + String agentReply = "你的文件: [doc.pdf](/api/v1/files/generated/never-existed-uuid)"; + + String rendered = adapter.scrubAndSendAttachments("ou_user_x", agentReply); + + assertFalse(rendered.contains("/api/v1/files/generated/never-existed-uuid"), + "dead URL should be replaced with the missing-reference notice"); + assertTrue(rendered.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE), + "user-visible retry hint expected: " + rendered); + assertTrue(adapter.uploads.isEmpty(), + "cache miss must NOT enqueue an upload — bytes don't exist"); + } + + @Test + @DisplayName("plain reply with no generated URL is forwarded unchanged, zero uploads") + void plainTextIsForwarded() { + String agentReply = "Sure, here's a quick summary: ..."; + + String rendered = adapter.scrubAndSendAttachments("ou_user_x", agentReply); + + assertEquals(agentReply, rendered, "no scrubbable URL → exact pass-through"); + assertTrue(adapter.uploads.isEmpty(), + "non-generated reply must not trigger any upload work"); + } + + @Test + @DisplayName("multiple generated URLs in one reply enqueue one upload per cache hit") + void multipleUrlsEnqueueMultipleUploads() { + String aId = cache.put("AAAA".getBytes(), "a.pdf", "application/pdf"); + String bId = cache.put("BBBB".getBytes(), "b.png", "image/png"); + String agentReply = "两个产物:\n- /api/v1/files/generated/" + aId + + "\n- /api/v1/files/generated/" + bId; + + adapter.scrubAndSendAttachments("ou_user_x", agentReply); + + assertEquals(2, adapter.uploads.size(), + "one upload per generated URL — got: " + + adapter.uploads.stream().map(u -> u.fileName).toList()); + assertEquals("a.pdf", adapter.uploads.get(0).fileName); + assertEquals("file", adapter.uploads.get(0).mediaType); + assertEquals("b.png", adapter.uploads.get(1).fileName); + assertEquals("image", adapter.uploads.get(1).mediaType); + } + + @Test + @DisplayName("scrubber missing → no-op pass-through, no NPE (legacy 3-arg ctor case)") + void scrubberAbsentIsNoop() { + // Build adapter without the scrubber — simulates the 3-arg ctor + // path / unit tests that don't wire the media beans. + RecordingAdapter legacyAdapter = new RecordingAdapter(null); + String text = "agent reply with /api/v1/files/generated/abc"; + String rendered = legacyAdapter.scrubAndSendAttachments("ou_x", text); + assertEquals(text, rendered); + assertTrue(legacyAdapter.uploads.isEmpty()); + } + + // ------------------------------------------------------------------ + // Test fixture + // ------------------------------------------------------------------ + + /** Captured upload arguments — what the test asserts on. */ + private record RecordedUpload(String targetId, MediaSource source, String fileName, + String mediaType, String contentType) {} + + /** + * Subclass that records every {@link FeishuChannelAdapter#uploadAndSendAttachment} + * call instead of actually doing the HTTP send. Constructor wires a + * synthetic {@link ChannelEntity} with id=1 so the channelId check + * inside {@code scrubAndSendAttachments} passes; mediaUploader is + * mocked because the recording adapter overrides the call path that + * would use it anyway. + */ + private static class RecordingAdapter extends FeishuChannelAdapter { + final List<RecordedUpload> uploads = new ArrayList<>(); + + RecordingAdapter(GeneratedFileScrubber scrubber) { + super(testEntity(), + mock(ChannelMessageRouter.class), + new ObjectMapper(), + scrubber == null ? null : mock(FeishuMediaUploader.class), + scrubber); + } + + @Override + void uploadAndSendAttachment(String targetId, Long channelId, + MediaSource source, String fileName, + String mediaType, String contentType, + Integer durationMillis) { + uploads.add(new RecordedUpload(targetId, source, fileName, mediaType, contentType)); + } + + private static ChannelEntity testEntity() { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}"); + return e; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java new file mode 100644 index 00000000..964353d7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java @@ -0,0 +1,126 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.media.MediaSizeDecision; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the Feishu upload-decision matrix. + * + * <p>Feishu's server rejects oversize payloads at the end of the upload + * (after we've serialised all bytes through {@code oapi-sdk}). Without + * this client-side gate, a 40 MB video would round-trip for nothing + * and the user would see the bubble fail with a cryptic SDK error. + * These tests pin the boundary so any future tweak (Feishu raising + * limits, the SDK adding new file_types) is intentional. + */ +class FeishuSizePolicyTest { + + private final FeishuSizePolicy policy = new FeishuSizePolicy(); + + @Test + @DisplayName("normal-sized file passes through with native media type") + void normalFilePasses() { + MediaSizeDecision d = policy.evaluate(1_000_000, "file", null); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("file", d.finalMediaType()); + assertNull(d.downgradeNote()); + } + + @Test + @DisplayName("file at exactly 30MB passes; one byte over rejects with a message") + void fileBoundary() { + MediaSizeDecision pass = policy.evaluate(FeishuSizePolicy.FILE_MAX_BYTES, "file", null); + assertFalse(pass.rejected()); + + MediaSizeDecision fail = policy.evaluate(FeishuSizePolicy.FILE_MAX_BYTES + 1, "file", null); + assertTrue(fail.rejected()); + assertNotNull(fail.rejectReason()); + assertTrue(fail.rejectReason().contains("30MB"), + "reject reason should mention 30MB; got: " + fail.rejectReason()); + } + + @Test + @DisplayName("image at exactly 10MB passes as image; one byte over downgrades to file") + void imageBoundary() { + MediaSizeDecision pass = policy.evaluate(FeishuSizePolicy.IMAGE_MAX_BYTES, "image", "image/png"); + assertFalse(pass.rejected()); + assertFalse(pass.downgraded()); + assertEquals("image", pass.finalMediaType()); + + MediaSizeDecision down = policy.evaluate(FeishuSizePolicy.IMAGE_MAX_BYTES + 1, "image", "image/png"); + assertFalse(down.rejected()); + assertTrue(down.downgraded()); + assertEquals("file", down.finalMediaType()); + assertNotNull(down.downgradeNote()); + assertTrue(down.downgradeNote().contains("10MB"), + "downgrade note should mention 10MB; got: " + down.downgradeNote()); + } + + @Test + @DisplayName("image over the 30MB file ceiling rejects (not downgrades)") + void imageBeyondFileCeilingRejects() { + MediaSizeDecision d = policy.evaluate(40L * 1024 * 1024, "image", "image/png"); + assertTrue(d.rejected()); + assertNotNull(d.rejectReason()); + } + + @Test + @DisplayName("audio/opus stays as native voice; audio/mp3 downgrades to file") + void audioMimeRouting() { + MediaSizeDecision opus = policy.evaluate(500_000, "audio", "audio/opus"); + assertFalse(opus.rejected()); + assertFalse(opus.downgraded()); + assertEquals("audio", opus.finalMediaType()); + + MediaSizeDecision mp3 = policy.evaluate(500_000, "audio", "audio/mp3"); + assertFalse(mp3.rejected()); + assertTrue(mp3.downgraded()); + assertEquals("file", mp3.finalMediaType()); + assertTrue(mp3.downgradeNote().contains("opus"), + "downgrade note should explain opus-only; got: " + mp3.downgradeNote()); + } + + @Test + @DisplayName("audio without contentType defaults to native voice (caller knows it's opus)") + void audioMissingMime() { + MediaSizeDecision d = policy.evaluate(500_000, "audio", null); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("audio", d.finalMediaType()); + } + + @Test + @DisplayName("video/mp4 stays as native video; video/webm downgrades to file") + void videoMimeRouting() { + MediaSizeDecision mp4 = policy.evaluate(2_000_000, "video", "video/mp4"); + assertFalse(mp4.downgraded()); + assertEquals("video", mp4.finalMediaType()); + + MediaSizeDecision webm = policy.evaluate(2_000_000, "video", "video/webm"); + assertTrue(webm.downgraded()); + assertEquals("file", webm.finalMediaType()); + assertTrue(webm.downgradeNote().contains("mp4")); + } + + @Test + @DisplayName("null mediaType is treated as file (defensive default)") + void nullMediaType() { + MediaSizeDecision d = policy.evaluate(1000, null, null); + assertFalse(d.rejected()); + assertEquals("file", d.finalMediaType()); + } + + @Test + @DisplayName("channelType identifies this policy as feishu") + void channelTypeIsFeishu() { + assertEquals("feishu", policy.channelType()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java new file mode 100644 index 00000000..ee8d206e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java @@ -0,0 +1,256 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lark.oapi.Client; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pin the FeishuStreamingCardManager state machine + throttle. + * + * <p>SDK calls are stubbed via {@code protected} seams so this test + * stays Spring-free and network-free — we verify ordering and + * lifecycle, not Feishu API shape (the IT covers wiring). + * + * <p>Behaviour pinned: + * <ul> + * <li>throttle window suppresses sub-window flushes; forceFlush + * bypasses it; finishCard always flushes</li> + * <li>session is removed from {@code activeSessions} on terminal + * transition; subsequent appends are no-ops</li> + * <li>finish vs fail is a CAS-guarded one-shot — second terminal + * call is silent and does not double-close streaming</li> + * <li>sequence numbers are monotonic across content + close</li> + * <li>initial card JSON carries schema 2.0 + streaming_mode + the + * agreed element id</li> + * </ul> + */ +class FeishuStreamingCardManagerTest { + + /** Recording SDK seam — captures each call so the test can replay them. */ + private static final class RecordingManager extends FeishuStreamingCardManager { + record ContentCall(String cardId, String elementId, String content, int sequence) {} + record CloseCall(String cardId, int sequence) {} + + final List<ContentCall> contentCalls = new java.util.concurrent.CopyOnWriteArrayList<>(); + final List<CloseCall> closeCalls = new java.util.concurrent.CopyOnWriteArrayList<>(); + final AtomicLong fakeNowMs = new AtomicLong(0); + final AtomicReference<String> nextCardId = new AtomicReference<>("card_abc"); + final AtomicReference<String> nextMessageId = new AtomicReference<>("msg_abc"); + + RecordingManager(FeishuClientFactory factory, ObjectMapper objectMapper) { + super(factory, objectMapper); + } + + @Override protected long currentTimeMs() { return fakeNowMs.get(); } + + @Override protected String sdkCreateCard(Client client, String initialText) { return nextCardId.get(); } + + @Override protected String sdkSendInteractiveMessage(Client client, String receiveIdType, + String receiveId, String cardId) { + return nextMessageId.get(); + } + + @Override protected void sdkPushElementContent(Client client, String cardId, String elementId, + String content, int sequence) { + contentCalls.add(new ContentCall(cardId, elementId, content, sequence)); + } + + @Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) { + closeCalls.add(new CloseCall(cardId, sequence)); + } + } + + private RecordingManager manager; + + @BeforeEach + void setUp() { + FeishuClientFactory factory = mock(FeishuClientFactory.class); + when(factory.client(anyLong())).thenReturn(mock(Client.class)); + // Mockito.any() also returns mock Client for boxed Long lookups + when(factory.client(any())).thenReturn(mock(Client.class)); + manager = new RecordingManager(factory, new ObjectMapper()); + } + + @Test + @DisplayName("createAndDeliver registers a session keyed by UUID") + void createAndDeliverRegistersSession() { + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + assertNotNull(key); + assertEquals(1, manager.activeSessionCount()); + assertNotNull(manager.sessionFor(key)); + } + + @Test + @DisplayName("createAndDeliver returns null when card creation fails") + void createReturnsNullOnFailure() { + manager.nextCardId.set(null); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + assertNull(key); + assertEquals(0, manager.activeSessionCount()); + } + + @Test + @DisplayName("the very first append flushes immediately (no prior flush gates)") + void firstAppendFlushesImmediately() { + manager.fakeNowMs.set(1000L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + + manager.fakeNowMs.set(1010L); // only 10ms later — still flushes because no prior flush + manager.appendContent(key, "Hel", false); + + assertEquals(1, manager.contentCalls.size(), + "First-ever append should flush immediately so the user sees an instant first token"); + assertEquals("Hel", manager.contentCalls.get(0).content()); + } + + @Test + @DisplayName("subsequent appends inside the throttle window are suppressed and accumulated") + void throttleSuppressesPostFirstFlushAppends() { + manager.fakeNowMs.set(1000L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + + // First — flushes immediately (seq 1) and sets lastFlushMs=1010 + manager.fakeNowMs.set(1010L); + manager.appendContent(key, "first ", false); + assertEquals(1, manager.contentCalls.size()); + + // Inside 500ms window → suppressed, just accumulated + manager.fakeNowMs.set(1100L); + manager.appendContent(key, "mid ", false); + manager.fakeNowMs.set(1400L); + manager.appendContent(key, "more ", false); + assertEquals(1, manager.contentCalls.size(), + "Mid-window appends should NOT trigger SDK calls"); + + // Past 500ms window → flushes the full accumulator (seq 2) + manager.fakeNowMs.set(1600L); + manager.appendContent(key, "end", false); + assertEquals(2, manager.contentCalls.size()); + assertEquals("first mid more end", manager.contentCalls.get(1).content()); + assertEquals(2, manager.contentCalls.get(1).sequence()); + } + + @Test + @DisplayName("forceFlush bypasses the throttle even inside the window") + void forceFlushBypassesThrottle() { + manager.fakeNowMs.set(0L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + + // First append flushes regardless (lastFlushMs=0) + manager.appendContent(key, "a", false); + assertEquals(1, manager.contentCalls.size()); + + // 50ms later (inside 500ms window) — without force this would be suppressed + manager.fakeNowMs.set(50L); + manager.appendContent(key, "b", true); + assertEquals(2, manager.contentCalls.size(), "forceFlush must bypass throttle"); + assertEquals("ab", manager.contentCalls.get(1).content()); + } + + @Test + @DisplayName("finishCard emits final content + close, in monotonic sequence order, then removes session") + void finishCardClosesAndUnregisters() { + manager.fakeNowMs.set(0L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + + manager.appendContent(key, "Hello, ", true); // seq 1 + manager.fakeNowMs.set(600L); + manager.appendContent(key, "world", false); // seq 2 + manager.finishCard(key, "Hello, world!"); // seq 3 (content) + seq 4 (close) + + assertEquals(3, manager.contentCalls.size()); + assertEquals("Hello, world!", manager.contentCalls.get(2).content()); + // Sequence is monotonic across all content + close calls + assertEquals(1, manager.contentCalls.get(0).sequence()); + assertEquals(2, manager.contentCalls.get(1).sequence()); + assertEquals(3, manager.contentCalls.get(2).sequence()); + assertEquals(1, manager.closeCalls.size()); + assertEquals(4, manager.closeCalls.get(0).sequence()); + assertEquals(0, manager.activeSessionCount()); + } + + @Test + @DisplayName("appendContent after finish is a no-op (no SDK call, no resurrection)") + void appendAfterFinishIsNoop() { + manager.fakeNowMs.set(0L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + manager.finishCard(key, "done"); + + int before = manager.contentCalls.size(); + manager.fakeNowMs.set(10_000L); + manager.appendContent(key, "ghost delta", true); + assertEquals(before, manager.contentCalls.size(), + "Append after terminal status must not produce another SDK call"); + } + + @Test + @DisplayName("failCard appends error suffix, closes, and ignores second terminal call") + void failCardAppendsTailAndIsOneShot() { + manager.fakeNowMs.set(0L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + manager.appendContent(key, "Partial reply", true); + + manager.failCard(key, "rate limited"); + + // last content push carries the failure suffix + assertTrue(manager.contentCalls.get(manager.contentCalls.size() - 1).content().contains("rate limited")); + assertEquals(1, manager.closeCalls.size()); + + // A subsequent finishCard must NOT trigger another close + manager.finishCard(key, "ignored"); + assertEquals(1, manager.closeCalls.size(), "Second terminal call must be ignored"); + } + + @Test + @DisplayName("failCard with no accumulator emits a stand-alone error message") + void failCardWithEmptyAccumulator() { + manager.fakeNowMs.set(0L); + String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null); + + manager.failCard(key, "network down"); + assertEquals(1, manager.contentCalls.size()); + assertTrue(manager.contentCalls.get(0).content().contains("network down")); + } + + @Test + @DisplayName("initial card JSON declares schema 2.0 + streaming_mode + the stream element id") + @SuppressWarnings("unchecked") + void initialCardJsonShape() { + Map<String, Object> card = manager.buildInitialCardJson("test"); + assertEquals("2.0", card.get("schema")); + Map<String, Object> config = (Map<String, Object>) card.get("config"); + assertEquals(Boolean.TRUE, config.get("streaming_mode")); + Map<String, Object> body = (Map<String, Object>) card.get("body"); + List<Map<String, Object>> elements = (List<Map<String, Object>>) body.get("elements"); + assertEquals(FeishuStreamingCardManager.STREAM_ELEMENT_ID, elements.get(0).get("element_id")); + assertEquals("markdown", elements.get(0).get("tag")); + } + + @Test + @DisplayName("unknown session key is a silent no-op on every public method") + void unknownSessionIsNoop() { + manager.appendContent("never-existed", "x", true); + manager.finishCard("never-existed", "x"); + manager.failCard("never-existed", "x"); + assertEquals(0, manager.contentCalls.size()); + assertEquals(0, manager.closeCalls.size()); + assertFalse(false); // assertion just to make junit happy with no real check + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java new file mode 100644 index 00000000..c9c18b20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java @@ -0,0 +1,79 @@ +package vip.mate.channel.feishu.cards; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.approval.ApprovalService; +import vip.mate.channel.feishu.cards.tool_guard.ToolGuardButtonValue; +import vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Pin the dispatcher's registration + lookup invariants. + */ +class FeishuCardDispatcherTest { + + private FeishuCardDispatcher newDispatcher() { + ToolGuardCardKindFactory factory = new ToolGuardCardKindFactory( + mock(ApprovalService.class), + new ObjectMapper()); + return new FeishuCardDispatcher(factory); + } + + @Test + @DisplayName("tool_guard kind is registered after construction") + void toolGuardRegistered() { + FeishuCardDispatcher d = newDispatcher(); + assertTrue(d.registeredKindNames().contains(ToolGuardCardKindFactory.KIND_NAME)); + assertEquals(1, d.registeredKindNames().size()); + } + + @Test + @DisplayName("lookupByName returns the registered kind") + void lookupByName() { + FeishuCardDispatcher d = newDispatcher(); + Optional<FeishuCardKind> opt = d.lookupByName(ToolGuardCardKindFactory.KIND_NAME); + assertTrue(opt.isPresent()); + assertEquals(ToolGuardCardKindFactory.KIND_NAME, opt.get().name()); + + assertFalse(d.lookupByName("nonexistent").isPresent()); + assertFalse(d.lookupByName(null).isPresent()); + assertFalse(d.lookupByName("").isPresent()); + } + + @Test + @DisplayName("lookupByAction matches the prefix and ignores unknown actions") + void lookupByAction() { + FeishuCardDispatcher d = newDispatcher(); + Optional<FeishuCardKind> approve = d.lookupByAction(ToolGuardButtonValue.ACTION_APPROVE); + assertTrue(approve.isPresent()); + Optional<FeishuCardKind> deny = d.lookupByAction(ToolGuardButtonValue.ACTION_DENY); + assertTrue(deny.isPresent()); + // Any string starting with the prefix matches + assertTrue(d.lookupByAction("tg_approval.future_subaction").isPresent()); + + assertFalse(d.lookupByAction("unknown.action").isPresent()); + assertFalse(d.lookupByAction(null).isPresent()); + assertFalse(d.lookupByAction("").isPresent()); + } + + @Test + @DisplayName("FeishuCardKind constructor rejects blank name / prefix") + void cardKindValidation() { + FeishuCardKind valid = new FeishuCardKind( + "ok", "ok.", (n) -> java.util.Map.of(), (adapter, data) -> null); + assertEquals("ok", valid.name()); + + assertThrows(IllegalArgumentException.class, + () -> new FeishuCardKind("", "x.", (n) -> java.util.Map.of(), (a, d) -> null)); + assertThrows(IllegalArgumentException.class, + () -> new FeishuCardKind("ok", " ", (n) -> java.util.Map.of(), (a, d) -> null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java new file mode 100644 index 00000000..0206b5b0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java @@ -0,0 +1,99 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.cards.CardOversizedException; + +import java.util.HashMap; +import java.util.Map; + +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.assertThrows; + +/** + * Pin the encode / decode contract for the tool-guard approval card + * button {@code value} field. Round-trips matter because Feishu + * deserialises the value to a Map and we need to identify which + * pending approval and which decision the click came from. + */ +class ToolGuardButtonValueTest { + + private final ToolGuardButtonValue encoder = new ToolGuardButtonValue(new ObjectMapper()); + + @Test + @DisplayName("approve round-trip preserves action / pendingId / tool / severity") + void approveRoundTrip() { + Map<String, Object> value = encoder.encode( + ToolGuardButtonValue.Action.APPROVE, "pend-1", "feishu_doc_create", "HIGH"); + + ToolGuardButtonValue.Decoded decoded = encoder.decode(value); + assertNotNull(decoded); + assertEquals(ToolGuardButtonValue.Action.APPROVE, decoded.action()); + assertEquals("pend-1", decoded.pendingId()); + assertEquals("feishu_doc_create", decoded.toolName()); + assertEquals("HIGH", decoded.severity()); + } + + @Test + @DisplayName("deny round-trip preserves the deny action") + void denyRoundTrip() { + Map<String, Object> value = encoder.encode( + ToolGuardButtonValue.Action.DENY, "pend-2", "feishu_calendar_create_event", "MEDIUM"); + ToolGuardButtonValue.Decoded decoded = encoder.decode(value); + assertEquals(ToolGuardButtonValue.Action.DENY, decoded.action()); + assertEquals("pend-2", decoded.pendingId()); + } + + @Test + @DisplayName("decode rejects unknown / missing action with null") + void decodeRejectsUnknownAction() { + Map<String, Object> bad = new HashMap<>(); + bad.put("action", "unknown.thing"); + bad.put("rid", "pend-1"); + assertNull(encoder.decode(bad)); + + bad.remove("action"); + assertNull(encoder.decode(bad)); + } + + @Test + @DisplayName("decode rejects missing pendingId with null") + void decodeRejectsMissingPendingId() { + Map<String, Object> bad = new HashMap<>(); + bad.put("action", ToolGuardButtonValue.ACTION_APPROVE); + assertNull(encoder.decode(bad)); + + bad.put("rid", ""); + assertNull(encoder.decode(bad)); + } + + @Test + @DisplayName("decode is tolerant of null / empty input") + void decodeTolerantOfNullEmpty() { + assertNull(encoder.decode(null)); + assertNull(encoder.decode(Map.of())); + } + + @Test + @DisplayName("encode rejects oversize payload with CardOversizedException") + void encodeRejectsOversize() { + // Build a tool name that will push the JSON well past MAX_VALUE_BYTES (2048) + String huge = "x".repeat(3000); + CardOversizedException ex = assertThrows(CardOversizedException.class, + () -> encoder.encode(ToolGuardButtonValue.Action.APPROVE, "pend-1", huge, "HIGH")); + assertEquals(true, ex.getMessage().contains("> limit")); + } + + @Test + @DisplayName("encoded JSON key order is stable (LinkedHashMap → predictable byte length)") + void encodedFieldOrderStable() { + Map<String, Object> a = encoder.encode( + ToolGuardButtonValue.Action.APPROVE, "p-1", "tool-a", "HIGH"); + Map<String, Object> b = encoder.encode( + ToolGuardButtonValue.Action.APPROVE, "p-1", "tool-a", "HIGH"); + assertEquals(a.keySet().iterator().next(), b.keySet().iterator().next()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java new file mode 100644 index 00000000..13f4a450 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java @@ -0,0 +1,118 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.notification.ApprovalNotice; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the shape of the rendered Schema-2.0 button card so a Feishu + * spec tweak (button format change, action element rename) breaks + * loudly here rather than in production. + */ +class ToolGuardCardRendererTest { + + private final ToolGuardCardRenderer renderer = new ToolGuardCardRenderer( + new ToolGuardButtonValue(new ObjectMapper())); + + @SuppressWarnings("unchecked") + @Test + @DisplayName("rendered card is Schema 1.0 inline: config + header + elements at root, action row wraps two buttons") + void cardShape() { + ApprovalNotice notice = new ApprovalNotice( + "pend-1", "feishu_doc_create", "Create a new Doc", + "{\"title\":\"meeting notes\"}", "HIGH", + List.of(Map.of("severity", "HIGH", "title", "Mutating Feishu doc")), + "/approve pend-1", "/deny pend-1"); + + Map<String, Object> card = renderer.render(notice); + + // Schema 1.0: NO 'schema' field, NO 'body' nesting — keeps the + // approval card and the callback-response resolved card on the + // same schema so Feishu's validator doesn't fault on update. + assertFalse(card.containsKey("schema"), + "Approval card must be Schema 1.0 for callback-response compatibility"); + assertFalse(card.containsKey("body")); + + Map<String, Object> config = (Map<String, Object>) card.get("config"); + assertEquals(Boolean.TRUE, config.get("wide_screen_mode")); + + Map<String, Object> header = (Map<String, Object>) card.get("header"); + assertNotNull(header); + List<Map<String, Object>> elements = (List<Map<String, Object>>) card.get("elements"); + assertEquals(2, elements.size(), "expect markdown + action row"); + + Map<String, Object> md = elements.get(0); + assertEquals("markdown", md.get("tag")); + String content = (String) md.get("content"); + assertTrue(content.contains("feishu_doc_create"), "tool name in summary"); + assertTrue(content.contains("HIGH"), "severity in summary"); + + // Schema 1.0 button row — {tag:"action", actions:[primary, danger]} + Map<String, Object> actionRow = elements.get(1); + assertEquals("action", actionRow.get("tag")); + List<Map<String, Object>> buttons = (List<Map<String, Object>>) actionRow.get("actions"); + assertEquals(2, buttons.size()); + assertEquals("button", buttons.get(0).get("tag")); + assertEquals("primary", buttons.get(0).get("type")); + assertEquals("button", buttons.get(1).get("tag")); + assertEquals("danger", buttons.get(1).get("type")); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("approve / deny buttons carry round-trippable action values") + void buttonsCarryRoundTrippableValues() { + ApprovalNotice notice = new ApprovalNotice( + "pend-42", "feishu_calendar_create_event", "schedule meeting", + "{}", "MEDIUM", List.of(), "/approve pend-42", "/deny pend-42"); + + Map<String, Object> card = renderer.render(notice); + List<Map<String, Object>> elements = (List<Map<String, Object>>) card.get("elements"); + List<Map<String, Object>> buttons = (List<Map<String, Object>>) elements.get(1).get("actions"); + + Map<String, Object> approveValue = (Map<String, Object>) buttons.get(0).get("value"); + assertEquals(ToolGuardButtonValue.ACTION_APPROVE, approveValue.get("action")); + assertEquals("pend-42", approveValue.get("rid")); + + Map<String, Object> denyValue = (Map<String, Object>) buttons.get(1).get("value"); + assertEquals(ToolGuardButtonValue.ACTION_DENY, denyValue.get("action")); + assertEquals("pend-42", denyValue.get("rid")); + } + + @Test + @DisplayName("buildResolvedCard returns Schema 1.0 inline layout (no schema field, elements at root) — required by Feishu callback-response validator") + @SuppressWarnings("unchecked") + void buildResolvedCardShape() { + Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard( + "✅ 已批准", "tool foo approved by Alice", "green"); + + // Schema 1.0 has NO "schema" field — callback-response validator + // returns 200672 if it sees Schema 2.0. + assertFalse(card.containsKey("schema"), + "Resolved card must be Schema 1.0 (no 'schema' field) for callback-response compatibility"); + assertFalse(card.containsKey("body"), + "Schema 1.0 puts elements at root, NOT under 'body'"); + + Map<String, Object> config = (Map<String, Object>) card.get("config"); + assertEquals(Boolean.TRUE, config.get("wide_screen_mode")); + + Map<String, Object> header = (Map<String, Object>) card.get("header"); + assertEquals("green", header.get("template")); + Map<String, Object> title = (Map<String, Object>) header.get("title"); + assertEquals("✅ 已批准", title.get("content")); + + List<Map<String, Object>> elements = (List<Map<String, Object>>) card.get("elements"); + assertEquals(1, elements.size()); + assertEquals("markdown", elements.get(0).get("tag")); + assertTrue(((String) elements.get(0).get("content")).contains("approved by Alice")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java new file mode 100644 index 00000000..098cd139 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java @@ -0,0 +1,59 @@ +package vip.mate.channel.feishu.tool; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.tool.ChannelToolDescriptor; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the descriptor catalog: read tools default on, write tools land + * disabled, names are stable (downstream rule IDs and channel-tool + * UI state bind on them). + */ +class FeishuToolCatalogTest { + + @Test + @DisplayName("catalog returns the agreed 3-tool initial set") + void catalogShape() { + List<ChannelToolDescriptor> ds = FeishuToolCatalog.descriptors(); + Set<String> names = ds.stream().map(ChannelToolDescriptor::name).collect(java.util.stream.Collectors.toSet()); + assertEquals(Set.of( + FeishuToolCatalog.TOOL_LIST_EVENTS, + FeishuToolCatalog.TOOL_DOC_READ, + FeishuToolCatalog.TOOL_DOC_CREATE + ), names); + } + + @Test + @DisplayName("read tools land default-enabled, write tool lands default-disabled") + void readWriteDefaults() { + Map<String, ChannelToolDescriptor> byName = FeishuToolCatalog.descriptors().stream() + .collect(java.util.stream.Collectors.toMap(ChannelToolDescriptor::name, d -> d)); + + assertFalse(byName.get(FeishuToolCatalog.TOOL_LIST_EVENTS).mutating()); + assertTrue(byName.get(FeishuToolCatalog.TOOL_LIST_EVENTS).enabledByDefault()); + + assertFalse(byName.get(FeishuToolCatalog.TOOL_DOC_READ).mutating()); + assertTrue(byName.get(FeishuToolCatalog.TOOL_DOC_READ).enabledByDefault()); + + assertTrue(byName.get(FeishuToolCatalog.TOOL_DOC_CREATE).mutating()); + assertFalse(byName.get(FeishuToolCatalog.TOOL_DOC_CREATE).enabledByDefault(), + "write tools must be disabled by default so a freshly-installed channel doesn't auto-create docs"); + } + + @Test + @DisplayName("every descriptor carries a non-blank JSON schema") + void schemasNonBlank() { + for (ChannelToolDescriptor d : FeishuToolCatalog.descriptors()) { + assertTrue(d.inputSchema().contains("type"), + d.name() + " schema should look like JSON Schema; got: " + d.inputSchema()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java new file mode 100644 index 00000000..907745ad --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java @@ -0,0 +1,96 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.document.GeneratedFileCache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Pin the cache-hit vs. cache-miss rewrite contract. + * + * <p>The scrubber rewrites differently per outcome because the user + * experience differs: + * <ul> + * <li>Cache hit → upload as native attachment; bubble text shows + * "📎 filename" so the link doesn't dangle.</li> + * <li>Cache miss (LLM hallucinated the URL OR the 10 min TTL + * expired) → bubble text shows + * {@link GeneratedFileCache#MISSING_REFERENCE_NOTICE} so the + * user knows to retry the request rather than tap a 404.</li> + * </ul> + */ +class GeneratedFileScrubberTest { + + @Test + @DisplayName("text without any generated URL is returned unchanged") + void noMatchesReturnInput() { + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(new GeneratedFileCache()); + String text = "hello world\nnothing to see here"; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + assertSame(text, r.rewrittenText()); + assertEquals(0, r.attachments().size()); + } + + @Test + @DisplayName("cache hit replaces URL with file-name marker and queues bytes for upload") + void cacheHitProducesAttachment() { + GeneratedFileCache cache = new GeneratedFileCache(); + byte[] bytes = "fake-pdf".getBytes(); + String id = cache.put(bytes, "report.pdf", "application/pdf"); + + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(cache); + String text = "See attached: /api/v1/files/generated/" + id + " for details."; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + + assertTrue(r.rewrittenText().contains("📎 report.pdf"), + "marker should appear; got: " + r.rewrittenText()); + assertEquals(1, r.attachments().size()); + GeneratedFileScrubber.AttachmentHit hit = r.attachments().get(0); + assertEquals("report.pdf", hit.fileName()); + assertEquals("file", hit.mediaType()); + assertSame(bytes, hit.bytes()); + } + + @Test + @DisplayName("cache hit with image MIME classifies as image media type") + void cacheHitImageMime() { + GeneratedFileCache cache = new GeneratedFileCache(); + String id = cache.put(new byte[]{1, 2, 3}, "screenshot.png", "image/png"); + + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(cache); + GeneratedFileScrubber.ScrubResult r = scrubber.scrub("look: /api/v1/files/generated/" + id); + assertEquals(1, r.attachments().size()); + assertEquals("image", r.attachments().get(0).mediaType()); + } + + @Test + @DisplayName("cache miss replaces URL with retry hint and produces no attachment") + void cacheMissProducesRetryHint() { + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(new GeneratedFileCache()); + // Random UUID-shaped string the LLM might hallucinate + String text = "click /api/v1/files/generated/00000000-0000-4000-8000-000000000000 to get it"; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + assertEquals(0, r.attachments().size()); + assertTrue(r.rewrittenText().contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE), + "miss should swap in MISSING_REFERENCE_NOTICE; got: " + r.rewrittenText()); + } + + @Test + @DisplayName("multiple URLs in one text produce hits in document order") + void multipleHitsOrdered() { + GeneratedFileCache cache = new GeneratedFileCache(); + String idA = cache.put(new byte[]{1}, "a.pdf", "application/pdf"); + String idB = cache.put(new byte[]{2}, "b.png", "image/png"); + + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(cache); + String text = "first: /api/v1/files/generated/" + idA + + " then: /api/v1/files/generated/" + idB + "."; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + assertEquals(2, r.attachments().size()); + assertEquals("a.pdf", r.attachments().get(0).fileName()); + assertEquals("b.png", r.attachments().get(1).fileName()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java new file mode 100644 index 00000000..33a9a1cb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java @@ -0,0 +1,82 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Confirm the channel-agnostic {@link ImageCompressor} behaves the + * same on Feishu's 10 MB ceiling and WeCom's 1.9 MB safe-margin. + * + * <p>The compressor's "give up if you can't fit; return smallest" + * fallback exists because every channel's adapter follows up with its + * own size policy that knows how to downgrade or reject — we never + * want to crash on an undecodable PNG. + */ +class ImageCompressorTest { + + @Test + @DisplayName("under-limit bytes are returned untouched (same reference)") + void underLimitPassesThrough() { + byte[] tiny = new byte[100]; + new Random(42).nextBytes(tiny); + byte[] out = ImageCompressor.compressIfNeeded(tiny, "tiny.bin", 1000); + assertSame(tiny, out, "must not copy when already under limit"); + } + + @Test + @DisplayName("null or empty input returns as-is, no NPE") + void nullAndEmpty() { + assertArrayEquals(null, ImageCompressor.compressIfNeeded(null, "n", 100)); + byte[] empty = new byte[0]; + assertSame(empty, ImageCompressor.compressIfNeeded(empty, "e", 100)); + } + + @Test + @DisplayName("undecodable garbage at over-limit size returns original (no crash)") + void undecodableReturnsOriginal() { + byte[] junk = new byte[1500]; + new Random(99).nextBytes(junk); + byte[] out = ImageCompressor.compressIfNeeded(junk, "junk.bin", 1000); + // ImageIO.read returns null on garbage; compressor logs and returns input. + assertSame(junk, out); + } + + @Test + @DisplayName("real PNG over the limit is shrunk to fit") + void realImageShrinksToFit() throws Exception { + // Generate a 256x256 PNG that's well over a tight limit when uncompressed RGBA + BufferedImage img = new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + // Fill with noisy pattern so PNG can't trivially compress to almost nothing + Random rnd = new Random(7); + for (int y = 0; y < 256; y++) { + for (int x = 0; x < 256; x++) { + img.setRGB(x, y, rnd.nextInt()); + } + } + g.setColor(Color.WHITE); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + byte[] pngBytes = baos.toByteArray(); + + // 4 KB ceiling — noisy PNG won't fit, but JPEG at low quality + resize will. + byte[] out = ImageCompressor.compressIfNeeded(pngBytes, "noise.png", 4000); + assertNotNull(out); + // Either fits, or returns the smallest variant. Either way it's smaller than the input. + assertTrue(out.length < pngBytes.length, + "compressor should at least shrink below original; in=" + pngBytes.length + " out=" + out.length); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java new file mode 100644 index 00000000..69b6b3c7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java @@ -0,0 +1,59 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Lock in the {@link MediaSource} sealed contract — each variant + * validates its required payload at construction so downstream + * uploaders don't have to defensive-null-check every field. + */ +class MediaSourceTest { + + @Test + @DisplayName("Bytes rejects null and empty payload") + void bytesRequiresContent() { + assertThrows(IllegalArgumentException.class, () -> new MediaSource.Bytes(null)); + assertThrows(IllegalArgumentException.class, () -> new MediaSource.Bytes(new byte[0])); + } + + @Test + @DisplayName("LocalPath rejects null path") + void localPathRequiresPath() { + assertThrows(IllegalArgumentException.class, () -> new MediaSource.LocalPath(null)); + } + + @Test + @DisplayName("RemoteUrl rejects blank URL") + void remoteUrlRequiresUrl() { + assertThrows(IllegalArgumentException.class, () -> new MediaSource.RemoteUrl(null)); + assertThrows(IllegalArgumentException.class, () -> new MediaSource.RemoteUrl("")); + assertThrows(IllegalArgumentException.class, () -> new MediaSource.RemoteUrl(" ")); + } + + @Test + @DisplayName("happy paths accept the three valid forms") + void happyPaths() { + MediaSource b = new MediaSource.Bytes(new byte[]{1, 2, 3}); + MediaSource p = new MediaSource.LocalPath(Paths.get("/tmp/x")); + MediaSource u = new MediaSource.RemoteUrl("https://example.com/x.png"); + + // Exhaustive switch — verifies the sealed contract at compile time too. + assertEquals("Bytes", classifyVariant(b)); + assertEquals("LocalPath", classifyVariant(p)); + assertEquals("RemoteUrl", classifyVariant(u)); + } + + private static String classifyVariant(MediaSource s) { + return switch (s) { + case MediaSource.Bytes ignored -> "Bytes"; + case MediaSource.LocalPath ignored -> "LocalPath"; + case MediaSource.RemoteUrl ignored -> "RemoteUrl"; + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/tool/AvailableToolChannelSourceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/tool/AvailableToolChannelSourceTest.java new file mode 100644 index 00000000..3f3705ea --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/tool/AvailableToolChannelSourceTest.java @@ -0,0 +1,52 @@ +package vip.mate.channel.tool; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.model.ToolEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pin the {@code source="channel"} classification — the picker UI + * shows "Channel · {channelName}" only when this branch fires; a + * regression here silently lumps channel tools into the Built-in + * bucket. + */ +class AvailableToolChannelSourceTest { + + @Test + @DisplayName("fromChannel sets source=channel + group label includes the channel name") + void fromChannelClassifiesAsChannel() { + ToolEntity row = new ToolEntity(); + row.setName("feishu_calendar_list_events_c99"); + row.setDisplayName("List calendar events (My Feishu Bot)"); + row.setDescription("List events on the user's Feishu calendar"); + row.setToolType("channel"); + row.setChannelId(99L); + row.setEnabled(true); + + AvailableToolDTO dto = AvailableToolDTO.fromChannel(row); + assertEquals("channel", dto.getSource()); + assertEquals(99L, dto.getProviderId()); + assertEquals("My Feishu Bot", dto.getProviderName()); + assertEquals("Channel · My Feishu Bot", dto.getGroup()); + assertEquals("channel:99", dto.getGroupId()); + assertEquals("feishu_calendar_list_events_c99", dto.getName()); + } + + @Test + @DisplayName("fromChannel handles missing channel name suffix gracefully (no NPE, default group)") + void fromChannelHandlesMissingChannelName() { + ToolEntity row = new ToolEntity(); + row.setName("feishu_x"); + row.setDisplayName("display without parens"); + row.setDescription("desc"); + row.setToolType("channel"); + row.setChannelId(null); + AvailableToolDTO dto = AvailableToolDTO.fromChannel(row); + assertEquals("channel", dto.getSource()); + assertEquals("Channel", dto.getGroup()); + assertEquals("channel", dto.getGroupId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolCallbackTest.java b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolCallbackTest.java new file mode 100644 index 00000000..c85d4270 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolCallbackTest.java @@ -0,0 +1,39 @@ +package vip.mate.channel.tool; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Pin the {@code renamed(actualName)} contract — {@link ChannelToolService} + * relies on it to apply the {@code _c<channelId>} suffix without + * forcing providers to know per-instance names. + */ +class ChannelToolCallbackTest { + + @Test + @DisplayName("call delegates to the supplied handler") + void callDelegates() { + ChannelToolCallback cb = new ChannelToolCallback( + "test_tool", "test description", "{\"type\":\"object\"}", + in -> "echo:" + in); + assertEquals("echo:hello", cb.call("hello")); + assertEquals("echo:world", cb.call("world", null)); + } + + @Test + @DisplayName("renamed returns a new callback carrying the same handler + description + schema") + void renamedKeepsBehaviorWithNewName() { + ChannelToolCallback original = new ChannelToolCallback( + "feishu_doc_read", "read doc", "{}", in -> "READ:" + in); + ToolCallback renamed = original.renamed("feishu_doc_read_c42"); + + assertNotSame(original, renamed); + assertEquals("feishu_doc_read_c42", renamed.getToolDefinition().name()); + assertEquals("read doc", renamed.getToolDefinition().description()); + assertEquals("READ:abc", renamed.call("abc")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolDescriptorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolDescriptorTest.java new file mode 100644 index 00000000..d62bea35 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolDescriptorTest.java @@ -0,0 +1,66 @@ +package vip.mate.channel.tool; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the descriptor's compact-record validation: + * <ul> + * <li>blank name / description / schema → IllegalArgumentException</li> + * <li>mutating implies enabledByDefault=false regardless of caller intent</li> + * </ul> + */ +class ChannelToolDescriptorTest { + + @Test + @DisplayName("non-mutating tool keeps the caller's enabledByDefault") + void readToolHonorsEnabledByDefault() { + ChannelToolDescriptor enabled = new ChannelToolDescriptor( + "feishu_calendar_list", "List calendars", "List user's Feishu calendars", + "{\"type\":\"object\"}", false, true); + assertTrue(enabled.enabledByDefault()); + + ChannelToolDescriptor disabled = new ChannelToolDescriptor( + "feishu_calendar_list", "List", "List", "{\"type\":\"object\"}", false, false); + assertFalse(disabled.enabledByDefault()); + } + + @Test + @DisplayName("mutating tool always lands disabled even when caller asks for enabled") + void mutatingForcesDisabledDefault() { + ChannelToolDescriptor d = new ChannelToolDescriptor( + "feishu_calendar_create_event", "Create event", + "Creates an event on the user's calendar", + "{\"type\":\"object\"}", true, true); + assertFalse(d.enabledByDefault(), + "mutating tools must start disabled — write surfaces should require explicit opt-in"); + assertTrue(d.mutating()); + } + + @Test + @DisplayName("blank name / description / inputSchema rejected at construction") + void blankFieldsRejected() { + assertThrows(IllegalArgumentException.class, + () -> new ChannelToolDescriptor("", "x", "x", "{}", false, true)); + assertThrows(IllegalArgumentException.class, + () -> new ChannelToolDescriptor("x", "x", "", "{}", false, true)); + assertThrows(IllegalArgumentException.class, + () -> new ChannelToolDescriptor("x", "x", "x", " ", false, true)); + } + + @Test + @DisplayName("equals / hashCode are record-default (value semantics)") + void valueSemantics() { + ChannelToolDescriptor a = new ChannelToolDescriptor( + "a", "A", "desc", "{}", false, true); + ChannelToolDescriptor b = new ChannelToolDescriptor( + "a", "A", "desc", "{}", false, true); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java new file mode 100644 index 00000000..aefc4cc2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java @@ -0,0 +1,131 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ChatStreamTracker#cleanupStaleRuns()} — the switch from + * wall-clock {@code MAX_LIFETIME_MS} to inactivity-based eviction. + * + * <p>Before the fix, a long-running agent that kept producing tool calls + * (47-minute LLM-review smoke test, round 6) was killed at the 30-minute + * wall-clock mark mid-task. The new behaviour mirrors hermes-agent's + * {@code gateway_timeout}: only completely idle runs are evicted, the + * actively-producing ones can run as long as they need to. + */ +class ChatStreamTrackerCleanupTest { + + private static ChatStreamTracker newTracker() { + ChatStreamTracker t = new ChatStreamTracker(new ObjectMapper()); + // Tighten the idle threshold so the test stays at unit-test speed. + t.setIdleTimeoutMinutesForTesting(5); + return t; + } + + @Test + @DisplayName("Active run (recent lastEventAt) survives cleanup regardless of total age.") + void activeRunSurvives() { + ChatStreamTracker tracker = newTracker(); + tracker.register("conv-active"); + // lastEventAt was set to "now" inside the RunState constructor — + // no backdate, so even a "very old createdAt" would be irrelevant. + tracker.cleanupStaleRuns(); + assertTrue(tracker.hasRunStateForTesting("conv-active"), + "actively-producing run must not be evicted"); + } + + @Test + @DisplayName("Idle run beyond threshold is evicted.") + void idleRunEvicted() { + ChatStreamTracker tracker = newTracker(); + tracker.register("conv-idle"); + // 6 minutes ago — past the 5-minute threshold set above. + tracker.backdateLastEventForTesting("conv-idle", System.currentTimeMillis() - 6 * 60_000L); + tracker.cleanupStaleRuns(); + assertFalse(tracker.hasRunStateForTesting("conv-idle"), + "idle run past threshold must be evicted"); + } + + @Test + @DisplayName("Idle run just inside the threshold survives — no premature eviction.") + void idleRunWithinThresholdSurvives() { + ChatStreamTracker tracker = newTracker(); + tracker.register("conv-borderline"); + // 4 minutes idle — within 5-minute window. + tracker.backdateLastEventForTesting("conv-borderline", System.currentTimeMillis() - 4 * 60_000L); + tracker.cleanupStaleRuns(); + assertTrue(tracker.hasRunStateForTesting("conv-borderline"), + "run idle below threshold must survive — would otherwise be a regression of the wall-clock bug"); + } + + @Test + @DisplayName("Mixed: active + idle runs — only the idle one is evicted.") + void mixedRunsSelectiveEviction() { + ChatStreamTracker tracker = newTracker(); + tracker.register("conv-active"); + tracker.register("conv-idle"); + tracker.backdateLastEventForTesting("conv-idle", System.currentTimeMillis() - 10 * 60_000L); + tracker.cleanupStaleRuns(); + assertTrue(tracker.hasRunStateForTesting("conv-active")); + assertFalse(tracker.hasRunStateForTesting("conv-idle")); + } + + @Test + @DisplayName("Default idle timeout from @Value matches the documented 30-minute fallback.") + void defaultIdleTimeoutIs30() { + // Bypass the @Value injection (no Spring context in this unit test) — + // the field initialiser pins the default so a refactor that drops the + // = 30 falls over here. + ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); + assertEquals(30, tracker.idleTimeoutMinutesForTesting()); + } + + @Test + @DisplayName("Eviction fires emergencySaveCallback first so the assistant trace survives the dispose.") + void evictionTriggersEmergencySave() { + ChatStreamTracker tracker = newTracker(); + tracker.register("conv-needs-save"); + + AtomicInteger saveCount = new AtomicInteger(); + tracker.setEmergencySaveCallback("conv-needs-save", saveCount::incrementAndGet); + + // Backdate so the eviction path triggers. + tracker.backdateLastEventForTesting("conv-needs-save", System.currentTimeMillis() - 10 * 60_000L); + tracker.cleanupStaleRuns(); + + assertEquals(1, saveCount.get(), + "emergency save must run exactly once before eviction disposes the Flux"); + assertFalse(tracker.hasRunStateForTesting("conv-needs-save")); + } + + @Test + @DisplayName("Completed (done) runs skip the emergency save — they already saved at doOnComplete.") + void doneRunsSkipEmergencySaveOnEviction() { + ChatStreamTracker tracker = newTracker(); + tracker.register("conv-already-done"); + tracker.complete("conv-already-done"); // mark done + // Drive its retention timer out by backdating createdAt; cleanup + // path for done runs uses age, not lastEventAt. + tracker.backdateLastEventForTesting("conv-already-done", System.currentTimeMillis() - 10 * 60_000L); + + AtomicInteger saveCount = new AtomicInteger(); + tracker.setEmergencySaveCallback("conv-already-done", saveCount::incrementAndGet); + // Tweak retention so the done branch fires. + // (DONE_RETENTION_MS is 5 min; backdate lastEventAt above already + // exceeds it relative to createdAt — but createdAt isn't backdated, + // so the done branch won't trigger here. The point is: even if it + // did, the emergency save should be skipped because done=true.) + // Run cleanup — done run with recent createdAt won't be evicted at + // all, so the callback shouldn't fire. + tracker.cleanupStaleRuns(); + assertEquals(0, saveCount.get(), + "callback must not fire when the run is marked done — that path already saved"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java new file mode 100644 index 00000000..16456c3a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java @@ -0,0 +1,52 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ChatStreamTrackerQueueDrainTest { + + private ChatStreamTracker newTracker() { + return new ChatStreamTracker(new ObjectMapper()); + } + + @Test + @DisplayName("Queued inputs survive RunState replacement and drain FIFO") + void queuedInputsSurviveRunStateReplacementAndDrainFifo() { + ChatStreamTracker tracker = newTracker(); + String conversationId = "queue-drain"; + + tracker.register(conversationId); + tracker.incrementFlux(conversationId); + assertTrue(tracker.enqueueMessage(conversationId, "q1", 101L, false)); + assertTrue(tracker.enqueueMessage(conversationId, "q2", 101L, false)); + assertTrue(tracker.enqueueMessage(conversationId, "q3", 101L, false)); + + ChatStreamTracker.CompletionResult first = tracker.completeAndConsumeIfLast(conversationId); + assertTrue(first.allDone()); + assertNotNull(first.queuedInput()); + assertEquals("q1", first.queuedInput().message()); + + tracker.register(conversationId); + tracker.incrementFlux(conversationId); + ChatStreamTracker.CompletionResult second = tracker.completeAndConsumeIfLast(conversationId); + assertTrue(second.allDone()); + assertNotNull(second.queuedInput()); + assertEquals("q2", second.queuedInput().message()); + + tracker.register(conversationId); + tracker.incrementFlux(conversationId); + ChatStreamTracker.CompletionResult third = tracker.completeAndConsumeIfLast(conversationId); + assertTrue(third.allDone()); + assertNotNull(third.queuedInput()); + assertEquals("q3", third.queuedInput().message()); + + tracker.register(conversationId); + tracker.incrementFlux(conversationId); + ChatStreamTracker.CompletionResult empty = tracker.completeAndConsumeIfLast(conversationId); + assertTrue(empty.allDone()); + assertNull(empty.queuedInput()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java new file mode 100644 index 00000000..b1a4819f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java @@ -0,0 +1,138 @@ +package vip.mate.channel.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SegmentSupersedeDetectorTest { + + @Test + @DisplayName("marks pre-tool forged render success when replaced by real render success") + void marksForgedRenderSuccess() { + List<Map<String, Object>> segments = segments( + content("ct-0", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/7a8b9c0d-1e2f-3g4h-5i6j-7k8l9m0n1o2p"), + tool("tc-0", "renderDocx", true), + content("ct-1", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/e4556d9f-cd69-4047-97c0-479dbbb6c256")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1") + .containsEntry("supersededReason", "tool_result_replaced_model_claim"); + } + + @Test + @DisplayName("does not mark legitimate preamble before a render tool") + void leavesLegitimatePreambleAlone() { + List<Map<String, Object>> segments = segments( + content("ct-0", "我听懂了,需要生成 PDF。让我立即执行这个操作:"), + tool("tc-0", "renderPdf", true), + content("ct-1", "PDF 已成功生成!\n\n下载链接: /api/v1/files/generated/e5fc9697-9d5a-4b20-a26d-89b623c8db9b")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("marks pre-tool forged write byte count when replaced by real write result") + void marksForgedWriteSuccess() { + List<Map<String, Object>> segments = segments( + content("ct-0", "文件已成功写入!\n\n写入字节数:45 字节"), + tool("tc-0", "write_file", true), + content("ct-1", "文件已成功写入!\n\n写入字节数:43 字节")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1"); + } + + @Test + @DisplayName("does not mark pre-tool success when the tool failed") + void leavesFailedToolClaimVisible() { + List<Map<String, Object>> segments = segments( + content("ct-0", "PPTX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/c8e2f4a1-9b3d-4f8c-a5e7-d9f6b2c1a3e4"), + tool("tc-0", "renderPptx", false), + content("ct-1", "渲染失败:模板错误")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("v1 does not mark when the post-tool content is a general summary") + void leavesSummaryFollowupAlone() { + List<Map<String, Object>> segments = segments( + content("ct-0", "文件内容已成功替换!\n\n替换次数:1 处"), + tool("tc-0", "edit_file", true), + content("ct-1", "所有文档生成和文件操作任务已完成。")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("does not cross another tool boundary looking for a replacement") + void doesNotCrossToolBoundary() { + List<Map<String, Object>> segments = segments( + content("ct-0", "XLSX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/8c3d4a9f-2e1b-4f5a-b6c7-d8e9f0a1b2c3"), + tool("tc-0", "renderXlsx", true), + tool("tc-1", "renderDocx", true), + content("ct-1", "XLSX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/f98d7fd0-3cda-4510-b056-5bd3c8343e19")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("does not mark an actual post-tool result as a later pre-tool prediction") + void doesNotMarkPostToolResult() { + List<Map<String, Object>> segments = segments( + tool("tc-0", "renderDocx", true), + content("ct-0", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/e4556d9f-cd69-4047-97c0-479dbbb6c256"), + tool("tc-1", "renderDocx", true), + content("ct-1", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/f98d7fd0-3cda-4510-b056-5bd3c8343e19")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(1)).doesNotContainKey("superseded"); + } + + @SafeVarargs + private static List<Map<String, Object>> segments(Map<String, Object>... entries) { + return new ArrayList<>(List.of(entries)); + } + + private static Map<String, Object> content(String id, String text) { + Map<String, Object> segment = base(id, "content"); + segment.put("text", text); + return segment; + } + + private static Map<String, Object> tool(String id, String toolName, boolean success) { + Map<String, Object> segment = base(id, "tool_call"); + segment.put("toolName", toolName); + segment.put("toolSuccess", success); + return segment; + } + + private static Map<String, Object> base(String id, String type) { + Map<String, Object> segment = new LinkedHashMap<>(); + segment.put("id", id); + segment.put("type", type); + segment.put("status", "completed"); + return segment; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java index a725c407..2a176ce3 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import vip.mate.channel.wecom.cards.CardOversizedException; +import vip.mate.channel.cards.CardOversizedException; import java.nio.charset.StandardCharsets; diff --git a/mateclaw-server/src/test/java/vip/mate/common/result/RHttpStatusAdviceTest.java b/mateclaw-server/src/test/java/vip/mate/common/result/RHttpStatusAdviceTest.java new file mode 100644 index 00000000..6fc9ed6a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/result/RHttpStatusAdviceTest.java @@ -0,0 +1,33 @@ +package vip.mate.common.result; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RHttpStatusAdviceTest { + + @Test + void failEnvelopeSetsHttpStatusFromBodyCode() { + RHttpStatusAdvice advice = new RHttpStatusAdvice(); + MockHttpServletResponse servlet = new MockHttpServletResponse(); + + advice.beforeBodyWrite(R.fail(400, "bad input"), null, MediaType.APPLICATION_JSON, + null, null, new ServletServerHttpResponse(servlet)); + + assertEquals(400, servlet.getStatus()); + } + + @Test + void defaultFailEnvelopeSetsInternalServerErrorStatus() { + RHttpStatusAdvice advice = new RHttpStatusAdvice(); + MockHttpServletResponse servlet = new MockHttpServletResponse(); + + advice.beforeBodyWrite(R.fail("boom"), null, MediaType.APPLICATION_JSON, + null, null, new ServletServerHttpResponse(servlet)); + + assertEquals(500, servlet.getStatus()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java deleted file mode 100644 index 8d633db7..00000000 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package vip.mate.cron.service; - -import org.junit.jupiter.api.Test; -import vip.mate.agent.context.ChannelTarget; -import vip.mate.agent.context.ChatOrigin; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * RFC-063r §2.13 (Issue #25 — second symptom): - * {@link CronJobRunner#wrapWithDeliveryGuard} must prepend a system note - * for channel-bound cron runs and pass through web-origin runs unchanged. - */ -class CronJobRunnerDeliveryGuardTest { - - @Test - void channelBoundCron_prependsDeliveryGuard() { - ChatOrigin channelOrigin = new ChatOrigin( - /* agentId */ 7L, "cron_7", "system", 1L, null, - /* channelId */ 9L, new ChannelTarget("group-a", null, null)); - String input = "提醒我喝水并发到微信"; - String wrapped = CronJobRunner.wrapWithDeliveryGuard(input, channelOrigin); - - assertTrue(wrapped.contains("[系统说明]"), - "Channel-bound cron must include system note (RFC-063r §2.13)"); - assertTrue(wrapped.contains("不要尝试调用 CLI"), - "system note must explicitly forbid CLI hallucination"); - assertTrue(wrapped.endsWith(input), - "user message must be appended after the system note"); - } - - @Test - void webOriginCron_passesThroughUnchanged() { - ChatOrigin webOrigin = ChatOrigin.web("cron_1", "system", 1L, null); - String input = "Daily wiki update"; - assertEquals(input, CronJobRunner.wrapWithDeliveryGuard(input, webOrigin), - "web-origin cron must keep pre-RFC behavior"); - } - - @Test - void emptyOrigin_passesThroughUnchanged() { - assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", ChatOrigin.EMPTY)); - assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", null)); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java new file mode 100644 index 00000000..2a711fe4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -0,0 +1,61 @@ +package vip.mate.cron.service; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.context.ChannelTarget; +import vip.mate.agent.context.ChatOrigin; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link CronJobRunner#buildCronPrompt} assembles the scheduled-job prompt: + * the execution-context note is always prepended, the channel-delivery clause + * appears only for channel-bound runs, and the no-op sentinel instruction is + * always present so the agent can explicitly skip a run. + */ +class CronJobRunnerPromptTest { + + @Test + void webOriginCron_prependsContextNote_withoutDeliveryClause() { + ChatOrigin webOrigin = ChatOrigin.web("tasks_1", "system", 1L, null); + String input = "汇总今天的科技新闻"; + String prompt = CronJobRunner.buildCronPrompt(input, webOrigin); + + assertTrue(prompt.contains("[定时任务执行说明]"), + "every scheduled run must carry the execution-context note"); + assertTrue(prompt.contains("隔离执行"), + "the note must tell the model this run has no prior history"); + assertFalse(prompt.contains("投递回原渠道"), + "web-origin runs have no channel — the delivery clause must be omitted"); + assertTrue(prompt.contains(CronJobRunner.CRON_SILENT_MARKER), + "the no-op sentinel instruction must always be present"); + assertTrue(prompt.endsWith(input), + "the task instruction must be the tail of the prompt"); + } + + @Test + void channelBoundCron_addsDeliveryClause() { + ChatOrigin channelOrigin = new ChatOrigin( + 7L, "cron_7", "system", 1L, null, + /* channelId */ 9L, new ChannelTarget("group-a", null, null), + /* cronOrigin */ true, + /* senderName */ null, + /* channelType */ "feishu", + /* chatId */ "group-a"); + String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); + + assertTrue(prompt.contains("[定时任务执行说明]")); + assertTrue(prompt.contains("投递回原渠道"), + "channel-bound runs must keep the framework-delivery clause"); + assertTrue(prompt.contains("不要尝试调用 CLI"), + "the channel clause must forbid CLI / send-tool hallucination"); + } + + @Test + void nullOrigin_stillProducesContextNote() { + String prompt = CronJobRunner.buildCronPrompt("hello", null); + assertTrue(prompt.contains("[定时任务执行说明]")); + assertFalse(prompt.contains("投递回原渠道")); + assertTrue(prompt.endsWith("hello")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java b/mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java new file mode 100644 index 00000000..422d1230 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java @@ -0,0 +1,75 @@ +package vip.mate.dashboard.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tool calls are persisted inside an assistant message's {@code metadata} JSON, + * never as standalone {@code role="tool"} rows. These tests pin the metadata + * parsing that feeds the dashboard's {@code toolCalls} metric. + */ +class DashboardServiceToolCallCountTest { + + private final DashboardService service = + new DashboardService(null, null, new ObjectMapper()); + + @Test + @DisplayName("Counts entries from metadata.toolCalls.") + void countsFromToolCallsArray() { + String metadata = "{\"toolCalls\":[" + + "{\"name\":\"search\",\"status\":\"completed\"}," + + "{\"name\":\"load_skill\",\"status\":\"completed\"}," + + "{\"name\":\"execute_shell_command\",\"status\":\"completed\"}" + + "],\"currentPhase\":\"reasoning\",\"finishReason\":\"normal\"}"; + assertEquals(3, service.countToolCalls(metadata)); + } + + @Test + @DisplayName("Unwraps H2's quoted JSON-string-literal form before counting.") + void unwrapsH2QuotedLiteral() throws Exception { + // H2's JSON column returns the value double-encoded: a quoted string + // literal whose body is the escaped JSON object. + String inner = "{\"toolCalls\":[{\"name\":\"a\"},{\"name\":\"b\"}]}"; + String h2Wrapped = new ObjectMapper().writeValueAsString(inner); // -> "\"{\\\"toolCalls\\\":...}\"" + assertEquals(2, service.countToolCalls(h2Wrapped)); + } + + @Test + @DisplayName("Falls back to segments[type=tool_call] when toolCalls is absent.") + void fallsBackToSegments() { + String metadata = "{\"segments\":[" + + "{\"type\":\"text\"}," + + "{\"type\":\"tool_call\",\"toolName\":\"a\"}," + + "{\"type\":\"thinking\"}," + + "{\"type\":\"tool_call\",\"toolName\":\"b\"}" + + "]}"; + assertEquals(2, service.countToolCalls(metadata)); + } + + @Test + @DisplayName("Prefers toolCalls over segments (no double counting).") + void prefersToolCallsOverSegments() { + String metadata = "{\"toolCalls\":[{\"name\":\"x\"}]," + + "\"segments\":[{\"type\":\"tool_call\"},{\"type\":\"tool_call\"}]}"; + assertEquals(1, service.countToolCalls(metadata)); + } + + @Test + @DisplayName("Returns 0 for null, blank, empty-object, or assistant text-only metadata.") + void zeroForNoToolCalls() { + assertEquals(0, service.countToolCalls(null)); + assertEquals(0, service.countToolCalls("")); + assertEquals(0, service.countToolCalls("{}")); + assertEquals(0, service.countToolCalls("{\"finishReason\":\"normal\"}")); + assertEquals(0, service.countToolCalls("{\"toolCalls\":[]}")); + } + + @Test + @DisplayName("Malformed JSON degrades to 0 instead of throwing.") + void malformedJsonIsZero() { + assertEquals(0, service.countToolCalls("{not valid json")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerConfirmTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerConfirmTest.java new file mode 100644 index 00000000..a38889b6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerConfirmTest.java @@ -0,0 +1,42 @@ +package vip.mate.exception; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import vip.mate.i18n.I18nService; +import vip.mate.skill.lifecycle.ConfirmRequiredException; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the manual-archive confirm contract: a {@link ConfirmRequiredException} + * maps to a real HTTP 409 with a structured body the client can branch on. + */ +@ExtendWith(MockitoExtension.class) +class GlobalExceptionHandlerConfirmTest { + + @Mock + private I18nService i18nService; + + @Test + void confirmRequiredMapsToHttp409WithStructuredBody() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(i18nService); + ConfirmRequiredException ex = new ConfirmRequiredException( + "BOUND_SKILL_CONFIRM_REQUIRED", + "Skill is explicitly bound to 2 agent(s); pass force=true to confirm", + List.of(new ConfirmRequiredException.AgentRow(42L, "DataAnalyst"), + new ConfirmRequiredException.AgentRow(71L, "ReportWriter"))); + + ResponseEntity<Map<String, Object>> response = handler.handleConfirmRequired(ex); + + assertEquals(409, response.getStatusCode().value()); + assertEquals("BOUND_SKILL_CONFIRM_REQUIRED", response.getBody().get("code")); + Object boundAgents = response.getBody().get("boundAgents"); + assertEquals(2, ((List<?>) boundAgents).size()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..b33099c0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,39 @@ +package vip.mate.exception; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import vip.mate.common.result.R; +import vip.mate.i18n.I18nService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(mock(I18nService.class)); + + @Test + void mateClawExceptionUsesMatchingHttpStatus() { + ResponseEntity<R<Void>> response = handler.handleMateClawException( + new MateClawException(404, "Not found")); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertEquals(404, response.getBody().getCode()); + } + + @Test + void genericExceptionUsesInternalServerErrorStatus() { + jakarta.servlet.http.HttpServletRequest request = mock(jakarta.servlet.http.HttpServletRequest.class); + jakarta.servlet.http.HttpServletResponse servletResponse = mock(jakarta.servlet.http.HttpServletResponse.class); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/missing"); + + ResponseEntity<R<Void>> response = handler.handleException( + new RuntimeException("boom"), request, servletResponse); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals(500, response.getBody().getCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTypeMismatchTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTypeMismatchTest.java new file mode 100644 index 00000000..0f4efe81 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTypeMismatchTest.java @@ -0,0 +1,62 @@ +package vip.mate.exception; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.i18n.I18nService; + +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that a non-coercible path variable on a typed route surfaces as a + * clean HTTP 400 (handled by {@link GlobalExceptionHandler}) instead of leaking + * a 500 with a full stack trace from the catch-all handler. + */ +class GlobalExceptionHandlerTypeMismatchTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + I18nService i18n = mock(I18nService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new ProbeController()) + .setControllerAdvice(new GlobalExceptionHandler(i18n)) + .build(); + } + + @Test + @DisplayName("Non-numeric segment on a Long {id} route returns 400, not 500.") + void nonNumericIdReturns400() throws Exception { + mockMvc.perform(get("/probe/status")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.msg").value("Invalid value for parameter 'id': expected Long")); + } + + @Test + @DisplayName("A valid numeric id still resolves the handler normally.") + void numericIdReturns200() throws Exception { + mockMvc.perform(get("/probe/123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value(123)); + } + + /** Minimal stand-in for any controller with a {@code Long} path variable. */ + @RestController + static class ProbeController { + @GetMapping("/probe/{id}") + R<Long> probe(@PathVariable Long id) { + return R.ok(id); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java new file mode 100644 index 00000000..21f96eb5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -0,0 +1,140 @@ +package vip.mate.goal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.service.GoalService; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +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.fail; + +/** + * Integration test that pins two load-bearing DB invariants: + * + * <ol> + * <li>{@code GoalStatus} persists as lowercase strings ({@code "active"} + * etc., NOT {@code "ACTIVE"}). The V120 predicate unique index + * compares {@code status = 'active'} as a literal — any uppercase + * write would silently defeat the uniqueness guarantee.</li> + * <li>The {@code uk_agent_goal_active_conv} unique index rejects a + * second active-row insert for the same conversation. Service-layer + * pre-check is a UX nicety; this is the source of truth.</li> + * </ol> + * + * <p>Uses an in-memory H2 MySQL-compat database so Flyway runs V120 + * exactly as it would in dev. The {@code DATABASE_TO_LOWER=TRUE} flag is + * standard across mateclaw's other Spring tests. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:goal_persistence_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.goal.enabled=false" +}) +class GoalPersistenceIntegrationTest { + + @Autowired private GoalService goalService; + @Autowired private JdbcTemplate jdbc; + + private GoalCreateRequest req(String convId, String title) { + GoalCreateRequest r = new GoalCreateRequest(); + r.setConversationId(convId); + r.setAgentId(1L); + r.setWorkspaceId(1L); + r.setTitle(title); + r.setDescription("desc"); + return r; + } + + @Test + @DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv") + void status_persistsAsLowercaseString() { + GoalEntity created = goalService.create(req("conv-status-1", "lower-case check"), "alice"); + String raw = jdbc.queryForObject( + "SELECT status FROM mate_agent_goal WHERE id = ?", + String.class, created.getId()); + assertEquals("active", raw, + "GoalStatus must persist as lowercase 'active' — uppercase 'ACTIVE' would " + + "silently bypass the V120 predicate unique index uk_agent_goal_active_conv."); + } + + @Test + @DisplayName("Each terminal status also persists lowercase") + void terminalStatuses_alsoPersistLowercase() { + GoalEntity g = goalService.create(req("conv-status-terminal", "terminal check"), "alice"); + + goalService.abandon(g.getId(), "alice"); + String s = jdbc.queryForObject( + "SELECT status FROM mate_agent_goal WHERE id = ?", + String.class, g.getId()); + assertEquals("abandoned", s); + } + + @Test + @DisplayName("Service rejects a second active goal on the same conversation (UX pre-check 409)") + void servicePreCheck_blocksDuplicateActiveCreation() { + goalService.create(req("conv-dup-1", "first"), "alice"); + MateClawException ex = assertThrows(MateClawException.class, + () -> goalService.create(req("conv-dup-1", "second"), "alice")); + assertEquals(409, ex.getCode()); + } + + @Test + @DisplayName("DB unique index rejects a second active row even when service pre-check is bypassed") + void uniqueIndex_isUltimateSourceOfTruth() { + // First goal — via service so it gets a real ID + workspace + timestamps. + goalService.create(req("conv-uq-1", "first"), "alice"); + + // Second insertion — bypass the service entirely and write through + // JdbcTemplate. Must hit DuplicateKeyException at the DB level. + LocalDateTime now = LocalDateTime.now(); + try { + jdbc.update( + "INSERT INTO mate_agent_goal " + + "(id, conversation_id, agent_id, workspace_id, created_by, " + + " title, description, status, turn_budget, turns_used, " + + " llm_call_budget, agent_llm_calls_used, eval_llm_calls_used, " + + " auto_followup_enabled, followup_cooldown_seconds, " + + " version, deleted, create_time, update_time) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 99999L, "conv-uq-1", 1L, 1L, "alice", + "second", "desc", "active", + 20, 0, 200, 0, 0, + false, 0, + 0, 0, Timestamp.valueOf(now), Timestamp.valueOf(now)); + fail("Expected DuplicateKeyException from uk_agent_goal_active_conv"); + } catch (DuplicateKeyException expected) { + // good + } + } + + @Test + @DisplayName("A new active goal is allowed after the previous one entered a terminal state") + void terminalGoal_releasesUniquenessSlot() { + GoalEntity first = goalService.create(req("conv-recycle-1", "first"), "alice"); + goalService.abandon(first.getId(), "alice"); + + // After abandon, the conversation should be free to host a new active goal. + GoalEntity second = goalService.create(req("conv-recycle-1", "second"), "alice"); + assertNotNull(second); + assertEquals(GoalStatus.ACTIVE, second.getStatus()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java new file mode 100644 index 00000000..f08fcc83 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java @@ -0,0 +1,219 @@ +package vip.mate.goal.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.GoalUpdateRequest; +import vip.mate.goal.service.GoalService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; + +import java.util.Map; + +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.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Authorization + happy-path coverage for {@link GoalController}. + * + * <p>Every write must: + * 1. Resolve the goal's conversation via service.getById (where applicable). + * 2. Reject non-owners with 403 before delegating to the service. + * 3. Delegate to the service when authorized. + */ +@ExtendWith(MockitoExtension.class) +class GoalControllerTest { + + @Mock private GoalService goalService; + @Mock private ConversationService conversationService; + @Mock private Authentication auth; + + private GoalController controller; + + @BeforeEach + void setUp() { + controller = new GoalController(goalService, conversationService); + when(auth.getName()).thenReturn("alice"); + } + + private GoalEntity goal(Long id, String convId, GoalStatus status) { + GoalEntity g = new GoalEntity(); + g.setId(id); + g.setConversationId(convId); + g.setAgentId(10L); + g.setWorkspaceId(1L); + g.setCreatedBy("alice"); + g.setTitle("ship"); + g.setStatus(status); + return g; + } + + private GoalCreateRequest req(String convId) { + GoalCreateRequest r = new GoalCreateRequest(); + r.setConversationId(convId); + r.setAgentId(10L); + r.setWorkspaceId(1L); + r.setTitle("ship"); + return r; + } + + private ConversationEntity conv(String convId, Long agentId, Long workspaceId) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(convId); + c.setUsername("alice"); + c.setAgentId(agentId); + c.setWorkspaceId(workspaceId); + return c; + } + + // ==================== create ==================== + + @Test + void create_succeeds_whenOwner() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(conversationService.findByConversationId("conv-1")).thenReturn(conv("conv-1", 10L, 1L)); + when(goalService.create(any(), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + R<GoalEntity> result = controller.create(req("conv-1"), auth); + assertNotNull(result.getData()); + assertEquals(1L, result.getData().getId()); + } + + @Test + void create_overridesAgentAndWorkspace_fromConversation() { + // Request claims agentId=99 / workspaceId=77 — the controller must + // ignore those and use the conversation's own bindings instead. + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(conversationService.findByConversationId("conv-1")).thenReturn(conv("conv-1", 42L, 7L)); + when(goalService.create(any(), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + GoalCreateRequest r = req("conv-1"); + r.setAgentId(99L); + r.setWorkspaceId(77L); + controller.create(r, auth); + org.mockito.ArgumentCaptor<GoalCreateRequest> captor = + org.mockito.ArgumentCaptor.forClass(GoalCreateRequest.class); + verify(goalService).create(captor.capture(), eq("alice")); + assertEquals(42L, captor.getValue().getAgentId()); + assertEquals(7L, captor.getValue().getWorkspaceId()); + } + + @Test + void create_returns404_whenConversationMissing() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(conversationService.findByConversationId("conv-1")).thenReturn(null); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.create(req("conv-1"), auth)); + assertEquals(404, ex.getCode()); + verify(goalService, never()).create(any(), anyString()); + } + + @Test + void create_returns409_whenConversationHasNoAgent() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(conversationService.findByConversationId("conv-1")) + .thenReturn(conv("conv-1", null, 1L)); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.create(req("conv-1"), auth)); + assertEquals(409, ex.getCode()); + verify(goalService, never()).create(any(), anyString()); + } + + @Test + void create_returns403_whenNotOwner() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.create(req("conv-1"), auth)); + assertEquals(403, ex.getCode()); + verify(goalService, never()).create(any(), anyString()); + } + + @Test + void create_returns400_whenConversationIdBlank() { + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.create(req(""), auth)); + assertEquals(400, ex.getCode()); + } + + // ==================== find / get ==================== + + @Test + void findActive_returnsNull_whenNoActiveGoal() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + assertNull(controller.findActive("conv-1", auth).getData()); + } + + @Test + void get_returns403_whenCallerIsNotOwner() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.get(1L, auth)); + assertEquals(403, ex.getCode()); + } + + // ==================== state machine ==================== + + @Test + void pause_delegatesToService_whenOwner() { + GoalEntity g = goal(1L, "conv-1", GoalStatus.ACTIVE); + when(goalService.getById(1L)).thenReturn(g); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.pause(1L, "alice")).thenReturn(goal(1L, "conv-1", GoalStatus.PAUSED)); + + R<GoalEntity> result = controller.pause(1L, auth); + assertEquals(GoalStatus.PAUSED, result.getData().getStatus()); + } + + @Test + void abandon_returns403_whenCallerIsNotOwner() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.abandon(1L, auth)); + assertEquals(403, ex.getCode()); + verify(goalService, never()).abandon(any(), anyString()); + } + + // ==================== update / criteria ==================== + + @Test + void update_delegatesToService_whenOwner() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.update(eq(1L), any(), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + GoalUpdateRequest req = new GoalUpdateRequest(); + req.setTitle("new title"); + controller.update(1L, req, auth); + verify(goalService).update(eq(1L), any(), eq("alice")); + } + + @Test + void addCriterion_passesCriterionStringFromBody() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.appendCriterion(eq(1L), eq("tests pass"), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + + controller.addCriterion(1L, Map.of("criterion", "tests pass"), auth); + verify(goalService).appendCriterion(1L, "tests pass", "alice"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java new file mode 100644 index 00000000..ff5c6809 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java @@ -0,0 +1,251 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.retry.support.RetryTemplate; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; +import vip.mate.llm.chatmodel.ProviderChatModelFactory; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the LLM-backed evaluator: prompt construction is exercised + * via the integration with a mocked {@link ChatModel}, JSON parsing + * corners (markdown fences, missing fields, malformed JSON), and the + * fallback degradation paths that protect the chat turn when the + * evaluator provider is unavailable. + */ +@ExtendWith(MockitoExtension.class) +class GoalEvaluationServiceTest { + + @Mock private ModelConfigService modelConfigService; + @Mock private ProviderChatModelFactory chatModelFactory; + @Mock private ChatModel chatModel; + + private GoalProperties props; + private GoalEvaluationService svc; + + @BeforeEach + void setUp() { + props = new GoalProperties(); + svc = new GoalEvaluationService(props, modelConfigService, chatModelFactory, new ObjectMapper()); + } + + private GoalEntity goal() { + GoalEntity g = new GoalEntity(); + g.setId(1L); + g.setTitle("ship the blog"); + g.setDescription("deploy to fly.io"); + g.setExitCriteria("hello world page accessible"); + g.setStatus(GoalStatus.ACTIVE); + return g; + } + + private ModelConfigEntity model(String name) { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider("dashscope"); + m.setModelName(name); + return m; + } + + private void stubChatResponse(String body) { + when(modelConfigService.getDefaultModel()).thenReturn(model("qwen-turbo")); + when(chatModelFactory.buildFor(any(ModelConfigEntity.class), any(RetryTemplate.class))) + .thenReturn(chatModel); + ChatResponse response = new ChatResponse(List.of( + new Generation(new AssistantMessage(body)))); + when(chatModel.call(any(Prompt.class))).thenReturn(response); + } + + // ==================== Pre-flight guards ==================== + + @Test + void nullGoal_returnsFallback_withoutTouchingProviders() { + GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertFalse(r.completed()); + assertEquals(0, r.llmCallsConsumed()); + verify(chatModelFactory, never()).buildFor(any(), any()); + } + + @Test + void emptyAnswer_returnsFallback_withoutTouchingProviders() { + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), ""); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + verify(chatModelFactory, never()).buildFor(any(), any()); + } + + @Test + void noModelAvailable_returnsFallback() { + // Both lookup paths return null — no default, no override. + when(modelConfigService.getDefaultModel()).thenReturn(null); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "any answer"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertTrue(r.gap().contains("no_model")); + verify(chatModelFactory, never()).buildFor(any(), any()); + } + + // ==================== Happy paths ==================== + + @Test + void continueDecision_whenScoreBelowOne() { + stubChatResponse("{\"score\": 0.6, \"gap\": \"DNS not configured yet\", \"completed\": false}"); + GoalEvaluationResult r = svc.evaluate(goal(), + List.of(new UserMessage("status?")), + "DNS configured, still need TLS"); + assertEquals(GoalEvaluationResult.DECISION_CONTINUE, r.decision()); + assertFalse(r.completed()); + assertEquals(0.6, r.score(), 1e-9); + assertEquals("DNS not configured yet", r.gap()); + assertEquals(1, r.llmCallsConsumed()); + assertEquals("qwen-turbo", r.evaluatorModel()); + } + + @Test + void completedDecision_whenJsonSaysCompleted() { + stubChatResponse("{\"score\": 0.95, \"gap\": \"\", \"completed\": true}"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "all green"); + assertEquals(GoalEvaluationResult.DECISION_COMPLETED, r.decision()); + assertTrue(r.completed()); + } + + @Test + void scoreOfOne_implicitlyCompletes_evenWhenJsonSaysFalse() { + stubChatResponse("{\"score\": 1.0, \"gap\": \"\", \"completed\": false}"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "perfect answer"); + assertTrue(r.completed(), "score=1.0 must imply completed regardless of the bool field"); + assertEquals(GoalEvaluationResult.DECISION_COMPLETED, r.decision()); + } + + @Test + void score_clampedTo01_whenModelReturnsOutOfRange() { + stubChatResponse("{\"score\": 1.7, \"gap\": \"\", \"completed\": true}"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "answer"); + assertEquals(1.0, r.score(), 1e-9); + } + + @Test + void negativeScore_clampedToZero() { + stubChatResponse("{\"score\": -0.2, \"gap\": \"x\", \"completed\": false}"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "answer"); + assertEquals(0.0, r.score(), 1e-9); + } + + // ==================== Parser tolerance ==================== + + @Test + void parsesEvenWhenWrappedInMarkdownFences() { + // Lenient stub: parser tolerance shouldn't depend on a specific code path. + stubChatResponse("```json\n" + + "{\"score\": 0.4, \"gap\": \"still need TLS\", \"completed\": false}\n" + + "```"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "DNS set up"); + assertEquals(GoalEvaluationResult.DECISION_CONTINUE, r.decision()); + assertEquals(0.4, r.score(), 1e-9); + assertEquals("still need TLS", r.gap()); + } + + @Test + void parseFails_whenNoJsonObjectInOutput() { + stubChatResponse("I think it's about 60% done."); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertEquals(0, r.llmCallsConsumed()); + } + + @Test + void parseFails_whenScoreFieldMissing() { + stubChatResponse("{\"gap\": \"missing\", \"completed\": false}"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertTrue(r.gap().contains("parse_missing_score")); + } + + @Test + void parseFails_whenJsonMalformed() { + // Closing brace present but interior is invalid — exercises the + // ObjectMapper.readTree exception path rather than the cheaper + // "no object found" pre-check. + stubChatResponse("{\"score\": 0.5, \"gap\": }"); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertTrue(r.gap().contains("parse_failed")); + } + + // ==================== Failure modes ==================== + + @Test + void emptyResponseFromModel_returnsFallback() { + stubChatResponse(" "); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertTrue(r.gap().contains("empty_response")); + } + + @Test + void modelCallThrows_returnsFallback_andDoesNotPropagate() { + when(modelConfigService.getDefaultModel()).thenReturn(model("qwen-turbo")); + when(chatModelFactory.buildFor(any(), any())).thenReturn(chatModel); + when(chatModel.call(any(Prompt.class))).thenThrow(new RuntimeException("provider down")); + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x"); + assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); + assertTrue(r.gap().contains("call_failed")); + assertEquals(0, r.llmCallsConsumed()); + } + + // ==================== Model resolution ==================== + + @Test + void usesNamedEvaluatorModel_whenPropertySet() { + props.setEvaluatorModel("qwen-evaluator-small"); + ModelConfigEntity named = model("qwen-evaluator-small"); + when(modelConfigService.resolveModel("qwen-evaluator-small")).thenReturn(named); + when(chatModelFactory.buildFor(eq(named), any())).thenReturn(chatModel); + ChatResponse response = new ChatResponse(List.of( + new Generation(new AssistantMessage( + "{\"score\":0.5,\"gap\":\"\",\"completed\":false}")))); + when(chatModel.call(any(Prompt.class))).thenReturn(response); + // Default lookup is never consulted when an override is configured. + lenient().when(modelConfigService.getDefaultModel()).thenReturn(null); + + GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x"); + assertEquals("qwen-evaluator-small", r.evaluatorModel()); + assertNotNull(r); + } + + // ==================== Fallback factory ==================== + + @Test + void fallback_doesNotChargeLlmCalls() { + GoalEvaluationResult r = GoalEvaluationResult.fallback("evaluator_unavailable"); + assertEquals(0, r.llmCallsConsumed()); + assertFalse(r.completed()); + assertTrue(r.gap().contains("evaluator unavailable")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java new file mode 100644 index 00000000..3082e49b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java @@ -0,0 +1,118 @@ +package vip.mate.goal.service; + +import org.junit.jupiter.api.Test; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the five follow-up gating conditions from RFC 48 §3.10. Every + * negative case must independently block the follow-up. + */ +class GoalFollowupServiceTest { + + private final GoalFollowupService svc = new GoalFollowupService(); + + private GoalEntity goal(boolean autoEnabled) { + GoalEntity g = new GoalEntity(); + g.setId(1L); + g.setTitle("ship"); + g.setStatus(GoalStatus.ACTIVE); + g.setTurnBudget(20); + g.setTurnsUsed(5); + g.setLlmCallBudget(200); + g.setAgentLlmCallsUsed(30); + g.setEvalLlmCallsUsed(4); + g.setAutoFollowupEnabled(autoEnabled); + g.setFollowupCooldownSeconds(0); + return g; + } + + private GoalEvaluationResult res(double score, String decision) { + return new GoalEvaluationResult( + score, "missing X", + decision, false, + "stub", 0, 0L); + } + + @Test + void disabledAutoFollowup_returnsEmpty() { + Optional<String> out = svc.maybeBuildFollowup( + goal(false), + res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void completedDecision_returnsEmpty() { + Optional<String> out = svc.maybeBuildFollowup( + goal(true), + res(0.99, GoalEvaluationResult.DECISION_COMPLETED)); + assertTrue(out.isEmpty()); + } + + @Test + void highScore_returnsEmpty() { + Optional<String> out = svc.maybeBuildFollowup( + goal(true), + res(0.96, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void cooldownNotElapsed_returnsEmpty() { + GoalEntity g = goal(true); + g.setFollowupCooldownSeconds(60); + g.setLastFollowupAt(LocalDateTime.now().minusSeconds(10)); + Optional<String> out = svc.maybeBuildFollowup( + g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void cooldownElapsed_allowsFollowup() { + GoalEntity g = goal(true); + g.setFollowupCooldownSeconds(60); + g.setLastFollowupAt(LocalDateTime.now().minusSeconds(120)); + Optional<String> out = svc.maybeBuildFollowup( + g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isPresent()); + } + + @Test + void nearTurnBudget_returnsEmpty() { + GoalEntity g = goal(true); + g.setTurnBudget(20); + g.setTurnsUsed(19); // only one slot left — reserved for the real user + Optional<String> out = svc.maybeBuildFollowup( + g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void over90PercentLlmBudget_returnsEmpty() { + GoalEntity g = goal(true); + g.setLlmCallBudget(100); + g.setAgentLlmCallsUsed(85); + g.setEvalLlmCallsUsed(10); // total 95 = 95% > 90% guard + Optional<String> out = svc.maybeBuildFollowup( + g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void happyPath_returnsPrompt_containingGap() { + Optional<String> out = svc.maybeBuildFollowup( + goal(true), + res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isPresent()); + assertTrue(out.get().contains("missing X")); + assertTrue(out.get().toLowerCase().contains("next concrete step")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java new file mode 100644 index 00000000..a39cfc9d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -0,0 +1,403 @@ +package vip.mate.goal.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalEventEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.GoalUpdateRequest; +import vip.mate.goal.repository.GoalEventMapper; +import vip.mate.goal.repository.GoalMapper; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link GoalServiceImpl} — covers CRUD, state machine, + * evaluation bookkeeping, budget exhaustion, optimistic-lock retry, and + * the DB unique-index 409 mapping. + */ +@ExtendWith(MockitoExtension.class) +class GoalServiceTest { + + @Mock private GoalMapper goalMapper; + @Mock private GoalEventMapper eventMapper; + @Mock private AuditEventService auditEventService; + + private GoalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + GoalEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + GoalEventEntity.class); + } + + @BeforeEach + void setUp() { + GoalProperties properties = new GoalProperties(); + service = new GoalServiceImpl(goalMapper, eventMapper, properties, + auditEventService, new ObjectMapper()); + } + + // ==================== Helpers ==================== + + private GoalCreateRequest validReq() { + GoalCreateRequest r = new GoalCreateRequest(); + r.setConversationId("conv-1"); + r.setAgentId(10L); + r.setWorkspaceId(1L); + r.setTitle("ship the blog"); + r.setDescription("deploy and verify"); + r.setExitCriteria("hello world accessible"); + return r; + } + + private GoalEntity persisted(Long id, GoalStatus status) { + GoalEntity g = new GoalEntity(); + g.setId(id); + g.setConversationId("conv-1"); + g.setAgentId(10L); + g.setWorkspaceId(1L); + g.setCreatedBy("alice"); + g.setTitle("ship the blog"); + g.setDescription("desc"); + g.setStatus(status); + g.setTurnBudget(20); + g.setTurnsUsed(0); + g.setLlmCallBudget(200); + g.setAgentLlmCallsUsed(0); + g.setEvalLlmCallsUsed(0); + g.setAutoFollowupEnabled(false); + g.setFollowupCooldownSeconds(0); + g.setVersion(0); + g.setDeleted(0); + g.setCreateTime(LocalDateTime.now()); + g.setUpdateTime(LocalDateTime.now()); + return g; + } + + // ==================== create ==================== + + @Test + void create_succeeds_whenNoActiveGoalExists() { + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); + + GoalEntity created = service.create(validReq(), "alice"); + + assertNotNull(created); + assertEquals("alice", created.getCreatedBy()); + assertEquals(GoalStatus.ACTIVE, created.getStatus()); + assertEquals(20, created.getTurnBudget()); + assertEquals(200, created.getLlmCallBudget()); + verify(eventMapper, times(1)).insert(any(GoalEventEntity.class)); + verify(auditEventService).record(eq("goal.created"), eq("goal"), + anyString(), anyString(), anyString(), any()); + } + + @Test + void create_returns409_whenActiveGoalAlreadyExists() { + when(goalMapper.selectOne(any())).thenReturn(persisted(99L, GoalStatus.ACTIVE)); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(validReq(), "alice")); + assertEquals(409, ex.getCode()); + verify(goalMapper, never()).insert(any(GoalEntity.class)); + } + + @Test + void create_returns409_whenDbUniqueIndexHits() { + // Concurrent race: pre-check sees nothing, but the DB does. + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))) + .thenThrow(new DuplicateKeyException("uk_agent_goal_active_conv")); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(validReq(), "alice")); + assertEquals(409, ex.getCode()); + } + + @Test + void create_rejectsBlankTitle() { + GoalCreateRequest r = validReq(); + r.setTitle(""); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(r, "alice")); + assertEquals(400, ex.getCode()); + } + + @Test + void create_rejectsNonPositiveBudget() { + GoalCreateRequest r = validReq(); + r.setTurnBudget(0); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(r, "alice")); + assertEquals(400, ex.getCode()); + } + + // ==================== state transitions ==================== + + @Test + void pause_flipsActiveToPaused_andWritesEvent() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + GoalEntity result = service.pause(1L, "alice"); + + assertEquals(GoalStatus.PAUSED, result.getStatus()); + ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("paused", evCaptor.getValue().getEventType()); + } + + @Test + void pause_failsWhenGoalIsTerminal() { + when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED)); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.pause(1L, "alice")); + assertEquals(409, ex.getCode()); + } + + @Test + void resume_flipsPausedToActive() { + GoalEntity g = persisted(1L, GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ACTIVE)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus()); + } + + @Test + void abandon_flipsAnyNonTerminalToAbandoned() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ABANDONED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.ABANDONED, service.abandon(1L, "alice").getStatus()); + } + + @Test + void markCompleted_isIdempotent_onTerminal() { + GoalEntity g = persisted(1L, GoalStatus.COMPLETED); + when(goalMapper.selectById(1L)).thenReturn(g); + GoalEntity result = service.markCompleted(1L, null); + assertEquals(GoalStatus.COMPLETED, result.getStatus()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void markExhausted_carriesReasonInDetail() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.EXHAUSTED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + service.markExhausted(1L, "turn_budget"); + + ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("exhausted", evCaptor.getValue().getEventType()); + assertTrue(evCaptor.getValue().getDetailJson().contains("turn_budget")); + } + + // ==================== evaluation bookkeeping ==================== + + @Test + void recordEvaluation_bumpsCountersAndWritesEvent() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + GoalEvaluationResult r = new GoalEvaluationResult( + 0.62, "DNS still missing", "continue", false, + "qwen-turbo", 1, 800L); + service.recordEvaluation(1L, r, 3, 1); + + ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("evaluated", evCaptor.getValue().getEventType()); + String detail = evCaptor.getValue().getDetailJson(); + assertTrue(detail.contains("agentLlmCallsDelta")); + assertTrue(detail.contains("evalLlmCallsDelta")); + assertTrue(detail.contains("qwen-turbo")); + } + + @Test + void recordEvaluation_isNoop_onTerminalGoal() { + when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED)); + service.recordEvaluation(1L, null, 5, 1); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + verify(eventMapper, never()).insert(any(GoalEventEntity.class)); + } + + @Test + void isBudgetExhausted_detectsTurnBudgetHit() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setTurnsUsed(20); + g.setTurnBudget(20); + assertTrue(service.isBudgetExhausted(g)); + assertEquals("turn_budget", service.exhaustionReason(g)); + } + + @Test + void isBudgetExhausted_detectsLlmBudgetHit() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setAgentLlmCallsUsed(180); + g.setEvalLlmCallsUsed(25); + g.setLlmCallBudget(200); + assertTrue(service.isBudgetExhausted(g)); + assertEquals("llm_call_budget", service.exhaustionReason(g)); + } + + @Test + void isBudgetExhausted_returnsFalse_whenHeadroomRemains() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setTurnsUsed(5); + g.setAgentLlmCallsUsed(30); + g.setEvalLlmCallsUsed(4); + assertFalse(service.isBudgetExhausted(g)); + } + + @Test + void appendCriterion_concatenatesWithMarker() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setExitCriteria("DNS works"); + when(goalMapper.selectById(1L)).thenReturn(g, g); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + service.appendCriterion(1L, "tests pass", "alice"); + + ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("criterion_added", evCaptor.getValue().getEventType()); + assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass")); + } + + @Test + void appendCriterion_rejectsBlankInput() { + // Validation happens before selectById, so we do NOT stub the mapper. + MateClawException ex = assertThrows(MateClawException.class, + () -> service.appendCriterion(1L, " ", "alice")); + assertEquals(400, ex.getCode()); + verify(goalMapper, never()).selectById(any()); + } + + // ==================== optimistic lock retry ==================== + + @Test + void update_failsAfterRetriesExhausted_whenVersionAlwaysStale() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g); + // Always return 0 rows affected — simulates persistent version conflict. + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); + + GoalUpdateRequest upd = new GoalUpdateRequest(); + upd.setTitle("new title"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.update(1L, upd, "alice")); + assertEquals(409, ex.getCode()); + verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class)); + } + + /** + * Regression: after the first CAS miss the retry loop must refetch the + * entity so the rebuilt wrapper carries the current version. Previously + * the wrapper was captured once with version=oldVersion, so once stale + * it could never succeed even when contention cleared. + */ + @Test + void update_succeedsOnSecondAttempt_afterRefetchPicksUpFreshVersion() { + GoalEntity v0 = persisted(1L, GoalStatus.ACTIVE); + v0.setVersion(0); + GoalEntity v1 = persisted(1L, GoalStatus.ACTIVE); + v1.setVersion(1); + GoalEntity v2 = persisted(1L, GoalStatus.ACTIVE); + v2.setVersion(2); + // First refetch returns v0 (stale — CAS will miss). Second refetch + // returns v1 (fresh — CAS will succeed). Third call (post-update + // selectById) returns the final v2 state for the return value. + when(goalMapper.selectById(1L)).thenReturn(v0, v1, v2); + // First update misses (rows=0), second update succeeds (rows=1). + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))) + .thenReturn(0).thenReturn(1); + + GoalUpdateRequest upd = new GoalUpdateRequest(); + upd.setTitle("retry-survives"); + + GoalEntity out = service.update(1L, upd, "alice"); + assertNotNull(out); + // Two update attempts (one miss + one hit) plus three selectById + // calls (two for the loop refetch, one for the post-update return). + verify(goalMapper, times(2)).update(any(), any(LambdaUpdateWrapper.class)); + verify(goalMapper, times(3)).selectById(1L); + } + + @Test + void findActiveByConversation_returnsNull_forBlankInput() { + assertNull(service.findActiveByConversation("")); + assertNull(service.findActiveByConversation(null)); + verify(goalMapper, never()).selectOne(any()); + } + + @Test + void getById_throws404_whenMissing() { + when(goalMapper.selectById(1L)).thenReturn(null); + MateClawException ex = assertThrows(MateClawException.class, () -> service.getById(1L)); + assertEquals(404, ex.getCode()); + } + + /** Helper: mutate a copy of {@code g} with a new status, simulating + * what the post-update selectById would return. */ + private GoalEntity statusFlipped(GoalEntity g, GoalStatus newStatus) { + GoalEntity copy = new GoalEntity(); + copy.setId(g.getId()); + copy.setConversationId(g.getConversationId()); + copy.setAgentId(g.getAgentId()); + copy.setWorkspaceId(g.getWorkspaceId()); + copy.setCreatedBy(g.getCreatedBy()); + copy.setTitle(g.getTitle()); + copy.setStatus(newStatus); + copy.setTurnBudget(g.getTurnBudget()); + copy.setTurnsUsed(g.getTurnsUsed()); + copy.setLlmCallBudget(g.getLlmCallBudget()); + copy.setAgentLlmCallsUsed(g.getAgentLlmCallsUsed()); + copy.setEvalLlmCallsUsed(g.getEvalLlmCallsUsed()); + copy.setVersion(g.getVersion() + 1); + copy.setDeleted(0); + copy.setCreateTime(g.getCreateTime()); + copy.setUpdateTime(LocalDateTime.now()); + return copy; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java index c14124c7..4ed3ec3a 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java @@ -16,8 +16,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * AND {@code oauth-2025-04-20}, comma-joined (no spaces).</li> * <li>{@code User-Agent} must be the bare {@code claude-cli/<ver>} — * NOT {@code claude-cli/<ver> (external, cli)}. The {@code (external, cli)} - * suffix is what hermes-agent and other third-party clients append, and - * Anthropic uses it as a fingerprint to rate-limit the anti-abuse path. + * suffix is what third-party clients append, and Anthropic uses it as a + * fingerprint to rate-limit the anti-abuse path. * Real Claude Code emits the bare form via the official JS SDK.</li> * </ol> */ @@ -36,7 +36,7 @@ class ClaudeCodeApiHeadersTest { } @Test - @DisplayName("allBetas: common betas appear before OAuth-only betas (matches hermes-agent ordering)") + @DisplayName("allBetas: common betas appear before OAuth-only betas") void allBetas_orderedCommonFirst() { String result = headers.allBetas(); int oauthIdx = result.indexOf("oauth-2025-04-20"); diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java new file mode 100644 index 00000000..87c031d8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderClaude47Test.java @@ -0,0 +1,70 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link AnthropicChatModelBuilder#isClaude47} must correctly classify the + * Claude 4.7 model variants we'll see in production. + * + * <p>Claude 4.7 forbids temperature / top_p / top_k entirely — the builder + * relies on this detector to skip those fields rather than letting Anthropic 400. + */ +class AnthropicChatModelBuilderClaude47Test { + + @Test + @DisplayName("isClaude47 detects hyphenated direct-API model names") + void detect_hyphenated() { + assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4-7")); + assertTrue(AnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7")); + assertTrue(AnthropicChatModelBuilder.isClaude47("claude-haiku-4-7")); + } + + @Test + @DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)") + void detect_dotted() { + assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4.7")); + assertTrue(AnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7")); + } + + @Test + @DisplayName("isClaude47 detects OpenRouter-style prefixed model ids") + void detect_openrouterPrefix() { + assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7")); + assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7")); + assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7")); + } + + @Test + @DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names") + void detect_negatives() { + assertFalse(AnthropicChatModelBuilder.isClaude47("claude-opus-4-6")); + assertFalse(AnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5")); + assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"), + "3.7 must not match 4.7"); + assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet")); + // The "claude" prefix guard prevents non-Anthropic models from spuriously + // matching even if they contain "4-7" / "4.7" substrings. + assertFalse(AnthropicChatModelBuilder.isClaude47("gpt-4-7"), + "Non-Claude models must NOT match — claude prefix guard active"); + assertFalse(AnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct")); + } + + @Test + @DisplayName("isClaude47 null-safe") + void detect_nullSafe() { + assertFalse(AnthropicChatModelBuilder.isClaude47(null)); + assertFalse(AnthropicChatModelBuilder.isClaude47("")); + } + + @Test + @DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*") + void detect_3_7_vs_4_7() { + // Both contain "-7" but only the second contains "4-7" as a substring. + assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219")); + assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"), + "Date-stamped 4-7 variants must still match"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AssistantThinkingRelayTest.java similarity index 95% rename from mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AssistantThinkingRelayTest.java index 63f858fe..96d01890 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AssistantThinkingRelayTest.java @@ -1,4 +1,4 @@ -package vip.mate.agent; +package vip.mate.llm.chatmodel; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -16,9 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * RFC-049 PR-2: {@link AssistantThinkingRelay} — RelayEntry carries both - * per-assistant thinking and the caller's original {@code user} field, so the - * consumer can restore it when rebuilding the outbound request. + * {@link AssistantThinkingRelay} — RelayEntry carries both per-assistant thinking + * and the caller's original {@code user} field, so the consumer can restore it + * when rebuilding the outbound request. */ class AssistantThinkingRelayTest { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilderTest.java similarity index 94% rename from mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilderTest.java index a5bd62fc..a4829abd 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeChatModelBuilderTest.java @@ -1,4 +1,4 @@ -package vip.mate.agent.chatmodel; +package vip.mate.llm.chatmodel; import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.BeforeEach; @@ -38,17 +38,17 @@ import static org.mockito.Mockito.when; * tests can exercise the full assembly path without mocking the API client. */ @ExtendWith(MockitoExtension.class) -class AgentClaudeCodeChatModelBuilderTest { +class ClaudeCodeChatModelBuilderTest { @Mock - private AgentAnthropicChatModelBuilder anthropicBuilder; + private AnthropicChatModelBuilder anthropicBuilder; @Mock private ClaudeCodeOAuthService oauthService; private ClaudeCodeApiHeaders apiHeaders; - private AgentClaudeCodeChatModelBuilder builder; + private ClaudeCodeChatModelBuilder builder; @BeforeEach void setUp() { @@ -60,7 +60,7 @@ class AgentClaudeCodeChatModelBuilderTest { }; apiHeaders = new ClaudeCodeApiHeaders(detector); - builder = new AgentClaudeCodeChatModelBuilder( + builder = new ClaudeCodeChatModelBuilder( anthropicBuilder, oauthService, apiHeaders, @@ -82,7 +82,7 @@ class AgentClaudeCodeChatModelBuilderTest { // Sanity check: the NoopApiKey path passes Spring AI's notNull assertion // and the OAuth headers attach without throwing. If this test ever // fails, the most likely cause is a Spring AI upgrade tightening the - // ApiKey contract — see AgentClaudeCodeChatModelBuilder javadoc. + // ApiKey contract — see ClaudeCodeChatModelBuilder javadoc. AnthropicApi api = builder.buildOauthAnthropicApi("sk-ant-oat01-test-token"); assertNotNull(api); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java similarity index 97% rename from mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java index 3356f1a1..6b484fa8 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java @@ -1,4 +1,4 @@ -package vip.mate.agent.chatmodel; +package vip.mate.llm.chatmodel; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * Verifies the OAuth-mode prompt rewriting that prevents Anthropic's edge * from rate-limiting MateClaw traffic. Each test corresponds to one of the - * transforms hermes-agent applies on {@code is_oauth=True} requests. + * transforms applied on OAuth-authenticated requests. */ class ClaudeCodeIdentityChatModelDecoratorTest { @@ -56,8 +56,8 @@ class ClaudeCodeIdentityChatModelDecoratorTest { Prompt input = new Prompt(List.of(new UserMessage("hello"))); Prompt result = d.transform(input); - // First message must be a system message with just the identity prefix — - // hermes-agent does the same: system = [cc_block] when none was supplied. + // First message must be a system message with just the identity prefix: + // when none was supplied, system = [identity block]. Message first = result.getInstructions().get(0); assertTrue(first instanceof SystemMessage); assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, @@ -70,9 +70,8 @@ class ClaudeCodeIdentityChatModelDecoratorTest { @DisplayName("transform is idempotent — second pass doesn't double-prefix") void transform_idempotent() { // Defends against accidental double-wrapping (e.g. nested decorators or - // a re-issue of the same Prompt). hermes-agent doesn't have this concern - // because its rewrite happens in one place; we keep this guard so the - // identity prefix doesn't compound to "You are Claude Code...You are Claude Code...". + // a re-issue of the same Prompt). The guard keeps the identity prefix + // from compounding to "You are Claude Code...You are Claude Code...". ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); Prompt original = new Prompt(List.of(new SystemMessage("Body"), new UserMessage("hi"))); Prompt once = d.transform(original); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecoratorTest.java similarity index 97% rename from mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecoratorTest.java index 73e8b1b2..273221d5 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/DeepSeekV4ThinkingDecoratorTest.java @@ -1,4 +1,4 @@ -package vip.mate.agent.chatmodel; +package vip.mate.llm.chatmodel; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -12,7 +12,6 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.openai.OpenAiChatOptions; import reactor.core.publisher.Flux; -import vip.mate.agent.ThinkingLevelHolder; import java.util.HashMap; import java.util.List; @@ -109,8 +108,8 @@ class DeepSeekV4ThinkingDecoratorTest { @Test @DisplayName("mapEffort: low/medium/high passthrough; max collapses to high; unknown → medium") void mapEffort_levels() { - // openclaw resolveDeepSeekV4ReasoningEffort folds "max" into "high" - // because DeepSeek doesn't expose a max tier. Pin both ends of the rule. + // "max" folds into "high" because DeepSeek doesn't expose a max tier. + // Pin both ends of the rule. assertEquals("low", DeepSeekV4ThinkingDecorator.mapEffort("low")); assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("medium")); assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("high")); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java similarity index 76% rename from mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java index b28bc1b7..6cde388c 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java @@ -1,4 +1,4 @@ -package vip.mate.agent; +package vip.mate.llm.chatmodel; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -21,8 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; /** - * RFC-049 PR-2 consumer-side tests for - * {@link AgentGraphBuilder#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}. + * Consumer-side tests for + * {@link OpenAiRequestRewriter#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}. * * <p>Covers four orthogonal dimensions: * <ul> @@ -82,11 +82,13 @@ class PatchReasoningContentTest { @BeforeEach void clearRelay() { AssistantThinkingRelay.clearAll(); + ReasoningContentCache.clear(); } @AfterEach void clearRelayAfter() { AssistantThinkingRelay.clearAll(); + ReasoningContentCache.clear(); } // ---------- No-relay, no-thinking-mode path ---------- @@ -101,7 +103,7 @@ class PatchReasoningContentTest { ), "caller-user-1"); // model is "test-model" which maps to STANDARD family → requiresReasoningContentPatch returns false - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertSame(req, out, "no thinking signal → no rebuild"); assertEquals("caller-user-1", out.user(), "user field untouched"); } @@ -117,7 +119,7 @@ class PatchReasoningContentTest { assistantPlain("hi") ), fakeToken); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("openai")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("openai")); assertNotSame(req, out, "rebuild expected to strip leaked token"); assertNull(out.user(), "leaked token must be sanitized to null"); } @@ -136,7 +138,7 @@ class PatchReasoningContentTest { assistantToolCall("a1", null) // i=2, position 1 in thinkings → "in-turn-think" ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals("original-caller-42", out.user(), "sanitizedUser must equal entry.originalUser()"); assertEquals("in-turn-think", out.messages().get(2).reasoningContent(), @@ -167,7 +169,7 @@ class PatchReasoningContentTest { assistantToolCall("a2", null) // i=4, in-turn (4 > 3) ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals(" ", out.messages().get(2).reasoningContent(), "cross-turn A1 gets ' ' fallback so DeepSeek thinking-mode validation passes"); @@ -193,7 +195,7 @@ class PatchReasoningContentTest { assistantToolCall("a4", null) // i=5 in-turn ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); // DEEPSEEK patchCrossTurn=true: cross-turn now also gets ' ' fallback. // Iterator alignment is preserved: A1/A2 consume the empty entries '', @@ -220,7 +222,7 @@ class PatchReasoningContentTest { assistantToolCall("a1", null) // in-turn ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals(" ", out.messages().get(1).reasoningContent(), "DeepSeek: ' ' fallback restores forward progress when relay has no real value"); @@ -243,7 +245,7 @@ class PatchReasoningContentTest { null, null, null, null, null, null ); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn")); assertEquals(" ", out.messages().get(1).reasoningContent(), "Kimi tolerates ' ' — preserve legacy behavior"); @@ -266,7 +268,7 @@ class PatchReasoningContentTest { null, null, null, null, null, null ); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("custom-gateway")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("custom-gateway")); assertEquals(" ", out.messages().get(1).reasoningContent(), "DEFAULT keeps legacy ' ' for unrecognized providers — avoid regressing self-hosted backends"); @@ -285,7 +287,7 @@ class PatchReasoningContentTest { assistantPlain("plain answer") // no tool_calls ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals("thinking-for-plain", out.messages().get(1).reasoningContent(), "DeepSeek contract requires reasoning_content even on non-tool_call assistants when in thinking mode"); @@ -307,7 +309,7 @@ class PatchReasoningContentTest { null, null, null, null, null, null ); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn")); assertNull(out.messages().get(1).reasoningContent(), "Kimi only patches tool_call assistants; plain assistants are untouched"); @@ -326,7 +328,7 @@ class PatchReasoningContentTest { assistantToolCall("a1", "pre-existing-real-thinking") // already has a value ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals("pre-existing-real-thinking", out.messages().get(1).reasoningContent(), "non-blank existing reasoning_content must not be overwritten by relay"); @@ -338,7 +340,7 @@ class PatchReasoningContentTest { @DisplayName("Empty messages list: no-op, returns same instance") void emptyMessages_noop() { ChatCompletionRequest req = request(List.of(), null); - assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"))); + assertSame(req, OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"))); } @Test @@ -349,7 +351,7 @@ class PatchReasoningContentTest { null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null ); - assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"))); + assertSame(req, OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"))); } // ---------- Fewer relay entries than assistants: defensive policy fallback ---------- @@ -368,7 +370,7 @@ class PatchReasoningContentTest { assistantToolCall("a2", null) )), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals("real-1", out.messages().get(1).reasoningContent()); assertEquals(" ", out.messages().get(2).reasoningContent(), @@ -394,7 +396,7 @@ class PatchReasoningContentTest { assistantToolCall("a2", null) // i=3, in-turn (3 > 2) ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn")); assertNull(out.messages().get(1).reasoningContent(), "KIMI does not patch cross-turn — thinking resets across user turns"); @@ -420,11 +422,80 @@ class PatchReasoningContentTest { new ChatCompletionMessage("plain a2", Role.ASSISTANT) ), token); - ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")); assertEquals(" ", out.messages().get(1).reasoningContent(), "DEEPSEEK plain prior-turn assistant gets ' ' so request validates"); assertEquals(" ", out.messages().get(3).reasoningContent(), "DEEPSEEK plain in-turn assistant gets ' ' as before"); } + + // ---------- XIAOMI_MIMO policy + cross-turn cache replay ---------- + + @Test + @DisplayName("XIAOMI_MIMO cross-turn tool_call: cache hit replays real reasoning_content") + void xiaomiMimoCrossTurn_replaysCachedReasoning() { + // Prior turn produced a tool_call with real thinking; NodeStreamingChatHelper + // stored it in the cache keyed by tool_call_id. On the next turn, the same + // assistant message is replayed as history with reasoning_content=null — + // resolveCrossTurnReasoning must fetch the cached value before falling + // back to the policy's empty " ". + ReasoningContentCache.store(List.of("call_1"), "real-prior-thinking"); + + // Empty relay: no in-turn thinking (current turn hasn't produced one yet). + String token = AssistantThinkingRelay.stash(List.of(""), null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantToolCall("a1", null), // i=1, cross-turn (1 <= 2), tool_call id="call_1" + user("q2") // i=2, lastUserIdx + ), token); + + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo")); + + assertEquals("real-prior-thinking", out.messages().get(1).reasoningContent(), + "XIAOMI_MIMO cross-turn tool_call must replay cached reasoning_content over the ' ' fallback"); + } + + @Test + @DisplayName("XIAOMI_MIMO cross-turn tool_call: cache miss falls back to ' '") + void xiaomiMimoCrossTurnCacheMiss_fallsBackToSpace() { + // No cache entry for call_1 — the multi-turn path must still validate by + // injecting the policy's emptyFallback so MiMo doesn't 400. + String token = AssistantThinkingRelay.stash(List.of(""), null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantToolCall("a1", null), // i=1, cross-turn, no cache entry + user("q2") + ), token); + + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo")); + + assertEquals(" ", out.messages().get(1).reasoningContent(), + "XIAOMI_MIMO cross-turn cache miss falls back to ' ' so the request still validates"); + } + + @Test + @DisplayName("XIAOMI_MIMO plain cross-turn assistant (no tool_calls) also patched via patchNonToolCall=true") + void xiaomiMimoCrossTurnPlainAssistant_patchedWithSpace() { + // XIAOMI_MIMO mirrors DEEPSEEK: patchNonToolCall=true means even plain + // text assistants in prior turns must carry reasoning_content. Cache + // can't help here (no tool_call_ids to key on) — fallback is " ". + String token = AssistantThinkingRelay.stash(List.of("", ""), null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + new ChatCompletionMessage("plain a1", Role.ASSISTANT), // i=1, cross-turn, no tool_calls + user("q2"), + new ChatCompletionMessage("plain a2", Role.ASSISTANT) // i=3, in-turn, no tool_calls + ), token); + + ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo")); + + assertEquals(" ", out.messages().get(1).reasoningContent(), + "XIAOMI_MIMO plain prior-turn assistant gets ' ' (patchNonToolCall=true + patchCrossTurn=true)"); + assertEquals(" ", out.messages().get(3).reasoningContent(), + "XIAOMI_MIMO plain in-turn assistant gets ' ' (patchNonToolCall=true)"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java new file mode 100644 index 00000000..a5410199 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java @@ -0,0 +1,69 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ReasoningContentCacheTest { + + @AfterEach + void cleanup() { + ReasoningContentCache.clear(); + } + + @Test + @DisplayName("Store and retrieve reasoning content by tool_call IDs") + void storeAndGet() { + List<String> ids = List.of("call_1", "call_2"); + ReasoningContentCache.store(ids, "thinking content here"); + + assertEquals("thinking content here", ReasoningContentCache.get(ids)); + } + + @Test + @DisplayName("Cache key is order-independent (sorted tool_call IDs)") + void orderIndependent() { + ReasoningContentCache.store(List.of("call_b", "call_a"), "content"); + + assertEquals("content", ReasoningContentCache.get(List.of("call_a", "call_b"))); + } + + @Test + @DisplayName("Miss returns null") + void cacheMiss() { + assertNull(ReasoningContentCache.get(List.of("nonexistent"))); + } + + @Test + @DisplayName("Empty/null tool_call IDs are no-ops") + void emptyIds() { + ReasoningContentCache.store(List.of(), "content"); + ReasoningContentCache.store(null, "content"); + assertEquals(0, ReasoningContentCache.size()); + } + + @Test + @DisplayName("Blank/null reasoning content is not cached") + void blankContent() { + ReasoningContentCache.store(List.of("call_1"), ""); + ReasoningContentCache.store(List.of("call_1"), " "); + ReasoningContentCache.store(List.of("call_1"), null); + assertEquals(0, ReasoningContentCache.size()); + } + + @Test + @DisplayName("Clear removes all entries") + void clearAll() { + ReasoningContentCache.store(List.of("call_1"), "content1"); + ReasoningContentCache.store(List.of("call_2"), "content2"); + assertEquals(2, ReasoningContentCache.size()); + + ReasoningContentCache.clear(); + assertEquals(0, ReasoningContentCache.size()); + assertNull(ReasoningContentCache.get(List.of("call_1"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningEffortSanitizerTest.java similarity index 73% rename from mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java rename to mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningEffortSanitizerTest.java index 762b15e2..e90f2e2b 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningEffortSanitizerTest.java @@ -1,4 +1,4 @@ -package vip.mate.agent; +package vip.mate.llm.chatmodel; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,8 +14,8 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * RFC-049 PR-1.3 verification — covers §5.2 Case E3.1 / E3.2 / E3.3 plus the - * whitelist positive path. + * Verification of {@link OpenAiRequestRewriter#sanitizeReasoningEffortForProvider} + * and {@link OpenAiRequestRewriter#isReasoningEffortWhitelistedProvider}. * * <p>The sanitizer is provider-first with default-deny: only providerId in * {@code {openai, azure-openai}} is allowed to carry {@code reasoning_effort}. @@ -77,41 +77,41 @@ class ReasoningEffortSanitizerTest { @Test @DisplayName("Whitelist: openai is allowed") void whitelist_openai() { - assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("openai"))); + assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("openai"))); } @Test @DisplayName("Whitelist: azure-openai is allowed") void whitelist_azureOpenai() { - assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("azure-openai"))); + assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("azure-openai"))); } @Test @DisplayName("Whitelist: case-insensitive (Azure-OpenAI)") void whitelist_caseInsensitive() { - assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI"))); + assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI"))); } @Test @DisplayName("Whitelist: deepseek is denied") void denylist_deepseek() { - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("deepseek"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("deepseek"))); } @Test @DisplayName("Whitelist: kimi family denied") void denylist_kimi() { - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-cn"))); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-intl"))); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-code"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-cn"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-intl"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-code"))); } @Test @DisplayName("Whitelist: dashscope / ollama / anthropic denied") void denylist_misc() { - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("dashscope"))); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("ollama"))); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("anthropic"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("dashscope"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("ollama"))); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("anthropic"))); } @Test @@ -119,19 +119,19 @@ class ReasoningEffortSanitizerTest { void denylist_unknownProvider() { // This is the critical regression guard: if anyone re-adds a default-allow // branch to isReasoningEffortWhitelistedProvider, this case fails first. - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider( + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider( provider("my-custom-openai-compat-gateway"))); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider( + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider( provider("openrouter"))); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider( + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider( provider("together"))); } @Test @DisplayName("Whitelist: null provider / null providerId denied") void denylist_nulls() { - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(null)); - assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(new ModelProviderEntity())); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(null)); + assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(new ModelProviderEntity())); } // ---------- sanitizeReasoningEffortForProvider ---------- @@ -140,7 +140,7 @@ class ReasoningEffortSanitizerTest { @DisplayName("Sanitize no-op: request has no reasoning_effort") void sanitize_noop_noReasoningEffort() { OpenAiApi.ChatCompletionRequest req = request("gpt-5", null); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek")); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("deepseek")); assertSame(req, out, "should return same instance when reasoning_effort is already null"); } @@ -149,7 +149,7 @@ class ReasoningEffortSanitizerTest { void sanitize_failover_deepseek_strips() { // Simulate failover: OpenAiChatOptions.model still leaked as "gpt-5" on the deepseek request. OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek")); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("deepseek")); assertNull(out.reasoningEffort(), "deepseek is not on the whitelist — strip regardless of model name"); // Other fields preserved assertEquals("gpt-5", out.model()); @@ -160,7 +160,7 @@ class ReasoningEffortSanitizerTest { void sanitize_failover_otherDenied_strips() { for (String pid : List.of("kimi-cn", "kimi-intl", "kimi-code", "dashscope", "ollama", "anthropic")) { OpenAiApi.ChatCompletionRequest req = request("gpt-5", "medium"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider(pid)); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider(pid)); assertNull(out.reasoningEffort(), "provider=" + pid + " must strip"); } } @@ -169,7 +169,7 @@ class ReasoningEffortSanitizerTest { @DisplayName("§5.2 Case E3.3: unknown provider strips (default-deny regression guard)") void sanitize_unknownProvider_strips() { OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider( + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider( req, provider("my-custom-openai-compat-gateway")); assertNull(out.reasoningEffort(), "unknown provider must strip (default-deny) — if this fails, someone re-added default-allow"); @@ -179,7 +179,7 @@ class ReasoningEffortSanitizerTest { @DisplayName("Whitelist + supporting model: keep reasoning_effort (gpt-5 on openai)") void sanitize_whitelisted_supportingModel_keeps() { OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai")); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("openai")); assertSame(req, out, "gpt-5 on openai should pass through unchanged"); assertEquals("high", out.reasoningEffort()); } @@ -189,7 +189,7 @@ class ReasoningEffortSanitizerTest { void sanitize_whitelisted_nonSupportingModel_strips() { // gpt-4 is NOT OPENAI_REASONING family — reasoning_effort is not applicable there. OpenAiApi.ChatCompletionRequest req = request("gpt-4", "medium"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai")); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("openai")); assertNull(out.reasoningEffort(), "gpt-4 is whitelisted-provider but non-supporting-family — family gate should strip"); } @@ -198,7 +198,7 @@ class ReasoningEffortSanitizerTest { @DisplayName("Azure OpenAI with supporting model: keep reasoning_effort") void sanitize_azureOpenai_supporting_keeps() { OpenAiApi.ChatCompletionRequest req = request("gpt-5", "low"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("azure-openai")); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("azure-openai")); assertEquals("low", out.reasoningEffort()); } @@ -206,7 +206,7 @@ class ReasoningEffortSanitizerTest { @DisplayName("Null provider: strip (defensive)") void sanitize_nullProvider_strips() { OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); - OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, null); + OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, null); assertNull(out.reasoningEffort()); } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/embedding/EmbeddingModelFactoryRoutingTest.java b/mateclaw-server/src/test/java/vip/mate/llm/embedding/EmbeddingModelFactoryRoutingTest.java new file mode 100644 index 00000000..eded92b1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/embedding/EmbeddingModelFactoryRoutingTest.java @@ -0,0 +1,62 @@ +package vip.mate.llm.embedding; + +import org.junit.jupiter.api.Test; +import vip.mate.llm.model.EmbeddingProtocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Routing tests for {@link EmbeddingModelFactory#resolveEmbeddingProtocol(String)}. + * The dashscope-compat regression (#166) lives in the OpenAIChatModel branch. + */ +class EmbeddingModelFactoryRoutingTest { + + @Test + void dashscopeChatModel_routesToNativeEmbedding() { + assertEquals(EmbeddingProtocol.DASHSCOPE_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol("DashScopeChatModel")); + } + + @Test + void openaiChatModel_routesToOpenAiCompat() { + // dashscope-compat carries chatModel='OpenAIChatModel' — must NOT take the native path + assertEquals(EmbeddingProtocol.OPENAI_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol("OpenAIChatModel")); + } + + @Test + void anthropicChatModel_routesToOpenAiCompat() { + assertEquals(EmbeddingProtocol.OPENAI_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol("AnthropicChatModel")); + } + + @Test + void nullChatModel_routesToOpenAiCompat() { + assertEquals(EmbeddingProtocol.OPENAI_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol(null)); + } + + @Test + void blankChatModel_routesToOpenAiCompat() { + assertEquals(EmbeddingProtocol.OPENAI_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol("")); + assertEquals(EmbeddingProtocol.OPENAI_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol(" ")); + } + + @Test + void chatModelMatchIsCaseInsensitiveAndTrimmed() { + // Mirror ModelProtocol.fromChatModel's normalization: equalsIgnoreCase + trim. + assertEquals(EmbeddingProtocol.DASHSCOPE_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol("dashscopechatmodel")); + assertEquals(EmbeddingProtocol.DASHSCOPE_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol(" DashScopeChatModel ")); + } + + @Test + void unknownChatModel_fallsBackToOpenAiCompat() { + // Future / custom chatModel strings default to OpenAI-compatible (safe default). + assertEquals(EmbeddingProtocol.OPENAI_EMBEDDING, + EmbeddingModelFactory.resolveEmbeddingProtocol("CustomGatewayChatModel")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java index 33f4d7f3..f000f0ee 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/AvailableProviderPoolTest.java @@ -91,14 +91,14 @@ class AvailableProviderPoolTest { void snapshotMixedView() { pool.add("openai"); pool.add("dashscope"); - pool.remove("anthropic", RemovalSource.MODEL_NOT_FOUND, "model claude-99 not found"); + pool.remove("anthropic", RemovalSource.BILLING, "402 insufficient credit"); var snap = pool.snapshot(); assertEquals(3, snap.size()); assertNull(snap.get("openai"), "in-pool members appear with null value"); assertNull(snap.get("dashscope")); assertNotNull(snap.get("anthropic")); - assertEquals(RemovalSource.MODEL_NOT_FOUND, snap.get("anthropic").source()); + assertEquals(RemovalSource.BILLING, snap.get("anthropic").source()); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/llm/gemini/GeminiNativeClientTest.java b/mateclaw-server/src/test/java/vip/mate/llm/gemini/GeminiNativeClientTest.java new file mode 100644 index 00000000..43dfe5d9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/gemini/GeminiNativeClientTest.java @@ -0,0 +1,138 @@ +package vip.mate.llm.gemini; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.tool.definition.ToolDefinition; +import vip.mate.llm.gemini.GeminiNativeClient.GeminiCall; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link GeminiNativeClient#buildRequestBody} — the Spring AI + * {@code Message} list → Gemini {@code generateContent} request translation. + */ +class GeminiNativeClientTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final GeminiNativeClient client = new GeminiNativeClient(mapper); + + private GeminiCall call(List<Message> messages, List<ToolDefinition> tools) { + return new GeminiCall("https://generativelanguage.googleapis.com", "test-key", + "gemini-3-pro-preview", messages, 0.7, 4096, tools); + } + + @Test + @DisplayName("system message is hoisted into systemInstruction") + void systemMessageBecomesSystemInstruction() { + ObjectNode body = client.buildRequestBody(call( + List.of(new SystemMessage("You are helpful"), new UserMessage("Hi")), null)); + + assertEquals("You are helpful", + body.path("systemInstruction").path("parts").path(0).path("text").asText()); + // The system turn must NOT also appear in contents. + assertEquals(1, body.path("contents").size()); + assertEquals("user", body.path("contents").path(0).path("role").asText()); + } + + @Test + @DisplayName("user message maps to a user-role text part") + void userMessageMapsToUserContent() { + ObjectNode body = client.buildRequestBody(call( + List.of(new UserMessage("What is the weather?")), null)); + + JsonNode content = body.path("contents").path(0); + assertEquals("user", content.path("role").asText()); + assertEquals("What is the weather?", content.path("parts").path(0).path("text").asText()); + } + + @Test + @DisplayName("assistant tool call maps to a model-role functionCall part") + void assistantToolCallMapsToFunctionCall() { + AssistantMessage assistant = AssistantMessage.builder() + .content("") + .toolCalls(List.of(new AssistantMessage.ToolCall( + "call_1", "function", "get_weather", "{\"city\":\"NYC\"}"))) + .build(); + ObjectNode body = client.buildRequestBody(call( + List.of(new UserMessage("weather?"), assistant), null)); + + JsonNode modelContent = body.path("contents").path(1); + assertEquals("model", modelContent.path("role").asText()); + JsonNode functionCall = modelContent.path("parts").path(0).path("functionCall"); + assertEquals("get_weather", functionCall.path("name").asText()); + assertEquals("NYC", functionCall.path("args").path("city").asText()); + } + + @Test + @DisplayName("tool response maps to a user-role functionResponse with an object payload") + void toolResponseMapsToFunctionResponse() { + ToolResponseMessage toolMsg = ToolResponseMessage.builder() + .responses(List.of( + new ToolResponseMessage.ToolResponse("call_1", "get_weather", "{\"temp\":20}"))) + .build(); + ObjectNode body = client.buildRequestBody(call( + List.of(new UserMessage("weather?"), toolMsg), null)); + + JsonNode functionResponse = body.path("contents").path(1) + .path("parts").path(0).path("functionResponse"); + assertEquals("get_weather", functionResponse.path("name").asText()); + assertEquals(20, functionResponse.path("response").path("temp").asInt()); + } + + @Test + @DisplayName("non-object tool response is wrapped under a result key") + void nonObjectToolResponseIsWrapped() { + ToolResponseMessage toolMsg = ToolResponseMessage.builder() + .responses(List.of( + new ToolResponseMessage.ToolResponse("call_1", "echo", "plain text result"))) + .build(); + ObjectNode body = client.buildRequestBody(call( + List.of(new UserMessage("echo"), toolMsg), null)); + + JsonNode response = body.path("contents").path(1) + .path("parts").path(0).path("functionResponse").path("response"); + assertTrue(response.isObject()); + assertEquals("plain text result", response.path("result").asText()); + } + + @Test + @DisplayName("tool definitions become sanitized functionDeclarations") + void toolsBecomeFunctionDeclarations() { + ToolDefinition tool = ToolDefinition.builder() + .name("get_weather") + .description("Get the weather for a city") + .inputSchema("{\"$schema\":\"x\",\"type\":\"object\"," + + "\"properties\":{\"city\":{\"type\":\"string\"}}}") + .build(); + ObjectNode body = client.buildRequestBody(call( + List.of(new UserMessage("weather?")), List.of(tool))); + + JsonNode decl = body.path("tools").path(0).path("functionDeclarations").path(0); + assertEquals("get_weather", decl.path("name").asText()); + assertEquals("Get the weather for a city", decl.path("description").asText()); + assertEquals("string", decl.path("parameters").path("properties").path("city").path("type").asText()); + assertFalse(decl.path("parameters").has("$schema"), "schema must be sanitized"); + } + + @Test + @DisplayName("generationConfig carries temperature and maxOutputTokens") + void generationConfigCarriesSamplingParams() { + ObjectNode body = client.buildRequestBody(call( + List.of(new UserMessage("hi")), null)); + + assertEquals(0.7, body.path("generationConfig").path("temperature").asDouble(), 1e-9); + assertEquals(4096, body.path("generationConfig").path("maxOutputTokens").asInt()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/gemini/GeminiSchemaSanitizerTest.java b/mateclaw-server/src/test/java/vip/mate/llm/gemini/GeminiSchemaSanitizerTest.java new file mode 100644 index 00000000..1377a40a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/gemini/GeminiSchemaSanitizerTest.java @@ -0,0 +1,118 @@ +package vip.mate.llm.gemini; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link GeminiSchemaSanitizer} — the JSON Schema → Gemini + * {@code Schema} subset translation used when sending tool declarations. + */ +class GeminiSchemaSanitizerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode parse(String json) { + try { + return mapper.readTree(json); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @DisplayName("drops unsupported JSON Schema keywords") + void dropsUnsupportedKeywords() { + JsonNode schema = parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "city": {"type": "string", "description": "City name"} + }, + "required": ["city"] + } + """); + + ObjectNode cleaned = GeminiSchemaSanitizer.sanitizeToolParameters(schema, mapper); + + assertFalse(cleaned.has("$schema"), "$schema must be stripped"); + assertFalse(cleaned.has("additionalProperties"), "additionalProperties must be stripped"); + assertEquals("object", cleaned.path("type").asText()); + assertTrue(cleaned.path("properties").has("city")); + assertEquals("City name", cleaned.path("properties").path("city").path("description").asText()); + assertTrue(cleaned.path("required").isArray()); + } + + @Test + @DisplayName("recurses into nested properties and array items") + void recursesIntoNested() { + JsonNode schema = parse(""" + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "additionalProperties": true, + "items": {"type": "string", "$comment": "drop me"} + }, + "nested": { + "type": "object", + "$ref": "#/defs/x", + "properties": {"inner": {"type": "number"}} + } + } + } + """); + + ObjectNode cleaned = GeminiSchemaSanitizer.sanitizeToolParameters(schema, mapper); + + JsonNode tags = cleaned.path("properties").path("tags"); + assertFalse(tags.has("additionalProperties")); + assertEquals("string", tags.path("items").path("type").asText()); + assertFalse(tags.path("items").has("$comment")); + + JsonNode nested = cleaned.path("properties").path("nested"); + assertFalse(nested.has("$ref")); + assertEquals("number", nested.path("properties").path("inner").path("type").asText()); + } + + @Test + @DisplayName("drops non-string enum on a numeric type") + void dropsNumericEnum() { + JsonNode schema = parse(""" + { + "type": "object", + "properties": { + "duration": {"type": "integer", "enum": [60, 1440, 4320]}, + "mode": {"type": "string", "enum": ["fast", "slow"]} + } + } + """); + + ObjectNode cleaned = GeminiSchemaSanitizer.sanitizeToolParameters(schema, mapper); + + assertFalse(cleaned.path("properties").path("duration").has("enum"), + "integer enum with numeric literals must be dropped"); + assertTrue(cleaned.path("properties").path("mode").has("enum"), + "string enum is valid for Gemini and must be kept"); + } + + @Test + @DisplayName("null / empty input yields a minimal object schema") + void emptyInputYieldsObjectSchema() { + ObjectNode fromNull = GeminiSchemaSanitizer.sanitizeToolParameters(null, mapper); + assertEquals("object", fromNull.path("type").asText()); + assertTrue(fromNull.has("properties")); + + ObjectNode fromEmpty = GeminiSchemaSanitizer.sanitizeToolParameters(parse("{}"), mapper); + assertEquals("object", fromEmpty.path("type").asText()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java index 707c0175..4b1fc07b 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java @@ -65,4 +65,15 @@ class ModelFamilyTest { assertEquals(ModelFamily.STANDARD, ModelFamily.detect("")); assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" ")); } + + @Test + @DisplayName("Xiaomi MiMo models → MIMO_THINKING (reasoning_content relay required)") + void mimo_thinking() { + assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("mimo-v2-flash")); + assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("MiMo-VL-7B-RL")); + assertTrue(ModelFamily.MIMO_THINKING.isThinking(), + "Mimo must be flagged as thinking so reasoning_content is patched"); + assertFalse(ModelFamily.MIMO_THINKING.supportsReasoningEffort(), + "Mimo does not accept the reasoning_effort parameter"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java index 36d055d1..9e7bf899 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java @@ -67,7 +67,7 @@ class ModelConfigServiceDefaultModelTest { ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true); when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); - when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true); ModelConfigEntity result = service.getDefaultModel(); @@ -89,8 +89,8 @@ class ModelConfigServiceDefaultModelTest { // First selectOne → the is_default=true model when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); // dashscope is NOT configured, zhipu IS - when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(false); - when(modelProviderService.isProviderConfigured("zhipu")).thenReturn(true); + when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(false); + when(modelProviderService.isProviderEnabledAndConfigured("zhipu")).thenReturn(true); // Full-scan returns both; zhipu comes second but dashscope is skipped when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))) .thenReturn(List.of(dashscopeDefault, zhipuModel)); @@ -110,7 +110,7 @@ class ModelConfigServiceDefaultModelTest { ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false); when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); - when(modelProviderService.isProviderConfigured(any())).thenReturn(false); + when(modelProviderService.isProviderEnabledAndConfigured(any())).thenReturn(false); when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))) .thenReturn(List.of(dashscopeDefault, zhipuModel)); @@ -140,7 +140,7 @@ class ModelConfigServiceDefaultModelTest { when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); - // With null providerService, isProviderConfigured returns true (lenient bootstrap) + // With null providerService, isProviderEnabledAndConfigured returns true (lenient bootstrap) ModelConfigEntity result = service.getDefaultModel(); assertEquals("dashscope", result.getProvider()); } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java index 8339ad56..b60cc33c 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java @@ -73,7 +73,7 @@ class ModelConfigServiceResolveModelTest { // resolveModel skips its own selectOne for null/blank input, then calls getDefaultModel(), // which itself runs one selectOne lookup for the default flag. when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel); - when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true); ModelConfigEntity result = service.resolveModel(null); @@ -88,7 +88,7 @@ class ModelConfigServiceResolveModelTest { void blankNameFallsBack() { ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true); when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel); - when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true); ModelConfigEntity result = service.resolveModel(" "); @@ -113,7 +113,7 @@ class ModelConfigServiceResolveModelTest { assertEquals("claude-3-5-sonnet", result.getModelName()); // Exactly one lookup — getDefaultModel must NOT be called. verify(modelConfigMapper, times(1)).selectOne(any()); - verify(modelProviderService, never()).isProviderConfigured(any()); + verify(modelProviderService, never()).isProviderEnabledAndConfigured(any()); } // ── Unmatched → fall back to default ─────────────────────────────────────── @@ -126,7 +126,7 @@ class ModelConfigServiceResolveModelTest { when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))) .thenReturn(null) // 1st: name lookup misses .thenReturn(defaultModel); // 2nd: default flag lookup - when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true); ModelConfigEntity result = service.resolveModel("ghost-model"); diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryEmbeddingDetectionTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryEmbeddingDetectionTest.java new file mode 100644 index 00000000..8d572f99 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryEmbeddingDetectionTest.java @@ -0,0 +1,61 @@ +package vip.mate.llm.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Coverage for {@link ModelDiscoveryService#isEmbeddingModelId(String)} — + * the predicate that keeps embedding models out of the chat-style probe and + * out of the chat-discovery "new models" suggestion bucket. + */ +class ModelDiscoveryEmbeddingDetectionTest { + + @Test + void dashscopeEmbeddingVariants_recognised() { + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-v1")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-v3")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-v4")); + } + + @Test + void openAiEmbeddingVariants_recognised() { + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-ada-002")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-3-small")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-3-large")); + } + + @Test + void genericEmbeddingPrefix_recognised() { + // Some providers ship just "embedding-..." without the "text-" prefix. + assertTrue(ModelDiscoveryService.isEmbeddingModelId("embedding-001")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("embedding-large")); + } + + @Test + void chatModels_notRecognisedAsEmbedding() { + assertFalse(ModelDiscoveryService.isEmbeddingModelId("qwen-plus")); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("gpt-4o")); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("claude-sonnet-4-6")); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("deepseek-r1")); + } + + @Test + void detectionIsCaseInsensitive() { + assertTrue(ModelDiscoveryService.isEmbeddingModelId("Text-Embedding-V4")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("TEXT-EMBEDDING-3-SMALL")); + } + + @Test + void nullAndBlank_notEmbedding() { + assertFalse(ModelDiscoveryService.isEmbeddingModelId(null)); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("")); + } + + @Test + void stringContainingEmbeddingMidway_notDetected() { + // Only prefix-anchored matches count; arbitrary mentions don't. + assertFalse(ModelDiscoveryService.isEmbeddingModelId("qwen-embedding-experimental")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java index 7d9f4967..05f4f0cb 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java @@ -1,12 +1,12 @@ package vip.mate.memory.controller; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.auth.service.AuthService; import vip.mate.memory.model.DreamReportEntity; import vip.mate.memory.model.MemoryRecallEntity; import vip.mate.memory.repository.DreamReportMapper; @@ -25,8 +25,9 @@ import static org.mockito.Mockito.*; /** * Tests for HiL edit API contract: - * - Report-scoped edit: key must belong to that report's entry set - * - Direct edit (reportId=0): key must be an existing MEMORY.md section + * - Report-scoped edit: key must belong to that report's entry set, target is MEMORY.md + * - Direct edit (reportId=0): key must be an existing section in the request's target file, + * which must be a whitelisted memory file (MEMORY.md / PROFILE.md / SOUL.md / structured/*.md) */ @ExtendWith(MockitoExtension.class) class HilEditValidationTest { @@ -36,13 +37,14 @@ class HilEditValidationTest { @Mock private MorningCardService morningCardService; @Mock private MemoryHilService hilService; @Mock private DreamEventBroadcaster eventBroadcaster; + @Mock private AuthService authService; private DreamController controller; @BeforeEach void setUp() { controller = new DreamController(dreamReportMapper, recallMapper, - morningCardService, hilService, eventBroadcaster); + morningCardService, hilService, eventBroadcaster, authService); } @Test @@ -69,11 +71,11 @@ class HilEditValidationTest { // Should fail — key doesn't belong to this report assertNotEquals(200, result.getCode()); - verify(hilService, never()).editMemoryEntry(any(), any(), any()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); } @Test - @DisplayName("Report-scoped edit: key matches report candidate → allowed") + @DisplayName("Report-scoped edit: key matches report candidate → allowed, writes MEMORY.md") void reportScopedEdit_keyInReport_allowed() { DreamReportEntity report = new DreamReportEntity(); report.setId(100L); @@ -93,9 +95,9 @@ class HilEditValidationTest { var result = controller.editEntry(1L, 100L, "deployment_info", Map.of("content", "updated content")); - // Should succeed + // Should succeed — report-scoped edits always target MEMORY.md assertEquals(200, result.getCode()); - verify(hilService).editMemoryEntry(eq(1L), eq("deployment_info"), eq("updated content")); + verify(hilService).editMemoryEntry(eq(1L), eq("MEMORY.md"), eq("deployment_info"), eq("updated content")); } @Test @@ -120,30 +122,97 @@ class HilEditValidationTest { Map.of("content", "content")); assertNotEquals(200, result.getCode()); - verify(hilService, never()).editMemoryEntry(any(), any(), any()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); } @Test - @DisplayName("Direct edit (reportId=0): existing section → allowed") - void directEdit_existingSection_allowed() { - when(hilService.sectionExists(1L, "stable_facts")).thenReturn(true); + @DisplayName("Direct edit (reportId=0): existing section, no filename → defaults to MEMORY.md") + void directEdit_existingSection_defaultsToMemoryMd() { + when(hilService.sectionExists(1L, "MEMORY.md", "stable_facts")).thenReturn(true); var result = controller.editEntry(1L, 0L, "stable_facts", Map.of("content", "new content")); assertEquals(200, result.getCode()); - verify(hilService).editMemoryEntry(eq(1L), eq("stable_facts"), eq("new content")); + verify(hilService).editMemoryEntry(eq(1L), eq("MEMORY.md"), eq("stable_facts"), eq("new content")); } @Test @DisplayName("Direct edit (reportId=0): non-existing section → rejected") void directEdit_nonExistingSection_rejected() { - when(hilService.sectionExists(1L, "ghost_section")).thenReturn(false); + when(hilService.sectionExists(1L, "MEMORY.md", "ghost_section")).thenReturn(false); var result = controller.editEntry(1L, 0L, "ghost_section", Map.of("content", "content")); assertNotEquals(200, result.getCode()); - verify(hilService, never()).editMemoryEntry(any(), any(), any()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): PROFILE.md section → writes PROFILE.md, not MEMORY.md") + void directEdit_profileFile_writesProfile() { + when(hilService.sectionExists(1L, "PROFILE.md", "Identity")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "Identity", + Map.of("content", "name: Mate", "filename", "PROFILE.md")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("PROFILE.md"), eq("Identity"), eq("name: Mate")); + } + + @Test + @DisplayName("Direct edit (reportId=0): SOUL.md section → writes SOUL.md") + void directEdit_soulFile_writesSoul() { + when(hilService.sectionExists(1L, "SOUL.md", "Tone")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "Tone", + Map.of("content", "warm and direct", "filename", "SOUL.md")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("SOUL.md"), eq("Tone"), eq("warm and direct")); + } + + @Test + @DisplayName("Direct edit (reportId=0): structured/*.md section → allowed") + void directEdit_structuredFile_allowed() { + when(hilService.sectionExists(1L, "structured/user.md", "Preferences")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "Preferences", + Map.of("content", "likes dark mode", "filename", "structured/user.md")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("structured/user.md"), eq("Preferences"), + eq("likes dark mode")); + } + + @Test + @DisplayName("Direct edit (reportId=0): non-whitelisted filename → rejected") + void directEdit_unsupportedFile_rejected() { + var result = controller.editEntry(1L, 0L, "anything", + Map.of("content", "content", "filename", "application.yml")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): path-traversal filename → rejected") + void directEdit_pathTraversal_rejected() { + var result = controller.editEntry(1L, 0L, "anything", + Map.of("content", "content", "filename", "../../etc/passwd")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): blank content → rejected") + void directEdit_blankContent_rejected() { + var result = controller.editEntry(1L, 0L, "stable_facts", + Map.of("content", " ")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any(), any()); } } diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java index f6f4e2bd..bb411525 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java @@ -15,6 +15,7 @@ import vip.mate.agent.repository.AgentMapper; import vip.mate.memory.MemoryProperties; import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.memory.spi.MemoryManager; +import vip.mate.workspace.conversation.repository.ConversationMapper; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -41,6 +42,7 @@ class LifecycleRecallCountIT { @Mock private MemoryManager memoryManager; @Mock private ApplicationEventPublisher eventPublisher; @Mock private BaseAgent mockAgent; + @Mock private ConversationMapper conversationMapper; private MemoryProperties props; private AgentService agentService; @@ -50,14 +52,14 @@ class LifecycleRecallCountIT { props = new MemoryProperties(); MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher); agentService = new AgentService(agentMapper, agentGraphBuilder, - memoryRecallTracker, mediator, props); + memoryRecallTracker, mediator, props, conversationMapper); // Stub agent resolution (lenient for structural-only tests) AgentEntity entity = new AgentEntity(); entity.setId(1L); entity.setEnabled(true); lenient().when(agentMapper.selectById(1L)).thenReturn(entity); - lenient().when(agentGraphBuilder.build(any(AgentEntity.class))).thenReturn(mockAgent); + lenient().when(agentGraphBuilder.build(any(AgentEntity.class), any(), any())).thenReturn(mockAgent); lenient().when(mockAgent.chat(any(), any())).thenReturn("reply"); } diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryHilServiceTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryHilServiceTest.java new file mode 100644 index 00000000..aa9b976d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemoryHilServiceTest.java @@ -0,0 +1,123 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Tests for MemoryHilService — a user edit must land in the file the user is + * editing (MEMORY.md / PROFILE.md / SOUL.md), not unconditionally in MEMORY.md. + */ +@ExtendWith(MockitoExtension.class) +class MemoryHilServiceTest { + + @Mock private WorkspaceFileService workspaceFileService; + @Mock private ApplicationEventPublisher eventPublisher; + + private MemoryHilService service; + + @BeforeEach + void setUp() { + service = new MemoryHilService(workspaceFileService, eventPublisher); + } + + private WorkspaceFileEntity file(String filename, String content) { + WorkspaceFileEntity e = new WorkspaceFileEntity(); + e.setAgentId(1L); + e.setFilename(filename); + e.setContent(content); + return e; + } + + @Test + @DisplayName("Editing a PROFILE.md section writes back to PROFILE.md, not MEMORY.md") + void editProfile_writesProfile() { + String profile = "## Identity\nold name\n\n## Goals\nlearn\n"; + when(workspaceFileService.getFile(1L, "PROFILE.md")).thenReturn(file("PROFILE.md", profile)); + + service.editMemoryEntry(1L, "PROFILE.md", "Identity", "new name"); + + ArgumentCaptor<String> content = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService).saveFile(eq(1L), eq("PROFILE.md"), content.capture()); + verify(workspaceFileService, never()).saveFile(eq(1L), eq("MEMORY.md"), any()); + + String saved = content.getValue(); + assertTrue(saved.contains("## Identity\nnew name"), "section body replaced"); + assertTrue(saved.contains("<!-- user-edited:"), "user-edited marker appended"); + assertTrue(saved.contains("## Goals\nlearn"), "other sections untouched"); + } + + @Test + @DisplayName("Editing SOUL.md does not publish a MemoryWriteEvent (avoids SOUL self-overwrite)") + void editSoul_noEvent() { + when(workspaceFileService.getFile(1L, "SOUL.md")) + .thenReturn(file("SOUL.md", "## Tone\ndry\n")); + + service.editMemoryEntry(1L, "SOUL.md", "Tone", "warm"); + + verify(workspaceFileService).saveFile(eq(1L), eq("SOUL.md"), any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("Editing MEMORY.md publishes a MemoryWriteEvent targeting MEMORY.md") + void editMemory_publishesEvent() { + when(workspaceFileService.getFile(1L, "MEMORY.md")) + .thenReturn(file("MEMORY.md", "## Facts\nold\n")); + + service.editMemoryEntry(1L, "MEMORY.md", "Facts", "fresh"); + + ArgumentCaptor<MemoryWriteEvent> event = ArgumentCaptor.forClass(MemoryWriteEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertEquals("MEMORY.md", event.getValue().target()); + } + + @Test + @DisplayName("Repeated edits do not accumulate user-edited markers") + void repeatedEdit_singleMarker() { + // Section body already carries a marker from a previous edit + String memory = "## Facts\nv1\n<!-- user-edited: 2026-05-01 -->\n"; + when(workspaceFileService.getFile(1L, "MEMORY.md")).thenReturn(file("MEMORY.md", memory)); + + // Simulate an editor that echoes the old marker back inside the new body + service.editMemoryEntry(1L, "MEMORY.md", "Facts", "v2\n<!-- user-edited: 2026-05-01 -->"); + + ArgumentCaptor<String> content = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService).saveFile(eq(1L), eq("MEMORY.md"), content.capture()); + + String saved = content.getValue(); + int markers = saved.split("<!-- user-edited:", -1).length - 1; + assertEquals(1, markers, "exactly one marker after a re-edit"); + assertTrue(saved.contains("v2")); + assertFalse(saved.contains("v1")); + } + + @Test + @DisplayName("Editing a section absent from the file appends it as a new section") + void editMissingSection_appends() { + when(workspaceFileService.getFile(1L, "PROFILE.md")) + .thenReturn(file("PROFILE.md", "## Identity\nMate\n")); + + service.editMemoryEntry(1L, "PROFILE.md", "Goals", "ship the fix"); + + ArgumentCaptor<String> content = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService).saveFile(eq(1L), eq("PROFILE.md"), content.capture()); + + String saved = content.getValue(); + assertTrue(saved.contains("## Identity\nMate"), "existing section kept"); + assertTrue(saved.contains("## Goals\nship the fix"), "new section appended"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/acp/AcpSkillBridgeContentTest.java b/mateclaw-server/src/test/java/vip/mate/skill/acp/AcpSkillBridgeContentTest.java new file mode 100644 index 00000000..b192796e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/acp/AcpSkillBridgeContentTest.java @@ -0,0 +1,186 @@ +package vip.mate.skill.acp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.acp.service.AcpDelegationService; +import vip.mate.acp.service.AcpEndpointService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.tool.ToolRegistry; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Asserts the bridge synthesizes a non-empty SKILL.md body for every + * ACP-derived virtual skill and wires it onto both the {@link ResolvedSkill} + * {@code content} field and the virtual {@link SkillEntity} {@code skillContent}. + * + * <p>Without a body, an agent calling + * {@code readSkillFile(filePath="SKILL.md")} on an ACP endpoint gets + * "Error: SKILL.md content not available" and has nothing beyond the + * one-line description to work from. + */ +class AcpSkillBridgeContentTest { + + private AcpEndpointService endpointService; + private AcpSkillBridge bridge; + + @BeforeEach + void setUp() { + endpointService = mock(AcpEndpointService.class); + AcpDelegationService delegationService = mock(AcpDelegationService.class); + ToolRegistry toolRegistry = mock(ToolRegistry.class); + bridge = new AcpSkillBridge(endpointService, delegationService, new ObjectMapper(), toolRegistry); + } + + @Test + @DisplayName("ResolvedSkill carries a synthesized SKILL.md body, not an empty string") + void resolvedSkillHasNonEmptyContent() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + ep.setDescription("OpenAI-compatible coding agent"); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + ResolvedSkill resolved = bridge.listAcpDerivedResolvedSkills().get(0); + + assertNotNull(resolved.getContent()); + assertFalse(resolved.getContent().isBlank(), "SKILL.md body must not be blank"); + String body = resolved.getContent(); + assertTrue(body.contains("# codex"), "expected a markdown title, got: " + body); + assertTrue(body.contains("OpenAI-compatible coding agent"), + "expected the endpoint description in the body, got: " + body); + assertTrue(body.contains("acp_codex_prompt"), + "expected the wrapper tool name in the body, got: " + body); + assertTrue(body.contains("`prompt`") && body.contains("`cwd`"), + "expected both wrapper parameters documented, got: " + body); + assertTrue(body.contains("## Usage notes"), + "expected a usage notes section, got: " + body); + } + + @Test + @DisplayName("virtual SkillEntity skillContent matches the ResolvedSkill content") + void skillEntityContentMatchesResolved() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + SkillEntity entity = bridge.listAcpDerivedSkillEntities().get(0); + ResolvedSkill resolved = bridge.listAcpDerivedResolvedSkills().get(0); + + assertNotNull(entity.getSkillContent()); + assertFalse(entity.getSkillContent().isBlank(), "skillContent must not be blank"); + assertEquals(resolved.getContent(), entity.getSkillContent(), + "entity skillContent and resolved content must be the same synthesized body"); + } + + @Test + @DisplayName("trusted endpoints document the no-approval behavior") + void trustedEndpointPhrasing() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + ep.setTrusted(true); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + String body = bridge.listAcpDerivedResolvedSkills().get(0).getContent(); + + assertTrue(body.contains("trusted: the agent's own tool calls are accepted"), + "expected trusted phrasing, got: " + body); + } + + @Test + @DisplayName("untrusted endpoints document that tool calls may need approval") + void untrustedEndpointPhrasing() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + ep.setTrusted(false); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + String body = bridge.listAcpDerivedResolvedSkills().get(0).getContent(); + + assertTrue(body.contains("not trusted: the agent's tool calls may require"), + "expected untrusted phrasing, got: " + body); + } + + @Test + @DisplayName("status hint reflects a failed connection test") + void erroredEndpointStatusHint() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + ep.setLastStatus("ERROR"); + ep.setLastError("command not found: codex"); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + String body = bridge.listAcpDerivedResolvedSkills().get(0).getContent(); + + assertTrue(body.contains("Last connection test failed: command not found: codex"), + "expected the error surfaced in the body, got: " + body); + } + + @Test + @DisplayName("status hint reflects an OK connection test") + void okEndpointStatusHint() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + ep.setLastStatus("OK"); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + String body = bridge.listAcpDerivedResolvedSkills().get(0).getContent(); + + assertTrue(body.contains("Last connection test: OK."), + "expected OK status in the body, got: " + body); + } + + @Test + @DisplayName("untested endpoints note the CLI is spawned on first call") + void untestedEndpointStatusHint() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + ep.setLastStatus("UNKNOWN"); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + String body = bridge.listAcpDerivedResolvedSkills().get(0).getContent(); + + assertTrue(body.contains("Not yet tested"), + "expected an untested hint, got: " + body); + } + + @Test + @DisplayName("CJK-only endpoint names slug to a stable id-based wrapper tool name") + void cjkOnlyNameUsesIdSlugInContent() { + AcpEndpointEntity ep = newEndpoint(42L, "代码助手"); + when(endpointService.listEnabled()).thenReturn(List.of(ep)); + + String body = bridge.listAcpDerivedResolvedSkills().get(0).getContent(); + + assertTrue(body.contains("acp_acp-42_prompt"), + "all-CJK name should fall back to an id-based wrapper name, got: " + body); + } + + @Test + @DisplayName("findResolvedById and findEntityById return entries with the synthesized body") + void lookupByVirtualIdCarriesContent() { + AcpEndpointEntity ep = newEndpoint(7L, "codex"); + when(endpointService.get(7L)).thenReturn(ep); + + long virtualId = AcpSkillBridge.virtualIdFor(ep); + ResolvedSkill resolved = bridge.findResolvedById(virtualId); + SkillEntity entity = bridge.findEntityById(virtualId); + + assertNotNull(resolved); + assertNotNull(entity); + assertFalse(resolved.getContent().isBlank(), "resolved content must not be blank"); + assertFalse(entity.getSkillContent().isBlank(), "entity skillContent must not be blank"); + } + + private static AcpEndpointEntity newEndpoint(long id, String name) { + AcpEndpointEntity ep = new AcpEndpointEntity(); + ep.setId(id); + ep.setName(name); + ep.setEnabled(true); + ep.setCommand("codex"); + return ep; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java new file mode 100644 index 00000000..32db9f24 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java @@ -0,0 +1,195 @@ +package vip.mate.skill.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.skill.lifecycle.ConfirmRequiredException; +import vip.mate.skill.lifecycle.LifecycleTransition; +import vip.mate.skill.lifecycle.SkillCuratorJob; +import vip.mate.skill.lifecycle.SkillCuratorReport; +import vip.mate.skill.lifecycle.SkillCuratorReportStore; +import vip.mate.skill.lifecycle.SkillLifecycleService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the skill lifecycle / curator controller endpoints: pin, archive + * (including the bound-skill 409 confirm handshake), restore, and the + * curator control panel. + */ +@ExtendWith(MockitoExtension.class) +class SkillControllerLifecycleTest { + + private static final long SID = 100L; + + @Mock + private SkillService skillService; + @Mock + private AgentBindingService agentBindingService; + @Mock + private SkillLifecycleService skillLifecycleService; + @Mock + private SkillCuratorJob skillCuratorJob; + @Mock + private SkillCuratorReportStore skillCuratorReportStore; + + private SkillController controller; + + @BeforeEach + void setUp() { + controller = new SkillController( + skillService, null, null, null, null, null, null, null, null, null, + agentBindingService, null, null, + skillLifecycleService, skillCuratorJob, skillCuratorReportStore); + } + + private SkillEntity skill(String state, boolean builtin) { + SkillEntity s = new SkillEntity(); + s.setId(SID); + s.setName("demo-skill"); + s.setSkillType(builtin ? "builtin" : "dynamic"); + s.setBuiltin(builtin); + s.setLifecycleState(state); + s.setWorkspaceId(1L); + return s; + } + + // ==================== pin ==================== + + @Test + void pinDelegatesToLifecycleService() { + SkillEntity s = skill("active", false); + when(skillService.getSkill(SID)).thenReturn(s); + when(skillLifecycleService.setPinned(SID, true)).thenReturn(s); + + R<SkillEntity> r = controller.pin(SID, new SkillController.PinRequest(true), 1L); + + assertEquals(200, r.getCode()); + verify(skillLifecycleService).setPinned(SID, true); + } + + // ==================== archive ==================== + + @Test + void archiveUnboundSkillGoesStraightThrough() { + when(skillService.getSkill(SID)).thenReturn(skill("active", false)); + when(agentBindingService.enabledAgentsBoundToSkill(SID)).thenReturn(List.of()); + + controller.archive(SID, false, null, 1L); + + verify(skillLifecycleService).applyManual(any(), eq(LifecycleTransition.TO_ARCHIVED), + any(), anyString()); + } + + @Test + void archiveBoundSkillWithoutForceRequiresConfirm() { + when(skillService.getSkill(SID)).thenReturn(skill("active", false)); + when(agentBindingService.enabledAgentsBoundToSkill(SID)) + .thenReturn(List.of(new ConfirmRequiredException.AgentRow(42L, "DataAnalyst"))); + + ConfirmRequiredException ex = assertThrows(ConfirmRequiredException.class, + () -> controller.archive(SID, false, null, 1L)); + assertEquals("BOUND_SKILL_CONFIRM_REQUIRED", ex.getCode()); + verify(skillLifecycleService, never()).applyManual(any(), any(), any(), anyString()); + } + + @Test + void archiveBoundSkillWithForceSkipsTheConfirm() { + when(skillService.getSkill(SID)).thenReturn(skill("active", false)); + + controller.archive(SID, true, null, 1L); + + verify(agentBindingService, never()).enabledAgentsBoundToSkill(any()); + verify(skillLifecycleService).applyManual(any(), eq(LifecycleTransition.TO_ARCHIVED), + any(), anyString()); + } + + @Test + void archiveRejectsBuiltinSkill() { + when(skillService.getSkill(SID)).thenReturn(skill("active", true)); + assertThrows(MateClawException.class, () -> controller.archive(SID, false, null, 1L)); + } + + @Test + void archiveRejectsAlreadyArchivedSkill() { + when(skillService.getSkill(SID)).thenReturn(skill("archived", false)); + assertThrows(MateClawException.class, () -> controller.archive(SID, false, null, 1L)); + } + + // ==================== restore ==================== + + @Test + void restoreDelegatesToLifecycleService() { + SkillEntity s = skill("archived", false); + when(skillService.getSkill(SID)).thenReturn(s); + when(skillLifecycleService.restore(SID)).thenReturn(s); + + controller.restore(SID, 1L); + + verify(skillLifecycleService).restore(SID); + } + + // ==================== curator control panel ==================== + + @Test + void curatorDryRunDelegatesToJob() { + when(skillCuratorJob.dryRunNow()) + .thenReturn(SkillCuratorReport.builder().runAt(LocalDateTime.now()).build()); + controller.curatorDryRun(); + verify(skillCuratorJob).dryRunNow(); + } + + @Test + void curatorActivateFlipsTheFlag() { + when(skillCuratorJob.status()).thenReturn(Map.of()); + controller.curatorActivate(true); + verify(skillCuratorJob).activate(true); + } + + @Test + void curatorPauseAndResumeToggleTheJob() { + when(skillCuratorJob.status()).thenReturn(Map.of()); + controller.curatorPause(); + verify(skillCuratorJob).setPaused(true); + controller.curatorResume(); + verify(skillCuratorJob).setPaused(false); + } + + @Test + void curatorReportsListsRunIds() { + when(skillCuratorReportStore.listRunIds(20)).thenReturn(List.of("20260519-020000")); + R<List<String>> r = controller.curatorReports(); + assertEquals(1, r.getData().size()); + } + + @Test + void curatorReportReadsAKnownRun() { + when(skillCuratorReportStore.readRun("20260519-020000")).thenReturn(Map.of("runId", "20260519-020000")); + R<Object> r = controller.curatorReport("20260519-020000"); + assertEquals(200, r.getCode()); + } + + @Test + void curatorReportThrowsForUnknownRun() { + when(skillCuratorReportStore.readRun("nope")).thenReturn(null); + assertThrows(MateClawException.class, () -> controller.curatorReport("nope")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java index 76f87522..b8dd7d92 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java @@ -51,11 +51,14 @@ class SkillControllerListEnabledTest { /* agentService */ null, /* agentBindingService */ null, mcpSkillBridge, - acpSkillBridge); + acpSkillBridge, + /* skillLifecycleService */ null, + /* skillCuratorJob */ null, + /* skillCuratorReportStore */ null); // listSkills() supplies realSkillNames() for shadow base — default // to empty so each test can override. - when(skillService.listSkills()).thenReturn(List.of()); - when(skillService.listEnabledSkills()).thenReturn(List.of()); + when(skillService.listSkills(null)).thenReturn(List.of()); + when(skillService.listEnabledSkills(null)).thenReturn(List.of()); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of()); when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of()); } @@ -66,7 +69,7 @@ class SkillControllerListEnabledTest { SkillEntity mcp = skill("github", "mcp"); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); - R<List<SkillEntity>> response = controller.listEnabled(); + R<List<SkillEntity>> response = controller.listEnabled(null); assertNotNull(response.getData()); assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())), @@ -79,7 +82,7 @@ class SkillControllerListEnabledTest { SkillEntity acp = skill("claude-code", "acp"); when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of(acp)); - R<List<SkillEntity>> response = controller.listEnabled(); + R<List<SkillEntity>> response = controller.listEnabled(null); assertTrue(response.getData().stream().anyMatch(s -> "claude-code".equals(s.getName()))); } @@ -92,13 +95,13 @@ class SkillControllerListEnabledTest { // (enabled-only) by mistake, the virtual would slip through here. SkillEntity disabledReal = skill("github", "custom"); disabledReal.setEnabled(false); - when(skillService.listSkills()).thenReturn(List.of(disabledReal)); - when(skillService.listEnabledSkills()).thenReturn(List.of()); + when(skillService.listSkills(null)).thenReturn(List.of(disabledReal)); + when(skillService.listEnabledSkills(null)).thenReturn(List.of()); SkillEntity virtualMcp = skill("github", "mcp"); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(virtualMcp)); - R<List<SkillEntity>> response = controller.listEnabled(); + R<List<SkillEntity>> response = controller.listEnabled(null); // The real skill is disabled, so listEnabledSkills() returns nothing; // the virtual MCP must also be filtered to keep this endpoint in step @@ -112,11 +115,11 @@ class SkillControllerListEnabledTest { void mcpBridgeFailureSwallowed() { SkillEntity enabled = skill("web_search", "builtin"); enabled.setEnabled(true); - when(skillService.listEnabledSkills()).thenReturn(List.of(enabled)); + when(skillService.listEnabledSkills(null)).thenReturn(List.of(enabled)); when(mcpSkillBridge.listMcpDerivedSkillEntities()) .thenThrow(new RuntimeException("MCP bridge offline")); - R<List<SkillEntity>> response = controller.listEnabled(); + R<List<SkillEntity>> response = controller.listEnabled(null); assertEquals(1, response.getData().size()); assertEquals("web_search", response.getData().get(0).getName()); @@ -130,11 +133,27 @@ class SkillControllerListEnabledTest { SkillEntity mcp = skill("github", "mcp"); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); - R<List<SkillEntity>> response = controller.listEnabled(); + R<List<SkillEntity>> response = controller.listEnabled(null); assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName()))); } + @Test + @DisplayName("listEnabled excludes a disabled MCP virtual skill") + void excludesDisabledVirtualMcpSkill() { + // The bridge now surfaces disabled MCP servers too (so the Skills + // page can show a toggled-off card); the enabled-only picker must + // filter them back out. + SkillEntity disabledMcp = skill("github", "mcp"); + disabledMcp.setEnabled(false); + when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(disabledMcp)); + + R<List<SkillEntity>> response = controller.listEnabled(null); + + assertEquals(0, response.getData().size(), + "a disabled MCP virtual skill must not appear in the enabled-only picker"); + } + private static SkillEntity skill(String name, String type) { SkillEntity s = new SkillEntity(); s.setName(name); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java index 4366574b..d9463b0c 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java @@ -2,31 +2,38 @@ package vip.mate.skill.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.skill.acp.AcpSkillBridge; import vip.mate.skill.mcp.McpSkillBridge; import vip.mate.skill.model.SkillEntity; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** - * Mutation paths refuse virtual MCP/ACP skill ids upfront so the user - * gets a clear redirect to the connection page instead of the previous - * "技能不存在" 500 surfacing from a doomed mate_skill lookup. + * The edit / delete mutation paths refuse virtual MCP/ACP skill ids upfront + * so the user gets a clear redirect to the connection page instead of the + * old "技能不存在" 500 from a doomed mate_skill lookup. Toggle is the one + * exception: a virtual MCP skill mirrors an MCP server, so toggling it + * forwards to that server's enable/disable. */ class SkillControllerVirtualGuardTest { private final SkillController controller = new SkillController( - null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null); @Test @DisplayName("update on a virtual MCP skill id is rejected before hitting the service") void updateRejectsVirtualMcpId() { long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; MateClawException ex = assertThrows(MateClawException.class, - () -> controller.update(virtualId, new SkillEntity())); + () -> controller.update(virtualId, new SkillEntity(), null)); assertTrue(ex.getMessage().contains("MCP/ACP"), "expected redirect-to-connection-page hint, got: " + ex.getMessage()); } @@ -41,16 +48,44 @@ class SkillControllerVirtualGuardTest { assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId), "test fixture id is not in ACP virtual range; ACP base layout changed?"); assertThrows(MateClawException.class, - () -> controller.update(virtualAcpId, new SkillEntity())); + () -> controller.update(virtualAcpId, new SkillEntity(), null)); } @Test - @DisplayName("delete / toggle / rescan all reject virtual ids the same way") + @DisplayName("delete / rescan still reject virtual ids the same way") void mutationFamilyAllGuarded() { long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; - assertThrows(MateClawException.class, () -> controller.delete(virtualId)); - assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true)); - assertThrows(MateClawException.class, () -> controller.rescan(virtualId)); + assertThrows(MateClawException.class, () -> controller.delete(virtualId, null)); + assertThrows(MateClawException.class, () -> controller.rescan(virtualId, null)); + } + + @Test + @DisplayName("toggle on a virtual MCP skill forwards to the bridge instead of rejecting") + void toggleForwardsVirtualMcpToBridge() { + McpSkillBridge bridge = mock(McpSkillBridge.class); + SkillController c = new SkillController( + null, null, null, null, null, null, null, null, null, null, null, + bridge, null, null, null, null); + long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; + SkillEntity toggled = new SkillEntity(); + toggled.setName("github"); + toggled.setEnabled(false); + when(bridge.toggleVirtualSkill(virtualMcpId, false)).thenReturn(toggled); + + R<SkillEntity> resp = c.toggle(virtualMcpId, false, null); + + verify(bridge).toggleVirtualSkill(virtualMcpId, false); + assertEquals("github", resp.getData().getName()); + } + + @Test + @DisplayName("toggle on a virtual ACP skill is still rejected — no MCP-server mapping") + void toggleRejectsVirtualAcp() { + long virtualAcpId = AcpSkillBridge.VIRTUAL_ID_BASE + 7L; + assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId), + "test fixture id is not in ACP virtual range; ACP base layout changed?"); + assertThrows(MateClawException.class, + () -> controller.toggle(virtualAcpId, true, null)); } @Test @@ -62,14 +97,15 @@ class SkillControllerVirtualGuardTest { // not the guard. SkillController real = new SkillController( mock(vip.mate.skill.service.SkillService.class), - null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null); long snowflakeId = 1_900_000_001_000_000_902L; // updateSkill on a mocked SkillService returns null without throwing, // which is fine — we just need to confirm the guard didn't fire. // A virtual-id call would have thrown MateClawException before // reaching the service. try { - real.update(snowflakeId, new SkillEntity()); + real.update(snowflakeId, new SkillEntity(), null); } catch (MateClawException e) { // The guard message contains "MCP/ACP"; any other MateClawException // (e.g. from the service layer) is acceptable. diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java index 9f720692..0037c325 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java @@ -204,4 +204,29 @@ class ZipSkillFetcherTest { assertEquals(1, ex.scripts().size()); assertEquals("#!/bin/sh\n", ex.scripts().get("setup.sh")); } + + @Test + @DisplayName("GBK-encoded entry names (Windows-authored zip) fall back from UTF-8 to GBK") + void extractsGbkEncodedNames() throws IOException { + org.junit.jupiter.api.Assumptions.assumeTrue( + java.nio.charset.Charset.isSupported("GBK"), "GBK charset not available on this JVM"); + java.nio.charset.Charset gbk = java.nio.charset.Charset.forName("GBK"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos, gbk)) { + zos.putNextEntry(new ZipEntry("SKILL.md")); + zos.write(SKILL_MD.getBytes(gbk)); + zos.closeEntry(); + // Chinese filename whose GBK bytes are invalid UTF-8 → forces the fallback. + zos.putNextEntry(new ZipEntry("references/中文说明.md")); + zos.write("# 中文内容\n".getBytes(gbk)); + zos.closeEntry(); + } + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(baos.toByteArray()); + + assertNotNull(ex.skillMdContent()); + assertEquals(1, ex.references().size(), "GBK-named reference should survive the charset fallback"); + assertEquals("# 中文内容\n", ex.references().get("中文说明.md")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java new file mode 100644 index 00000000..8e1ccb85 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/CuratorRunNotifierTest.java @@ -0,0 +1,55 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.audit.service.AuditEventService; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; + +/** + * Covers the run notifier: every completed sweep records a durable audit row + * and publishes a {@link SkillCuratorRunCompletedEvent}. + */ +@ExtendWith(MockitoExtension.class) +class CuratorRunNotifierTest { + + @Mock + private AuditEventService auditEventService; + @Mock + private ApplicationEventPublisher eventPublisher; + + private CuratorRunNotifier notifier; + + @BeforeEach + void setUp() { + notifier = new CuratorRunNotifier(auditEventService, eventPublisher, new ObjectMapper()); + } + + @Test + void onRunCompleteRecordsAuditAndPublishesEvent() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(LocalDateTime.now()) + .dryRun(false) + .config(30, 90, "AGENT_CREATED") + .plannedCounts(2, 1, 0) + .appliedCounts(2, 1, 0) + .build(); + + notifier.onRunComplete(report); + + verify(auditEventService).record(eq("CURATOR_RUN"), eq("SKILL"), + eq(report.getRunId()), isNull(), anyString()); + verify(eventPublisher).publishEvent(any(SkillCuratorRunCompletedEvent.class)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java new file mode 100644 index 00000000..ba00a062 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java @@ -0,0 +1,236 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.system.service.SystemSettingService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the daily sweep gates (enabled / paused / first-run throttle), the + * dry-run vs applied count split, orphan reconciliation, and the status + * payload. + */ +@ExtendWith(MockitoExtension.class) +class SkillCuratorJobTest { + + @Mock + private SkillLifecycleService lifecycleService; + @Mock + private SkillMapper skillMapper; + @Mock + private SkillCuratorReportStore reportStore; + @Mock + private SystemSettingService systemSettingService; + @Mock + private AgentBindingService agentBindingService; + @Mock + private SkillWorkspaceManager workspaceManager; + @Mock + private CuratorRunNotifier notifier; + + private SkillLifecycleProperties properties; + private SkillCuratorJob job; + + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SkillEntity.class); + } + + @BeforeEach + void setUp() { + properties = new SkillLifecycleProperties(); + job = new SkillCuratorJob(lifecycleService, skillMapper, reportStore, properties, + systemSettingService, agentBindingService, workspaceManager, notifier); + } + + private SkillEntity candidate(long id, String state, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName("skill-" + id); + s.setSkillType("dynamic"); + s.setBuiltin(false); + s.setPinned(false); + s.setLifecycleState(state); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + /** Stub the sweep collaborators with empty reconcile + the given candidates. */ + private void stubSweep(List<SkillEntity> candidates) { + when(reportStore.write(any())).thenAnswer(i -> i.getArgument(0)); + when(agentBindingService.skillIdsBoundToEnabledAgents()).thenReturn(Set.of()); + when(agentBindingService.blockedByBindingCandidates(any())).thenReturn(List.of()); + // reconcileOrphans queries archived rows first, loadCandidates second. + when(skillMapper.selectList(any())).thenReturn(List.of(), candidates); + } + + // ==================== Gates ==================== + + @Test + void disabledCuratorNeverSweeps() { + properties.setEnabled(false); + job.run(); + verify(reportStore, never()).write(any()); + } + + @Test + void offScopeNeverSweeps() { + properties.setScope("OFF"); + job.run(); + verify(reportStore, never()).write(any()); + } + + @Test + void pausedCuratorNeverSweeps() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(true); + job.run(); + verify(reportStore, never()).write(any()); + } + + @Test + void firstObservationSeedsTimestampAndDefers() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any())).thenReturn(null); + + job.run(); + + verify(systemSettingService).saveString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), anyString(), anyString()); + verify(reportStore, never()).write(any()); + } + + @Test + void dryRunIsThrottledWithinTheInterval() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any())) + .thenReturn(now.minusHours(2).toString()); + when(systemSettingService.getString(eq(SkillCuratorJob.LAST_DRY_RUN_KEY), any())) + .thenReturn(now.minusHours(2).toString()); + + job.run(); + + verify(reportStore, never()).write(any()); + } + + @Test + void dryRunSweepsOncePerIntervalWhenDue() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any())) + .thenReturn(now.minusHours(30).toString()); + when(systemSettingService.getString(eq(SkillCuratorJob.LAST_DRY_RUN_KEY), any())).thenReturn(null); + stubSweep(List.of()); + + job.run(); + + ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class); + verify(reportStore).write(cap.capture()); + assertTrue(cap.getValue().isDryRun()); + verify(systemSettingService).saveString(eq(SkillCuratorJob.LAST_DRY_RUN_KEY), anyString(), anyString()); + } + + // ==================== Sweep counts ==================== + + @Test + void dryRunReportShowsPlannedButNotApplied() { + stubSweep(List.of(candidate(1L, "active", now.minusDays(40)))); + when(lifecycleService.planTransition(any(), any())).thenReturn(LifecycleTransition.TO_STALE); + + SkillCuratorReport report = job.dryRunNow(); + + assertTrue(report.isDryRun()); + assertEquals(1, report.getPlanned().stale()); + assertEquals(0, report.getApplied().stale()); + assertEquals(1, report.getScanned()); + verify(lifecycleService, never()).apply(any(), any(), any()); + } + + @Test + void activatedSweepAppliesTransitions() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true); + stubSweep(List.of(candidate(1L, "active", now.minusDays(40)))); + when(lifecycleService.planTransition(any(), any())).thenReturn(LifecycleTransition.TO_STALE); + when(lifecycleService.apply(any(), any(), any())).thenReturn(true); + + job.run(); + + ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class); + verify(reportStore).write(cap.capture()); + assertEquals(1, cap.getValue().getPlanned().stale()); + assertEquals(1, cap.getValue().getApplied().stale()); + } + + @Test + void reconcileReactivatesArchivedRowWhoseWorkspaceReturned() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true); + when(reportStore.write(any())).thenAnswer(i -> i.getArgument(0)); + when(agentBindingService.skillIdsBoundToEnabledAgents()).thenReturn(Set.of()); + when(agentBindingService.blockedByBindingCandidates(any())).thenReturn(List.of()); + SkillEntity orphan = candidate(9L, "archived", now.minusDays(100)); + // 1st selectList = reconcile (archived rows); 2nd = loadCandidates. + when(skillMapper.selectList(any())).thenReturn(List.of(orphan), List.of()); + when(workspaceManager.conventionWorkspaceExists("skill-9")).thenReturn(true); + + job.run(); + + // reconcileOrphans flips the divergent row back via a direct update. + verify(skillMapper).update(any(), any()); + } + + // ==================== Status & setters ==================== + + @Test + void statusReturnsConfigControlAndCounts() { + when(skillMapper.selectCount(any())).thenReturn(0L); + when(agentBindingService.blockedByBindingCandidates(any())).thenReturn(List.of()); + when(reportStore.latestRunId()).thenReturn(null); + + Map<String, Object> status = job.status(); + + assertTrue(status.containsKey("config")); + assertTrue(status.containsKey("control")); + assertTrue(status.containsKey("counts")); + } + + @Test + void activateAndPauseWriteSystemSettings() { + job.activate(true); + verify(systemSettingService).saveBool(eq(SkillCuratorJob.FIRST_RUN_KEY), eq(true), anyString()); + job.setPaused(true); + verify(systemSettingService).saveBool(eq(SkillCuratorJob.PAUSED_KEY), eq(true), anyString()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorReportTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorReportTest.java new file mode 100644 index 00000000..b97dc1ba --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorReportTest.java @@ -0,0 +1,92 @@ +package vip.mate.skill.lifecycle; + +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the report builder — in particular the planned / applied count + * split so a dry-run preview still shows what <em>would</em> happen. + */ +class SkillCuratorReportTest { + + private final LocalDateTime now = LocalDateTime.now(); + + private SkillEntity skill(long id, String name, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(name); + s.setLifecycleState("active"); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + @Test + void dryRunReportKeepsPlannedCountsButZeroApplied() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(now) + .dryRun(true) + .config(30, 90, "AGENT_CREATED") + .add(skill(1L, "tmp-helper", now.minusDays(40)), LifecycleTransition.TO_STALE) + .add(skill(2L, "old-grep", now.minusDays(95)), LifecycleTransition.TO_ARCHIVED) + .scanned(2) + .plannedCounts(1, 1, 0) + .appliedCounts(0, 0, 0) + .build(); + + assertTrue(report.isDryRun()); + assertEquals(1, report.getPlanned().stale()); + assertEquals(1, report.getPlanned().archived()); + assertEquals(0, report.getApplied().stale()); + assertEquals(0, report.getApplied().archived()); + assertEquals(2, report.getTransitions().size()); + // Convenience accessors report what actually happened — zero for a dry-run. + assertEquals(0, report.markedStale()); + assertEquals(0, report.archived()); + } + + @Test + void appliedReportCountsMatchPlannedOnCleanRun() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(now) + .dryRun(false) + .config(30, 90, "AGENT_CREATED") + .scanned(3) + .plannedCounts(2, 1, 0) + .appliedCounts(2, 1, 0) + .build(); + + assertFalse(report.isDryRun()); + assertEquals(2, report.markedStale()); + assertEquals(1, report.archived()); + assertEquals(0, report.reactivated()); + } + + @Test + void transitionRowCarriesDaysIdleFromAnchor() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(now) + .config(30, 90, "AGENT_CREATED") + .add(skill(7L, "stale-thing", now.minusDays(42)), LifecycleTransition.TO_STALE) + .build(); + + SkillCuratorReport.TransitionRow row = report.getTransitions().get(0); + assertEquals(7L, row.skillId()); + assertEquals("active", row.from()); + assertEquals("stale", row.to()); + assertEquals(42L, row.daysIdle()); + } + + @Test + void runIdIsDerivedFromRunTimestamp() { + LocalDateTime fixed = LocalDateTime.of(2026, 5, 19, 2, 0, 0); + SkillCuratorReport report = SkillCuratorReport.builder().runAt(fixed).build(); + assertEquals("20260519-020000", report.getRunId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java new file mode 100644 index 00000000..f817dc80 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java @@ -0,0 +1,272 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.SkillWorkspaceProperties; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the lifecycle state machine ({@code planTransition}) and the + * archive atomicity / compensation path. + */ +@ExtendWith(MockitoExtension.class) +class SkillLifecycleServiceTest { + + @Mock + private SkillMapper skillMapper; + @Mock + private SkillWorkspaceManager workspaceManager; + @Mock + private SkillRuntimeService runtimeService; + @Mock + private AuditEventService auditEventService; + + private SkillLifecycleService service; + + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeAll + static void initTableInfo() { + // Lambda wrappers resolve column names from MyBatis-Plus's static + // TableInfo cache; in a Spring context this happens during mapper + // scan, in a plain MockitoExtension test we trigger it manually. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SkillEntity.class); + } + + @BeforeEach + void setUp() { + SkillWorkspaceProperties workspaceProperties = new SkillWorkspaceProperties(); + SkillLifecycleProperties properties = new SkillLifecycleProperties(); + service = new SkillLifecycleService(skillMapper, workspaceManager, workspaceProperties, + runtimeService, auditEventService, new ObjectMapper(), properties); + } + + private SkillEntity skill(String type, String state, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(1L); + s.setName("demo-skill"); + s.setSkillType(type); + s.setBuiltin(false); + s.setPinned(false); + s.setLifecycleState(state); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + // ==================== planTransition ==================== + + @Test + void activeIdlePastStaleThresholdBecomesStale() { + SkillEntity s = skill("dynamic", "active", now.minusDays(31)); + assertEquals(LifecycleTransition.TO_STALE, service.planTransition(s, now)); + } + + @Test + void staleIdlePastArchiveThresholdBecomesArchived() { + SkillEntity s = skill("custom", "stale", now.minusDays(91)); + assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now)); + } + + @Test + void staleSkillWithRecentActivityReactivates() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(5)); + assertEquals(LifecycleTransition.REACTIVATE, service.planTransition(s, now)); + } + + @Test + void pinnedSkillIsNeverTouched() { + SkillEntity s = skill("dynamic", "active", now.minusDays(120)); + s.setPinned(true); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void builtinSkillIsNeverTouched() { + SkillEntity s = skill("builtin", "active", now.minusDays(120)); + s.setBuiltin(true); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void protectedPrefixSkillIsNeverTouched() { + SkillEntity s = skill("dynamic", "active", now.minusDays(120)); + s.setName("sys-health-probe"); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void freshSkillStaysActive() { + SkillEntity s = skill("dynamic", "active", now.minusDays(3)); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void alreadyStaleSkillWithinArchiveWindowStaysPut() { + // A skill already 'stale' and idle 50d (>= stale, < archive) yields + // NONE — a second sweep makes no further change (idempotency). + SkillEntity s = skill("dynamic", "stale", now.minusDays(50)); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + // ==================== bumpActivity / setPinned ==================== + + @Test + void bumpActivityWritesTheActivityAnchor() { + service.bumpActivity(7L); + verify(skillMapper).update(any(), any()); + } + + @Test + void bumpActivityWithNullIdIsANoOp() { + service.bumpActivity(null); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + void setPinnedUpdatesTheRow() { + SkillEntity s = skill("dynamic", "active", now.minusDays(1)); + when(skillMapper.selectById(1L)).thenReturn(s); + service.setPinned(1L, true); + verify(skillMapper).update(any(), any()); + } + + @Test + void setPinnedThrowsWhenSkillMissing() { + when(skillMapper.selectById(99L)).thenReturn(null); + assertThrows(MateClawException.class, () -> service.setPinned(99L, true)); + } + + // ==================== restore ==================== + + private SkillEntity archivedSkill() { + SkillEntity s = skill("dynamic", "archived", now.minusDays(100)); + s.setSkillContent("---\nname: demo-skill\n---\n# body"); + return s; + } + + @Test + void restoreMovesWorkspaceBackAndFlipsTheRow() { + when(skillMapper.selectById(1L)).thenReturn(archivedSkill()); + when(workspaceManager.restoreWorkspace("demo-skill")) + .thenReturn(SkillWorkspaceManager.RestoreResult.MOVED); + + service.restore(1L); + + verify(skillMapper).update(any(), any()); + verify(runtimeService).refreshActiveSkills(); + } + + @Test + void restoreDbOnlySkillFlipsRowWithoutWorkspace() { + when(skillMapper.selectById(1L)).thenReturn(archivedSkill()); + when(workspaceManager.restoreWorkspace("demo-skill")) + .thenReturn(SkillWorkspaceManager.RestoreResult.MISSING); + + service.restore(1L); + + verify(skillMapper).update(any(), any()); + } + + @Test + void restoreRejectsUnrecoverableSkill() { + SkillEntity s = archivedSkill(); + s.setSkillContent(" "); + when(skillMapper.selectById(1L)).thenReturn(s); + when(workspaceManager.restoreWorkspace("demo-skill")) + .thenReturn(SkillWorkspaceManager.RestoreResult.MISSING); + + assertThrows(MateClawException.class, () -> service.restore(1L)); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + void restoreRejectsWhenWorkspaceMoveBackFails() { + when(skillMapper.selectById(1L)).thenReturn(archivedSkill()); + when(workspaceManager.restoreWorkspace("demo-skill")) + .thenReturn(SkillWorkspaceManager.RestoreResult.FAILED); + + assertThrows(MateClawException.class, () -> service.restore(1L)); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + void restoreRejectsSkillThatIsNotArchived() { + when(skillMapper.selectById(1L)).thenReturn(skill("dynamic", "active", now)); + assertThrows(MateClawException.class, () -> service.restore(1L)); + } + + @Test + void restoreThrowsWhenSkillMissing() { + when(skillMapper.selectById(1L)).thenReturn(null); + assertThrows(MateClawException.class, () -> service.restore(1L)); + } + + // ==================== archive atomicity ==================== + + @Test + void archiveDefersWhenWorkspaceMoveFails() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(100)); + when(workspaceManager.archiveWorkspace(anyString())) + .thenReturn(SkillWorkspaceManager.ArchiveResult.FAILED); + + boolean applied = service.apply(s, LifecycleTransition.TO_ARCHIVED, now); + + assertFalse(applied); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + void archiveCommitsForDbOnlySkillWithNoWorkspace() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(100)); + when(workspaceManager.archiveWorkspace(anyString())) + .thenReturn(SkillWorkspaceManager.ArchiveResult.MISSING); + when(skillMapper.update(any(), any())).thenReturn(1); + + boolean applied = service.apply(s, LifecycleTransition.TO_ARCHIVED, now); + + assertTrue(applied); + verify(runtimeService).deregisterSkillWrappers(1L); + verify(runtimeService).refreshActiveSkills(); + } + + @Test + void archiveCompensatesWorkspaceWhenDbWriteTouchesNoRows() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(100)); + when(workspaceManager.archiveWorkspace(anyString())) + .thenReturn(SkillWorkspaceManager.ArchiveResult.MOVED); + when(skillMapper.update(any(), any())).thenReturn(0); + + boolean applied = service.apply(s, LifecycleTransition.TO_ARCHIVED, now); + + assertFalse(applied); + verify(workspaceManager).restoreWorkspace("demo-skill"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserScriptsTest.java b/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserScriptsTest.java new file mode 100644 index 00000000..7c4e4cb9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserScriptsTest.java @@ -0,0 +1,69 @@ +package vip.mate.skill.manifest; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SkillManifestParser}'s handling of the {@code scripts} + * frontmatter block — the typed script entrypoint declarations. + */ +class SkillManifestParserScriptsTest { + + /** parseRawMap / parseFromFrontmatter never touch the frontmatter parser. */ + private final SkillManifestParser parser = new SkillManifestParser(null); + + @Test + @DisplayName("a scripts block parses into typed ScriptDef entries") + void parsesScriptsBlock() { + Map<String, Object> entry = Map.of( + "id", "create_meeting", + "label", "Create Meeting", + "path", "scripts/dispatcher.py", + "description", "Schedule a meeting", + "arg_style", "json", + "fixed_args", List.of("schedule_meeting"), + "parameters", Map.of( + "type", "object", + "properties", Map.of("topic", Map.of("type", "string")), + "required", List.of("topic"))); + Map<String, Object> fm = Map.of( + "name", "demo", + "type", "code", + "scripts", List.of(entry)); + + SkillManifest manifest = parser.parseRawMap(fm, null, null); + + assertThat(manifest).isNotNull(); + assertThat(manifest.getScripts()).hasSize(1); + SkillManifest.ScriptDef def = manifest.getScripts().get(0); + assertThat(def.getId()).isEqualTo("create_meeting"); + assertThat(def.getPath()).isEqualTo("scripts/dispatcher.py"); + assertThat(def.getArgStyle()).isEqualTo("json"); + assertThat(def.getFixedArgs()).containsExactly("schedule_meeting"); + assertThat(def.getParameters()).containsKey("properties"); + // 'scripts' is a known key — it must not also leak into extras. + assertThat(manifest.getExtras()).doesNotContainKey("scripts"); + } + + @Test + @DisplayName("arg_style defaults to json when omitted") + void argStyleDefaults() { + Map<String, Object> entry = Map.of("id", "run", "path", "scripts/run.sh"); + SkillManifest manifest = parser.parseRawMap( + Map.of("name", "demo", "scripts", List.of(entry)), null, null); + assertThat(manifest.getScripts()).hasSize(1); + assertThat(manifest.getScripts().get(0).getArgStyle()).isEqualTo("json"); + } + + @Test + @DisplayName("no scripts block yields an empty list, not null") + void noScriptsBlock() { + SkillManifest manifest = parser.parseRawMap(Map.of("name", "demo"), null, null); + assertThat(manifest.getScripts()).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeContentTest.java b/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeContentTest.java new file mode 100644 index 00000000..368ab14c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeContentTest.java @@ -0,0 +1,184 @@ +package vip.mate.skill.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.runtime.McpClientManager; +import vip.mate.tool.mcp.service.McpServerService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Asserts that MCP-derived virtual skills carry a synthesized SKILL.md body + * instead of the empty string. Without it, {@code readSkillFile("SKILL.md")} + * returns "content not available" and the agent cannot reason about the + * MCP server's tools. + */ +class McpSkillBridgeContentTest { + + private McpServerService mcpServerService; + private McpClientManager mcpClientManager; + private McpSkillBridge bridge; + + @BeforeEach + void setUp() { + mcpServerService = mock(McpServerService.class); + mcpClientManager = mock(McpClientManager.class); + bridge = new McpSkillBridge(mcpServerService, mcpClientManager, new ObjectMapper()); + } + + @Test + @DisplayName("resolved skill carries a non-empty SKILL.md listing the server's tools") + void resolvedSkillHasSynthesizedContent() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(toolsJson( + "create_issue", "Open a new issue in a repository", + "list_issues", "List issues filtered by state and labels")); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + + ResolvedSkill resolved = bridge.listMcpDerivedResolvedSkills().get(0); + + String content = resolved.getContent(); + assertFalse(content == null || content.isBlank(), "SKILL.md content must not be empty"); + assertTrue(content.contains("github"), "content should name the MCP server"); + assertTrue(content.contains("create_issue"), "content should list the create_issue tool"); + assertTrue(content.contains("list_issues"), "content should list the list_issues tool"); + assertTrue(content.contains("Open a new issue in a repository"), + "content should carry the upstream tool description"); + } + + @Test + @DisplayName("virtual SkillEntity carries skillContent so the detail drawer can render it") + void entityHasSynthesizedSkillContent() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(toolsJson("create_issue", "Open a new issue")); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + + SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); + + assertFalse(entity.getSkillContent() == null || entity.getSkillContent().isBlank(), + "skillContent must be populated for MCP-derived skills"); + assertTrue(entity.getSkillContent().contains("create_issue")); + } + + @Test + @DisplayName("a server with no known tools still gets a content body that explains the gap") + void emptyToolListStillProducesContent() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(""); + server.setLastStatus("disconnected"); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + when(mcpClientManager.getServerTools(42L)).thenReturn(List.of()); + + ResolvedSkill resolved = bridge.listMcpDerivedResolvedSkills().get(0); + + String content = resolved.getContent(); + assertFalse(content == null || content.isBlank(), + "content must not be empty even when the tool list is unavailable"); + assertTrue(content.contains("MCP Connections"), + "content should point the user at the MCP Connections page"); + } + + @Test + @DisplayName("tool descriptions are clamped to a single prompt-friendly line") + void longDescriptionsAreClampedToOneLine() { + McpServerEntity server = newServer(42L, "github"); + String longDesc = "x".repeat(400); + server.setToolsCacheJson(toolsJson("create_issue", longDesc)); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + + ResolvedSkill resolved = bridge.listMcpDerivedResolvedSkills().get(0); + + String toolLine = resolved.getContent().lines() + .filter(l -> l.startsWith("- **create_issue**")) + .findFirst() + .orElseThrow(); + assertTrue(toolLine.length() < longDesc.length(), + "an over-long description should be truncated, got: " + toolLine.length()); + assertTrue(toolLine.endsWith("…"), "truncated descriptions should end with an ellipsis"); + } + + private static McpServerEntity newServer(long id, String name) { + McpServerEntity s = new McpServerEntity(); + s.setId(id); + s.setName(name); + s.setEnabled(true); + s.setTransport("stdio"); + s.setCommand("/usr/bin/echo"); + s.setLastStatus("connected"); + return s; + } + + /** Builds a tools_cache_json array from alternating name/description pairs. */ + private static String toolsJson(String... nameDescPairs) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i + 1 < nameDescPairs.length; i += 2) { + if (i > 0) { + sb.append(","); + } + sb.append("{\"name\":\"").append(nameDescPairs[i]) + .append("\",\"description\":\"").append(nameDescPairs[i + 1]) + .append("\",\"inputSchema\":{}}"); + } + sb.append("]"); + return sb.toString(); + } + + @Test + @DisplayName("two name/description pairs round-trip into the catalog") + void multipleToolsRenderAsCatalogRows() { + McpServerEntity server = newServer(7L, "filesystem"); + server.setToolsCacheJson(toolsJson( + "read_file", "Read the contents of a file", + "write_file", "Write content to a file")); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + + String content = bridge.listMcpDerivedResolvedSkills().get(0).getContent(); + + long rows = content.lines().filter(l -> l.startsWith("- **")).count(); + assertEquals(2, rows, "each MCP tool should be one catalog row"); + } + + @Test + @DisplayName("toggleVirtualSkill forwards to mcpServerService.toggle and reflects the new state") + void toggleVirtualSkillForwardsToServer() { + McpServerEntity disabled = newServer(42L, "github"); + disabled.setEnabled(false); + long virtualId = McpSkillBridge.virtualIdFor(disabled); + + McpServerEntity enabledAfter = newServer(42L, "github"); + enabledAfter.setEnabled(true); + when(mcpServerService.toggle(42L, true)).thenReturn(enabledAfter); + + SkillEntity result = bridge.toggleVirtualSkill(virtualId, true); + + verify(mcpServerService).toggle(42L, true); + assertEquals("github", result.getName()); + assertEquals(Boolean.TRUE, result.getEnabled()); + } + + @Test + @DisplayName("a disabled MCP server still surfaces as a (disabled) virtual skill row") + void disabledServerStillListedAsDisabledSkill() { + McpServerEntity server = newServer(42L, "github"); + server.setEnabled(false); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + + List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities(); + + assertEquals(1, entities.size(), + "disabled MCP servers must still appear so the toggle can be flipped back on"); + assertEquals(Boolean.FALSE, entities.get(0).getEnabled()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java b/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java index 64210027..f4f0ea69 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java @@ -47,7 +47,7 @@ class McpSkillBridgeManifestTest { void allowedToolsArePrefixed() { McpServerEntity server = newServer(42L, "github"); server.setToolsCacheJson(toolsJson("create_issue", "list_issues")); - when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + when(mcpServerService.listAll()).thenReturn(List.of(server)); SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); @@ -65,7 +65,7 @@ class McpSkillBridgeManifestTest { void readsFromCacheFirst() { McpServerEntity server = newServer(42L, "github"); server.setToolsCacheJson(toolsJson("create_issue")); - when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + when(mcpServerService.listAll()).thenReturn(List.of(server)); bridge.listMcpDerivedSkillEntities(); @@ -77,7 +77,7 @@ class McpSkillBridgeManifestTest { void fallsBackToLiveWhenCacheMissing() { McpServerEntity server = newServer(42L, "github"); server.setToolsCacheJson(null); // first-ever connect just happened, cache not yet written - when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + when(mcpServerService.listAll()).thenReturn(List.of(server)); when(mcpClientManager.getServerTools(42L)).thenReturn(List.of( fakeTool("create_issue"), fakeTool("list_issues"))); @@ -94,7 +94,7 @@ class McpSkillBridgeManifestTest { McpServerEntity server = newServer(42L, "github"); server.setToolsCacheJson(""); server.setLastStatus("disconnected"); - when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + when(mcpServerService.listAll()).thenReturn(List.of(server)); when(mcpClientManager.getServerTools(42L)).thenReturn(List.of()); SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); @@ -108,6 +108,72 @@ class McpSkillBridgeManifestTest { "no prefixed tool name expected, got: " + entity.getManifestJson()); } + @Test + @DisplayName("virtual id encoding round-trips for Snowflake-magnitude server ids (regression)") + void virtualIdRoundTripsForLargeSnowflakeIds() { + // 2054864660577071106 is a real Snowflake observed in the wild; + // the previous BASE + serverId scheme overflowed signed long for + // ids of this magnitude, producing negative virtual ids that + // failed isVirtualMcpSkillId and broke the skill detail lookup. + long[] cases = {1L, 1_000_001L, 2_054_864_660_577_071_106L, (1L << 61), (1L << 62) - 1L}; + for (long sid : cases) { + McpServerEntity server = newServer(sid, "anything"); + long vid = McpSkillBridge.virtualIdFor(server); + assertTrue(McpSkillBridge.isVirtualMcpSkillId(vid), + "vid for serverId=" + sid + " should be classified as MCP virtual: got 0x" + + Long.toHexString(vid)); + assertEquals(sid, McpSkillBridge.extractMcpServerId(vid), + "extract did not round-trip for serverId=" + sid); + } + } + + @Test + @DisplayName("MCP and ACP virtual id spaces never overlap, real Snowflake ids classify as neither") + void virtualIdSpacesAreDisjoint() { + long serverId = 2_054_864_660_577_071_106L; // Snowflake magnitude + McpServerEntity server = newServer(serverId, "anything"); + long mcpVid = McpSkillBridge.virtualIdFor(server); + + assertTrue(McpSkillBridge.isVirtualMcpSkillId(mcpVid)); + // An MCP virtual id must NOT be misread as ACP. + assertTrue(!vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(mcpVid), + "MCP vid 0x" + Long.toHexString(mcpVid) + " leaked into the ACP range"); + // Real Snowflake ids (positive, top bits clear) must be neither. + assertTrue(!McpSkillBridge.isVirtualMcpSkillId(serverId)); + assertTrue(!vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(serverId)); + } + + @Test + @DisplayName("CJK-only server names slug to a stable id-based fallback instead of an all-dash collision") + void cjkOnlyNameFallsBackToIdSlug() { + McpServerEntity a = newServer(42L, "知识图谱对象数据查询服务"); + McpServerEntity b = newServer(43L, "客户档案信息查询服务"); + a.setToolsCacheJson(toolsJson("search")); + b.setToolsCacheJson(toolsJson("search")); + when(mcpServerService.listAll()).thenReturn(List.of(a, b)); + + List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities(); + + assertEquals("mcp-42", entities.get(0).getName(), + "all-CJK name should fall back to id-based slug"); + assertEquals("mcp-43", entities.get(1).getName(), + "second all-CJK name must not collide with the first"); + assertTrue(entities.get(0).getManifestJson().contains("\"id\":\"mcp-42\""), + "manifest id should mirror the fallback slug, got: " + entities.get(0).getManifestJson()); + } + + @Test + @DisplayName("ASCII server names keep their existing slug — no regression for English names") + void asciiNamePreservesExistingSlug() { + McpServerEntity server = newServer(42L, "GitHub"); + server.setToolsCacheJson(toolsJson("create_issue")); + when(mcpServerService.listAll()).thenReturn(List.of(server)); + + SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); + + assertEquals("github", entity.getName()); + } + @Test @DisplayName("two servers exposing the same raw tool name produce distinct prefixed names") void twoServersSameRawNameDistinct() { @@ -115,7 +181,7 @@ class McpSkillBridgeManifestTest { a.setToolsCacheJson(toolsJson("search")); McpServerEntity b = newServer(43L, "filesystem"); b.setToolsCacheJson(toolsJson("search")); - when(mcpServerService.listEnabled()).thenReturn(List.of(a, b)); + when(mcpServerService.listAll()).thenReturn(List.of(a, b)); List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities(); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactoryTest.java new file mode 100644 index 00000000..70d48d49 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactoryTest.java @@ -0,0 +1,120 @@ +package vip.mate.skill.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.manifest.SkillManifest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ScriptSkillWrapperToolFactory} — the argument + * translation ({@code buildArgv}) and wrapper naming ({@code wrapperNames}) + * that turn a declared script entrypoint into a typed tool. The model fills + * schema fields; these are the steps that carry that typed input into the + * script process without the model hand-crafting a JSON string. + */ +class ScriptSkillWrapperToolFactoryTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** wrapperNames touches no collaborators — null deps are fine here. */ + private final ScriptSkillWrapperToolFactory factory = + new ScriptSkillWrapperToolFactory(null, null, null, objectMapper); + + private JsonNode json(String raw) { + try { + return objectMapper.readTree(raw); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @DisplayName("json style forwards the whole object as one compact JSON argument") + void jsonStyleSingleArg() { + List<String> argv = ScriptSkillWrapperToolFactory.buildArgv( + List.of(), "json", json("{\"date\":\"2026-05-19\",\"topic\":\"智能体\"}")); + assertThat(argv).hasSize(1); + JsonNode back = json(argv.get(0)); + assertThat(back.get("date").asText()).isEqualTo("2026-05-19"); + assertThat(back.get("topic").asText()).isEqualTo("智能体"); + } + + @Test + @DisplayName("json style with an empty / absent object yields no arguments") + void jsonStyleEmpty() { + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "json", json("{}"))).isNull(); + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "json", null)).isNull(); + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), null, json("{}"))).isNull(); + } + + @Test + @DisplayName("flags style emits --key value pairs and drops false / null properties") + void flagsStyle() { + List<String> argv = ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", + json("{\"verbose\":true,\"file\":\"in.txt\",\"count\":3,\"debug\":false,\"note\":null}")); + assertThat(argv).containsExactly("--verbose", "--file", "in.txt", "--count", "3"); + } + + @Test + @DisplayName("flags style with no usable properties yields no arguments") + void flagsStyleEmpty() { + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", json("{}"))).isNull(); + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", json("{\"off\":false}"))).isNull(); + } + + @Test + @DisplayName("fixedArgs are emitted before the typed JSON argument") + void fixedArgsPrependDispatcherMethod() { + // A dispatcher script: argv[1] = method, argv[2] = JSON payload. + List<String> argv = ScriptSkillWrapperToolFactory.buildArgv( + List.of("schedule_meeting"), "json", json("{\"subject\":\"智能体\"}")); + assertThat(argv).hasSize(2); + assertThat(argv.get(0)).isEqualTo("schedule_meeting"); + assertThat(json(argv.get(1)).get("subject").asText()).isEqualTo("智能体"); + } + + @Test + @DisplayName("fixedArgs survive even when the entrypoint takes no typed input") + void fixedArgsWithoutTypedArgs() { + assertThat(ScriptSkillWrapperToolFactory.buildArgv( + List.of("convert_timestamp"), "json", json("{}"))) + .containsExactly("convert_timestamp"); + assertThat(ScriptSkillWrapperToolFactory.buildArgv( + List.of("m"), "flags", json("{\"v\":true}"))) + .containsExactly("m", "--v"); + } + + @Test + @DisplayName("wrapperNames builds skill_<slug>_<id> for each usable entrypoint") + void wrapperNames() { + SkillManifest manifest = SkillManifest.builder() + .name("Tencent Meeting") + .scripts(List.of( + SkillManifest.ScriptDef.builder() + .id("create_meeting").path("scripts/create.py").build(), + SkillManifest.ScriptDef.builder() + .id("cancel_meeting").path("scripts/cancel.py").build())) + .build(); + assertThat(factory.wrapperNames(manifest)) + .containsExactly("skill_tencent_meeting_create_meeting", + "skill_tencent_meeting_cancel_meeting"); + } + + @Test + @DisplayName("wrapperNames skips entrypoints missing an id or a script path") + void wrapperNamesSkipsIncomplete() { + SkillManifest manifest = SkillManifest.builder() + .name("demo") + .scripts(List.of( + SkillManifest.ScriptDef.builder().id("ok").path("scripts/ok.py").build(), + SkillManifest.ScriptDef.builder().id("no_path").build(), + SkillManifest.ScriptDef.builder().path("scripts/no_id.py").build())) + .build(); + assertThat(factory.wrapperNames(manifest)).containsExactly("skill_demo_ok"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java new file mode 100644 index 00000000..7f63647e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java @@ -0,0 +1,35 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class SkillFileAccessPolicyTest { + + private final SkillFileAccessPolicy policy = new SkillFileAccessPolicy(); + private final Path skillDir = Path.of("/workspace/skills/architecture-diagram"); + + @Test + @DisplayName("allows architecture skill templates") + void allowsTemplatesDirectory() { + Path resolved = policy.validateAndResolve(skillDir, "templates/template.html"); + + assertEquals(skillDir.resolve("templates/template.html"), resolved); + } + + @Test + @DisplayName("still rejects unsupported top-level paths") + void rejectsUnsupportedTopLevelPaths() { + assertNull(policy.validateAndResolve(skillDir, "assets/logo.svg")); + } + + @Test + @DisplayName("rejects traversal from allowed directories") + void rejectsTraversal() { + assertNull(policy.validateAndResolve(skillDir, "templates/../SKILL.md")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceLoadedPinTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceLoadedPinTest.java new file mode 100644 index 00000000..448f9e49 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceLoadedPinTest.java @@ -0,0 +1,90 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.lessons.SkillLessonsService; +import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.service.SkillService; +import vip.mate.skill.usage.SkillUsageService; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that skills loaded this run (the {@code LOADED_SKILLS} signal) are + * pinned to the top of the runtime catalog so a multi-iteration loop stops + * re-loading the same skill. + */ +class SkillRuntimeServiceLoadedPinTest { + + @Test + @DisplayName("a skill loaded this run floats to the top, ahead of the budget-truncated default order") + void loadedSkillIsPinnedToTop() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + List<SkillEntity> entities = java.util.stream.IntStream.rangeClosed(1, 12) + .mapToObj(i -> entity((long) i, "skill-%02d".formatted(i), "builtin")) + .toList(); + when(skillService.listEnabledSkills()).thenReturn(entities); + for (SkillEntity entity : entities) { + when(resolver.resolve(entity)).thenReturn(resolved(entity)); + } + when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of()); + when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of()); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + // Control: without the pin, skill-10 is past the 8-entry budget and not shown. + String baseline = runtime.buildSkillPromptEnhancement(null, null, 8192); + assertFalse(baseline.contains("skill-10"), + "control: skill-10 should be truncated out of the default 8-of-12 catalog"); + + // With skill-10 loaded this run, it should be pinned to the top. + String pinned = runtime.buildSkillPromptEnhancement( + null, null, 8192, null, null, Set.of("skill-10")); + + assertTrue(pinned.contains("skill-10"), + "skill loaded this run must appear in the catalog even past the budget; prompt was: " + pinned); + assertTrue(pinned.indexOf("skill-10") < pinned.indexOf("skill-01"), + "skill loaded this run must be pinned ahead of the default-order entries; prompt was: " + pinned); + } + + private static ResolvedSkill resolved(SkillEntity entity) { + return ResolvedSkill.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .enabled(Boolean.TRUE.equals(entity.getEnabled())) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .build(); + } + + private static SkillEntity entity(Long id, String name, String type) { + SkillEntity entity = new SkillEntity(); + entity.setId(id); + entity.setName(name); + entity.setDescription("Description for " + name); + entity.setSkillType(type); + entity.setEnabled(true); + entity.setSecurityScanStatus("PASSED"); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java index e0c48f11..ad89d09c 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java @@ -159,6 +159,53 @@ class SkillRuntimeServicePromptBudgetTest { + "prompt was: " + prompt); } + @Test + @DisplayName("workspace filter hides other workspaces' skills, keeps builtin global") + void workspaceFilterHidesOtherWorkspaceSkills() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + SkillEntity builtin = entity(1L, "pdf-builtin", "builtin"); + SkillEntity ownWorkspace = entity(2L, "ws1-skill", "dynamic"); + SkillEntity otherWorkspace = entity(3L, "ws2-skill", "dynamic"); + when(skillService.listEnabledSkills()).thenReturn(List.of(builtin, ownWorkspace, otherWorkspace)); + when(resolver.resolve(builtin)).thenReturn(scopedResolved(builtin, true, null)); + when(resolver.resolve(ownWorkspace)).thenReturn(scopedResolved(ownWorkspace, false, 1L)); + when(resolver.resolve(otherWorkspace)).thenReturn(scopedResolved(otherWorkspace, false, 2L)); + when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of()); + when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of()); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + // Agent lives in workspace 1. + String prompt = runtime.buildSkillPromptEnhancement(null, null, 8192, null, 1L); + + assertTrue(prompt.contains("pdf-builtin"), "builtin skill must stay globally visible"); + assertTrue(prompt.contains("ws1-skill"), "the agent's own workspace skill must be visible"); + assertFalse(prompt.contains("ws2-skill"), "another workspace's skill must not leak into the prompt"); + } + + private static ResolvedSkill scopedResolved(SkillEntity entity, boolean builtin, Long workspaceId) { + return ResolvedSkill.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .enabled(true) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .builtin(builtin) + .workspaceId(workspaceId) + .build(); + } + private static SkillEntity entity(Long id, String name, String type) { SkillEntity entity = new SkillEntity(); entity.setId(id); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillSecurityServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillSecurityServiceTest.java index a20027b8..3e44052d 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillSecurityServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillSecurityServiceTest.java @@ -214,4 +214,65 @@ class SkillSecurityServiceTest { assertEquals(3, sudoFinding.getLineNumber()); assertNotNull(sudoFinding.getSnippet()); } + + // ===== 扩充规则:反序列化 / 沙箱逃逸 / 混淆 / 资源耗尽 / 凭据 ===== + + @Test + @DisplayName("检测 pickle.loads 不可信反序列化 → HIGH → blocked") + void shouldBlockPickleDeserialize(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test"); + Path scripts = Files.createDirectory(tempDir.resolve("scripts")); + Files.writeString(scripts.resolve("load.py"), "import pickle\nobj = pickle.loads(payload)\n"); + + SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill"); + + assertTrue(result.isBlocked()); + assertTrue(result.getFindings().stream() + .anyMatch(f -> f.getRuleId().equals("PICKLE_DESERIALIZE"))); + } + + @Test + @DisplayName("检测 fork bomb → CRITICAL → blocked") + void shouldBlockForkBomb(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test"); + Path scripts = Files.createDirectory(tempDir.resolve("scripts")); + Files.writeString(scripts.resolve("bomb.sh"), "#!/bin/bash\n:(){ :|:& };:\n"); + + SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill"); + + assertTrue(result.isBlocked()); + assertEquals(SkillValidationResult.Severity.CRITICAL, result.getMaxSeverity()); + assertTrue(result.getFindings().stream() + .anyMatch(f -> f.getRuleId().equals("FORK_BOMB"))); + } + + @Test + @DisplayName("检测读取 SSH 私钥 → HIGH → blocked") + void shouldBlockSecretFileRead(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test"); + Path scripts = Files.createDirectory(tempDir.resolve("scripts")); + Files.writeString(scripts.resolve("steal.sh"), "cat ~/.ssh/id_rsa\n"); + + SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill"); + + assertTrue(result.isBlocked()); + assertTrue(result.getFindings().stream() + .anyMatch(f -> f.getRuleId().equals("SECRET_FILE_READ"))); + } + + @Test + @DisplayName("Node child_process → MEDIUM → 警告但不阻断") + void shouldWarnNodeChildProcess(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test"); + Path scripts = Files.createDirectory(tempDir.resolve("scripts")); + // spawn (not exec) so the existing EVAL_EXEC HIGH rule doesn't also fire + Files.writeString(scripts.resolve("run.js"), "const cp = require('child_process');\ncp.spawn('ls');\n"); + + SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill"); + + assertTrue(result.isPassed(), "MEDIUM should pass"); + assertFalse(result.isBlocked(), "MEDIUM should not block"); + assertTrue(result.getFindings().stream() + .anyMatch(f -> f.getRuleId().equals("NODE_CHILD_PROCESS"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java new file mode 100644 index 00000000..e71fa2ac --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java @@ -0,0 +1,97 @@ +package vip.mate.skill.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.skill.event.SkillRemovedEvent; +import vip.mate.skill.lifecycle.SkillLifecycleService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillFileMapper; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.secret.SkillSecretService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.SkillWorkspaceProperties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Issue #127 — verifies the delete paths publish {@link SkillRemovedEvent} + * so the agent-binding listener can scrub {@code mate_agent_skill} orphans. + * + * <p>Earlier behavior dropped only {@code mate_skill}/{@code mate_skill_file} + * /secrets/workspace and left binding rows pointing at a vanished skill id, + * which is what users saw as "agent still shows N skills bound". + */ +class SkillServiceRemovalEventTest { + + @Test + @DisplayName("uninstallSkill publishes SkillRemovedEvent with the row's id and name") + void uninstallPublishesEvent() { + SkillMapper mapper = mock(SkillMapper.class); + SkillFileMapper fileMapper = mock(SkillFileMapper.class); + SkillWorkspaceManager workspaceManager = mock(SkillWorkspaceManager.class); + SkillWorkspaceProperties workspaceProps = mock(SkillWorkspaceProperties.class); + SkillSecretService secretService = mock(SkillSecretService.class); + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + + SkillEntity row = new SkillEntity(); + row.setId(42L); + row.setName("pdf"); + row.setBuiltin(false); + when(mapper.selectById(42L)).thenReturn(row); + // Workspace policy other than "archive" keeps the test focused on the + // event publish behavior — the archive branch has its own coverage. + when(workspaceProps.getDeletePolicy()).thenReturn("purge"); + + SkillService service = new SkillService( + mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher, + mock(SkillLifecycleService.class)); + service.setRuntimeService(runtimeService); + + service.uninstallSkill(42L); + + ArgumentCaptor<SkillRemovedEvent> captor = ArgumentCaptor.forClass(SkillRemovedEvent.class); + verify(publisher).publishEvent(captor.capture()); + SkillRemovedEvent event = captor.getValue(); + assertEquals(42L, event.skillId()); + assertEquals("pdf", event.skillName()); + } + + @Test + @DisplayName("hardDeleteSkill publishes SkillRemovedEvent for the admin-only delete path") + void hardDeletePublishesEvent() { + SkillMapper mapper = mock(SkillMapper.class); + SkillFileMapper fileMapper = mock(SkillFileMapper.class); + SkillWorkspaceManager workspaceManager = mock(SkillWorkspaceManager.class); + SkillWorkspaceProperties workspaceProps = mock(SkillWorkspaceProperties.class); + SkillSecretService secretService = mock(SkillSecretService.class); + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + + SkillEntity row = new SkillEntity(); + row.setId(99L); + row.setName("legacy-cleanup"); + row.setBuiltin(false); + when(mapper.selectById(99L)).thenReturn(row); + when(fileMapper.deleteBySkillId(99L)).thenReturn(0); + + SkillService service = new SkillService( + mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher, + mock(SkillLifecycleService.class)); + service.setRuntimeService(runtimeService); + + service.hardDeleteSkill(99L); + + ArgumentCaptor<SkillRemovedEvent> captor = ArgumentCaptor.forClass(SkillRemovedEvent.class); + verify(publisher).publishEvent(captor.capture()); + SkillRemovedEvent event = captor.getValue(); + assertEquals(99L, event.skillId()); + assertEquals("legacy-cleanup", event.skillName()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java index c47ca877..094c0b3d 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java @@ -3,6 +3,7 @@ package vip.mate.skill.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillRuntimeService; @@ -59,7 +60,9 @@ class SkillServiceUpdatePartialTest { SkillService service = new SkillService( mapper, mock(vip.mate.skill.repository.SkillFileMapper.class), - workspaceManager, workspaceProps, secretService); + workspaceManager, workspaceProps, secretService, + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(SkillLifecycleService.class)); service.setRuntimeService(runtimeService); SkillEntity existing = new SkillEntity(); @@ -137,7 +140,9 @@ class SkillServiceUpdatePartialTest { SkillService service = new SkillService( mapper, mock(vip.mate.skill.repository.SkillFileMapper.class), - workspaceManager, workspaceProps, secretService); + workspaceManager, workspaceProps, secretService, + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(SkillLifecycleService.class)); service.setRuntimeService(runtimeService); SkillEntity existing = new SkillEntity(); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java new file mode 100644 index 00000000..f63505bd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java @@ -0,0 +1,104 @@ +package vip.mate.skill.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.skill.model.SkillEntity; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Issue #135 — verifies that skill read paths are scoped to one workspace: + * builtin skills are global (visible everywhere), every other skill is only + * visible inside its owning workspace. Without this, a workspace-B user saw + * workspace-A's skills in the marketplace but hit a 403 when binding them. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:skill_ws_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class SkillServiceWorkspaceScopeTest { + + private static final AtomicLong SKILL_ID_SEQ = new AtomicLong(8_135_000L); + + @Autowired + private SkillService skillService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** Insert a skill row directly so {@code createSkill}'s workspace/FS side effects stay out of scope. */ + private long seedSkill(String name, long workspaceId, boolean builtin) { + long id = SKILL_ID_SEQ.getAndIncrement(); + jdbcTemplate.update( + "MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, '1.0.0', TRUE, ?, ?, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, name, builtin ? "builtin" : "dynamic", builtin, workspaceId); + return id; + } + + @Test + @DisplayName("listSkills(workspaceId) returns builtin + own-workspace skills, hides other workspaces") + void listSkillsIsWorkspaceScoped() { + String ws1Name = "ws1-skill-" + SKILL_ID_SEQ.get(); + String ws2Name = "ws2-skill-" + SKILL_ID_SEQ.get(); + String builtinName = "builtin-skill-" + SKILL_ID_SEQ.get(); + seedSkill(ws1Name, 1L, false); + seedSkill(ws2Name, 2L, false); + seedSkill(builtinName, 1L, true); + + Set<String> ws2Names = skillService.listSkills(2L).stream() + .map(SkillEntity::getName) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(ws2Names.contains(ws2Name), "workspace 2 must see its own skill"); + assertTrue(ws2Names.contains(builtinName), "workspace 2 must see the global builtin skill"); + assertFalse(ws2Names.contains(ws1Name), "workspace 2 must not see workspace 1's skill"); + } + + @Test + @DisplayName("pageSkills(workspaceId) excludes other workspaces' skills from the marketplace listing") + void pageSkillsIsWorkspaceScoped() { + String ws1Name = "page-ws1-" + SKILL_ID_SEQ.get(); + String ws2Name = "page-ws2-" + SKILL_ID_SEQ.get(); + seedSkill(ws1Name, 1L, false); + seedSkill(ws2Name, 2L, false); + + IPage<SkillEntity> ws2Page = skillService.pageSkills( + 1, 200, null, null, null, null, null, null, null, Set.of(), 2L, null); + Set<String> names = ws2Page.getRecords().stream() + .map(SkillEntity::getName) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(names.contains(ws2Name), "marketplace page for workspace 2 must list its own skill"); + assertFalse(names.contains(ws1Name), "marketplace page for workspace 2 must not list workspace 1's skill"); + } + + @Test + @DisplayName("countByType(workspaceId) counts builtin globally but other skills per workspace") + void countByTypeIsWorkspaceScoped() { + long before = skillService.countByType(2L).getOrDefault("dynamic", 0L); + seedSkill("count-ws1-" + SKILL_ID_SEQ.get(), 1L, false); + seedSkill("count-ws2-" + SKILL_ID_SEQ.get(), 2L, false); + + long after = skillService.countByType(2L).getOrDefault("dynamic", 0L); + assertTrue(after == before + 1, + "workspace 2's dynamic count should rise by exactly one (its own skill), not two"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java new file mode 100644 index 00000000..0e94e902 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java @@ -0,0 +1,63 @@ +package vip.mate.skill.usage; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.skill.lifecycle.SkillLifecycleService; +import vip.mate.skill.repository.SkillUsageStatMapper; +import vip.mate.skill.runtime.model.ResolvedSkill; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies that recording a skill load bubbles the activity timestamp up to + * {@code mate_skill} via the lifecycle service, so the curator's daily scan + * sees the skill as active. + */ +@ExtendWith(MockitoExtension.class) +class SkillUsageServiceActivityBubbleTest { + + @Mock + private SkillUsageStatMapper mapper; + @Mock + private SkillLifecycleService lifecycleService; + + private SkillUsageService service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SkillUsageStatEntity.class); + } + + @BeforeEach + void setUp() { + service = new SkillUsageService(mapper, lifecycleService); + } + + @Test + void recordLoadedBubblesActivityToLifecycle() { + ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build(); + when(mapper.selectOne(any())).thenReturn(null); + + service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100); + + verify(lifecycleService).bumpActivity(7L); + } + + @Test + void recordLoadedIgnoresNullSkill() { + service.recordLoaded(null, 1L, "conv-1", "SKILL.md", 100); + verify(lifecycleService, never()).bumpActivity(any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java new file mode 100644 index 00000000..6aaffcdb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java @@ -0,0 +1,90 @@ +package vip.mate.system.service; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.system.model.SystemSettingEntity; +import vip.mate.system.repository.SystemSettingMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the typed accessors added for the lifecycle curator: + * getBool / saveBool / getString / saveString. + */ +@ExtendWith(MockitoExtension.class) +class SystemSettingBoolApiTest { + + @Mock + private SystemSettingMapper mapper; + + private SystemSettingService service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SystemSettingEntity.class); + } + + @BeforeEach + void setUp() { + service = new SystemSettingService(mapper); + } + + private SystemSettingEntity row(String value) { + SystemSettingEntity e = new SystemSettingEntity(); + e.setSettingKey("k"); + e.setSettingValue(value); + return e; + } + + @Test + void getBoolReturnsDefaultWhenKeyAbsent() { + when(mapper.selectOne(any())).thenReturn(null); + assertTrue(service.getBool("k", true)); + assertFalse(service.getBool("k", false)); + } + + @Test + void getBoolReadsTheStoredValue() { + when(mapper.selectOne(any())).thenReturn(row("true")); + assertTrue(service.getBool("k", false)); + } + + @Test + void getStringReturnsTheStoredValue() { + when(mapper.selectOne(any())).thenReturn(row("2026-05-19T02:00:00")); + assertEquals("2026-05-19T02:00:00", service.getString("k", null)); + } + + @Test + void saveBoolInsertsWhenKeyAbsent() { + when(mapper.selectOne(any())).thenReturn(null); + service.saveBool("k", true, "desc"); + ArgumentCaptor<SystemSettingEntity> cap = ArgumentCaptor.forClass(SystemSettingEntity.class); + verify(mapper).insert(cap.capture()); + assertEquals("true", cap.getValue().getSettingValue()); + } + + @Test + void saveStringUpdatesWhenKeyPresent() { + when(mapper.selectOne(any())).thenReturn(row("old")); + service.saveString("k", "new", "desc"); + ArgumentCaptor<SystemSettingEntity> cap = ArgumentCaptor.forClass(SystemSettingEntity.class); + verify(mapper).updateById(cap.capture()); + assertEquals("new", cap.getValue().getSettingValue()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/task/AsyncTaskServiceOneShotTest.java b/mateclaw-server/src/test/java/vip/mate/task/AsyncTaskServiceOneShotTest.java new file mode 100644 index 00000000..4bd8f853 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/task/AsyncTaskServiceOneShotTest.java @@ -0,0 +1,352 @@ +package vip.mate.task; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.task.model.AsyncTaskEntity; +import vip.mate.task.repository.AsyncTaskMapper; +import vip.mate.workspace.conversation.event.ConversationDeletedEvent; + +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +/** + * Contract for {@link AsyncTaskService#submitOneShot} — the one-shot Callable + * entry point that lets a caller hand off arbitrary work to {@code pollExecutor} + * and have the standard {@code mate_async_task} lifecycle (running → terminal + * + automatic cancel-on-parent-conversation-deleted + bookkeeping cleanup) take + * over. + * <p> + * The fixture installs an anonymous subclass that overrides {@code createTask}, + * {@code updateStatus} and {@code findEntityByTaskId} with in-memory equivalents. + * Reason: the production methods round-trip through a MyBatis-Plus + * LambdaUpdateWrapper that obscures the {@code (status, progress, resultJson, + * errorMessage)} tuple under MPGENVAL placeholders. Asserting on a + * test-controlled state map is faster, less brittle, and lets the suite focus + * on what {@code submitOneShot} actually does — schedule the worker, register + * bookkeeping, observe cancellation, and clean up. + * + * <h3>Covered paths</h3> + * <ol> + * <li>Success — Callable returns, status lands on succeeded with resultJson.</li> + * <li>Exception — Callable throws, status lands on failed with errorMessage.</li> + * <li>Conversation deletion mid-run — listener writes failed + worker's + * second cancel-check also writes failed; both messages match.</li> + * <li>schedule/put race stress — 200 zero-cost tasks all succeed and drain + * both bookkeeping maps to empty (catches any ghost entry).</li> + * <li>Latch ordering — worker observes itself enrolled in both maps the + * moment Callable.call() begins (the latch invariant).</li> + * </ol> + */ +class AsyncTaskServiceOneShotTest { + + private AsyncTaskMapper mapper; + private ChatStreamTracker tracker; + + /** Test-side per-taskId snapshot — populated by the fixture's overrides + * of createTask + updateStatus instead of going through MyBatis-Plus. */ + private static final class TaskState { + String taskId; + String taskType; + String conversationId; + String status; + Integer progress; + String resultJson; + String errorMessage; + } + + private final ConcurrentMap<String, TaskState> states = new ConcurrentHashMap<>(); + private AsyncTaskService service; + + @BeforeEach + void setUp() { + mapper = mock(AsyncTaskMapper.class); + tracker = mock(ChatStreamTracker.class); + states.clear(); + service = new AsyncTaskService(mapper, tracker) { + @Override + public AsyncTaskEntity createTask(String taskType, String conversationId, Long messageId, + String providerName, String providerTaskId, + String requestJson, String createdBy) { + String taskId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + AsyncTaskEntity e = new AsyncTaskEntity(); + e.setTaskId(taskId); + e.setTaskType(taskType); + e.setStatus("pending"); + e.setConversationId(conversationId); + e.setMessageId(messageId); + e.setProviderName(providerName); + e.setProviderTaskId(providerTaskId); + e.setRequestJson(requestJson); + e.setCreatedBy(createdBy); + TaskState s = new TaskState(); + s.taskId = taskId; + s.taskType = taskType; + s.conversationId = conversationId; + s.status = "pending"; + states.put(taskId, s); + return e; + } + + @Override + public void updateStatus(String taskId, String status, Integer progress, + String resultJson, String errorMessage) { + states.compute(taskId, (k, old) -> { + TaskState s = old != null ? old : new TaskState(); + s.taskId = taskId; + s.status = status; + if (progress != null) s.progress = progress; + if (resultJson != null) s.resultJson = resultJson; + if (errorMessage != null) s.errorMessage = errorMessage; + return s; + }); + } + + @Override + public AsyncTaskEntity findEntityByTaskId(String taskId) { + TaskState s = states.get(taskId); + if (s == null) return null; + AsyncTaskEntity e = new AsyncTaskEntity(); + e.setTaskId(s.taskId); + e.setTaskType(s.taskType); + e.setStatus(s.status); + e.setConversationId(s.conversationId); + return e; + } + }; + } + + @AfterEach + void tearDown() { + service.shutdown(); + } + + @Test + @DisplayName("Success path: status = succeeded, resultJson captures Callable return, maps drain") + void successPath() throws Exception { + AsyncTaskEntity entity = service.submitOneShot( + "agent_delegate", "conv-success", null, "{}", "user-1", + () -> "ok"); + + awaitDone(entity.getTaskId(), 5_000); + + TaskState s = states.get(entity.getTaskId()); + assertThat(s.status).isEqualTo("succeeded"); + assertThat(s.resultJson).isEqualTo("ok"); + assertThat(s.progress).isEqualTo(100); + assertActiveMapsEmpty(); + } + + @Test + @DisplayName("Exception path: status = failed, errorMessage carries Callable's message") + void exceptionPath() throws Exception { + AsyncTaskEntity entity = service.submitOneShot( + "agent_delegate", "conv-fail", null, "{}", "user-1", + () -> { throw new RuntimeException("boom-msg"); }); + + awaitDone(entity.getTaskId(), 5_000); + + TaskState s = states.get(entity.getTaskId()); + assertThat(s.status).isEqualTo("failed"); + assertThat(s.errorMessage).isNotNull().contains("boom-msg"); + assertActiveMapsEmpty(); + } + + @Test + @DisplayName("Conversation deleted while running: terminal status is failed with deletion message") + void cancelPathViaConversationDeleted() throws Exception { + CountDownLatch workerStarted = new CountDownLatch(1); + CountDownLatch workerCanProceed = new CountDownLatch(1); + + String convId = "conv-cancel"; + AsyncTaskEntity entity = service.submitOneShot( + "agent_delegate", convId, null, "{}", "user-1", + () -> { + workerStarted.countDown(); + // Hold the worker inside work.call() until the test fires + // the deletion event. Future.cancel(false) — what the + // listener calls — does NOT interrupt, so this await won't + // unblock until the test's explicit countDown below. + workerCanProceed.await(5, TimeUnit.SECONDS); + return "should-not-be-applied"; + }); + + assertThat(workerStarted.await(5, TimeUnit.SECONDS)).isTrue(); + + // Listener runs synchronously on the test thread: it cancels the + // future, looks up the (still-running) entity, sees taskType + // agent_delegate and writes failed + "conversation deleted". This + // closes the contract gap that bare cancelPolling left open before + // this PR (DB row would otherwise stay running forever). + service.onConversationDeleted(new ConversationDeletedEvent(convId)); + + // Release the worker so it observes isConversationCanceled = true at + // the post-call cancel check and writes the during-execution variant + // (also matches "conversation deleted"). + workerCanProceed.countDown(); + awaitDone(entity.getTaskId(), 5_000); + + TaskState s = states.get(entity.getTaskId()); + assertThat(s.status).isEqualTo("failed"); + assertThat(s.errorMessage).isNotNull().contains("conversation deleted"); + assertActiveMapsEmpty(); + } + + @Test + @DisplayName("Terminal status broadcasts async_task_completed on the parent conversation") + void broadcastsCompletionEvent() throws Exception { + AsyncTaskEntity successEntity = service.submitOneShot( + "agent_delegate", "conv-broadcast-ok", null, "{}", "user-1", + () -> "ok"); + AsyncTaskEntity failEntity = service.submitOneShot( + "agent_delegate", "conv-broadcast-fail", null, "{}", "user-1", + () -> { throw new RuntimeException("boom"); }); + + awaitDone(successEntity.getTaskId(), 5_000); + awaitDone(failEntity.getTaskId(), 5_000); + + // Success path → event with success=true, no errorMessage. + verify(tracker, timeout(2_000)).broadcastObject( + eq("conv-broadcast-ok"), eq("async_task_completed"), any()); + // Failure path → event with success=false, errorMessage carries + // the Callable's exception message. Broadcast routes to the parent + // conversation_id stored on the entity, which IS the parent for + // agent-delegate one-shots per AsyncTaskService.submitOneShot. + verify(tracker, timeout(2_000)).broadcastObject( + eq("conv-broadcast-fail"), eq("async_task_completed"), any()); + } + + @Test + @DisplayName("schedule/put race stress: 200 zero-cost tasks all succeed and bookkeeping drains") + void scheduleAndPutRaceStress() throws Exception { + int iterations = 200; + AsyncTaskEntity[] entities = new AsyncTaskEntity[iterations]; + for (int i = 0; i < iterations; i++) { + entities[i] = service.submitOneShot( + "agent_delegate", "conv-race-" + i, null, "{}", "user-1", + () -> "ok"); + } + for (AsyncTaskEntity e : entities) { + awaitDone(e.getTaskId(), 15_000); + } + // All terminal statuses must be succeeded. + for (AsyncTaskEntity e : entities) { + assertThat(states.get(e.getTaskId()).status) + .as("task %s succeeded", e.getTaskId()) + .isEqualTo("succeeded"); + } + // Belt-and-suspenders: any ghost (future.isDone() but key still in + // map) would keep one or both of these non-empty. + awaitMapsDrained(5_000); + assertActiveMapsEmpty(); + } + + @Test + @DisplayName("Latch ordering: Callable.call() observes its taskId enrolled in both maps") + void enrolledLatchOrdering() throws Exception { + ConcurrentHashMap<String, ?> activePolls = getInternalMap("activePolls"); + ConcurrentHashMap<String, ?> pollTaskToConv = getInternalMap("pollTaskToConv"); + + AtomicBoolean observedActive = new AtomicBoolean(); + AtomicBoolean observedConvLink = new AtomicBoolean(); + AtomicReference<String> observedKey = new AtomicReference<>(); + CountDownLatch probed = new CountDownLatch(1); + + // The Callable acts as a probe: if the latch invariant holds, the + // calling thread has already put this task into both bookkeeping + // maps by the time the worker runs (put happens before countDown, + // the worker awaits countDown). The probe cannot read its own taskId + // — submitOneShot only hands it back to the caller *after* releasing + // the latch — so it instead checks that each map holds exactly the + // one entry this single-task test created, and records the enrolled + // key for an after-the-fact identity check. If the latch were + // removed, the worker could race ahead and observe empty maps. + Callable<String> work = () -> { + observedActive.set(activePolls.size() == 1); + observedConvLink.set(pollTaskToConv.size() == 1); + observedKey.set(activePolls.keySet().stream().findFirst().orElse(null)); + probed.countDown(); + return "ok"; + }; + + AsyncTaskEntity entity = service.submitOneShot( + "agent_delegate", "conv-latch", null, "{}", "user-1", work); + + assertThat(probed.await(5, TimeUnit.SECONDS)) + .as("probe must run within timeout") + .isTrue(); + awaitDone(entity.getTaskId(), 5_000); + + assertThat(observedActive) + .as("activePolls must hold the task when work.call() begins") + .isTrue(); + assertThat(observedConvLink) + .as("pollTaskToConv must hold the task when work.call() begins") + .isTrue(); + assertThat(observedKey) + .as("the key enrolled in activePolls must be this task's id") + .hasValue(entity.getTaskId()); + assertActiveMapsEmpty(); + } + + // ---------- helpers ---------- + + /** Waits until the task has reached a terminal status AND the worker's + * finally cleanup has cleared this taskId from both bookkeeping maps. */ + private void awaitDone(String taskId, long timeoutMs) throws InterruptedException { + awaitUntil(() -> { + TaskState s = states.get(taskId); + if (s == null) return false; + if (!"succeeded".equals(s.status) && !"failed".equals(s.status)) return false; + return !getInternalMap("activePolls").containsKey(taskId) + && !getInternalMap("pollTaskToConv").containsKey(taskId); + }, timeoutMs, "task " + taskId + " never reached cleaned-up terminal state"); + } + + private void awaitMapsDrained(long timeoutMs) throws InterruptedException { + awaitUntil(() -> getInternalMap("activePolls").isEmpty() + && getInternalMap("pollTaskToConv").isEmpty(), + timeoutMs, "activePolls / pollTaskToConv never drained"); + } + + private void awaitUntil(BooleanSupplier cond, long timeoutMs, String message) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (cond.getAsBoolean()) return; + Thread.sleep(20); + } + throw new AssertionError(message + " (waited " + timeoutMs + "ms)"); + } + + private void assertActiveMapsEmpty() { + assertThat(getInternalMap("activePolls")) + .as("activePolls should be empty after all workers finish") + .isEmpty(); + assertThat(getInternalMap("pollTaskToConv")) + .as("pollTaskToConv should be empty after all workers finish") + .isEmpty(); + } + + @SuppressWarnings("unchecked") + private ConcurrentHashMap<String, ?> getInternalMap(String name) { + return (ConcurrentHashMap<String, ?>) ReflectionTestUtils.getField(service, name); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java index 67e24718..355e71b6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -54,9 +54,10 @@ class DelegateAgentToolDenyListTest { ObjectMapper objectMapper = new ObjectMapper(); registry = new SubagentRegistry(); AuditEventService auditEventService = mock(AuditEventService.class); + vip.mate.task.AsyncTaskService asyncTaskService = mock(vip.mate.task.AsyncTaskService.class); tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService, - objectMapper, registry, auditEventService); + objectMapper, registry, auditEventService, asyncTaskService); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java new file mode 100644 index 00000000..acdf2eef --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -0,0 +1,203 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.task.AsyncTaskService; +import vip.mate.task.model.AsyncTaskEntity; +import vip.mate.workspace.conversation.ConversationService; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * Attribution gate for {@code taskOutput}. The persistent {@code mate_async_task} + * row carries both {@code created_by} (the requester) and a JSON blob whose + * {@code parentConversationId} field anchors the task to one conversation — + * any mismatch against the caller's current {@link ChatOrigin} must short-circuit + * to {@code Forbidden} before the row's body / result can leak. + * <p> + * Three scenarios make up the threat model: + * <ul> + * <li>Cross-user — Alice's taskId is read by Bob in the same conversation.</li> + * <li>Cross-conversation — Alice reads her own taskId from a different + * conversation than the one that spawned it.</li> + * <li>Already-succeeded — same as above, but the row is terminal with a + * non-empty {@code result_json}; the failure mode here would leak the + * result body itself.</li> + * </ul> + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DelegateAsyncTaskOutputAttributionTest { + + @Mock private AgentService agentService; + @Mock private AgentMapper agentMapper; + @Mock private ChatStreamTracker streamTracker; + @Mock private ConversationService conversationService; + @Mock private SubagentRegistry subagentRegistry; + @Mock private AuditEventService auditEventService; + @Mock private AsyncTaskService asyncTaskService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private DelegateAgentTool tool; + + @BeforeEach + void setUp() { + tool = new DelegateAgentTool( + agentService, agentMapper, streamTracker, conversationService, + objectMapper, subagentRegistry, auditEventService, asyncTaskService); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + } + + @Test + @DisplayName("(a) Cross-user: task created by other-user → Forbidden, no result leaked") + void crossUserTaskIdForbidden() throws Exception { + // Task created by `other-user` in conv-shared. + AsyncTaskEntity entity = makeAsyncTask("tid-cross-user", "running", + "conv-shared", "other-user", null); + when(asyncTaskService.findEntityByTaskId("tid-cross-user")).thenReturn(entity); + + // Caller is user-1, sitting in conv-shared (so parentConv matches — + // only the user attribution should reject this). + ToolExecutionContext.set("conv-shared", "user-1"); + String result = tool.taskOutput("tid-cross-user", false, null, + makeCtx("user-1", "conv-shared")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("current user"); + } + + @Test + @DisplayName("(b) Same-user different conversation: → Forbidden on conversation gate") + void sameUserDifferentConversationForbidden() throws Exception { + AsyncTaskEntity entity = makeAsyncTask("tid-cross-conv", "running", + "conv-A", "user-1", null); + when(asyncTaskService.findEntityByTaskId("tid-cross-conv")).thenReturn(entity); + + // user-1 is asking from conv-B; the task belongs to conv-A. + ToolExecutionContext.set("conv-B", "user-1"); + String result = tool.taskOutput("tid-cross-conv", false, null, + makeCtx("user-1", "conv-B")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("current conversation"); + } + + @Test + @DisplayName("(c) Already-succeeded task accessed from wrong parent → Forbidden, no result body leaked") + void succeededTaskWrongParentForbidden() throws Exception { + // Succeeded row carries a non-empty result_json — exactly the body we + // must NOT echo back to a stranger guessing taskIds. + AsyncTaskEntity entity = makeAsyncTask("tid-done", "succeeded", + "conv-A", "user-1", "SECRET-ANSWER-PAYLOAD"); + when(asyncTaskService.findEntityByTaskId("tid-done")).thenReturn(entity); + + ToolExecutionContext.set("conv-B", "user-1"); + String result = tool.taskOutput("tid-done", false, null, + makeCtx("user-1", "conv-B")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("current conversation"); + // Critical: the result body must not appear anywhere in the response. + assertThat(result).doesNotContain("SECRET-ANSWER-PAYLOAD"); + } + + @Test + @DisplayName("Legitimate caller (matching user + conversation) is allowed through") + void legitimateCallerAllowed() throws Exception { + AsyncTaskEntity entity = makeAsyncTask("tid-ok", "succeeded", + "conv-mine", "user-1", "valid result"); + when(asyncTaskService.findEntityByTaskId("tid-ok")).thenReturn(entity); + + ToolExecutionContext.set("conv-mine", "user-1"); + String result = tool.taskOutput("tid-ok", false, null, + makeCtx("user-1", "conv-mine")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("status", "succeeded") + .containsEntry("result", "valid result"); + } + + @Test + @DisplayName("Empty parentConversationId in request_json → Forbidden (defensive)") + void emptyParentInPayloadForbidden() throws Exception { + AsyncTaskEntity entity = new AsyncTaskEntity(); + entity.setTaskId("tid-empty-parent"); + entity.setTaskType("agent_delegate"); + entity.setStatus("running"); + entity.setCreatedBy("user-1"); + // request_json with empty parentConversationId — should never happen + // in practice but the gate must still close. + entity.setRequestJson("{\"parentConversationId\":\"\",\"childConversationId\":\"child-x\"}"); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateTime(LocalDateTime.now()); + when(asyncTaskService.findEntityByTaskId("tid-empty-parent")).thenReturn(entity); + + ToolExecutionContext.set("conv-X", "user-1"); + String result = tool.taskOutput("tid-empty-parent", false, null, + makeCtx("user-1", "conv-X")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("current conversation"); + } + + // ---------- helpers ---------- + + private AsyncTaskEntity makeAsyncTask(String taskId, String status, String parentConv, + String createdBy, String resultJson) throws Exception { + AsyncTaskEntity e = new AsyncTaskEntity(); + e.setTaskId(taskId); + e.setTaskType("agent_delegate"); + e.setStatus(status); + e.setCreatedBy(createdBy); + e.setResultJson(resultJson); + e.setProgress("succeeded".equals(status) ? 100 : 50); + e.setCreateTime(LocalDateTime.now().minusSeconds(5)); + e.setUpdateTime(LocalDateTime.now()); + Map<String, Object> req = new LinkedHashMap<>(); + req.put("parentConversationId", parentConv); + req.put("childConversationId", "child-x"); + req.put("childAgentId", 10L); + req.put("task", "task"); + req.put("label", ""); + e.setRequestJson(objectMapper.writeValueAsString(req)); + return e; + } + + private ToolContext makeCtx(String requester, String conversationId) { + ChatOrigin origin = new ChatOrigin( + 1L, conversationId, requester, null, null, null, null, false, null, null, null); + Map<String, Object> map = new HashMap<>(); + map.put(ChatOrigin.CTX_KEY, origin); + return new ToolContext(map); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java new file mode 100644 index 00000000..32956b8f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -0,0 +1,396 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.task.AsyncTaskService; +import vip.mate.task.model.AsyncTaskEntity; +import vip.mate.workspace.conversation.ConversationService; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Behavioral contract for the two async-delegation tools — + * {@code delegateAsync} (spawn returns task_id immediately) and + * {@code taskOutput} (status / result retrieval). + * <p> + * AsyncTaskService is mocked, so the Callable submitted by delegateAsync is + * never invoked here: the inner execution path is covered by + * {@code AsyncTaskServiceOneShotTest}. What this suite locks down is + * the synchronous shell — argument validation, depth / spawn-pause guards, + * cap-overflow degradation, JSON shape, and the SSE spawn-event side effect. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DelegateAsyncToolTest { + + @Mock private AgentService agentService; + @Mock private AgentMapper agentMapper; + @Mock private ChatStreamTracker streamTracker; + @Mock private ConversationService conversationService; + @Mock private SubagentRegistry subagentRegistry; + @Mock private AuditEventService auditEventService; + @Mock private AsyncTaskService asyncTaskService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private DelegateAgentTool tool; + + @BeforeEach + void setUp() { + tool = new DelegateAgentTool( + agentService, agentMapper, streamTracker, conversationService, + objectMapper, subagentRegistry, auditEventService, asyncTaskService); + // resolveParentConversationId reads from ToolExecutionContext first; + // seed it so the async delegation has a parent to attach the task to. + ToolExecutionContext.set("parent-conv-1", "user-1"); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + // ---------- delegateAsync ---------- + + @Test + @DisplayName("delegateAsync returns task_id, child_conversation_id, status=running synchronously") + @SuppressWarnings("unchecked") + void delegateAsyncReturnsTaskIdImmediately() throws Exception { + AgentEntity target = makeAgent(10L, "Researcher"); + when(agentMapper.selectOne(any())).thenReturn(target); + when(subagentRegistry.isSpawnPaused("parent-conv-1")).thenReturn(false); + when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any(), + any(), anyInt(), anyString())) + .thenReturn("sa-1"); + + AsyncTaskEntity entity = new AsyncTaskEntity(); + entity.setTaskId("tid-123"); + when(asyncTaskService.submitOneShot( + eq("agent_delegate"), eq("parent-conv-1"), any(), anyString(), eq("user-1"), any())) + .thenReturn(entity); + when(streamTracker.isRunning("parent-conv-1")).thenReturn(true); + + String result = tool.delegateAsync("Researcher", "Go research things", "label-x", makeCtx("user-1", "parent-conv-1")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("task_id", "tid-123") + .containsEntry("status", "running") + .containsEntry("agent_name", "Researcher") + .containsEntry("label", "label-x"); + assertThat((String) parsed.get("child_conversation_id")).startsWith("child-"); + assertThat((String) parsed.get("hint")).contains("task_output"); + + // The spawn SSE event reaches the parent's stream. + verify(streamTracker).broadcastObject(eq("parent-conv-1"), + eq("delegation_async_spawned"), any(Map.class)); + } + + @Test + @DisplayName("delegateAsync passes a request_json payload carrying parent + child + agentId + label") + void delegateAsyncRequestJsonShape() throws Exception { + AgentEntity target = makeAgent(10L, "Researcher"); + when(agentMapper.selectOne(any())).thenReturn(target); + when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any(), + any(), anyInt(), anyString())) + .thenReturn("sa-2"); + AsyncTaskEntity entity = new AsyncTaskEntity(); + entity.setTaskId("tid-200"); + when(asyncTaskService.submitOneShot(anyString(), anyString(), any(), anyString(), anyString(), any())) + .thenReturn(entity); + + tool.delegateAsync("Researcher", "task body", "myLabel", makeCtx("user-1", "parent-conv-1")); + + org.mockito.ArgumentCaptor<String> jsonCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(asyncTaskService).submitOneShot( + eq("agent_delegate"), eq("parent-conv-1"), any(), + jsonCaptor.capture(), eq("user-1"), any()); + Map<String, Object> payload = objectMapper.readValue(jsonCaptor.getValue(), new TypeReference<>() {}); + assertThat(payload).containsEntry("parentConversationId", "parent-conv-1") + .containsEntry("label", "myLabel") + .containsEntry("task", "task body") + // Durable async identity — task_output's route-B authorization reads + // these persisted fields (the registry is process-local), so lock them. + .containsEntry("rootConversationId", "parent-conv-1") + .containsEntry("subagentId", "sa-2") + .containsEntry("depth", 1); + // A top-level spawn has no parent subagent, so the key is omitted entirely. + assertThat(payload).doesNotContainKey("parentSubagentId"); + assertThat(payload.get("childConversationId")).asString().startsWith("child-"); + assertThat(((Number) payload.get("childAgentId")).longValue()).isEqualTo(10L); + } + + @Test + @DisplayName("Concurrency-cap (IllegalStateException) → error JSON + registry unregistered") + void delegateAsyncConcurrencyCap() throws Exception { + AgentEntity target = makeAgent(10L, "Researcher"); + when(agentMapper.selectOne(any())).thenReturn(target); + when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any(), + any(), anyInt(), anyString())) + .thenReturn("sa-cap"); + when(asyncTaskService.submitOneShot(anyString(), anyString(), any(), anyString(), anyString(), any())) + .thenThrow(new IllegalStateException("已达到最大并行任务数(3),请等待现有任务完成")); + + String result = tool.delegateAsync("Researcher", "task", null, makeCtx("user-1", "parent-conv-1")); + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("最大并行任务数"); + + // Registry entry MUST be released so it doesn't dangle through the cap. + verify(subagentRegistry).unregister("sa-cap"); + // No spawn event broadcast for a failed spawn. + verify(streamTracker, never()).broadcastObject(anyString(), + eq("delegation_async_spawned"), any()); + } + + @Test + @DisplayName("Missing agentName / task → error JSON without touching downstream services") + void delegateAsyncMissingArgs() throws Exception { + String r1 = tool.delegateAsync("", "task", null, makeCtx("user-1", "parent-conv-1")); + String r2 = tool.delegateAsync("X", " ", null, makeCtx("user-1", "parent-conv-1")); + for (String r : new String[]{r1, r2}) { + Map<String, Object> parsed = objectMapper.readValue(r, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + } + verify(asyncTaskService, never()).submitOneShot(any(), any(), any(), any(), any(), any()); + verify(subagentRegistry, never()).register(any(), any(), any(), any(), any(), + any(), anyInt(), any()); + } + + @Test + @DisplayName("Agent not found → error JSON") + void delegateAsyncAgentNotFound() throws Exception { + when(agentMapper.selectOne(any())).thenReturn(null); + String result = tool.delegateAsync("Ghost", "task", null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("Ghost"); + verify(asyncTaskService, never()).submitOneShot(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("Spawn-pause active → error JSON, no task submitted, no registry entry") + void delegateAsyncSpawnPause() throws Exception { + AgentEntity target = makeAgent(10L, "Researcher"); + when(agentMapper.selectOne(any())).thenReturn(target); + when(subagentRegistry.isSpawnPaused("parent-conv-1")).thenReturn(true); + + String result = tool.delegateAsync("Researcher", "task", null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("paused"); + verify(asyncTaskService, never()).submitOneShot(any(), any(), any(), any(), any(), any()); + verify(subagentRegistry, never()).register(any(), any(), any(), any(), any(), + any(), anyInt(), any()); + } + + @Test + @DisplayName("Depth limit reached → error JSON") + void delegateAsyncDepthLimit() throws Exception { + // Push depth to MAX (3) so currentDepth >= MAX_DELEGATION_DEPTH. + for (int i = 0; i < 3; i++) { + DelegationContext.enter("parent-conv-1", java.util.Set.of()); + } + String result = tool.delegateAsync("Researcher", "task", null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("depth"); + verify(asyncTaskService, never()).submitOneShot(any(), any(), any(), any(), any(), any()); + } + + // ---------- taskOutput ---------- + + @Test + @DisplayName("taskOutput on running task with block=false returns status=running") + void taskOutputRunning() throws Exception { + AsyncTaskEntity entity = makeAsyncTask("tid-run", "running", "parent-conv-1", "user-1", null); + when(asyncTaskService.findEntityByTaskId("tid-run")).thenReturn(entity); + + String result = tool.taskOutput("tid-run", false, null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("status", "running") + .containsEntry("task_id", "tid-run"); + assertThat((String) parsed.get("hint")).contains("Try again"); + } + + @Test + @DisplayName("taskOutput on succeeded task returns result + duration_ms") + void taskOutputSucceeded() throws Exception { + AsyncTaskEntity entity = makeAsyncTask("tid-ok", "succeeded", "parent-conv-1", "user-1", "child final answer"); + when(asyncTaskService.findEntityByTaskId("tid-ok")).thenReturn(entity); + + String result = tool.taskOutput("tid-ok", null, null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("status", "succeeded") + .containsEntry("result", "child final answer"); + assertThat(((Number) parsed.get("duration_ms")).longValue()).isGreaterThanOrEqualTo(0L); + } + + @Test + @DisplayName("taskOutput authorized via root conversation when caller is the root of a child-spawned task") + void taskOutputAllowedViaRootConversation() throws Exception { + // A (grand)child stamped its OWN conversation as parentConversationId, but + // rootConversationId points back at the user-facing conversation the root + // agent runs in. The root agent (caller) must be able to poll that task even + // though it is not the immediate spawn conversation. + AsyncTaskEntity entity = new AsyncTaskEntity(); + entity.setTaskId("tid-root"); + entity.setTaskType("agent_delegate"); + entity.setStatus("succeeded"); + entity.setCreatedBy("user-1"); + entity.setResultJson("deep result"); + entity.setProgress(100); + entity.setCreateTime(LocalDateTime.now().minusSeconds(5)); + entity.setUpdateTime(LocalDateTime.now()); + Map<String, Object> req = new LinkedHashMap<>(); + req.put("parentConversationId", "child-conv-2"); // NOT the caller's conversation + req.put("rootConversationId", "parent-conv-1"); // caller IS the root + req.put("childConversationId", "child-conv-3"); + req.put("childAgentId", 10L); + req.put("subagentId", "sa-deep"); + req.put("depth", 2); + req.put("task", "deep task"); + req.put("label", ""); + entity.setRequestJson(objectMapper.writeValueAsString(req)); + when(asyncTaskService.findEntityByTaskId("tid-root")).thenReturn(entity); + + // Caller runs in parent-conv-1: != taskParentConv but == taskRootConv → allowed. + String result = tool.taskOutput("tid-root", false, null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).doesNotContainKey("error"); + assertThat(parsed).containsEntry("status", "succeeded") + .containsEntry("result", "deep result"); + } + + @Test + @DisplayName("taskOutput on failed task returns error message") + void taskOutputFailed() throws Exception { + AsyncTaskEntity entity = makeAsyncTask("tid-fail", "failed", "parent-conv-1", "user-1", null); + entity.setErrorMessage("agent boom"); + when(asyncTaskService.findEntityByTaskId("tid-fail")).thenReturn(entity); + + String result = tool.taskOutput("tid-fail", false, null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("status", "failed") + .containsEntry("error", "agent boom"); + } + + @Test + @DisplayName("Unknown taskId → error JSON without touching parent SSE") + void taskOutputNotFound() throws Exception { + when(asyncTaskService.findEntityByTaskId("tid-missing")).thenReturn(null); + String result = tool.taskOutput("tid-missing", false, null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("Task not found"); + verify(streamTracker, never()).broadcastObject(any(), + eq("delegation_async_polled"), any()); + } + + @Test + @DisplayName("Non-agent_delegate taskType (e.g. video_generation) → error JSON") + void taskOutputWrongTaskType() throws Exception { + AsyncTaskEntity entity = new AsyncTaskEntity(); + entity.setTaskId("tid-vid"); + entity.setTaskType("video_generation"); + entity.setStatus("running"); + when(asyncTaskService.findEntityByTaskId("tid-vid")).thenReturn(entity); + + String result = tool.taskOutput("tid-vid", false, null, makeCtx("user-1", "parent-conv-1")); + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("error", true); + assertThat((String) parsed.get("message")).contains("not a delegate task"); + } + + @Test + @DisplayName("block=true on a task that stays running for the full timeout returns status=running") + void taskOutputBlockTimeout() throws Exception { + AsyncTaskEntity entity = makeAsyncTask("tid-block", "running", "parent-conv-1", "user-1", null); + when(asyncTaskService.findEntityByTaskId("tid-block")).thenReturn(entity); + + long start = System.currentTimeMillis(); + // 1s budget → poll loop runs ~2 iterations of 500ms before deadline. + String result = tool.taskOutput("tid-block", true, 1, makeCtx("user-1", "parent-conv-1")); + long elapsed = System.currentTimeMillis() - start; + + Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {}); + assertThat(parsed).containsEntry("status", "running"); + // Real-time guard: ≥ ~900 ms (loop ran) but well under the 120 s cap. + assertThat(elapsed).isBetween(900L, 5_000L); + // Initial read + at least one poll iteration. + verify(asyncTaskService, atLeast(2)).findEntityByTaskId("tid-block"); + } + + // ---------- helpers ---------- + + private static AgentEntity makeAgent(Long id, String name) { + AgentEntity a = new AgentEntity(); + a.setId(id); + a.setName(name); + a.setEnabled(true); + a.setWorkspaceId(1L); + return a; + } + + private AsyncTaskEntity makeAsyncTask(String taskId, String status, String parentConv, + String createdBy, String resultJson) throws Exception { + AsyncTaskEntity e = new AsyncTaskEntity(); + e.setTaskId(taskId); + e.setTaskType("agent_delegate"); + e.setStatus(status); + e.setCreatedBy(createdBy); + e.setResultJson(resultJson); + e.setProgress("running".equals(status) ? 50 : ("succeeded".equals(status) ? 100 : 0)); + e.setCreateTime(LocalDateTime.now().minusSeconds(5)); + e.setUpdateTime(LocalDateTime.now()); + Map<String, Object> req = new LinkedHashMap<>(); + req.put("parentConversationId", parentConv); + req.put("childConversationId", "child-x"); + req.put("childAgentId", 10L); + req.put("task", "task"); + req.put("label", ""); + e.setRequestJson(objectMapper.writeValueAsString(req)); + return e; + } + + private ToolContext makeCtx(String requester, String conversationId) { + ChatOrigin origin = new ChatOrigin( + 1L, conversationId, requester, null, null, null, null, false, null, null, null); + Map<String, Object> map = new HashMap<>(); + map.put(ChatOrigin.CTX_KEY, origin); + return new ToolContext(map); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java index bdf04eb2..f36cabe2 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java @@ -24,6 +24,7 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.workspace.conversation.ConversationService; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; @@ -258,4 +259,91 @@ class DelegateEventSequenceTest { assertEquals(2, progressCount, "Should have exactly 2 delegation_progress events (tool_call_started + phase), got: " + events); } + + // ===== Nested delegation: grandchild events route to root with tree identity ===== + + @Test + @DisplayName("A child delegating a grandchild broadcasts to root with parentSubagentId + depth=2") + @SuppressWarnings("unchecked") + void nestedDelegationRoutesGrandchildToRootWithIdentity() { + AgentEntity child = makeAgent(100L, "Child"); + AgentEntity grandchild = makeAgent(200L, "Grandchild"); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(child) // root delegates Child + .thenReturn(grandchild); // Child delegates Grandchild + + String rootConv = "root-conv"; + ToolExecutionContext.set(rootConv, "admin"); + when(streamTracker.isRunning(rootConv)).thenReturn(true); + when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any())) + .thenReturn(() -> {}); + + // Capture each created child conversation + its immediate parent so we can + // assert the grandchild's immediate parent is the Child's conversation, + // not the root — the createChildConversation(childConvId, ..., parent) call. + List<String> createdConvs = new java.util.ArrayList<>(); + List<String> createdParents = new java.util.ArrayList<>(); + doAnswer(inv -> { + createdConvs.add(inv.getArgument(0)); + createdParents.add(inv.getArgument(4)); + return null; + }).when(conversationService).createChildConversation( + anyString(), anyLong(), anyString(), anyLong(), anyString()); + + // When the Child runs, the real ToolExecutionExecutor would switch the + // ToolExecutionContext to the Child's own conversation. Reproduce that so + // the grandchild's immediate parent resolves to childConv, while its + // events must still target rootConv (carried via DelegationContext). + when(agentService.chat(eq(100L), anyString(), anyString(), any())) + .thenAnswer(inv -> { + String childConv = inv.getArgument(2); + ToolExecutionContext.set(childConv, "admin"); + try { + return delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null); + } finally { + ToolExecutionContext.set(rootConv, "admin"); + } + }); + when(agentService.chat(eq(200L), anyString(), anyString(), any())) + .thenReturn("grandchild done"); + + delegateAgentTool.delegateToAgent("Child", "ctask", null, null); + + ArgumentCaptor<String> convCap = ArgumentCaptor.forClass(String.class); + ArgumentCaptor<String> evCap = ArgumentCaptor.forClass(String.class); + ArgumentCaptor<Object> payloadCap = ArgumentCaptor.forClass(Object.class); + verify(streamTracker, atLeast(4)).broadcastObject(convCap.capture(), evCap.capture(), payloadCap.capture()); + + Map<String, Object> childStart = null; + Map<String, Object> grandStart = null; + for (int i = 0; i < evCap.getAllValues().size(); i++) { + if (!"delegation_start".equals(evCap.getAllValues().get(i))) continue; + Map<String, Object> p = (Map<String, Object>) payloadCap.getAllValues().get(i); + // Every delegation_start — at any depth — targets the root conversation. + assertEquals(rootConv, convCap.getAllValues().get(i), + "delegation_start must target the root conversation"); + String name = String.valueOf(p.get("childAgentName")); + if ("Child".equals(name)) childStart = p; + else if ("Grandchild".equals(name)) grandStart = p; + } + assertNotNull(childStart, "child delegation_start present"); + assertNotNull(grandStart, "grandchild delegation_start present"); + + // depth-1 child: depth=1, no parentSubagentId. + assertEquals(1, ((Number) childStart.get("depth")).intValue()); + assertNull(childStart.get("parentSubagentId"), "depth-1 child carries no parentSubagentId"); + + // depth-2 grandchild: depth=2, parented to the child's subagentId. + assertEquals(2, ((Number) grandStart.get("depth")).intValue()); + assertNotNull(grandStart.get("parentSubagentId"), "grandchild must carry parentSubagentId"); + assertEquals(childStart.get("subagentId"), grandStart.get("parentSubagentId"), + "grandchild's parentSubagentId must equal the child's subagentId"); + + // Two child conversations were created: [0] = Child (parent=root), + // [1] = Grandchild (parent must be the Child's conversation, not root). + assertEquals(2, createdConvs.size(), "Child + Grandchild conversations created"); + assertEquals(rootConv, createdParents.get(0), "Child's immediate parent is the root conversation"); + assertEquals(createdConvs.get(0), createdParents.get(1), + "Grandchild's immediate parent must be the Child's conversation"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java index 0331a726..99b95789 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java @@ -5,6 +5,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import static org.junit.jupiter.api.Assertions.*; @@ -149,4 +152,38 @@ class DelegationContextTest { assertEquals(1, DelegationContext.currentDepth()); assertEquals("main-thread-conv", DelegationContext.parentConversationId()); } + + // ===== Explicit depth (async/parallel recursion-cap bypass guard) ===== + + @Test + @DisplayName("Explicit-depth enter reports the depth verbatim, not stack size") + void explicitDepthReportedVerbatim() { + DelegationContext.enter("conv", Set.of(), "root", "sub", 3); + assertEquals(3, DelegationContext.currentDepth()); + DelegationContext.exit(); + assertEquals(0, DelegationContext.currentDepth()); + } + + @Test + @DisplayName("Explicit childDepth survives the executor-thread hop (async/parallel bypass guard)") + void explicitDepthSurvivesExecutorThreadHop() throws Exception { + // Async/parallel children run on a fresh executor thread with an EMPTY + // stack. Before the fix, currentDepth() used stack size and reset to 1 + // here, letting a child exceed MAX_DELEGATION_DEPTH at every hop. + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future<Integer> f = executor.submit(() -> { + assertEquals(0, DelegationContext.currentDepth()); // fresh thread, empty stack + // Enter with the real tree depth computed on the dispatching thread. + DelegationContext.enter("childConv", Set.of(), "root", "sub", 3); + int observed = DelegationContext.currentDepth(); + DelegationContext.exit(); + return observed; + }); + assertEquals(3, f.get(), + "child on a fresh thread must observe the explicit childDepth, not the stack size"); + } finally { + executor.shutdownNow(); + } + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/EnableExtensionToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/EnableExtensionToolTest.java new file mode 100644 index 00000000..a4f2a5e4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/EnableExtensionToolTest.java @@ -0,0 +1,80 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.disclosure.DisclosureTier; +import vip.mate.tool.disclosure.ToolDisclosureService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EnableExtensionToolTest { + + static class Tools { + @Tool(description = "text to image") + public String image_generate() { return ""; } + + @Tool(description = "a plain core tool") + public String my_core_tool() { return ""; } + } + + private static AgentToolSet toolSet() { + return AgentToolSet.fromCallbacks(List.of(new Tools()), List.of(ToolCallbacks.from(new Tools()))); + } + + @Test + @DisplayName("blank toolName returns a required-arg error") + void blankRejected() { + EnableExtensionTool tool = new EnableExtensionTool(mock(ToolRegistry.class), + mock(ToolDisclosureService.class), mock(AgentBindingService.class)); + String out = tool.enableTool(" ", null); + assertTrue(out.startsWith("Error:")); + assertTrue(out.contains("required")); + } + + @Test + @DisplayName("tool not in the agent's set returns an availability error") + void unknownReturnsNotAvailable() { + ToolRegistry registry = mock(ToolRegistry.class); + when(registry.getEnabledToolSet()).thenReturn(toolSet()); + EnableExtensionTool tool = new EnableExtensionTool(registry, + mock(ToolDisclosureService.class), mock(AgentBindingService.class)); + // ctx=null → no agentId → agent set falls back to the full set; "nope" still absent + String out = tool.enableTool("nope", null); + assertTrue(out.startsWith("Error:")); + assertTrue(out.contains("not available")); + } + + @Test + @DisplayName("core-tier tool reports it is already callable") + void coreToolAlreadyCallable() { + ToolRegistry registry = mock(ToolRegistry.class); + when(registry.getEnabledToolSet()).thenReturn(toolSet()); + ToolDisclosureService disclosure = mock(ToolDisclosureService.class); + when(disclosure.resolveTier(any())).thenReturn(DisclosureTier.CORE); + EnableExtensionTool tool = new EnableExtensionTool(registry, disclosure, mock(AgentBindingService.class)); + String out = tool.enableTool("my_core_tool", null); + assertTrue(out.contains("already directly callable")); + } + + @Test + @DisplayName("extension-tier tool is activated") + void extensionToolActivated() { + ToolRegistry registry = mock(ToolRegistry.class); + when(registry.getEnabledToolSet()).thenReturn(toolSet()); + ToolDisclosureService disclosure = mock(ToolDisclosureService.class); + when(disclosure.resolveTier(any())).thenReturn(DisclosureTier.EXTENSION); + EnableExtensionTool tool = new EnableExtensionTool(registry, disclosure, mock(AgentBindingService.class)); + String out = tool.enableTool("image_generate", null); + assertTrue(out.contains("now active")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java new file mode 100644 index 00000000..800ab397 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -0,0 +1,180 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.service.GoalService; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the four @Tool methods on {@link GoalManagementTool}, especially + * the disable-flag short-circuit + ChatOrigin requirement gates. + */ +@ExtendWith(MockitoExtension.class) +class GoalManagementToolTest { + + @Mock private GoalService goalService; + @Mock private vip.mate.channel.web.ChatStreamTracker streamTracker; + + private GoalProperties properties; + private GoalManagementTool tool; + + @BeforeEach + void setUp() { + properties = new GoalProperties(); + properties.setEnabled(true); + tool = new GoalManagementTool(goalService, properties, new ObjectMapper(), streamTracker); + } + + private ToolContext ctxWith(String convId, Long agentId, String requester) { + ChatOrigin origin = ChatOrigin.web(convId, requester, 1L, "/tmp") + .withAgent(agentId); + return origin.toToolContext(); + } + + private GoalEntity goal(GoalStatus status) { + GoalEntity g = new GoalEntity(); + g.setId(123L); + g.setConversationId("conv-1"); + g.setAgentId(10L); + g.setWorkspaceId(1L); + g.setTitle("ship the blog"); + g.setStatus(status); + g.setTurnBudget(20); + g.setTurnsUsed(3); + g.setLlmCallBudget(200); + g.setAgentLlmCallsUsed(12); + g.setEvalLlmCallsUsed(2); + g.setAutoFollowupEnabled(false); + return g; + } + + // ==================== setGoal ==================== + + @Test + void setGoal_disabledFlag_returnsError() { + properties.setEnabled(false); + String result = tool.setGoal("title", null, null, null, null, + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("disabled")); + verify(goalService, never()).create(any(), anyString()); + } + + @Test + void setGoal_blankTitle_returnsError() { + String result = tool.setGoal(" ", null, null, null, null, + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("title is required")); + } + + @Test + void setGoal_happyPath_returnsGoalId() { + when(goalService.create(any(GoalCreateRequest.class), eq("alice"))) + .thenReturn(goal(GoalStatus.ACTIVE)); + String result = tool.setGoal("ship the blog", + "deploy to fly.io", + "tests pass + deployed", + 15, true, + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"goalId\":\"123\"")); + assertTrue(result.contains("\"status\":\"active\"")); + } + + @Test + void setGoal_missingConversationContext_returnsError() { + String result = tool.setGoal("title", null, null, null, null, null); + assertTrue(result.contains("requires a bound conversation")); + } + + // ==================== addGoalCriterion ==================== + + @Test + void addCriterion_noActiveGoal_returnsError() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + String result = tool.addGoalCriterion("test on Safari too", + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("No active goal")); + verify(goalService, never()).appendCriterion(any(), anyString(), anyString()); + } + + @Test + void addCriterion_blankInput_returnsError() { + String result = tool.addGoalCriterion(" ", + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("must not be empty")); + } + + @Test + void addCriterion_happyPath_delegatesToService() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE)); + when(goalService.appendCriterion(eq(123L), eq("test on Safari"), eq("alice"))) + .thenReturn(goal(GoalStatus.ACTIVE)); + String result = tool.addGoalCriterion("test on Safari", + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"goalId\":\"123\"")); + } + + // ==================== completeGoal ==================== + + @Test + void completeGoal_noActiveGoal_returnsError() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("No active goal")); + verify(goalService, never()).markCompleted(any(), any(GoalEvaluationResult.class)); + } + + @Test + void completeGoal_happyPath_callsMarkCompleted() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE)); + GoalEntity completed = goal(GoalStatus.COMPLETED); + when(goalService.markCompleted(eq(123L), any(GoalEvaluationResult.class))) + .thenReturn(completed); + String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"status\":\"completed\"")); + } + + // ==================== getGoalStatus ==================== + + @Test + void getGoalStatus_noActive_returnsActiveFalse() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"active\":false")); + } + + @Test + void getGoalStatus_active_carriesProgressSummary() { + GoalEntity g = goal(GoalStatus.ACTIVE); + g.setProgressSummary("missing DNS"); + g.setCompletionScore(0.62); + when(goalService.findActiveByConversation("conv-1")).thenReturn(g); + String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"goalId\":\"123\"")); + assertTrue(result.contains("\"completionScore\":0.62")); + assertTrue(result.contains("missing DNS")); + // total = agent(12) + eval(2) + assertTrue(result.contains("\"totalLlmCallsUsed\":14")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java new file mode 100644 index 00000000..4c0f7743 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java @@ -0,0 +1,129 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.progress.ProgressEntry; +import vip.mate.agent.progress.ProgressLedger; +import vip.mate.agent.progress.ProgressLedgerService; +import vip.mate.agent.progress.ProgressStatus; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Pins {@link ProgressLedgerTool#progress_update} — the only mutation entry + * the LLM has into the conversation-scoped progress ledger. Bad inputs must + * surface as structured "Error:" strings rather than throwing, so the model + * can recover by correcting the call instead of breaking the agent run. + */ +class ProgressLedgerToolTest { + + @AfterEach + void clearContext() { + ToolExecutionContext.clear(); + } + + @Test + @DisplayName("Happy path: tool result includes the full rendered snapshot for positive feedback.") + void happyPath() { + ToolExecutionContext.set("conv-1", "admin"); + ProgressLedgerService service = mock(ProgressLedgerService.class); + Map<String, ProgressEntry> after = new LinkedHashMap<>(); + after.put("step_a", new ProgressEntry("step_a", "Step A", + ProgressStatus.IN_PROGRESS, "note", Instant.now())); + when(service.upsert(eq("conv-1"), eq("step_a"), eq("Step A"), + eq(ProgressStatus.IN_PROGRESS), eq("starting now"))) + .thenReturn(new ProgressLedger(after)); + + ProgressLedgerTool tool = new ProgressLedgerTool(service); + String out = tool.progress_update("step_a", "Step A", "in_progress", "starting now", null); + + // Header line confirms the write so the model has a clear ack. + assertTrue(out.startsWith("✓ Recorded step_a → in_progress (1 entries total)"), out); + // The rendered snapshot must be appended so the model sees its own + // update reflected in the same view the runtime injects each turn. + assertTrue(out.contains("当前任务进度"), "expected snapshot in tool result: " + out); + assertTrue(out.contains("Step A"), "expected entry label in snapshot: " + out); + assertTrue(out.contains("`step_a`"), "expected bracketed key in snapshot: " + out); + verify(service, times(1)).upsert(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("No conversation context → structured error, no DB call.") + void missingContext() { + ProgressLedgerService service = mock(ProgressLedgerService.class); + ProgressLedgerTool tool = new ProgressLedgerTool(service); + + String out = tool.progress_update("step_a", "Step A", "done", null, null); + assertTrue(out.startsWith("Error: no conversation context"), out); + verifyNoInteractions(service); + } + + @Test + @DisplayName("Blank stepKey rejected without touching the service.") + void blankKey() { + ToolExecutionContext.set("conv-1", "admin"); + ProgressLedgerService service = mock(ProgressLedgerService.class); + ProgressLedgerTool tool = new ProgressLedgerTool(service); + + String out = tool.progress_update(" ", "L", "done", null, null); + assertTrue(out.startsWith("Error: stepKey is required"), out); + verifyNoInteractions(service); + } + + @Test + @DisplayName("Unknown status string → structured error listing valid values.") + void unknownStatus() { + ToolExecutionContext.set("conv-1", "admin"); + ProgressLedgerService service = mock(ProgressLedgerService.class); + ProgressLedgerTool tool = new ProgressLedgerTool(service); + + String out = tool.progress_update("step_a", "Step A", "finished", null, null); + assertTrue(out.contains("pending"), out); + assertTrue(out.contains("in_progress"), out); + assertTrue(out.contains("done"), out); + assertTrue(out.contains("blocked"), out); + verifyNoInteractions(service); + } + + @Test + @DisplayName("Service failure surfaces as a structured error instead of throwing.") + void serviceFailureNotPropagated() { + ToolExecutionContext.set("conv-1", "admin"); + ProgressLedgerService service = mock(ProgressLedgerService.class); + when(service.upsert(any(), any(), any(), any(), any())) + .thenThrow(new IllegalStateException("disk full")); + ProgressLedgerTool tool = new ProgressLedgerTool(service); + + String out = tool.progress_update("step_a", "Step A", "done", null, null); + assertTrue(out.startsWith("Error:"), out); + assertTrue(out.contains("disk full"), out); + } + + @Test + @DisplayName("Empty 'note' is forwarded verbatim — service decides how to store null vs blank.") + void emptyNoteForwarded() { + ToolExecutionContext.set("conv-1", "admin"); + ProgressLedgerService service = mock(ProgressLedgerService.class); + when(service.upsert(any(), any(), any(), any(), any())) + .thenReturn(ProgressLedger.empty()); + ProgressLedgerTool tool = new ProgressLedgerTool(service); + + tool.progress_update("step_a", "Step A", "done", null, null); + verify(service, times(1)).upsert(eq("conv-1"), eq("step_a"), eq("Step A"), + eq(ProgressStatus.DONE), isNull()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java new file mode 100644 index 00000000..cc847ea5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java @@ -0,0 +1,194 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.i18n.I18nService; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Regression tests for reading oversized content with {@link ReadFileTool}. + * <p> + * The original bug: a file whose only line exceeds the output budget returned + * empty content with readLines=0 and a continuation hint (startLine) that never + * advanced — an infinite retry loop. These tests assert that the tool always + * makes progress, clearly flags truncation, and lets a caller page through both + * a very long single line (via nextStartColumn) and subsequent normal lines + * (via nextStartLine). + */ +class ReadFileToolLargeLineTest { + + private ReadFileTool tool; + + @BeforeEach + void setUp() { + // Default answer echoes msg(key, ...) back as the key, so assertions stay + // locale-agnostic and the answer covers every varargs arity uniformly. + I18nService i18n = mock(I18nService.class, inv -> + "msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null); + tool = new ReadFileTool(i18n); + // Ensure no workspace boundary is active so absolute temp paths validate. + ToolExecutionContext.clear(); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + } + + /** Build a single-line JSON array of {@code n} string elements. */ + private static String oneLineJsonArray(int n) { + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < n; i++) { + if (i > 0) json.append(','); + json.append("\"item-").append(i).append("\""); + } + return json.append(']').toString(); + } + + /** Strip the "%6d\t" line-number prefixes and the truncation marker from content. */ + private static String stripDecorations(String content) { + StringBuilder out = new StringBuilder(); + for (String l : content.split("\n", -1)) { + if (l.isEmpty()) continue; + int tab = l.indexOf('\t'); + String body = tab >= 0 ? l.substring(tab + 1) : l; + int marker = body.indexOf("tool.read_file.line_truncated_marker"); + if (marker >= 0) body = body.substring(0, marker); + out.append(body); + } + return out.toString(); + } + + @Test + @DisplayName("single line larger than the 30KB budget returns clipped content, not empty + infinite loop") + void singleOversizedLine_returnsClippedContent(@TempDir Path dir) throws Exception { + String json = oneLineJsonArray(4000); // ~40KB on one physical line + Path file = dir.resolve("big.json"); + Files.writeString(file, json, StandardCharsets.UTF_8); + + String raw = tool.read_file(file.toString(), null, null, null, null); + JSONObject res = JSONUtil.parseObj(raw); + + assertFalse(res.getBool("error", false), "should not be an error result"); + assertTrue(res.getBool("truncated"), "should be marked truncated"); + assertTrue(res.getBool("lineTruncated", false), "should flag in-line truncation"); + assertEquals(1, res.getInt("readLines"), "must count the clipped line as read"); + assertTrue(res.getStr("content").length() > 1000, "content must carry the clipped line, not be empty"); + // Continuation must advance into the same line, not loop on column 1. + assertEquals(1, res.getInt("nextStartLine")); + assertTrue(res.getInt("nextStartColumn") > 1, "nextStartColumn must advance past the head"); + } + + @Test + @DisplayName("a very long single line can be fully read by paging through nextStartColumn") + void oversizedLine_pagesToCompletionViaColumn(@TempDir Path dir) throws Exception { + String json = oneLineJsonArray(10000); // big enough to need several windows + Path file = dir.resolve("huge.json"); + Files.writeString(file, json, StandardCharsets.UTF_8); + + StringBuilder reassembled = new StringBuilder(); + Integer startLine = null; + Integer startColumn = null; + int guard = 0; + while (true) { + String raw = tool.read_file(file.toString(), startLine, null, startColumn, null); + JSONObject res = JSONUtil.parseObj(raw); + assertFalse(res.getBool("error", false), "no error while paging"); + reassembled.append(stripDecorations(res.getStr("content"))); + if (!res.getBool("truncated")) { + break; + } + startLine = res.getInt("nextStartLine"); + startColumn = res.containsKey("nextStartColumn") ? res.getInt("nextStartColumn") : 1; + assertTrue(++guard < 50, "must terminate, not loop forever"); + } + assertEquals(json, reassembled.toString(), "paging through columns must reconstruct the whole line"); + } + + @Test + @DisplayName("multi-line file with a huge first line still lets the caller reach later normal lines") + void hugeFirstLine_thenNormalLines_offerNextLine(@TempDir Path dir) throws Exception { + String first = oneLineJsonArray(4000); // oversized line 1 + Path file = dir.resolve("mixed.txt"); + Files.writeString(file, first + "\nsecond-line\nthird-line\n", StandardCharsets.UTF_8); + + // First read clips line 1 and must point both at the line's tail and the next line. + String raw = tool.read_file(file.toString(), null, null, null, null); + JSONObject res = JSONUtil.parseObj(raw); + assertTrue(res.getBool("truncated")); + assertTrue(res.getBool("lineTruncated", false)); + assertEquals(3, res.getInt("totalLines")); + + // The caller can skip the rest of the giant line and read the normal lines. + String raw2 = tool.read_file(file.toString(), 2, null, null, null); + JSONObject res2 = JSONUtil.parseObj(raw2); + assertFalse(res2.getBool("truncated")); + assertEquals(2, res2.getInt("readLines")); + assertTrue(res2.getStr("content").contains("second-line")); + assertTrue(res2.getStr("content").contains("third-line")); + } + + @Test + @DisplayName("single-line spill-style JSON {\"stdout\":\"...\"} is windowed, not dropped") + void spillStyleStdoutJson_isWindowed(@TempDir Path dir) throws Exception { + String payload = "x".repeat(50 * 1024); // 50KB payload on one line + String line = "{\"stdout\":\"" + payload + "\"}"; + Path file = dir.resolve("spill.json"); + Files.writeString(file, line, StandardCharsets.UTF_8); + + String raw = tool.read_file(file.toString(), null, null, null, null); + JSONObject res = JSONUtil.parseObj(raw); + assertTrue(res.getBool("truncated")); + assertTrue(res.getBool("lineTruncated", false)); + assertTrue(res.getStr("content").contains("{\"stdout\":"), "head of the line must be present"); + assertTrue(res.getInt("nextStartColumn") > 1); + } + + @Test + @DisplayName("normal truncation at a line boundary advertises nextStartLine for continuation") + void manyNormalLines_truncateAtLineBoundary(@TempDir Path dir) throws Exception { + // 2000 lines of ~50 chars each well exceeds the 30KB budget but no single + // line is oversized, so truncation must happen at a clean line boundary. + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 2000; i++) { + sb.append("line-").append(i).append("-").append("y".repeat(40)).append('\n'); + } + Path file = dir.resolve("many.txt"); + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + String raw = tool.read_file(file.toString(), null, null, null, null); + JSONObject res = JSONUtil.parseObj(raw); + assertTrue(res.getBool("truncated")); + assertFalse(res.getBool("lineTruncated", false), "no individual line is oversized"); + int next = res.getInt("nextStartLine"); + assertEquals(res.getInt("endLine") + 1, next, "continuation must resume right after the last read line"); + assertFalse(res.containsKey("nextStartColumn"), "line-boundary truncation has no column"); + } + + @Test + @DisplayName("normal multi-line file reads fully without truncation") + void smallFile_readsFully(@TempDir Path dir) throws Exception { + Path file = dir.resolve("small.txt"); + Files.writeString(file, "alpha\nbeta\ngamma\n", StandardCharsets.UTF_8); + + String raw = tool.read_file(file.toString(), null, null, null, null); + JSONObject res = JSONUtil.parseObj(raw); + + assertFalse(res.getBool("truncated"), "small file should not truncate"); + assertEquals(3, res.getInt("readLines")); + assertTrue(res.getStr("content").contains("beta")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java new file mode 100644 index 00000000..e4f03ae1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java @@ -0,0 +1,84 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillLoadToolTest { + + private static ResolvedSkill skill(String name) { + return ResolvedSkill.builder().id((long) name.hashCode()).name(name).build(); + } + + @Test + @DisplayName("blank skillName returns a friendly required-arg error and does not read") + void blankSkillNameRejected() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill(" ", null, null); + + assertTrue(out.startsWith("Error:")); + assertTrue(out.contains("required")); + verify(fileTool, never()).readSkillFile(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("unknown skill returns not-found error with a listAvailableSkills hint") + void unknownSkillReturnsError() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + when(runtime.findActiveSkill("nope")).thenReturn(null); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill("nope", null, null); + + assertTrue(out.contains("not found")); + assertTrue(out.contains("listAvailableSkills")); + verify(fileTool, never()).readSkillFile(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("known skill with no filePath loads SKILL.md via the shared reader") + void loadsSkillMdByDefault() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + when(runtime.findActiveSkill("foo")).thenReturn(skill("foo")); + when(fileTool.readSkillFile(eq("foo"), eq("SKILL.md"), isNull(), isNull(), any())) + .thenReturn("SKILL CONTENT"); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill("foo", null, null); + + assertEquals("SKILL CONTENT", out); + verify(fileTool).readSkillFile(eq("foo"), eq("SKILL.md"), isNull(), isNull(), any()); + } + + @Test + @DisplayName("explicit filePath is forwarded to the shared reader") + void loadsExplicitSubFile() { + SkillRuntimeService runtime = mock(SkillRuntimeService.class); + SkillFileTool fileTool = mock(SkillFileTool.class); + when(runtime.findActiveSkill("foo")).thenReturn(skill("foo")); + when(fileTool.readSkillFile(eq("foo"), eq("references/api.md"), isNull(), isNull(), any())) + .thenReturn("REF CONTENT"); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + + String out = tool.loadSkill("foo", "references/api.md", null); + + assertEquals("REF CONTENT", out); + verify(fileTool).readSkillFile(eq("foo"), eq("references/api.md"), isNull(), isNull(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java new file mode 100644 index 00000000..642d38d3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java @@ -0,0 +1,90 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SkillScriptTool#normalizeArgs(String)} — the decode + * step that turns the JSON-encoded {@code args} tool parameter into the + * positional argument list handed to a skill script. + * + * <p>The decisive cases are the ones a model gets wrong when a script needs a + * JSON payload: the object passed directly, the object wrapped in an array, + * and the object pre-escaped into an array of one string all have to converge + * on the same single JSON argument. A bare scalar must survive untouched — + * decoding {@code 2026-05-19} would otherwise truncate it to {@code 2026}. + */ +class SkillScriptToolArgsTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ + private final SkillScriptTool tool = + new SkillScriptTool(null, null, null, null, objectMapper); + + @Test + @DisplayName("null / blank args yield no argument list") + void emptyInputs() { + assertThat(tool.normalizeArgs(null)).isNull(); + assertThat(tool.normalizeArgs("")).isNull(); + assertThat(tool.normalizeArgs(" ")).isNull(); + assertThat(tool.normalizeArgs("[]")).isNull(); + } + + @Test + @DisplayName("a JSON object is forwarded as a single JSON argument") + void objectBecomesOneArg() throws Exception { + List<String> args = tool.normalizeArgs("{\"date\":\"2026-05-19\",\"topic\":\"智能体\"}"); + assertThat(args).hasSize(1); + JsonNode parsed = objectMapper.readTree(args.get(0)); + assertThat(parsed.get("date").asText()).isEqualTo("2026-05-19"); + assertThat(parsed.get("topic").asText()).isEqualTo("智能体"); + } + + @Test + @DisplayName("an object wrapped in a single-element array still reaches the script as JSON") + void objectWrappedInArray() throws Exception { + List<String> args = tool.normalizeArgs("[{\"date\":\"x\"}]"); + assertThat(args).hasSize(1); + assertThat(objectMapper.readTree(args.get(0)).get("date").asText()).isEqualTo("x"); + } + + @Test + @DisplayName("an object pre-escaped into an array of one string is unwrapped") + void objectPreEscapedInArray() throws Exception { + List<String> args = tool.normalizeArgs("[\"{\\\"date\\\":\\\"x\\\"}\"]"); + assertThat(args).hasSize(1); + assertThat(objectMapper.readTree(args.get(0)).get("date").asText()).isEqualTo("x"); + } + + @Test + @DisplayName("a plain JSON array maps to one positional argument per element") + void plainArrayKeepsElements() { + assertThat(tool.normalizeArgs("[\"--verbose\",\"input.txt\"]")) + .containsExactly("--verbose", "input.txt"); + assertThat(tool.normalizeArgs("[1,2,3]")) + .containsExactly("1", "2", "3"); + } + + @Test + @DisplayName("a bare scalar is forwarded verbatim, never JSON-decoded") + void bareScalarUntouched() { + // Decoding would truncate this to "2026" — it must survive intact. + assertThat(tool.normalizeArgs("2026-05-19")).containsExactly("2026-05-19"); + assertThat(tool.normalizeArgs("智能体")).containsExactly("智能体"); + assertThat(tool.normalizeArgs(" hello world ")).containsExactly("hello world"); + } + + @Test + @DisplayName("text that looks like JSON but does not parse is forwarded verbatim") + void malformedJsonForwardedVerbatim() { + assertThat(tool.normalizeArgs("{bad json")).containsExactly("{bad json"); + assertThat(tool.normalizeArgs("[1,2")).containsExactly("[1,2"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java new file mode 100644 index 00000000..15ba8aad --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java @@ -0,0 +1,205 @@ +package vip.mate.tool.disclosure; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.service.McpServerService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.service.AvailableToolService; +import vip.mate.tool.service.ToolService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ToolDisclosureServiceTest { + + /** Fixture beans whose @Tool function names drive tier resolution. */ + static class Tools { + @Tool(description = "text to image") + public String image_generate() { return ""; } + + @Tool(description = "a plain core tool") + public String my_core_tool() { return ""; } + } + + /** Fixture whose class simple name is {@code ImageGenerateTool} and function + * name is {@code image_generate} — mirrors the real builtin's name skew so + * the class-name → function-name bridge can be tested. */ + static class ImageGenerateTool { + @Tool(description = "text to image") + public String image_generate() { return ""; } + } + + /** Global tool set the bridge resolves DB class/bean names against. */ + private static AgentToolSet globalSet() { + Object t1 = new Tools(); + Object t2 = new ImageGenerateTool(); + List<org.springframework.ai.tool.ToolCallback> cbs = new ArrayList<>(); + cbs.addAll(List.of(ToolCallbacks.from(t1))); + cbs.addAll(List.of(ToolCallbacks.from(t2))); + Map<Object, String> beanNames = Map.of(t1, "tools", t2, "imageGenerateTool"); + return AgentToolSet.fromCallbacks(List.of(t1, t2), cbs, beanNames::get); + } + + private static ToolEntity toolRow(String name, String type, String tier) { + ToolEntity t = new ToolEntity(); + t.setName(name); + t.setToolType(type); + t.setDisclosureTier(tier); + return t; + } + + private static McpServerEntity server(Long id, String name, String tier) { + McpServerEntity s = new McpServerEntity(); + s.setId(id); + s.setName(name); + s.setDisclosureTier(tier); + return s; + } + + private static AvailableToolDTO mcpDto(String name, Long serverId) { + return AvailableToolDTO.builder().source("mcp").providerId(serverId).name(name).build(); + } + + private DefaultToolDisclosureService service(List<ToolEntity> tools, + List<McpServerEntity> servers, + List<AvailableToolDTO> available) { + ToolService ts = mock(ToolService.class); + McpServerService ms = mock(McpServerService.class); + AvailableToolService as = mock(AvailableToolService.class); + ToolRegistry tr = mock(ToolRegistry.class); + lenient().when(ts.listTools()).thenReturn(tools); + lenient().when(ms.listAll()).thenReturn(servers); + lenient().when(as.listAvailable()).thenReturn(available); + lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); + return new DefaultToolDisclosureService(ts, ms, as, tr); + } + + @Test + @DisplayName("meta-tools enable_tool / load_skill are always core") + void metaToolsAlwaysCore() { + var svc = service(List.of(toolRow("enable_tool", "builtin", "extension")), List.of(), List.of()); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("enable_tool")); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("load_skill")); + } + + @Test + @DisplayName("generative tools default to extension even without a DB row") + void generativeDefaultsExtension() { + var svc = service(List.of(), List.of(), List.of()); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("image_generate")); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("browser_use")); + } + + @Test + @DisplayName("unknown tools default to core (conservative)") + void unknownDefaultsCore() { + var svc = service(List.of(), List.of(), List.of()); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("memory_recall")); + } + + @Test + @DisplayName("mate_tool.disclosure_tier overrides the code default") + void dbRowOverrides() { + var svc = service(List.of(toolRow("my_core_tool", "builtin", "extension")), List.of(), List.of()); + assertEquals(DisclosureTier.EXTENSION, svc.resolveTierByName("my_core_tool")); + } + + @Test + @DisplayName("DB tier stored by Java class name bridges to the runtime function name") + void dbTierBridgesClassNameToFunctionName() { + // mate_tool.name = class name; resolveTier is queried by function name. + var hidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "extension")), List.of(), List.of()); + assertEquals(DisclosureTier.EXTENSION, hidden.resolveTierByName("image_generate")); + + // Admin un-hides it by setting the row to core; the DB value must win over + // the code-level extension default. + var unhidden = service(List.of(toolRow("ImageGenerateTool", "builtin", "core")), List.of(), List.of()); + assertEquals(DisclosureTier.CORE, unhidden.resolveTierByName("image_generate")); + } + + @Test + @DisplayName("MCP tool tier follows its owning server") + void mcpFollowsServer() { + var extSvc = service(List.of(), List.of(server(7L, "github", "extension")), + List.of(mcpDto("mcp_github_create_issue", 7L))); + assertEquals(DisclosureTier.EXTENSION, extSvc.resolveTierByName("mcp_github_create_issue")); + + var coreSvc = service(List.of(), List.of(server(7L, "github", "core")), + List.of(mcpDto("mcp_github_create_issue", 7L))); + assertEquals(DisclosureTier.CORE, coreSvc.resolveTierByName("mcp_github_create_issue")); + } + + @Test + @DisplayName("MCP tool whose server has no tier set defaults to core (visible)") + void mcpDefaultsCoreWhenServerTierUnset() { + var svc = service(List.of(), List.of(server(7L, "github", null)), + List.of(mcpDto("mcp_github_create_issue", 7L))); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("mcp_github_create_issue")); + } + + @Test + @DisplayName("split partitions into active (core + enabled) and the full extension catalog") + void splitPartitions() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), + List.of(ToolCallbacks.from(new Tools()))); + + var noneEnabled = svc.split(set, Set.of()); + assertEquals(List.of("my_core_tool"), names(noneEnabled.activeCallbacks())); + assertEquals(List.of("image_generate"), names(noneEnabled.extensionCatalog())); + + var imgEnabled = svc.split(set, Set.of("image_generate")); + assertTrue(names(imgEnabled.activeCallbacks()).contains("image_generate")); + assertTrue(names(imgEnabled.activeCallbacks()).contains("my_core_tool")); + assertEquals(List.of("image_generate"), names(imgEnabled.extensionCatalog())); + } + + @Test + @DisplayName("legacy mode advertises everything and renders no catalog") + void legacyMode() { + var svc = service(List.of(), List.of(), List.of()); + ReflectionTestUtils.setField(svc, "disclosureMode", "legacy"); + AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), + List.of(ToolCallbacks.from(new Tools()))); + + var split = svc.split(set, Set.of()); + assertEquals(2, split.activeCallbacks().size()); + assertTrue(split.extensionCatalog().isEmpty()); + assertEquals("", svc.renderExtensionCatalog(set, 8192)); + assertEquals(DisclosureTier.CORE, svc.resolveTierByName("image_generate")); + } + + @Test + @DisplayName("renderExtensionCatalog lists extension tools under a heading") + void rendersCatalog() { + var svc = service(List.of(), List.of(), List.of()); + AgentToolSet set = AgentToolSet.fromCallbacks(List.of(new Tools()), + List.of(ToolCallbacks.from(new Tools()))); + String catalog = svc.renderExtensionCatalog(set, 8192); + assertTrue(catalog.contains("## Extension Tools")); + assertTrue(catalog.contains("image_generate")); + assertTrue(catalog.contains("enable_tool")); + assertFalse(catalog.contains("my_core_tool"), "core tools must not appear in the extension catalog"); + } + + private static List<String> names(List<ToolCallback> cbs) { + return cbs.stream().map(c -> c.getToolDefinition().name()).toList(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java new file mode 100644 index 00000000..d3a34dc8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java @@ -0,0 +1,111 @@ +package vip.mate.tool.guard.guardian; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.GuardCategory; +import vip.mate.tool.guard.model.GuardDecision; +import vip.mate.tool.guard.model.GuardFinding; +import vip.mate.tool.guard.model.GuardSeverity; +import vip.mate.tool.guard.model.ToolGuardRuleEntity; +import vip.mate.tool.guard.model.ToolInvocationContext; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pin the DbRuleGuardian contract: + * <ul> + * <li>supports(): true iff DB rules exist for the tool</li> + * <li>evaluate(): produces a finding when the rule pattern matches</li> + * <li>exclude pattern suppresses the finding</li> + * <li>rule decision carries through to GuardFinding.decision</li> + * </ul> + */ +class DbRuleGuardianTest { + + private static ToolGuardRuleEntity rule(String pattern, String severity, String decision, String exclude) { + ToolGuardRuleEntity r = new ToolGuardRuleEntity(); + r.setRuleId("rule-1"); + r.setToolName("feishu_doc_create_c1"); + r.setName("Approval gate"); + r.setDescription("desc"); + r.setRemediation("approve to proceed"); + r.setParamName("args"); + r.setCategory(GuardCategory.SENSITIVE_FILE_ACCESS.name()); + r.setSeverity(severity); + r.setDecision(decision); + r.setPattern(pattern); + r.setExcludePattern(exclude); + r.setEnabled(true); + r.setPriority(100); + return r; + } + + private static ToolInvocationContext ctx(String toolName, String args) { + return ToolInvocationContext.of(toolName, args, null, null); + } + + @Test + @DisplayName("supports() returns false when registry has no rules for the tool") + void supportsOnlyWhenRulesPresent() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + when(reg.getRulesForTool(any())).thenReturn(List.of()); + DbRuleGuardian g = new DbRuleGuardian(reg); + assertFalse(g.supports(ctx("feishu_doc_create_c1", "{}"))); + } + + @Test + @DisplayName("supports() returns true and matching .* pattern produces a finding") + void evaluateMatchingPatternProducesFinding() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + ToolGuardRuleEntity r = rule(".*", "HIGH", "NEEDS_APPROVAL", null); + when(reg.getRulesForTool(eq("feishu_doc_create_c1"))).thenReturn(List.of(r)); + when(reg.getCompiledPattern(".*")).thenReturn(Pattern.compile(".*", Pattern.CASE_INSENSITIVE)); + + DbRuleGuardian g = new DbRuleGuardian(reg); + ToolInvocationContext context = ctx("feishu_doc_create_c1", "{\"title\":\"meeting notes\"}"); + assertTrue(g.supports(context)); + List<GuardFinding> findings = g.evaluate(context); + assertEquals(1, findings.size()); + GuardFinding f = findings.get(0); + assertEquals(GuardSeverity.HIGH, f.severity()); + assertEquals(GuardDecision.NEEDS_APPROVAL, f.decision()); + assertEquals("feishu_doc_create_c1", f.toolName()); + } + + @Test + @DisplayName("exclude pattern suppresses the finding") + void excludePatternSuppresses() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + ToolGuardRuleEntity r = rule("title", "HIGH", "NEEDS_APPROVAL", "test"); + when(reg.getRulesForTool(any())).thenReturn(List.of(r)); + when(reg.getCompiledPattern("title")).thenReturn(Pattern.compile("title", Pattern.CASE_INSENSITIVE)); + when(reg.getCompiledExcludePattern("test")).thenReturn(Pattern.compile("test", Pattern.CASE_INSENSITIVE)); + + DbRuleGuardian g = new DbRuleGuardian(reg); + List<GuardFinding> findings = g.evaluate(ctx("any_tool", "{\"title\":\"test notes\"}")); + assertEquals(0, findings.size(), "exclude pattern should suppress finding"); + } + + @Test + @DisplayName("non-matching pattern produces no findings") + void nonMatchingPatternNoFinding() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + ToolGuardRuleEntity r = rule("THIS_NEVER_MATCHES", "HIGH", "NEEDS_APPROVAL", null); + when(reg.getRulesForTool(any())).thenReturn(List.of(r)); + when(reg.getCompiledPattern("THIS_NEVER_MATCHES")) + .thenReturn(Pattern.compile("THIS_NEVER_MATCHES", Pattern.CASE_INSENSITIVE)); + + DbRuleGuardian g = new DbRuleGuardian(reg); + assertEquals(0, g.evaluate(ctx("any_tool", "{}")).size()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java index 101c3818..79cfa6af 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java @@ -11,8 +11,7 @@ import static org.junit.jupiter.api.Assertions.*; /** * Unit tests for {@link OpenAiImageProvider} GPT-Image-2 wiring. * - * <p>Inspired by hermes-agent's plugins/image_gen/openai/__init__.py — three - * virtual model IDs (gpt-image-2-low/medium/high) all map to API model + * <p>Three virtual model IDs (gpt-image-2-low/medium/high) all map to API model * {@code gpt-image-2} with a different {@code quality} parameter. The new * size set is 1024x1024 / 1024x1536 / 1536x1024, distinct from DALL-E's * 1024x1024 / 1024x1792 / 1792x1024. @@ -86,8 +85,7 @@ class OpenAiImageProviderGptImage2Test { assertEquals("medium", OpenAiImageProvider.qualityForTier("gpt-image-2-medium")); assertEquals("high", OpenAiImageProvider.qualityForTier("gpt-image-2-high")); - // Defensive: any unrecognised id falls back to medium (sane default; - // matches hermes-agent DEFAULT_MODEL = gpt-image-2-medium). + // Defensive: any unrecognised id falls back to medium (sane default). assertEquals("medium", OpenAiImageProvider.qualityForTier("anything-else")); assertEquals("medium", OpenAiImageProvider.qualityForTier("")); } @@ -134,7 +132,7 @@ class OpenAiImageProviderGptImage2Test { @DisplayName("normalizeSize: extra gpt-image-2 aspect-ratio aliases (3:4, 2:3, 4:3, 3:2) work") void normalizeSize_gptImage2_extraAspectAliases() { OpenAiImageProvider p = newProvider(); - // Per hermes-agent's spec: portrait aliases → 1024x1536, landscape → 1536x1024 + // Portrait aliases → 1024x1536, landscape → 1536x1024 assertEquals("1024x1536", p.normalizeSize(null, "3:4", true)); assertEquals("1024x1536", p.normalizeSize(null, "2:3", true)); assertEquals("1536x1024", p.normalizeSize(null, "4:3", true)); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java b/mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java new file mode 100644 index 00000000..e4e71d4f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/service/ToolServiceRuntimeNamesTest.java @@ -0,0 +1,76 @@ +package vip.mate.tool.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.ToolRegistry; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.repository.ToolMapper; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ToolServiceRuntimeNamesTest { + + static class ImageGenerateTool { + @Tool(description = "text to image") + public String image_generate() { + return ""; + } + } + + @Test + @DisplayName("listTools enriches DB class/bean rows with runtime function names") + void listToolsEnrichesRuntimeNames() { + ToolMapper mapper = mock(ToolMapper.class); + ToolRegistry registry = mock(ToolRegistry.class); + ToolEntity row = new ToolEntity(); + row.setName("ImageGenerateTool"); + row.setBeanName("imageGenerateTool"); + + ImageGenerateTool bean = new ImageGenerateTool(); + AgentToolSet set = AgentToolSet.fromCallbacks( + List.of(bean), + List.of(ToolCallbacks.from(bean)), + Map.of(bean, "imageGenerateTool")::get); + when(registry.listToolEntities()).thenReturn(List.of(row)); + when(registry.getAllToolBeanSetForAdmin()).thenReturn(set); + + ToolService service = new ToolService(mapper, registry); + + ToolEntity result = service.listTools().get(0); + + assertEquals(List.of("image_generate"), result.getRuntimeNames()); + } + + @Test + @DisplayName("runtime names are still enriched for disabled tool rows") + void disabledRowsStillGetRuntimeNames() { + ToolMapper mapper = mock(ToolMapper.class); + ToolRegistry registry = mock(ToolRegistry.class); + ToolEntity row = new ToolEntity(); + row.setName("ImageGenerateTool"); + row.setBeanName("imageGenerateTool"); + row.setEnabled(false); + + ImageGenerateTool bean = new ImageGenerateTool(); + AgentToolSet adminSet = AgentToolSet.fromCallbacks( + List.of(bean), + List.of(ToolCallbacks.from(bean)), + Map.of(bean, "imageGenerateTool")::get); + when(registry.listToolEntities()).thenReturn(List.of(row)); + when(registry.getAllToolBeanSetForAdmin()).thenReturn(adminSet); + + ToolService service = new ToolService(mapper, registry); + + ToolEntity result = service.listTools().get(0); + + assertEquals(List.of("image_generate"), result.getRuntimeNames()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiTransformationControllerTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiTransformationControllerTest.java new file mode 100644 index 00000000..fe9c7f87 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiTransformationControllerTest.java @@ -0,0 +1,56 @@ +package vip.mate.wiki.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vip.mate.common.result.R; +import vip.mate.wiki.model.WikiTransformationEntity; +import vip.mate.wiki.model.WikiTransformationRunEntity; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiTransformationAggregator; +import vip.mate.wiki.service.WikiTransformationExecutor; +import vip.mate.wiki.service.WikiTransformationService; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WikiTransformationControllerTest { + + private WikiTransformationService transformationService; + private WikiTransformationController controller; + + @BeforeEach + void setUp() { + transformationService = mock(WikiTransformationService.class); + controller = new WikiTransformationController( + transformationService, + mock(WikiTransformationExecutor.class), + mock(WikiTransformationAggregator.class), + mock(WikiKnowledgeBaseService.class)); + } + + @Test + void applyMissingTemplateReturns404Envelope() { + when(transformationService.getById(99L)).thenReturn(null); + + R<WikiTransformationRunEntity> response = controller.apply( + 99L, Map.of("rawId", 1L), false, 1L); + + assertEquals(404, response.getCode()); + } + + @Test + void applyWithRawIdAndPageIdReturns400Envelope() { + WikiTransformationEntity transformation = new WikiTransformationEntity(); + transformation.setId(99L); + transformation.setWorkspaceId(1L); + when(transformationService.getById(99L)).thenReturn(transformation); + + R<WikiTransformationRunEntity> response = controller.apply( + 99L, Map.of("rawId", 1L, "pageId", 2L), false, 1L); + + assertEquals(400, response.getCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/KbModelStrategyConfigTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/KbModelStrategyConfigTest.java new file mode 100644 index 00000000..c5f73e83 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/KbModelStrategyConfigTest.java @@ -0,0 +1,66 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class KbModelStrategyConfigTest { + + @Test + void defaultModelStrategyReadsMarkdownFrontmatter() { + WikiKnowledgeBaseEntity kb = kb(""" + --- + wikiDefaultModelId: 12345 + --- + # Wiki Processing Rules + """); + + Long modelId = new KbDefaultModelStrategy(new ObjectMapper()) + .selectModelId(job(), kb, WikiJobStep.ROUTE); + + assertEquals(12345L, modelId); + } + + @Test + void stepModelStrategyReadsDottedFrontmatterKey() { + WikiKnowledgeBaseEntity kb = kb(""" + --- + stepModels.heavy_ingest.create_page: 67890 + --- + # Wiki Processing Rules + """); + + Long modelId = new KbConfigStepModelStrategy(new ObjectMapper()) + .selectModelId(job(), kb, WikiJobStep.CREATE_PAGE); + + assertEquals(67890L, modelId); + } + + @Test + void plainMarkdownConfigIsTreatedAsEmptyConfig() { + WikiKnowledgeBaseEntity kb = kb("# Wiki Processing Rules\n\nNo machine config here."); + + Long modelId = new KbDefaultModelStrategy(new ObjectMapper()) + .selectModelId(job(), kb, WikiJobStep.ROUTE); + + assertNull(modelId); + } + + private static WikiKnowledgeBaseEntity kb(String configContent) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(7L); + kb.setConfigContent(configContent); + return kb; + } + + private static WikiProcessingJobEntity job() { + WikiProcessingJobEntity job = new WikiProcessingJobEntity(); + job.setJobType("heavy_ingest"); + return job; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContentNormalizerSecurityTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContentNormalizerSecurityTest.java new file mode 100644 index 00000000..9fe7d08a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContentNormalizerSecurityTest.java @@ -0,0 +1,215 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Adversarial tests for {@link WikiContentNormalizer}. + * <p> + * Each test feeds hostile HTML through the html / htm / url normalization path + * and asserts that no executable markup — tags, event-handler attributes, or + * script/style bodies — survives into the normalized output, while legitimate + * prose and headings are preserved. The normalized output is plain text by + * contract, so it must contain no tag tokens at all. + */ +class WikiContentNormalizerSecurityTest { + + private WikiContentNormalizer normalizer; + + /** Any HTML start or end tag token ({@code <a}, {@code </a}). The output must contain none. */ + private static final Pattern TAG_TOKEN = Pattern.compile("</?[a-zA-Z]"); + + /** Source types that route through the HTML cleanup path. */ + private static final String[] HTML_TYPES = {"html", "htm", "url"}; + + @BeforeEach + void setUp() { + normalizer = new WikiContentNormalizer(); + } + + /** Asserts a hostile payload normalizes to text with no tag token and no event-handler attribute. */ + private void assertNoLiveMarkup(String label, String payload) { + for (String type : HTML_TYPES) { + String out = normalizer.normalize(type, payload); + String lower = out.toLowerCase(); + assertFalse(TAG_TOKEN.matcher(out).find(), + label + " [" + type + "]: a tag token survived → " + out); + assertFalse(lower.contains("<script"), label + " [" + type + "]: <script leaked → " + out); + assertFalse(lower.contains("<style"), label + " [" + type + "]: <style leaked → " + out); + assertFalse(lower.contains("<iframe"), label + " [" + type + "]: <iframe leaked → " + out); + assertFalse(lower.contains("onerror="), label + " [" + type + "]: onerror= leaked → " + out); + assertFalse(lower.contains("onload="), label + " [" + type + "]: onload= leaked → " + out); + assertFalse(lower.contains("onclick="), label + " [" + type + "]: onclick= leaked → " + out); + assertFalse(lower.contains("onmouseover="), label + " [" + type + "]: onmouseover= leaked → " + out); + } + } + + /** assertNoLiveMarkup, and additionally that the JavaScript payload marker is fully gone. */ + private void assertFullyStripped(String label, String payload, String jsMarker) { + assertNoLiveMarkup(label, payload); + for (String type : HTML_TYPES) { + String out = normalizer.normalize(type, payload); + assertFalse(out.contains(jsMarker), + label + " [" + type + "]: JS payload marker survived → " + out); + } + } + + // ───────────────────────── well-formed element attacks ───────────────────────── + + @Test + @DisplayName("classic <script> in <head> is fully removed") + void scriptInHead() { + String p = "<html><head><script>STEAL_COOKIES()</script></head>" + + "<body><h1>Doc</h1><p>Body text.</p></body></html>"; + assertFullyStripped("script-in-head", p, "STEAL_COOKIES"); + String out = normalizer.normalize("html", p); + assertTrue(out.contains("# Doc"), "heading survives"); + assertTrue(out.contains("Body text."), "body survives"); + } + + @Test + @DisplayName("img onerror handler does not survive") + void imgOnError() { + assertNoLiveMarkup("img-onerror", + "<body><p>Before.</p><img src=x onerror=\"STEAL()\"><p>After.</p></body>"); + } + + @Test + @DisplayName("svg onload handler does not survive") + void svgOnLoad() { + assertNoLiveMarkup("svg-onload", + "<body><svg onload=\"STEAL()\"></svg><p>Visible.</p></body>"); + } + + @Test + @DisplayName("iframe with javascript: src does not survive") + void iframeJavascriptSrc() { + assertNoLiveMarkup("iframe-js", + "<body><iframe src=\"javascript:STEAL()\"></iframe><p>Visible.</p></body>"); + } + + @Test + @DisplayName("mixed-case <ScRiPt> is still removed") + void mixedCaseScript() { + assertFullyStripped("mixed-case", + "<body><h1>T</h1><ScRiPt>STEAL()</ScRiPt><p>Body.</p></body>", "STEAL"); + } + + @Test + @DisplayName("anchor with javascript: href keeps only its visible text") + void anchorJavascriptHref() { + String p = "<body><a href=\"javascript:STEAL()\">click here</a></body>"; + assertNoLiveMarkup("anchor-js", p); + String out = normalizer.normalize("html", p); + assertTrue(out.contains("click here"), "anchor visible text survives"); + assertFalse(out.contains("javascript:"), "javascript: URI dropped"); + } + + @Test + @DisplayName("event-handler attribute on a heading is dropped, heading text kept") + void eventHandlerOnHeading() { + String p = "<body><h2 onclick=\"STEAL()\">Section Title</h2><p>x</p></body>"; + assertNoLiveMarkup("onclick-heading", p); + String out = normalizer.normalize("html", p); + assertTrue(out.contains("## Section Title"), "heading text and level survive"); + } + + @Test + @DisplayName("nested / mutation script tags do not yield a live tag") + void mutationScript() { + assertNoLiveMarkup("mutation", + "<body><scr<script>ipt>STEAL()</scr</script>ipt><p>Body.</p></body>"); + } + + @Test + @DisplayName("noscript-wrapped script is removed") + void noscriptWrappedScript() { + assertNoLiveMarkup("noscript", + "<body><noscript><script>STEAL()</script></noscript><p>Body.</p></body>"); + } + + @Test + @DisplayName("object and embed elements do not survive") + void objectAndEmbed() { + assertNoLiveMarkup("object-embed", + "<body><object data=\"javascript:STEAL()\"></object>" + + "<embed src=\"javascript:STEAL()\"><p>Body.</p></body>"); + } + + @Test + @DisplayName("style block with a url() payload is removed") + void styleBlockRemoved() { + assertFullyStripped("style-url", + "<html><head><style>body{background:url('STEAL')}</style></head>" + + "<body><p>Body.</p></body></html>", "STEAL"); + } + + // ──────────────── sniff-evasion: markup with no element children ──────────────── + + @Test + @DisplayName("event handler on the <html> skeleton tag does not survive") + void handlerOnHtmlSkeleton() { + assertNoLiveMarkup("html-skeleton-handler", + "<html onmouseover=\"STEAL()\">plain body text only</html>"); + } + + @Test + @DisplayName("event handler on the <body> skeleton tag does not survive") + void handlerOnBodySkeleton() { + assertNoLiveMarkup("body-skeleton-handler", + "<body onload=\"STEAL()\">just text, no child elements</body>"); + } + + @Test + @DisplayName("script hidden inside an HTML comment does not survive") + void scriptHiddenInComment() { + assertNoLiveMarkup("comment-script", + "<!-- harmless --><body onload=\"STEAL()\"><!-- <script>STEAL()</script> --></body>"); + } + + // ─────────────────────── oversized payload: lossy strip path ─────────────────────── + + @Test + @DisplayName("oversized HTML beyond the parse cap still has its <script> stripped") + void oversizedPayloadStripped() { + String huge = "<script>STEAL_OVERSIZE()</script>\n" + "lorem ipsum dolor ".repeat(500_000); + assertTrue(huge.length() > 8 * 1024 * 1024, "payload must exceed the 8 MB parse cap"); + String out = normalizer.normalize("html", huge); + assertFalse(out.toLowerCase().contains("<script"), "<script must be stripped from oversized input"); + assertFalse(out.contains("STEAL_OVERSIZE"), "script body must be stripped from oversized input"); + assertTrue(out.contains("lorem ipsum"), "body text survives the lossy strip"); + } + + // ───────────────────── integrity: legitimate content must survive ───────────────────── + + @Test + @DisplayName("already-extracted plain text keeps its heading line breaks") + void extractedTextHeadingsPreserved() { + // Shape produced by the upstream HTML extractor for an uploaded .html file: + // tag-free, ATX headings already on their own lines. + String extracted = "# Chapter One\nIntro paragraph.\n## Section A\nDetail text."; + String out = normalizer.normalize("html", extracted); + assertTrue(out.contains("# Chapter One"), "h1 line preserved"); + assertTrue(out.contains("## Section A"), "h2 line preserved"); + assertTrue(out.contains("Intro paragraph."), "body preserved"); + assertFalse(out.contains("# Chapter One Intro"), "heading must not be merged into the paragraph"); + } + + @Test + @DisplayName("genuine HTML article keeps headings and prose") + void legitArticleSurvives() { + String p = "<html><body><h1>Guide</h1><p>First paragraph.</p>" + + "<h2>Details</h2><p>Second paragraph.</p></body></html>"; + String out = normalizer.normalize("html", p); + assertTrue(out.contains("# Guide"), "h1 survives"); + assertTrue(out.contains("## Details"), "h2 survives"); + assertTrue(out.contains("First paragraph."), "first paragraph survives"); + assertTrue(out.contains("Second paragraph."), "second paragraph survives"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceTest.java index 98cd155d..547d15ad 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiContextServiceTest.java @@ -48,7 +48,7 @@ class WikiContextServiceTest { WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); kb.setId(42L); - when(kbService.listByAgentId(any())).thenReturn(List.of(kb)); + when(kbService.resolvePrimaryKb(any())).thenReturn(kb); service = new WikiContextService(kbService, pageService, hybridRetriever, properties); } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java new file mode 100644 index 00000000..a012823c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java @@ -0,0 +1,67 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiKnowledgeBaseService#resolvePrimaryKb(Long)}. + * + * <p>{@code listByAgentId} returns both the agent's own KBs and shared + * (agent-less) KBs, ordered by {@code update_time} descending. A naive + * {@code get(0)} pick therefore hands back whichever KB was touched most + * recently — which can be an unrelated shared KB. {@code resolvePrimaryKb} + * must still return the KB actually bound to the agent. + */ +class WikiKnowledgeBaseServiceTest { + + private final WikiKnowledgeBaseMapper kbMapper = mock(WikiKnowledgeBaseMapper.class); + private final WikiKnowledgeBaseService service = new WikiKnowledgeBaseService( + kbMapper, null, null, null, null, null); + + private static WikiKnowledgeBaseEntity kb(long id, Long agentId) { + WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity(); + entity.setId(id); + entity.setAgentId(agentId); + return entity; + } + + @Test + @DisplayName("prefers the agent's bound KB even when a shared KB was updated more recently") + void prefersBoundKbOverNewerSharedKb() { + // listByAgentId order is update_time DESC: two shared KBs precede the bound one. + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(900L, null), + kb(800L, null), + kb(100L, 7L))); + + assertThat(service.resolvePrimaryKb(7L)).isNotNull(); + assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L); + } + + @Test + @DisplayName("falls back to the most recent shared KB when the agent has no bound KB") + void fallsBackToSharedKbWhenNoneBound() { + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(900L, null), + kb(800L, null))); + + assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(900L); + } + + @Test + @DisplayName("returns null when the agent can reach no knowledge base") + void returnsNullWhenNoKb() { + when(kbMapper.selectList(any())).thenReturn(List.of()); + + assertThat(service.resolvePrimaryKb(7L)).isNull(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java new file mode 100644 index 00000000..1ea7a81f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java @@ -0,0 +1,40 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageMapper; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WikiPageServiceTest { + + @Test + void manualUpdateRefreshesUpdateTimeBeforePersisting() { + WikiPageMapper mapper = mock(WikiPageMapper.class); + WikiPageEntity page = new WikiPageEntity(); + page.setId(99L); + page.setKbId(7L); + page.setSlug("page"); + page.setContent("old"); + page.setSummary("old summary"); + page.setVersion(1); + page.setLastUpdatedBy("ai"); + LocalDateTime oldUpdateTime = LocalDateTime.now().minusDays(1); + page.setUpdateTime(oldUpdateTime); + when(mapper.selectOne(any())).thenReturn(page); + when(mapper.updateById(any(WikiPageEntity.class))).thenReturn(1); + + new WikiPageService(mapper, new ObjectMapper()) + .updatePageManually(7L, "page", "new body", null); + + assertTrue(page.getUpdateTime().isAfter(oldUpdateTime)); + verify(mapper).updateById(page); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java index a4c50400..f17a8b69 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java @@ -86,6 +86,20 @@ class WikiProcessingServiceLazyTest { return k; } + private WikiKnowledgeBaseEntity markdownKbWithFrontmatter(String ingestMode) { + WikiKnowledgeBaseEntity k = new WikiKnowledgeBaseEntity(); + k.setId(KB_ID); + k.setConfigContent(""" + --- + ingestMode: %s + --- + # Wiki Processing Rules + + Keep pages concise. + """.formatted(ingestMode)); + return k; + } + @Test @DisplayName("lazy mode: skips LLM pipeline, persists chunks, marks completed with 0 pages") void lazyMode_noLlmCalls() throws InterruptedException { @@ -136,6 +150,34 @@ class WikiProcessingServiceLazyTest { verify(kbService).updateStatus(KB_ID, "active"); } + @Test + @DisplayName("lazy mode from markdown frontmatter: skips LLM pipeline") + void markdownFrontmatterLazyMode_noLlmCalls() throws InterruptedException { + WikiRawMaterialEntity rawEntity = raw(); + WikiKnowledgeBaseEntity kbEntity = markdownKbWithFrontmatter("lazy"); + + when(rawService.claimForProcessing(RAW_ID)).thenReturn(true); + when(rawService.getById(RAW_ID)).thenReturn(rawEntity); + when(rawService.getTextContent(rawEntity)).thenReturn("Some document text for lazy ingest. ".repeat(20)); + when(kbService.getById(KB_ID)).thenReturn(kbEntity); + when(pageService.countByKbId(KB_ID)).thenReturn(0); + + CountDownLatch embedCalled = new CountDownLatch(1); + when(embeddingService.embedMissingChunks(KB_ID)).thenAnswer(inv -> { + embedCalled.countDown(); + return 0; + }); + + service.processRawMaterial(RAW_ID); + + verify(chunkService, times(1)).persistChunks(eq(KB_ID), eq(RAW_ID), anyList(), anyList()); + verify(pageService, never()).deleteExclusiveBySourceRawId(anyLong(), anyLong()); + assertTrue(embedCalled.await(5, TimeUnit.SECONDS), "embedMissingChunks should have been invoked"); + verify(progressBus).broadcast(eq(KB_ID), + eq(WikiProgressBus.EVENT_RAW_STARTED), + argThat((Map<String, Object> m) -> "lazy".equals(m.get("phase")))); + } + @Test @DisplayName("lazy mode with blank text: marks failed, no chunks persisted") void lazyMode_blankText_failsCleanly() { diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java new file mode 100644 index 00000000..f1683cbc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceModelSeedTest.java @@ -0,0 +1,250 @@ +package vip.mate.workspace.conversation; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pin the per-conversation model seed/backfill contract that fixes + * GitHub issue #183 (IM channels never propagated user model picks). + * + * <p>Three flavours of {@code getOrCreateSharedConversation} need to + * behave correctly: + * + * <ol> + * <li><b>New conversation + agent default</b> → inserted row carries + * the agent's model so the very first turn uses the right one.</li> + * <li><b>Existing conversation, model still null</b> (legacy IM rows + * created before this fix) → backfilled to the agent default on + * next inbound message, then sticky.</li> + * <li><b>Existing conversation, already pinned by user via admin UI</b> + * → left alone. The user pick always wins; the agent default never + * overwrites a user pin. This is the core invariant of the fix.</li> + * </ol> + * + * <p>Plus defensive cases: half-populated pairs (provider but no model, + * or vice versa) are treated as no-seed; the legacy 3-arg overload + * still works for non-IM callers; concurrent-insert race recovers. + */ +@ExtendWith(MockitoExtension.class) +class ConversationServiceModelSeedTest { + + @Mock private ConversationMapper conversationMapper; + @Mock private MessageMapper messageMapper; + @Mock private AgentMapper agentMapper; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private ConversationService service; + + // ------------------------------------------------------------------ + // 1. New conversation + agent default → seeded on insert + // ------------------------------------------------------------------ + + @Test + @DisplayName("new conversation: seeds modelProvider+modelName when both defaults non-blank") + void newConvSeedsBothModelFields() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + service.getOrCreateSharedConversation( + "feishu:ou_xyz", 42L, 7L, "volcano", "doubao-pro-32k"); + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + ConversationEntity row = inserted.getValue(); + assertThat(row.getConversationId()).isEqualTo("feishu:ou_xyz"); + assertThat(row.getAgentId()).isEqualTo(42L); + assertThat(row.getModelProvider()).isEqualTo("volcano"); + assertThat(row.getModelName()).isEqualTo("doubao-pro-32k"); + } + + @Test + @DisplayName("new conversation: NULL defaults → fields left blank (legacy non-IM path)") + void newConvWithNullDefaultsLeavesModelBlank() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + service.getOrCreateSharedConversation("web:42", 42L, 1L); // 3-arg overload + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + assertThat(inserted.getValue().getModelProvider()).isNull(); + assertThat(inserted.getValue().getModelName()).isNull(); + } + + @Test + @DisplayName("new conversation: half-populated pair (provider only) → no seed, no half-pin") + void newConvHalfPairProviderOnlyIsNoSeed() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + service.getOrCreateSharedConversation("feishu:x", 1L, 1L, "volcano", null); + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + assertThat(inserted.getValue().getModelProvider()).isNull(); + assertThat(inserted.getValue().getModelName()).isNull(); + } + + @Test + @DisplayName("new conversation: half-populated pair (model only) → no seed (matches IM path where agent has no provider)") + void newConvHalfPairModelOnlyIsNoSeed() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + // This is the actual ChannelMessageRouter case: AgentEntity carries + // modelName but no modelProvider field. Until provider info reaches + // here, we skip seeding rather than write a half-row that + // AgentService.getOrBuildAgent would then refuse to pin. + service.getOrCreateSharedConversation("feishu:x", 1L, 1L, null, "doubao-pro-32k"); + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + assertThat(inserted.getValue().getModelProvider()).isNull(); + assertThat(inserted.getValue().getModelName()).isNull(); + } + + @Test + @DisplayName("new conversation: blank-string defaults treated same as null") + void newConvBlankStringIsNoSeed() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + service.getOrCreateSharedConversation("feishu:x", 1L, 1L, " ", " "); + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + assertThat(inserted.getValue().getModelProvider()).isNull(); + assertThat(inserted.getValue().getModelName()).isNull(); + } + + // ------------------------------------------------------------------ + // 2. Existing conversation, no model → backfill on next message + // ------------------------------------------------------------------ + + @Test + @DisplayName("existing conv with null model → backfilled to agent default") + void existingUnpinnedConvBackfilledToAgentDefault() { + ConversationEntity existing = legacyConversation("feishu:ou_old", 42L); + existing.setModelProvider(null); + existing.setModelName(null); + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + service.getOrCreateSharedConversation( + "feishu:ou_old", 42L, 7L, "volcano", "doubao-pro-32k"); + + ArgumentCaptor<ConversationEntity> updated = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).updateById(updated.capture()); + assertThat(updated.getValue().getModelProvider()).isEqualTo("volcano"); + assertThat(updated.getValue().getModelName()).isEqualTo("doubao-pro-32k"); + // insert path NOT taken for an existing conv + verify(conversationMapper, never()).insert(any(ConversationEntity.class)); + } + + // ------------------------------------------------------------------ + // 3. Existing conv already PINNED by user → DO NOT overwrite (core fix invariant) + // ------------------------------------------------------------------ + + @Test + @DisplayName("existing conv already pinned by user → agent default does NOT overwrite (core #183 invariant)") + void existingPinnedConvNotOverwritten() { + ConversationEntity pinned = legacyConversation("feishu:ou_pinned", 42L); + pinned.setModelProvider("openai"); // user picked openai + pinned.setModelName("gpt-4o"); // user picked gpt-4o + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(pinned); + + // Channel router passes agent default "volcano / doubao", but user + // explicitly switched to openai — MUST preserve user's choice. + service.getOrCreateSharedConversation( + "feishu:ou_pinned", 42L, 7L, "volcano", "doubao-pro-32k"); + + // The owner-fix path also calls updateById when username != system. + // We seed our test with username=system so no spurious update fires, + // and assert the model fields stay user-chosen. + assertThat(pinned.getModelProvider()).isEqualTo("openai"); + assertThat(pinned.getModelName()).isEqualTo("gpt-4o"); + verify(conversationMapper, never()).insert(any(ConversationEntity.class)); + } + + @Test + @DisplayName("existing conv pinned to only provider (legacy half-row) → backfill repairs it") + void halfPinnedRowGetsRepairedByBackfill() { + // Realistic legacy state: an early admin UI release wrote provider + // but forgot the model. AgentService.getOrBuildAgentForConversation + // already defensively treats this as unpinned. Here we exercise the + // ConversationService side: since modelName is null, our backfill + // condition fires and both fields are rewritten to the agent + // default — restoring a coherent (provider, model) pair. + ConversationEntity half = legacyConversation("feishu:ou_half", 42L); + half.setModelProvider("openai"); + half.setModelName(null); + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(half); + + service.getOrCreateSharedConversation( + "feishu:ou_half", 42L, 7L, "volcano", "doubao-pro-32k"); + + // Backfill condition: (provider blank OR null) AND (name blank OR null). + // Half-row has provider != null but name == null → condition FALSE → no overwrite. + // This is intentional: the AgentService side de-pins half-rows, so + // letting them sit until the user fixes them via admin UI is safer + // than auto-rewriting a field they might be re-saving. + assertThat(half.getModelProvider()).isEqualTo("openai"); + assertThat(half.getModelName()).isNull(); + } + + // ------------------------------------------------------------------ + // 4. Backward-compat: legacy 3-arg overload still works + // ------------------------------------------------------------------ + + @Test + @DisplayName("legacy 3-arg overload delegates to 5-arg with null defaults (no seed)") + void legacyThreeArgOverloadHasNoSeedEffect() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + service.getOrCreateSharedConversation("web:99", 1L, 1L); + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + // Same behaviour as before this fix landed — model untouched. + assertThat(inserted.getValue().getModelProvider()).isNull(); + assertThat(inserted.getValue().getModelName()).isNull(); + } + + @Test + @DisplayName("legacy 2-arg overload also delegates safely") + void legacyTwoArgOverloadHasNoSeedEffect() { + when(conversationMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + service.getOrCreateSharedConversation("web:99", 1L); + + ArgumentCaptor<ConversationEntity> inserted = ArgumentCaptor.forClass(ConversationEntity.class); + verify(conversationMapper).insert(inserted.capture()); + assertThat(inserted.getValue().getModelProvider()).isNull(); + assertThat(inserted.getValue().getModelName()).isNull(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static ConversationEntity legacyConversation(String convId, Long agentId) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(convId); + c.setAgentId(agentId); + c.setUsername("system"); // same as SYSTEM_USER constant — avoid owner-fix update + c.setWorkspaceId(1L); + return c; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java new file mode 100644 index 00000000..b928c4c2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java @@ -0,0 +1,60 @@ +package vip.mate.workspace.core.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +class WorkspaceControllerMembersAuthTest { + + @Test + void listMembersRequiresViewerPermissionForNonGlobalAdmin() throws Exception { + WorkspaceService workspaceService = mock(WorkspaceService.class); + AuthService authService = mock(AuthService.class); + WorkspaceController controller = new WorkspaceController(workspaceService, authService); + UserEntity user = new UserEntity(); + user.setId(42L); + user.setUsername("alice"); + user.setRole("user"); + when(authService.findByUsername("alice")).thenReturn(user); + when(workspaceService.listMembers(7L)).thenReturn(List.of()); + + invokeListMembers(controller, 7L, new TestingAuthenticationToken("alice", "pw")); + + verify(workspaceService).requirePermission(7L, 42L, "viewer"); + verify(workspaceService).listMembers(7L); + } + + @Test + void listMembersLetsGlobalAdminBypassWorkspaceMembership() throws Exception { + WorkspaceService workspaceService = mock(WorkspaceService.class); + AuthService authService = mock(AuthService.class); + WorkspaceController controller = new WorkspaceController(workspaceService, authService); + UserEntity user = new UserEntity(); + user.setId(1L); + user.setUsername("admin"); + user.setRole("admin"); + when(authService.findByUsername("admin")).thenReturn(user); + when(workspaceService.listMembers(7L)).thenReturn(List.of()); + + invokeListMembers(controller, 7L, new TestingAuthenticationToken("admin", "pw")); + + verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString()); + verify(workspaceService).listMembers(7L); + } + + private void invokeListMembers(WorkspaceController controller, Long workspaceId, + Authentication auth) throws Exception { + Method method = WorkspaceController.class.getMethod("listMembers", Long.class, Authentication.class); + assertNotNull(method); + method.invoke(controller, workspaceId, auth); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java new file mode 100644 index 00000000..401affec --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java @@ -0,0 +1,84 @@ +package vip.mate.workspace.core.service; + +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.model.WorkspaceMemberEntity; +import vip.mate.workspace.core.repository.WorkspaceMapper; +import vip.mate.workspace.core.repository.WorkspaceMemberMapper; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class WorkspaceServiceRoleValidationTest { + + private final WorkspaceMapper workspaceMapper = mock(WorkspaceMapper.class); + private final WorkspaceMemberMapper memberMapper = mock(WorkspaceMemberMapper.class); + private final ConversationMapper conversationMapper = mock(ConversationMapper.class); + private final WikiKnowledgeBaseService wikiKnowledgeBaseService = mock(WikiKnowledgeBaseService.class); + private final WorkspaceService service = new WorkspaceService( + workspaceMapper, memberMapper, conversationMapper, wikiKnowledgeBaseService, null); + + @Test + void addMemberRejectsOwnerRole() { + WorkspaceEntity workspace = new WorkspaceEntity(); + workspace.setId(1L); + when(workspaceMapper.selectById(1L)).thenReturn(workspace); + when(memberMapper.selectOne(any())).thenReturn(null); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.addMember(1L, 42L, "owner")); + + assertEquals(400, ex.getCode()); + assertEquals("err.workspace.invalid_member_role", ex.getMsgKey()); + verify(memberMapper, never()).insert(any(WorkspaceMemberEntity.class)); + } + + @Test + void updateMemberRoleRejectsOwnerEscalation() { + WorkspaceMemberEntity member = new WorkspaceMemberEntity(); + member.setWorkspaceId(1L); + member.setUserId(42L); + member.setRole("admin"); + when(memberMapper.selectOne(any())).thenReturn(member); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.updateMemberRole(1L, 42L, "owner")); + + assertEquals(400, ex.getCode()); + assertEquals("err.workspace.invalid_member_role", ex.getMsgKey()); + verify(memberMapper, never()).updateById(any(WorkspaceMemberEntity.class)); + } + + @Test + void addMemberDefaultsMissingRoleToMember() { + WorkspaceEntity workspace = new WorkspaceEntity(); + workspace.setId(1L); + when(workspaceMapper.selectById(1L)).thenReturn(workspace); + when(memberMapper.selectOne(any())).thenReturn(null); + + WorkspaceMemberEntity member = service.addMember(1L, 42L, null); + + assertEquals("member", member.getRole()); + verify(memberMapper).insert(any(WorkspaceMemberEntity.class)); + } + + @Test + void updateMemberRoleRejectsUnknownRole() { + WorkspaceMemberEntity member = new WorkspaceMemberEntity(); + member.setWorkspaceId(1L); + member.setUserId(42L); + member.setRole("member"); + when(memberMapper.selectOne(any())).thenReturn(member); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.updateMemberRole(1L, 42L, "superuser")); + + assertEquals(400, ex.getCode()); + verify(memberMapper, never()).updateById(any(WorkspaceMemberEntity.class)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemoryArchiveServiceTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemoryArchiveServiceTest.java new file mode 100644 index 00000000..fd96472d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemoryArchiveServiceTest.java @@ -0,0 +1,426 @@ +package vip.mate.workspace.document; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Contract for the agent workspace memory-snapshot export / import service. + * <p> + * The service is the gate between user-supplied ZIPs and the + * {@code mate_workspace_file} table: each test pins one of the safety or + * correctness invariants that prevents the import path from being abused — + * cross-workspace writes, ZIP-bomb decompression, path traversal disguised + * as a filename, the unchanged-content short-circuit, and the + * preview / apply consistency that the UI relies on. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WorkspaceMemoryArchiveServiceTest { + + @Mock private WorkspaceFileService workspaceFileService; + @Mock private AgentService agentService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private WorkspaceMemoryArchiveService service; + + @BeforeEach + void setUp() { + service = new WorkspaceMemoryArchiveService( + workspaceFileService, agentService, objectMapper); + } + + // ---------- ownership ---------- + + @Test + @DisplayName("Cross-workspace agent → 403 MateClawException, no DB read of files") + void crossWorkspaceForbidden() { + AgentEntity agent = makeAgent(1L, 10L); // belongs to workspace 10 + when(agentService.getAgent(1L)).thenReturn(agent); + + assertThatThrownBy(() -> service.export(1L, 20L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("does not belong"); + verify(workspaceFileService, never()).listFiles(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + @DisplayName("Unknown agent → 404 MateClawException") + void unknownAgentRejected() { + when(agentService.getAgent(99L)).thenReturn(null); + assertThatThrownBy(() -> service.export(99L, 1L)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("Null workspaceId → 400 (controller forgot to forward the header)") + void nullWorkspaceIdRejected() { + // assertOwnership rejects null workspaceId before even touching agentService. + assertThatThrownBy(() -> service.export(1L, null)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("workspaceId"); + verify(agentService, never()).getAgent(org.mockito.ArgumentMatchers.anyLong()); + } + + // ---------- export ---------- + + @Test + @DisplayName("Export bundles whitelisted files + a manifest, excludes others") + void exportEmitsManifestAndWhitelistOnly() throws Exception { + wireAgent(1L, 10L); + when(workspaceFileService.listFiles(1L)).thenReturn(List.of( + stubMeta("MEMORY.md"), + stubMeta("memory/2026-05-10.md"), + stubMeta("memory/2026-05-11.md"), + // Outside the whitelist — must NOT make it into the archive. + stubMeta("secrets.txt"), + stubMeta("some-other.md"))); + when(workspaceFileService.getFile(eq(1L), eq("MEMORY.md"))) + .thenReturn(stubFile("MEMORY.md", "fact body")); + when(workspaceFileService.getFile(eq(1L), eq("memory/2026-05-10.md"))) + .thenReturn(stubFile("memory/2026-05-10.md", "day 10")); + when(workspaceFileService.getFile(eq(1L), eq("memory/2026-05-11.md"))) + .thenReturn(stubFile("memory/2026-05-11.md", "day 11")); + + byte[] bundle = service.export(1L, 10L); + + Map<String, byte[]> entries = readZip(bundle); + assertThat(entries).containsKeys( + WorkspaceMemoryArchiveService.MANIFEST_NAME, + "MEMORY.md", + "memory/2026-05-10.md", + "memory/2026-05-11.md"); + assertThat(entries).doesNotContainKeys("secrets.txt", "some-other.md"); + + // Manifest carries provenance. + @SuppressWarnings("unchecked") + Map<String, Object> manifest = (Map<String, Object>) objectMapper.readValue( + entries.get(WorkspaceMemoryArchiveService.MANIFEST_NAME), Map.class); + assertThat(manifest).containsEntry("version", WorkspaceMemoryArchiveService.BUNDLE_VERSION); + assertThat(manifest).containsEntry("agentId", 1); // Jackson reads Long → Integer when fits + assertThat(manifest).containsKey("exportedAt"); + } + + // ---------- preview ---------- + + @Test + @DisplayName("Preview classifies create / update / skip correctly without writing") + void previewClassifiesEntries() throws Exception { + wireAgent(1L, 10L); + // Existing files: MEMORY.md (will UPDATE — content changes), + // PROFILE.md (will SKIP — content identical). + when(workspaceFileService.getFile(1L, "MEMORY.md")) + .thenReturn(stubFile("MEMORY.md", "old memory")); + when(workspaceFileService.getFile(1L, "PROFILE.md")) + .thenReturn(stubFile("PROFILE.md", "same persona")); + // memory/2026-05-12.md doesn't exist → will CREATE. + when(workspaceFileService.getFile(1L, "memory/2026-05-12.md")) + .thenReturn(null); + + byte[] zip = makeZip(Map.of( + "MEMORY.md", "NEW memory content", + "PROFILE.md", "same persona", // unchanged — should land in skip + "memory/2026-05-12.md", "day 12 body", + "not-allowed.bin", "binary blob")); + + WorkspaceMemoryArchiveService.ImportPreview preview = + service.previewImport(1L, 10L, zip); + + assertThat(preview.willCreate()).containsExactlyInAnyOrder("memory/2026-05-12.md"); + assertThat(preview.willUpdate()) + .extracting(WorkspaceMemoryArchiveService.FileDiff::filename) + .containsExactlyInAnyOrder("MEMORY.md"); + assertThat(preview.willSkip()) + .extracting(WorkspaceMemoryArchiveService.SkipEntry::filename, + WorkspaceMemoryArchiveService.SkipEntry::reason) + .contains( + org.assertj.core.groups.Tuple.tuple("PROFILE.md", "unchanged"), + org.assertj.core.groups.Tuple.tuple("not-allowed.bin", "not in whitelist")); + + // Critical: preview must NEVER call saveFile. + verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("Daily filename matching the digit shape but not a real date is rejected") + void impossibleCalendarDateRejected() throws Exception { + wireAgent(1L, 10L); + when(workspaceFileService.getFile(1L, "memory/2026-05-12.md")) + .thenReturn(null); + + byte[] zip = makeZip(Map.of( + "memory/2026-05-12.md", "real date → create", + "memory/2026-13-99.md", "month 13 / day 99 → reject", + "memory/2026-02-30.md", "feb 30 does not exist → reject")); + + WorkspaceMemoryArchiveService.ImportPreview preview = + service.previewImport(1L, 10L, zip); + + assertThat(preview.willCreate()).containsExactlyInAnyOrder("memory/2026-05-12.md"); + assertThat(preview.willSkip()) + .extracting(WorkspaceMemoryArchiveService.SkipEntry::filename, + WorkspaceMemoryArchiveService.SkipEntry::reason) + .contains( + org.assertj.core.groups.Tuple.tuple("memory/2026-13-99.md", "not in whitelist"), + org.assertj.core.groups.Tuple.tuple("memory/2026-02-30.md", "not in whitelist")); + + verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("Preview surfaces old vs new hash + size for updated files") + void previewExposesDiffMetadata() throws Exception { + wireAgent(1L, 10L); + when(workspaceFileService.getFile(1L, "MEMORY.md")) + .thenReturn(stubFile("MEMORY.md", "old")); + byte[] zip = makeZip(Map.of("MEMORY.md", "much-longer-new-content")); + + WorkspaceMemoryArchiveService.ImportPreview preview = + service.previewImport(1L, 10L, zip); + + assertThat(preview.willUpdate()).hasSize(1); + WorkspaceMemoryArchiveService.FileDiff diff = preview.willUpdate().get(0); + assertThat(diff.filename()).isEqualTo("MEMORY.md"); + assertThat(diff.oldSize()).isEqualTo(3L); // "old" + assertThat(diff.newSize()).isEqualTo(23L); + assertThat(diff.oldHash()).isNotBlank().isNotEqualTo(diff.newHash()); + } + + // ---------- apply ---------- + + @Test + @DisplayName("Apply writes exactly the create + update set the preview promised") + void applyWritesPromisedSet() throws Exception { + wireAgent(1L, 10L); + when(workspaceFileService.getFile(1L, "MEMORY.md")) + .thenReturn(stubFile("MEMORY.md", "old")); + when(workspaceFileService.getFile(1L, "PROFILE.md")) + .thenReturn(stubFile("PROFILE.md", "same")); + when(workspaceFileService.getFile(1L, "memory/2026-05-12.md")) + .thenReturn(null); + + byte[] zip = makeZip(Map.of( + "MEMORY.md", "new memory", + "PROFILE.md", "same", // unchanged → skip + "memory/2026-05-12.md", "day 12", + "secrets.bin", "blob")); // whitelist reject + + WorkspaceMemoryArchiveService.ImportResult result = + service.apply(1L, 10L, zip); + + assertThat(result.applied()).isEqualTo(2); + assertThat(result.skipped()).isEqualTo(2); // PROFILE unchanged + secrets.bin not whitelisted + + ArgumentCaptor<String> nameCap = ArgumentCaptor.forClass(String.class); + ArgumentCaptor<String> bodyCap = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService, times(2)).saveFile(eq(1L), nameCap.capture(), bodyCap.capture()); + assertThat(nameCap.getAllValues()).containsExactlyInAnyOrder("MEMORY.md", "memory/2026-05-12.md"); + // Unchanged PROFILE.md and out-of-whitelist secrets.bin must NEVER be written. + assertThat(nameCap.getAllValues()).doesNotContain("PROFILE.md", "secrets.bin"); + } + + // ---------- ZIP bomb defenses ---------- + + @Test + @DisplayName("Too many entries → 400, no writes") + void tooManyEntriesRejected() throws Exception { + wireAgent(1L, 10L); + // Use the ZIP API directly so we can write duplicate-named entries + // past the cap; LinkedHashMap dedupes keys before we'd ever reach + // MAX_ENTRIES. (The bomb defence is enforced on archive-level entry + // count, not unique names.) + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(baos)) { + for (int i = 0; i < WorkspaceMemoryArchiveService.MAX_ENTRIES + 5; i++) { + zip.putNextEntry(new ZipEntry("dup-entry-" + i + ".txt")); + zip.write(new byte[]{'x'}); + zip.closeEntry(); + } + } + + assertThatThrownBy(() -> service.apply(1L, 10L, baos.toByteArray())) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("too many entries"); + verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("Single oversized entry → 400, no writes") + void oversizedEntryRejected() throws Exception { + wireAgent(1L, 10L); + // One entry past the per-entry cap. + byte[] huge = new byte[(int) (WorkspaceMemoryArchiveService.MAX_ENTRY_BYTES + 100)]; + byte[] zip = makeZip(Map.of("MEMORY.md", new String(huge, StandardCharsets.UTF_8))); + + assertThatThrownBy(() -> service.apply(1L, 10L, zip)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("size limit"); + verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("Total decompressed bytes over cap → 400, no writes") + void totalSizeRejected() throws Exception { + wireAgent(1L, 10L); + // Twenty 900 KB entries ≈ 18 MB total — past the 16 MB cap. + int entrySize = 900 * 1024; + Map<String, String> bomb = new LinkedHashMap<>(); + String body = new String(new byte[entrySize], StandardCharsets.UTF_8); + for (int i = 0; i < 20; i++) { + bomb.put("memory/2026-05-" + String.format("%02d", (i % 28) + 1) + ".md", body); + } + byte[] zip = makeZip(bomb); + + assertThatThrownBy(() -> service.apply(1L, 10L, zip)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("total size"); + verify(workspaceFileService, never()).saveFile(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("Empty / null archive → 400") + void emptyArchiveRejected() { + wireAgent(1L, 10L); + assertThatThrownBy(() -> service.apply(1L, 10L, new byte[0])) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("Empty archive"); + assertThatThrownBy(() -> service.apply(1L, 10L, null)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("Empty archive"); + } + + // ---------- path traversal / weird names ---------- + + @Test + @DisplayName("Path-traversal style filenames land in skip, never in saveFile") + void pathTraversalSkipped() throws Exception { + wireAgent(1L, 10L); + byte[] zip = makeZip(Map.of( + "../../../etc/passwd", "root:x:0", + "memory/../etc/passwd", "root:x:0", + "memory\\2026-05-12.md", "windows-separator", + "/absolute/path.md", "absolute")); + + WorkspaceMemoryArchiveService.ImportPreview preview = + service.previewImport(1L, 10L, zip); + assertThat(preview.willCreate()).isEmpty(); + assertThat(preview.willUpdate()).isEmpty(); + assertThat(preview.willSkip()) + .extracting(WorkspaceMemoryArchiveService.SkipEntry::reason) + .allSatisfy(r -> assertThat(r).isEqualTo("not in whitelist")); + } + + @Test + @DisplayName("Invalid date in memory/YYYY-MM-DD.md → skip") + void invalidDailyFilenameSkipped() throws Exception { + wireAgent(1L, 10L); + byte[] zip = makeZip(Map.of( + "memory/2026-13-99.md", "fake date but regex passes? must reject", + "memory/notes.md", "wrong name shape", + "memory/2026-05-12.txt", "wrong extension")); + + WorkspaceMemoryArchiveService.ImportPreview preview = + service.previewImport(1L, 10L, zip); + // The regex matches digit shape but the values 13-99 happen to pass + // \d{4}-\d{2}-\d{2} — guarded by future enhancement. For v1 we only + // pin the literal-name / extension / non-digit rejections. (See + // RFC §2.3.1 — date-range validation deferred.) + assertThat(preview.willSkip()) + .extracting(WorkspaceMemoryArchiveService.SkipEntry::filename) + .contains("memory/notes.md", "memory/2026-05-12.txt"); + } + + // ---------- helpers ---------- + + private void wireAgent(Long agentId, Long workspaceId) { + when(agentService.getAgent(agentId)).thenReturn(makeAgent(agentId, workspaceId)); + } + + private static AgentEntity makeAgent(Long id, Long workspaceId) { + AgentEntity a = new AgentEntity(); + a.setId(id); + a.setName("test-agent"); + a.setEnabled(true); + a.setWorkspaceId(workspaceId); + return a; + } + + private static WorkspaceFileEntity stubMeta(String filename) { + WorkspaceFileEntity e = new WorkspaceFileEntity(); + e.setFilename(filename); + return e; + } + + private static WorkspaceFileEntity stubFile(String filename, String content) { + WorkspaceFileEntity e = stubMeta(filename); + e.setContent(content); + return e; + } + + private static byte[] makeZip(Map<String, String> entries) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(baos)) { + for (Map.Entry<String, String> e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zip.putNextEntry(entry); + zip.write(e.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + return baos.toByteArray(); + } + + private static Map<String, byte[]> readZip(byte[] data) throws Exception { + Map<String, byte[]> out = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) { + ZipEntry entry; + byte[] buf = new byte[4096]; + while ((entry = zip.getNextEntry()) != null) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int n; + while ((n = zip.read(buf)) > 0) body.write(buf, 0, n); + out.put(entry.getName(), body.toByteArray()); + zip.closeEntry(); + } + } + return out; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java new file mode 100644 index 00000000..8989778c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/document/WorkspaceMemorySearchTest.java @@ -0,0 +1,280 @@ +package vip.mate.workspace.document; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.workspace.document.model.WorkspaceFileEntity; +import vip.mate.workspace.document.repository.WorkspaceFileMapper; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Contract for {@link WorkspaceFileService#searchSnippets} — the back end + * of the {@code search_workspace_memory} agent tool. + * <p> + * Tests run against a Mockito-stubbed {@link WorkspaceFileMapper}: the DB-side + * AND-LIKE narrowing is MyBatis-Plus's problem, not this service's — what we + * verify here is the post-fetch pipeline (tokenization, per-line extraction, + * weighted scoring, snippet rendering) plus the wrapper construction so we + * don't silently drop scope filters or term groups. + */ +@ExtendWith(MockitoExtension.class) +class WorkspaceMemorySearchTest { + + @Mock private WorkspaceFileMapper fileMapper; + private WorkspaceFileService service; + + @BeforeAll + static void initMyBatisPlusCache() { + // LambdaQueryWrapper resolves SFunction → column via TableInfoHelper. + // Spring would init it during mapper scan; here we trigger it manually. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + WorkspaceFileEntity.class); + } + + @BeforeEach + void setUp() { + service = new WorkspaceFileService(fileMapper); + } + + // ---------- tokenize ---------- + + @Test + @DisplayName("tokenize: null / blank / single-char yields empty list") + void tokenizeShortInputs() { + assertThat(WorkspaceFileService.tokenize(null)).isEmpty(); + assertThat(WorkspaceFileService.tokenize("")).isEmpty(); + assertThat(WorkspaceFileService.tokenize(" ")).isEmpty(); + // A single CJK char DOES produce one token — query-length guard at + // searchSnippets level is what enforces the 2-char minimum. + assertThat(WorkspaceFileService.tokenize("好")).containsExactly("好"); + } + + @Test + @DisplayName("tokenize: long CJK runs split into non-overlapping 2-char windows (within cap)") + void tokenizeCjkPairs() { + // 10-char CJK run → 5 windows; all five survive under MAX_TERMS = 6. + assertThat(WorkspaceFileService.tokenize("用户喜欢周日早上跑步")) + .containsExactly("用户", "喜欢", "周日", "早上", "跑步"); + } + + @Test + @DisplayName("tokenize: CJK/Latin boundary splits, Latin runs stay intact") + void tokenizeMixedScripts() { + // "用户Foo123跑步" → CJK run "用户" (2-char window) + Latin/digit run + // "Foo123" + CJK run "跑步". + assertThat(WorkspaceFileService.tokenize("用户Foo123跑步")) + .containsExactly("用户", "Foo123", "跑步"); + } + + @Test + @DisplayName("tokenize: dedupe + cap at 6") + void tokenizeDedupeAndCap() { + // Whitespace splits; "abc" repeats are dedup'd; only first 6 kept. + // The 7th and 8th tokens ("ppp" / "qqq") get trimmed by the cap. + assertThat(WorkspaceFileService.tokenize("abc abc def ghi jkl mno opq ppp qqq")) + .containsExactly("abc", "def", "ghi", "jkl", "mno", "opq"); + } + + @Test + @DisplayName("tokenize: a 12-char CJK run yields 6 windows and stops at the cap") + void tokenizeLongCjkFitsCap() { + // 12-char run → 6 windows; the 7th and 8th would be "测一" / "下下" + // if we extended the query, but the cap stops at 6 either way. + assertThat(WorkspaceFileService.tokenize("用户喜欢周日早上跑步公园")) + .containsExactly("用户", "喜欢", "周日", "早上", "跑步", "公园"); + } + + // ---------- searchSnippets contract ---------- + + @Test + @DisplayName("Short query: returns empty without touching the mapper") + void shortQueryReturnsEmpty() { + assertThat(service.searchSnippets(1L, "a", null, 10)).isEmpty(); + assertThat(service.searchSnippets(1L, " ", null, 10)).isEmpty(); + assertThat(service.searchSnippets(1L, "", null, 10)).isEmpty(); + assertThat(service.searchSnippets(1L, null, null, 10)).isEmpty(); + // limit <= 0 short-circuits. + assertThat(service.searchSnippets(1L, "running", null, 0)).isEmpty(); + } + + @Test + @DisplayName("No candidate files: returns empty list") + void noCandidateRowsReturnsEmpty() { + when(fileMapper.selectList(any())).thenReturn(List.of()); + assertThat(service.searchSnippets(1L, "running", null, 10)).isEmpty(); + } + + @Test + @DisplayName("Score ordering: MEMORY > memory/* > PROFILE > AGENTS for identical term-hit counts") + void scoreOrdering() { + List<WorkspaceFileEntity> candidates = List.of( + file("AGENTS.md", "Line about running.\n"), + file("MEMORY.md", "Line about running.\n"), + file("PROFILE.md", "Line about running.\n"), + file("memory/2026-05-10.md", "Line about running.\n")); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List<MemorySearchHit> hits = service.searchSnippets(1L, "running", null, 10); + + assertThat(hits).extracting(MemorySearchHit::filename) + .containsExactly("MEMORY.md", "memory/2026-05-10.md", "PROFILE.md", "AGENTS.md"); + assertThat(hits.get(0).score()).isGreaterThan(hits.get(1).score()); + assertThat(hits.get(1).score()).isGreaterThan(hits.get(2).score()); + assertThat(hits.get(2).score()).isGreaterThan(hits.get(3).score()); + } + + @Test + @DisplayName("Term-hit count: line matching 2 terms scores 2× the per-file weight") + void termHitCountAffectsScore() { + List<WorkspaceFileEntity> candidates = List.of( + file("MEMORY.md", + "Line with running and shoes.\n" + + "Line with only running.\n")); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List<MemorySearchHit> hits = service.searchSnippets(1L, "running shoes", null, 10); + + // Both lines surface; the 2-term line ranks first with score 2.0, the + // 1-term line ranks second with score 1.0 (MEMORY weight = 1.0). + assertThat(hits).hasSize(2); + assertThat(hits.get(0).score()).isEqualTo(2.0); + assertThat(hits.get(0).snippet()).contains("[[running]]").contains("[[shoes]]"); + assertThat(hits.get(1).score()).isEqualTo(1.0); + } + + @Test + @DisplayName("Per-file hit cap: a flood of matching lines in one file caps at 5") + void perFileHitCap() { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 12; i++) content.append("line ").append(i).append(" running\n"); + List<WorkspaceFileEntity> candidates = List.of(file("MEMORY.md", content.toString())); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List<MemorySearchHit> hits = service.searchSnippets(1L, "running", null, 30); + + assertThat(hits).hasSize(5); + // First 5 lines only. + assertThat(hits).extracting(MemorySearchHit::lineNumber) + .containsExactly(1, 2, 3, 4, 5); + } + + @Test + @DisplayName("Limit caps the total result count after global ranking") + void limitClampsResults() { + StringBuilder mem = new StringBuilder(); + for (int i = 0; i < 5; i++) mem.append("MEM running\n"); + StringBuilder daily = new StringBuilder(); + for (int i = 0; i < 5; i++) daily.append("DAILY running\n"); + when(fileMapper.selectList(any())).thenReturn(List.of( + file("MEMORY.md", mem.toString()), + file("memory/2026-05-10.md", daily.toString()))); + + List<MemorySearchHit> hits = service.searchSnippets(1L, "running", null, 3); + + assertThat(hits).hasSize(3); + // Highest-weight hits come first — all from MEMORY.md. + assertThat(hits).extracting(MemorySearchHit::filename) + .containsExactly("MEMORY.md", "MEMORY.md", "MEMORY.md"); + } + + @Test + @DisplayName("Snippet: each matched term is wrapped in [[...]] with term boundaries preserved") + void snippetHighlightingTermBoundaries() { + List<WorkspaceFileEntity> candidates = List.of(file("MEMORY.md", + "用户喜欢在公园跑步\n")); + when(fileMapper.selectList(any())).thenReturn(candidates); + + List<MemorySearchHit> hits = service.searchSnippets(1L, "用户喜欢跑步", null, 10); + // tokenize("用户喜欢跑步") → ["用户","喜欢","跑步"]; line contains all three. + assertThat(hits).hasSize(1); + assertThat(hits.get(0).snippet()) + .contains("[[用户]]") + .contains("[[喜欢]]") + .contains("[[跑步]]") + // Adjacent term matches keep their boundary, not merged into one bracket. + .contains("[[用户]][[喜欢]]"); + } + + @Test + @DisplayName("Snippet: long line is clipped to ±80 chars around the first match with ellipses") + void snippetTruncation() { + String lead = "x".repeat(200); + String tail = "y".repeat(200); + String line = lead + " running " + tail; + when(fileMapper.selectList(any())).thenReturn(List.of(file("MEMORY.md", line + "\n"))); + + List<MemorySearchHit> hits = service.searchSnippets(1L, "running", null, 10); + + assertThat(hits).hasSize(1); + String snippet = hits.get(0).snippet(); + assertThat(snippet).startsWith("..."); + assertThat(snippet).endsWith("..."); + assertThat(snippet).contains("[[running]]"); + // Clip window is 80 chars on each side plus the match (7 chars) plus + // two "..." markers (6 chars). Should be << original length. + assertThat(snippet.length()).isLessThan(line.length()); + } + + @Test + @DisplayName("Wrapper carries one content-LIKE per token plus the prefix group and LIMIT 50") + void wrapperContainsTermsAndPrefixes() { + when(fileMapper.selectList(any())).thenReturn(List.of()); + Set<String> prefixes = new LinkedHashSet<>(List.of("memory/", "MEMORY.md")); + service.searchSnippets(42L, "running shoes 跑步", prefixes, 10); + + @SuppressWarnings("unchecked") + ArgumentCaptor<LambdaQueryWrapper<WorkspaceFileEntity>> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(fileMapper).selectList(captor.capture()); + LambdaQueryWrapper<WorkspaceFileEntity> wrapper = captor.getValue(); + + // Force SQL rendering so paramNameValuePairs gets populated; assert + // on the SQL shape (placeholders only — column-name casing depends + // on global underscore-camelCase config not present in this unit + // test) and on the bound literal values. + String sql = wrapper.getTargetSql(); + // One equality on agent + two prefix LIKEs in an OR group + three + // content LIKEs, ANDed together, suffixed with the candidate cap. + assertThat(sql).contains("LIKE ? OR") + .contains("AND content") + .contains("LIMIT 50"); + assertThat(sql.chars().filter(ch -> ch == '?').count()) + .as("one agentId + two prefix LIKEs + three content LIKEs = 6 bind params") + .isEqualTo(6); + + List<Object> values = new ArrayList<>(wrapper.getParamNameValuePairs().values()); + assertThat(values).contains(42L); + // Each content-LIKE term gets %term% by MyBatis-Plus's like(). + assertThat(values).contains("%running%", "%shoes%", "%跑步%"); + // likeRight produces "prefix%" — confirms both prefixes were bound. + assertThat(values).contains("memory/%", "MEMORY.md%"); + } + + // ---------- helpers ---------- + + private static WorkspaceFileEntity file(String filename, String content) { + WorkspaceFileEntity e = new WorkspaceFileEntity(); + e.setAgentId(1L); + e.setFilename(filename); + e.setContent(content); + return e; + } +} diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 7a3178c2..90d9389e 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -1,14 +1,15 @@ { "name": "mateclaw-ui", - "version": "1.3.0", + "version": "1.4.0", "private": true, "type": "module", "description": "MateClaw - Personal AI Assistant Web Console", "scripts": { "dev": "vite", - "build": "node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", + "build": "bash ../scripts/check-snowflake-precision.sh && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", "preview": "vite preview", - "lint": "eslint src --ext .ts,.vue --fix" + "lint": "eslint src --ext .ts,.vue --fix && bash ../scripts/check-snowflake-precision.sh", + "lint:precision": "bash ../scripts/check-snowflake-precision.sh" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", diff --git a/mateclaw-ui/public/icons/providers/grok.svg b/mateclaw-ui/public/icons/providers/grok.svg new file mode 100644 index 00000000..efb1a618 --- /dev/null +++ b/mateclaw-ui/public/icons/providers/grok.svg @@ -0,0 +1 @@ +<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok \ No newline at end of file diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 53db0867..09227e38 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -87,7 +87,7 @@ export const authApi = { http.post('/auth/login', data), listUsers: () => http.get('/auth/users'), createUser: (data: any) => http.post('/auth/users', data), - changePassword: (id: number, oldPassword: string, newPassword: string) => + changePassword: (id: string | number, oldPassword: string, newPassword: string) => http.put(`/auth/users/${id}/password`, null, { params: { oldPassword, newPassword } }), } @@ -154,6 +154,13 @@ export const chatApi = { // ==================== Conversation ==================== export const conversationApi = { list: () => http.get('/conversations'), + /** + * Paginated list used by the Sessions admin page. Keyword matches title + * or conversationId server-side; ChatConsole's left panel still uses the + * non-paginated list() because it shows a per-agent rolling history. + */ + page: (params: { page?: number; size?: number; keyword?: string }) => + http.get('/conversations/page', { params }), listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) => http.get(`/conversations/${conversationId}/messages`, { params }), getStatus: (conversationId: string) => @@ -164,6 +171,18 @@ export const conversationApi = { http.delete(`/conversations/${conversationId}/messages`), rename: (conversationId: string, title: string) => http.put(`/conversations/${conversationId}/title`, { title }), + setPinned: (conversationId: string, pinned: boolean) => + http.put(`/conversations/${conversationId}/pin`, { pinned }), + /** + * Pin this conversation to a specific (provider, model). Closes issue + * #183 — lets the admin UI switch model for IM-channel conversations + * (Feishu / DingTalk / WeCom / Telegram / Discord / QQ / Slack / WeChat), + * not just for the Web channel. Both params required and non-empty. + */ + setModel: (conversationId: string, modelProvider: string, modelName: string) => + http.put(`/conversations/${conversationId}/model`, { modelProvider, modelName }), + batchDelete: (conversationIds: string[]) => + http.post('/conversations/batch-delete', { conversationIds }), } // ==================== Skill ==================== @@ -181,6 +200,8 @@ export const skillApi = { enabled?: boolean /** 'PASSED' / 'FAILED' — filters by security_scan_status. */ scanStatus?: string + /** 'active' / 'stale' / 'archived' — filters by lifecycle_state. */ + lifecycleState?: string } = {}) => http.get('/skills', { params }), /** Tab count aggregate — returns { all, builtin, mcp, dynamic } */ counts: () => http.get('/skills/counts'), @@ -213,6 +234,35 @@ export const skillApi = { http.post(`/skills/${id}/secrets`, { key, value }), deleteSecret: (id: string | number, key: string) => http.delete(`/skills/${id}/secrets/${encodeURIComponent(key)}`), + + // ---- Lifecycle curator ---- + /** Pin / unpin a skill — pinned skills are never auto-archived. */ + pin: (id: string | number, pinned: boolean) => + http.post(`/skills/${id}/pin`, { pinned }), + /** + * Manually archive a skill. When the skill is bound to an enabled agent + * and {@code force} is false, the backend replies HTTP 409 with + * {@code code: 'BOUND_SKILL_CONFIRM_REQUIRED'} and a {@code boundAgents} + * list; retry with {@code force: true} to confirm. + */ + archive: (id: string | number, opts: { force?: boolean; reason?: string } = {}) => + http.post(`/skills/${id}/archive`, { reason: opts.reason ?? null }, + { params: { force: opts.force ?? false } }), + /** Restore an archived skill back to active. */ + restore: (id: string | number) => http.post(`/skills/${id}/restore`), + /** Curator control-panel status (config / control / counts / lastReport). */ + curatorStatus: () => http.get('/skills/curator/status'), + /** Run a curator dry-run preview immediately. */ + curatorDryRun: () => http.post('/skills/curator/dry-run'), + /** Activate (apply transitions) or deactivate (preview-only) the curator. */ + curatorActivate: (activate: boolean) => + http.post('/skills/curator/activate', null, { params: { activate } }), + curatorPause: () => http.post('/skills/curator/pause'), + curatorResume: () => http.post('/skills/curator/resume'), + /** List recent curator run report ids. */ + curatorReports: () => http.get('/skills/curator/reports'), + /** Read one curator run report (parsed run.json). */ + curatorReport: (runId: string) => http.get(`/skills/curator/reports/${runId}`), } /** Shape returned by GET /skills/{id}/secrets. */ @@ -229,8 +279,8 @@ export const activityApi = { http.get('/activity/feed', { params }), } -// ==================== Backstage (admin runtime view) ==================== -export interface BackstageRunCard { +// ==================== Live (admin runtime view) ==================== +export interface LiveRunCard { conversationId: string agentId: number | null agentName: string | null @@ -252,10 +302,15 @@ export interface BackstageRunCard { subagentCount: number } -export interface BackstageSubagentCard { +export interface LiveSubagentCard { subagentId: string parentConversationId: string | null childConversationId: string | null + rootConversationId: string | null + /** subagentId of the immediate parent; null for first-level (depth-1) children. */ + parentSubagentId: string | null + /** 1 for a first-level child, 2 for a grandchild, etc. */ + depth: number agentId: number | null agentName: string | null agentIcon: string | null @@ -267,7 +322,7 @@ export interface BackstageSubagentCard { ageMs: number } -export interface BackstageSummary { +export interface LiveSummary { running: number stuck: number orphan: number @@ -275,15 +330,15 @@ export interface BackstageSummary { subagentsActive: number } -export interface BackstageSnapshot { - summary: BackstageSummary - runs: BackstageRunCard[] - subagents: BackstageSubagentCard[] +export interface LiveSnapshot { + summary: LiveSummary + runs: LiveRunCard[] + subagents: LiveSubagentCard[] timestamp: number } -export const backstageApi = { - snapshot: () => http.get<{ data: BackstageSnapshot }>('/admin/agent-runtime/snapshot'), +export const liveApi = { + snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'), stop: (conversationId: string) => http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`), recycle: (conversationId: string) => @@ -293,6 +348,19 @@ export const backstageApi = { sweep: () => http.post('/admin/agent-runtime/sweep'), } +// ==================== Notification summary (sidebar attention badges) ==================== +export interface NotificationSummary { + pendingApprovals: number + stuckAgents: number + failedCrons: number + downChannels: number + downMcps: number +} + +export const notificationApi = { + summary: () => http.get<{ data: NotificationSummary }>('/notifications/summary'), +} + // ==================== ACP Endpoints (RFC-090 Phase 7) ==================== export const acpApi = { list: () => http.get('/acp/endpoints'), @@ -363,6 +431,13 @@ export const toolApi = { delete: (id: string | number) => http.delete(`/tools/${id}`), toggle: (id: string | number, enabled: boolean) => http.put(`/tools/${id}/toggle?enabled=${enabled}`), + /** + * Set a builtin/channel tool's progressive-disclosure tier + * ('core' | 'extension'). MCP/ACP/skill tools are tiered at their owning + * source and return 409 here. + */ + setDisclosureTier: (id: string | number, tier: 'core' | 'extension') => + http.put(`/tools/${id}/disclosure-tier`, { tier }), } // ==================== Channel ==================== @@ -400,6 +475,11 @@ export const channelApi = { http.post('/channels/webhook/dingtalk/register/begin'), dingtalkRegisterStatus: (sessionId: string) => http.get(`/channels/webhook/dingtalk/register/status?session=${encodeURIComponent(sessionId)}`), + // QQ Bot scan-to-bind (Lite portal). Uses the unified channel QR auth endpoint. + qqRegisterBegin: () => + http.post('/channels/qrcode/qq/begin'), + qqRegisterStatus: (sessionId: string) => + http.get(`/channels/qrcode/qq/status?session=${encodeURIComponent(sessionId)}`), } // ==================== MCP Server ==================== @@ -413,6 +493,9 @@ export const mcpApi = { http.put(`/mcp/servers/${id}/toggle?enabled=${enabled}`), test: (id: string | number) => http.post(`/mcp/servers/${id}/test`), refresh: () => http.post('/mcp/servers/refresh'), + /** Set the whole server's tool disclosure tier ('core' | 'extension'). */ + setDisclosureTier: (id: string | number, tier: 'core' | 'extension') => + http.put(`/mcp/servers/${id}/disclosure-tier`, { tier }), } // ==================== Plan ==================== @@ -446,7 +529,7 @@ export const modelApi = { addProviderModel: (providerId: string, data: any) => http.post(`/models/${providerId}/models`, data), removeProviderModel: (providerId: string, modelId: string) => - http.delete(`/models/${providerId}/models/${encodeURIComponent(modelId)}`), + http.delete(`/models/${providerId}/models`, { params: { modelId } }), getActive: () => http.get('/models/active'), setActive: (data: { providerId: string; model: string }) => http.put('/models/active', data), @@ -458,7 +541,7 @@ export const modelApi = { testConnection: (providerId: string) => http.post(`/models/${providerId}/test-connection`), testModel: (providerId: string, modelId: string) => - http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`), + http.post(`/models/${providerId}/models/test`, null, { params: { modelId } }), // ==================== RFC-074: enabled / catalog ==================== /** Full provider catalog including enabled=false rows; powers the Add Provider drawer. */ @@ -546,7 +629,10 @@ export const settingsApi = { // unrelated settings pages can't clobber them via partial payloads. This // endpoint is the only path that writes those fields unconditionally — // pass {defaultVisionModelId: null} here to explicitly clear a sidecar. - updateSidecar: (data: { defaultVisionModelId: number | null; defaultVideoModelId: number | null }) => + // Model IDs are accepted as either JSON numbers or strings — Jackson + // coerces both into Long. The string form is preferred from the UI to + // sidestep JS Number precision loss on 19-digit Snowflake IDs. + updateSidecar: (data: { defaultVisionModelId: number | string | null; defaultVideoModelId: number | string | null }) => http.put('/settings/sidecar', data), } @@ -567,6 +653,44 @@ export const agentContextApi = { http.get(`/agents/${agentId}/workspace/prompt-files`), setPromptFiles: (agentId: string | number, files: string[]) => http.put(`/agents/${agentId}/workspace/prompt-files`, { files }), + + // Memory snapshot — export downloads a ZIP via native fetch (axios R + // interceptor would mis-handle the binary body); import + preview go + // through axios with multipart so the standard auth / workspace headers + // flow automatically. + exportMemorySnapshot: async (agentId: string | number): Promise => { + const token = localStorage.getItem('token') + const workspaceId = localStorage.getItem('mc-workspace-id') + const headers: Record = {} + if (token) headers.Authorization = `Bearer ${token}` + if (workspaceId) headers['X-Workspace-Id'] = workspaceId + const url = `/api/v1/agents/${agentId}/workspace/memory/export` + const response = await fetch(url, { headers }) + if (!response.ok) { + // Try to parse the standard R error envelope for a useful message. + let detail = `HTTP ${response.status}` + try { + const body = await response.json() + if (body && typeof body.msg === 'string') detail = body.msg + } catch { /* ignore — non-JSON body */ } + throw new Error(detail) + } + return response.blob() + }, + previewImportMemorySnapshot: (agentId: string | number, file: File) => { + const form = new FormData() + form.append('file', file) + return http.post(`/agents/${agentId}/workspace/memory/import/preview`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + applyImportMemorySnapshot: (agentId: string | number, file: File) => { + const form = new FormData() + form.append('file', file) + return http.post(`/agents/${agentId}/workspace/memory/import`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, } // ==================== Security ==================== @@ -582,6 +706,8 @@ export const securityApi = { toggleRule: (ruleId: string, enabled: boolean) => http.put(`/security/guard/rules/${ruleId}/toggle?enabled=${enabled}`), deleteRule: (ruleId: string) => http.delete(`/security/guard/rules/${ruleId}`), + exportRules: () => http.get('/security/guard/rules/export'), + importRules: (data: { rules: any[] }) => http.post('/security/guard/rules/import', data), listAuditLogs: (params?: any) => http.get('/security/audit/logs', { params }), getAuditStats: () => http.get('/security/audit/stats'), listApprovals: (params?: any) => http.get('/security/approvals', { params }), @@ -663,7 +789,7 @@ export const wikiApi = { getBacklinks: (kbId: number, slug: string) => http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`), - // RFC-051 PR-7: archived pages + // Archived pages listArchivedPages: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/pages/archived`), archivePage: (kbId: number, slug: string) => @@ -676,11 +802,11 @@ export const wikiApi = { getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`), // RFC-029: Relations - getRelatedPages: (kbId: number, slug: string, topK = 5) => + getRelatedPages: (kbId: number | string, slug: string, topK = 5) => http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/related`, { params: { topK } }), explainRelation: (kbId: number, slugA: string, slugB: string) => http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slugA)}/relation/${encodeURIComponent(slugB)}`), - getPageCitations: (kbId: number, pageId: number) => + getPageCitations: (kbId: number | string, pageId: number | string) => http.get(`/wiki/kb/${kbId}/pages/${pageId}/citations`), // RFC-030: Jobs @@ -754,6 +880,7 @@ export const wikiApi = { export const workspaceTeamApi = { list: () => http.get('/workspaces'), get: (id: string | number) => http.get(`/workspaces/${id}`), + getAccess: (id: string | number) => http.get(`/workspaces/${id}/access`), create: (data: any) => http.post('/workspaces', data), update: (id: string | number, data: any) => http.put(`/workspaces/${id}`, data), delete: (id: string | number) => http.delete(`/workspaces/${id}`), @@ -863,13 +990,19 @@ export const hotCacheApi = { export interface WorkflowSummary { id: number - workspaceId: number + workspaceId: string | number name: string description?: string enabled: boolean draftJson?: string draftUpdatedAt?: string latestRevisionId?: number + /** Human version number of the latest published revision (1, 2, 3…) — shown + * as "v3" instead of the latestRevisionId snowflake. Null when unpublished. */ + latestRevisionNumber?: number + /** Latest published revision's graph JSON — populated by GET /workflows/{id} + * so the editor can render a published workflow whose draft was cleared. */ + publishedGraphJson?: string createTime: string updateTime: string } @@ -889,7 +1022,7 @@ export interface WorkflowRun { id: number workflowId: number revisionId: number - workspaceId: number + workspaceId: string | number state: string triggeredBy?: string initialInputRef?: string @@ -952,7 +1085,7 @@ export interface ResumeResponse { } export const workflowApi = { - list: (workspaceId: number) => + list: (workspaceId: string | number) => http.get('/workflows', { params: { workspaceId } }), get: (id: number) => http.get(`/workflows/${id}`), create: (data: Partial) => @@ -1023,12 +1156,15 @@ export interface WorkflowDraftTemplate { export interface TriggerSummary { id: number - workspaceId: number + workspaceId: string | number name?: string patternType: string patternJson: string targetType: string - targetId: number + // Snowflake ID — backend serializes Long as string (ToStringSerializer). + // Keep the union so v-model can hold the string form without TS errors + // and JS Number() coercion stays out of the round-trip. + targetId: number | string payloadTemplate?: string rateLimitPerMin: number dedupWindowSecs: number @@ -1050,7 +1186,7 @@ export interface TriggerSummary { } export const triggerApi = { - list: (workspaceId: number) => + list: (workspaceId: string | number) => http.get('/triggers', { params: { workspaceId } }), get: (id: number) => http.get(`/triggers/${id}`), create: (data: Partial) => @@ -1059,10 +1195,83 @@ export const triggerApi = { http.put(`/triggers/${id}`, data), delete: (id: number) => http.delete(`/triggers/${id}`), ingestEvent: (envelope: { - workspaceId: number + workspaceId: string | number patternType: string eventId?: string senderId?: string data?: Record }) => http.post('/triggers/events', envelope), } + +// ==================== Persistent goals (RFC 48) ==================== +// +// Snowflake IDs are sent as strings end-to-end — the backend's +// ToStringSerializer makes responses strings, and request payloads keep +// them as strings to dodge JS Number precision loss. See CLAUDE.md +// "ID Handling — Snowflake Precision Convention". +export interface Goal { + id: string + conversationId: string + agentId: string + workspaceId: string + createdBy: string + title: string + description: string + exitCriteria?: string | null + status: 'active' | 'paused' | 'completed' | 'abandoned' | 'exhausted' + turnBudget: number + turnsUsed: number + llmCallBudget: number + agentLlmCallsUsed: number + evalLlmCallsUsed: number + progressSummary?: string | null + completionScore?: number | null + lastEvaluationAt?: string | null + autoFollowupEnabled: boolean + followupCooldownSeconds: number + lastFollowupAt?: string | null + createTime: string + updateTime: string +} + +export interface GoalEvent { + id: string + goalId: string + eventType: string + messageId?: string | null + detailJson?: string | null + createTime: string +} + +export const goalApi = { + create: (data: { + conversationId: string + agentId: string | number + workspaceId: string | number + title: string + description?: string + exitCriteria?: string + turnBudget?: number + llmCallBudget?: number + autoFollowupEnabled?: boolean + followupCooldownSeconds?: number + }) => http.post('/goals', data), + + findActive: (conversationId: string) => + http.get(`/goals/by-conversation/${conversationId}`), + + get: (id: string) => http.get(`/goals/${id}`), + + events: (id: string, limit = 100) => + http.get(`/goals/${id}/events`, { params: { limit } }), + + list: (params?: { status?: string; limit?: number }) => + http.get('/goals', { params }), + + update: (id: string, data: Partial) => http.patch(`/goals/${id}`, data), + pause: (id: string) => http.post(`/goals/${id}/pause`), + resume: (id: string) => http.post(`/goals/${id}/resume`), + abandon: (id: string) => http.post(`/goals/${id}/abandon`), + addCriterion: (id: string, criterion: string) => + http.post(`/goals/${id}/criteria`, { criterion }), +} diff --git a/mateclaw-ui/src/assets/icons/mcp/atlassian.svg b/mateclaw-ui/src/assets/icons/mcp/atlassian.svg new file mode 100644 index 00000000..87521386 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/atlassian.svg @@ -0,0 +1 @@ +Atlassian \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/chrome_devtools.svg b/mateclaw-ui/src/assets/icons/mcp/chrome_devtools.svg new file mode 100644 index 00000000..919ac2a1 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/chrome_devtools.svg @@ -0,0 +1 @@ +Google Chrome \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/cloudflare.svg b/mateclaw-ui/src/assets/icons/mcp/cloudflare.svg new file mode 100644 index 00000000..a1cf2d6d --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/cloudflare.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/context7.svg b/mateclaw-ui/src/assets/icons/mcp/context7.svg new file mode 100644 index 00000000..123d62ea --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/context7.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mateclaw-ui/src/assets/icons/mcp/figma.svg b/mateclaw-ui/src/assets/icons/mcp/figma.svg new file mode 100644 index 00000000..6a06daa6 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/figma.svg @@ -0,0 +1 @@ +Figma \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/github.svg b/mateclaw-ui/src/assets/icons/mcp/github.svg new file mode 100644 index 00000000..23349765 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/github.svg @@ -0,0 +1 @@ +GitHub diff --git a/mateclaw-ui/src/assets/icons/mcp/hugging_face.svg b/mateclaw-ui/src/assets/icons/mcp/hugging_face.svg new file mode 100644 index 00000000..dd2db93b --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/hugging_face.svg @@ -0,0 +1 @@ +Hugging Face \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/linear.svg b/mateclaw-ui/src/assets/icons/mcp/linear.svg new file mode 100644 index 00000000..f3770d65 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/linear.svg @@ -0,0 +1 @@ +Linear \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/notion.svg b/mateclaw-ui/src/assets/icons/mcp/notion.svg new file mode 100644 index 00000000..2917f42e --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/playwright.svg b/mateclaw-ui/src/assets/icons/mcp/playwright.svg new file mode 100644 index 00000000..3d45a763 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/playwright.svg @@ -0,0 +1 @@ +Playwright \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/posthog.svg b/mateclaw-ui/src/assets/icons/mcp/posthog.svg new file mode 100644 index 00000000..70d8cb77 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/posthog.svg @@ -0,0 +1 @@ +PostHog \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/sentry.svg b/mateclaw-ui/src/assets/icons/mcp/sentry.svg new file mode 100644 index 00000000..11bb3c8a --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/sentry.svg @@ -0,0 +1 @@ +Sentry \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/slack.svg b/mateclaw-ui/src/assets/icons/mcp/slack.svg new file mode 100644 index 00000000..004e2663 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/slack.svg @@ -0,0 +1 @@ +Slack \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/stripe.svg b/mateclaw-ui/src/assets/icons/mcp/stripe.svg new file mode 100644 index 00000000..8ebadf74 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/stripe.svg @@ -0,0 +1 @@ +Stripe \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/supabase.svg b/mateclaw-ui/src/assets/icons/mcp/supabase.svg new file mode 100644 index 00000000..b7735570 --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/supabase.svg @@ -0,0 +1 @@ +Supabase \ No newline at end of file diff --git a/mateclaw-ui/src/assets/icons/mcp/vercel.svg b/mateclaw-ui/src/assets/icons/mcp/vercel.svg new file mode 100644 index 00000000..821ecfff --- /dev/null +++ b/mateclaw-ui/src/assets/icons/mcp/vercel.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index 81de41ac..c96ec57d 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -824,7 +824,17 @@ html.dark .hljs-deletion { color: #e06c75; background: rgba(224, 108, 117, 0.1); .markdown-body p { margin: 8px 0; } .markdown-body ul, .markdown-body ol { padding-left: 1.5rem; margin: 8px 0; } -.markdown-body table { border-collapse: collapse; width: 100%; margin: 12px 0; } +.markdown-body table { + border-collapse: collapse; + /* display:block + overflow-x:auto turns a wide table into its own horizontal + scroll region instead of overflowing the message bubble and getting clipped + by the list's overflow-x:hidden. Narrow tables still stretch to full width. */ + display: block; + width: 100%; + max-width: 100%; + overflow-x: auto; + margin: 12px 0; +} .markdown-body th, .markdown-body td { border: 1px solid var(--mc-table-border); padding: 8px 12px; text-align: left; } .markdown-body th { background: var(--mc-table-header-bg); font-weight: 600; } .markdown-body blockquote { diff --git a/mateclaw-ui/src/components/ChangePasswordDialog.vue b/mateclaw-ui/src/components/ChangePasswordDialog.vue index 7895e95c..24432e27 100644 --- a/mateclaw-ui/src/components/ChangePasswordDialog.vue +++ b/mateclaw-ui/src/components/ChangePasswordDialog.vue @@ -70,7 +70,7 @@ + + diff --git a/mateclaw-ui/src/components/chat/DelegationNodeView.vue b/mateclaw-ui/src/components/chat/DelegationNodeView.vue new file mode 100644 index 00000000..cab4906e --- /dev/null +++ b/mateclaw-ui/src/components/chat/DelegationNodeView.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 0c5b4e01..400971ac 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -10,7 +10,19 @@

    - + + + + {{ avatarIcon }}
    @@ -18,11 +30,14 @@
    + + +