release: v1.4.0

This commit is contained in:
matevip 2026-05-25 09:58:38 +08:00
parent 493910bf5a
commit fa6a5366b4
715 changed files with 76909 additions and 8512 deletions

View File

@ -8,6 +8,8 @@
<p align="center"><b>Your second brain</b></p> <p align="center"><b>Your second brain</b></p>
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![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) [![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) [![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.** > **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. > 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. 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.

View File

@ -8,6 +8,8 @@
<p align="center"><b>你的超级大脑</b></p> <p align="center"><b>你的超级大脑</b></p>
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![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/文档-在线-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) [![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
@ -31,6 +33,8 @@
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。** > **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
> >
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。 > 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
>
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。 大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。

View File

@ -4,36 +4,21 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId> <parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-api</artifactId> <artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>MateClaw Plugin API</name> <name>MateClaw Plugin API</name>
<description>Plugin SDK contract for MateClaw — external plugins depend only on this module</description> <description>Plugin SDK contract for MateClaw - external plugins depend only on this module</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- Spring AI core — for ToolCallback, ChatModel --> <!-- Spring AI core for ToolCallback and ChatModel. -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId> <artifactId>spring-ai-model</artifactId>
@ -44,7 +29,6 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
@ -52,16 +36,7 @@
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project> </project>

View File

@ -4,40 +4,24 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId> <parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-sample</artifactId> <artifactId>mateclaw-plugin-sample</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>MateClaw Sample Plugin</name> <name>MateClaw Sample Plugin</name>
<description>A sample plugin demonstrating the MateClaw Plugin SDK</description> <description>A sample plugin demonstrating the MateClaw Plugin SDK</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- MateClaw Plugin API --> <!-- MateClaw Plugin API -->
<dependency> <dependency>
<groupId>vip.mate</groupId> <groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId> <artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
@ -52,16 +36,7 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project> </project>

View File

@ -35,28 +35,29 @@ FROM maven:3.9-eclipse-temurin-21 AS builder
# Optional Maven extra flags passed at build time. # Optional Maven extra flags passed at build time.
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun # 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="" ARG MAVEN_FLAGS=""
# Inject mirror settings to avoid Maven Central timeouts in restricted networks # Inject mirror settings to avoid Maven Central timeouts in restricted networks
COPY mateclaw-server/settings.xml /root/.m2/settings.xml COPY mateclaw-server/settings.xml /root/.m2/settings.xml
# Build and install plugin-api into the local Maven cache first # Copy the root parent plus module POMs first for Docker layer caching.
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)
WORKDIR /build WORKDIR /build
COPY mateclaw-server/pom.xml . COPY pom.xml ./pom.xml
RUN mvn dependency:go-offline -q ${MAVEN_FLAGS} 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 backend source and inject pre-built frontend into the right classpath location
COPY mateclaw-server/src ./src COPY mateclaw-plugin-api/src mateclaw-plugin-api/src
COPY --from=frontend-builder /static ./src/main/resources/static 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 # 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 # pre-installed. This avoids the `playwright install` step and the Alpine/musl
# incompatibility that blocks browser_use on minimal images. # 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 # 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 # 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). # 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 WORKDIR /app
# JDK 21 is NOT part of the base image (it ships Node for the JS driver). # 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 \ TZ=Asia/Shanghai \
JAVA_TOOL_OPTIONS="-Duser.timezone=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 18088
EXPOSE 1455 EXPOSE 1455
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"] ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]

View File

@ -4,70 +4,33 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId> <parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-server</artifactId> <artifactId>mateclaw-server</artifactId>
<version>1.3.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>MateClaw Server</name> <name>MateClaw Server</name>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description> <description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.14</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.6 正式版patch upgrade from 1.1.5 -->
<spring-ai.version>1.1.6</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.3(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.3</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<hutool.version>5.8.26</hutool.version>
<springdoc.version>2.8.16</springdoc.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM统一管理 spring-ai-* 版本) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- SpringDoc OpenAPI BOM统一管理 springdoc-* 版本) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-bom</artifactId>
<version>${springdoc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- ===== MateClaw Plugin API ===== --> <!-- ===== MateClaw Plugin API ===== -->
<dependency> <dependency>
<groupId>vip.mate</groupId> <groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId> <artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
</dependency> </dependency>
<!-- ===== Web MVC(不引入 WebFlux避免自动切换为响应式模式 ===== --> <!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- ===== Actuator exposes Spring AI observation metrics (gen_ai.*) ===== --> <!-- ===== Actuator - exposes Spring AI observation metrics (gen_ai.*) ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
@ -75,14 +38,13 @@
<!-- ===== Spring AI Alibaba DashScope ===== --> <!-- ===== Spring AI Alibaba DashScope ===== -->
<!-- <!--
1.1.2.2 需单独指定版本,不在 BOM 中 Version is managed centrally because this artifact is outside the Spring AI BOM.
内置 DashScope ChatModel / EmbeddingModel / ImageModel Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
--> -->
<dependency> <dependency>
<groupId>com.alibaba.cloud.ai</groupId> <groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId> <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version> <!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
<exclusions> <exclusions>
<exclusion> <exclusion>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@ -91,11 +53,10 @@
</exclusions> </exclusions>
</dependency> </dependency>
<!-- ===== Spring AI Alibaba Graph CoreStateGraph 工作流引擎) ===== --> <!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
<dependency> <dependency>
<groupId>com.alibaba.cloud.ai</groupId> <groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId> <artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency> </dependency>
<!-- ===== Spring AI OpenAI Compatible ===== --> <!-- ===== Spring AI OpenAI Compatible ===== -->
@ -104,16 +65,15 @@
<artifactId>spring-ai-openai</artifactId> <artifactId>spring-ai-openai</artifactId>
</dependency> </dependency>
<!-- ===== Spring AI AnthropicClaude 模型支持) ===== --> <!-- ===== Spring AI Anthropic (Claude model support) ===== -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic</artifactId> <artifactId>spring-ai-anthropic</artifactId>
</dependency> </dependency>
<!-- ===== Spring AI MCP Client(动态 MCP server 连接管理) ===== --> <!-- ===== Spring AI MCP Client (dynamic MCP server connection management) ===== -->
<!-- <!--
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库, Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
--> -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
@ -126,31 +86,29 @@
</exclusions> </exclusions>
</dependency> </dependency>
<!-- ===== H2 内嵌数据库(开发环境) ===== --> <!-- ===== H2 embedded database (development) ===== -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ===== MySQL 驱动(生产环境) ===== --> <!-- ===== MySQL driver (production) ===== -->
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ===== MyBatis Plus(不引入 JPA避免双 ORM 冲突) ===== --> <!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency> </dependency>
<!-- MyBatis Plus 分页插件3.5.16 拆分为独立模块) --> <!-- MyBatis Plus pagination support is split into a separate module. -->
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId> <artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency> </dependency>
<!-- ===== Spring Security ===== --> <!-- ===== Spring Security ===== -->
@ -163,55 +121,49 @@
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId> <artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId> <artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ===== SpringDoc OpenAPISwagger UI for Spring MVC ===== --> <!-- ===== SpringDoc OpenAPI (Swagger UI for Spring MVC) ===== -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency> </dependency>
<!-- ===== Hutool 工具库 ===== --> <!-- ===== Hutool utilities ===== -->
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency> </dependency>
<!-- ===== 钉钉 Stream SDKWebSocket 长连接,无需公网 IP ===== --> <!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
<dependency> <dependency>
<groupId>com.dingtalk.open</groupId> <groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId> <artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency> </dependency>
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== --> <!-- ===== Lark Open API SDK (WebSocket long connection and event dispatch) ===== -->
<dependency> <dependency>
<groupId>com.larksuite.oapi</groupId> <groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId> <artifactId>oapi-sdk</artifactId>
<version>2.6.1</version>
</dependency> </dependency>
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== --> <!-- ===== Caffeine cache for skill runtime caching ===== -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
</dependency> </dependency>
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== --> <!-- ===== SnakeYAML for SKILL.md frontmatter parsing ===== -->
<dependency> <dependency>
<groupId>org.yaml</groupId> <groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId> <artifactId>snakeyaml</artifactId>
@ -228,28 +180,24 @@
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>core</artifactId> <artifactId>core</artifactId>
<version>3.5.3</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId> <artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency> </dependency>
<!-- ===== Playwright (Browser Automation) ===== --> <!-- ===== Playwright (Browser Automation) ===== -->
<dependency> <dependency>
<groupId>com.microsoft.playwright</groupId> <groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId> <artifactId>playwright</artifactId>
<version>1.52.0</version>
</dependency> </dependency>
<!-- ===== JDADiscord Bot Gateway WebSocket 长连接) ===== --> <!-- ===== JDA (Discord Bot Gateway WebSocket long connection) ===== -->
<dependency> <dependency>
<groupId>net.dv8tion</groupId> <groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId> <artifactId>JDA</artifactId>
<version>5.2.3</version>
<exclusions> <exclusions>
<!-- 排除 audio 相关依赖MateClaw 不需要语音功能) --> <!-- Exclude audio dependencies because voice features are not used. -->
<exclusion> <exclusion>
<groupId>club.minnced</groupId> <groupId>club.minnced</groupId>
<artifactId>opus-java</artifactId> <artifactId>opus-java</artifactId>
@ -257,27 +205,24 @@
</exclusions> </exclusions>
</dependency> </dependency>
<!-- ===== Spring WebSocketTalk Mode ===== --> <!-- ===== Spring WebSocket (Talk Mode) ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId> <artifactId>spring-boot-starter-websocket</artifactId>
</dependency> </dependency>
<!-- ===== Slack SDKSocket Mode + Web API ===== --> <!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
<dependency> <dependency>
<groupId>com.slack.api</groupId> <groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId> <artifactId>slack-api-client</artifactId>
<version>1.44.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.slack.api</groupId> <groupId>com.slack.api</groupId>
<artifactId>bolt-socket-mode</artifactId> <artifactId>bolt-socket-mode</artifactId>
<version>1.44.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.glassfish.tyrus.bundles</groupId> <groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId> <artifactId>tyrus-standalone-client</artifactId>
<version>2.2.0</version>
</dependency> </dependency>
<!-- ===== Apache POI (in-process .docx generation) ===== --> <!-- ===== Apache POI (in-process .docx generation) ===== -->
@ -288,7 +233,6 @@
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.4.1</version>
</dependency> </dependency>
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== --> <!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
@ -302,60 +246,54 @@
<dependency> <dependency>
<groupId>org.apache.xmlgraphics</groupId> <groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId> <artifactId>batik-transcoder</artifactId>
<version>1.18</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.xmlgraphics</groupId> <groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId> <artifactId>batik-codec</artifactId>
<version>1.18</version>
</dependency> </dependency>
<!-- ===== jsoup (HTML cleanup for Wiki ingest, RFC-051 PR-1c) ===== --> <!-- ===== jsoup (HTML cleanup for Wiki ingest) ===== -->
<!-- <!--
Used by WikiContentNormalizer to strip nav/footer/script/style/aside Used by WikiContentNormalizer to strip nav/footer/script/style/aside
and ad-class nodes from URL/HTML uploads before chunking. Small and ad-class nodes from URL/HTML uploads before chunking. Small
(~430KB), no transitive deps, JVM-only safe for the desktop bundle. (~430KB), no transitive deps, JVM-only, and safe for the desktop bundle.
--> -->
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.18.3</version>
</dependency> </dependency>
<!-- ===== Apache Tika (RFC-051 PR-?: Java-side last-resort extractor) ===== --> <!-- ===== Apache Tika (Java-side last-resort document extractor) ===== -->
<!-- <!--
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
chains, after every system command + Python + POI-based path has failed. chains, after every system command + Python + POI-based path has failed.
Used in production primarily by Windows users without Python or poppler Used in production primarily by Windows users without Python or poppler
installed; otherwise idle. installed; otherwise idle.
Pinned to the precise format modules called out in RFC-051 §5.2 — we Pinned to the precise format modules the extractor calls directly. This
deliberately avoid `tika-parsers-standard-package`, which pulls in mail, deliberately avoids `tika-parsers-standard-package`, which pulls in mail,
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint: audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) + tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
tika-parser-microsoft-module (POI-scratchpad ~10MB) 16MB. tika-parser-microsoft-module (POI-scratchpad ~10MB) is about 16MB.
--> -->
<dependency> <dependency>
<groupId>org.apache.tika</groupId> <groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId> <artifactId>tika-core</artifactId>
<version>3.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.tika</groupId> <groupId>org.apache.tika</groupId>
<artifactId>tika-parser-pdf-module</artifactId> <artifactId>tika-parser-pdf-module</artifactId>
<version>3.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.tika</groupId> <groupId>org.apache.tika</groupId>
<artifactId>tika-parser-microsoft-module</artifactId> <artifactId>tika-parser-microsoft-module</artifactId>
<version>3.0.0</version>
</dependency> </dependency>
<!-- ===== Markdown -> PDF rendering ===== <!-- ===== Markdown -> PDF rendering =====
Flying Saucer 9.13 ships a single `flying-saucer-pdf` artifact that Flying Saucer ships a single `flying-saucer-pdf` artifact that
writes PDF via OpenPDF (LGPL fork of iText 5). It does NOT depend on writes PDF via OpenPDF (LGPL fork of iText). It does NOT depend on
PDFBox, so it sidesteps a version conflict with the existing PDFBox, so it sidesteps a version conflict with the existing
pdfbox:3.0.3 dependency. CSS3 paged-media features (@page, pdfbox dependency. CSS3 paged-media features (@page,
counter(page), counter(pages), @top-center / @bottom-center) are counter(page), counter(pages), @top-center / @bottom-center) are
supported, which the cover / header / footer rendering relies on. supported, which the cover / header / footer rendering relies on.
@ -368,32 +306,26 @@
<dependency> <dependency>
<groupId>org.xhtmlrenderer</groupId> <groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId> <artifactId>flying-saucer-pdf</artifactId>
<version>9.13.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.commonmark</groupId> <groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId> <artifactId>commonmark</artifactId>
<version>0.28.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.commonmark</groupId> <groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId> <artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.28.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.commonmark</groupId> <groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-yaml-front-matter</artifactId> <artifactId>commonmark-ext-yaml-front-matter</artifactId>
<version>0.28.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.commonmark</groupId> <groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-strikethrough</artifactId> <artifactId>commonmark-ext-gfm-strikethrough</artifactId>
<version>0.28.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.commonmark</groupId> <groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-autolink</artifactId> <artifactId>commonmark-ext-autolink</artifactId>
<version>0.28.0</version>
</dependency> </dependency>
<!-- ===== Database Migration (Flyway) ===== --> <!-- ===== Database Migration (Flyway) ===== -->
@ -413,33 +345,30 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 architecture invariants) ===== <!-- ===== ArchUnit architecture invariants =====
test-scope only guards: test-scope only, guards:
- every ToolCallback implementation overrides call(String, ToolContext) - every ToolCallback implementation overrides call(String, ToolContext)
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
- CronJobRunner (introduced in PR-3) must not carry @Transactional - CronJobRunner must not carry @Transactional
(would silently fail under self-invocation; see RFC §5.2) because it would silently fail under self-invocation
--> -->
<dependency> <dependency>
<groupId>com.tngtech.archunit</groupId> <groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId> <artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- ShedLock: distributed lock for the cron scheduler so a <!-- ShedLock: distributed lock for the cron scheduler so a
multi-instance deployment doesn't fire the same job N times. multi-instance deployment doesn't fire the same job N times.
JDBC mode reuses the existing DataSource no Redis dependency JDBC mode reuses the existing DataSource, so there is no Redis dependency
on the desktop / single-node footprint. --> on the desktop / single-node footprint. -->
<dependency> <dependency>
<groupId>net.javacrumbs.shedlock</groupId> <groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId> <artifactId>shedlock-spring</artifactId>
<version>5.16.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>net.javacrumbs.shedlock</groupId> <groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId> <artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.16.0</version>
</dependency> </dependency>
<!-- Graph algorithms (community detection, shortest path, centrality) <!-- Graph algorithms (community detection, shortest path, centrality)
@ -447,7 +376,6 @@
<dependency> <dependency>
<groupId>org.jgrapht</groupId> <groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId> <artifactId>jgrapht-core</artifactId>
<version>1.5.2</version>
</dependency> </dependency>
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline). <!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
@ -456,7 +384,6 @@
<dependency> <dependency>
<groupId>org.apache.pdfbox</groupId> <groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId> <artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency> </dependency>
<!-- Expression language used by the workflow compiler to evaluate <!-- Expression language used by the workflow compiler to evaluate
@ -467,7 +394,6 @@
<dependency> <dependency>
<groupId>io.pebbletemplates</groupId> <groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId> <artifactId>pebble</artifactId>
<version>3.2.2</version>
</dependency> </dependency>
</dependencies> </dependencies>
@ -476,6 +402,13 @@
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration> <configuration>
<excludes> <excludes>
<exclude> <exclude>
@ -515,115 +448,7 @@
</plugins> </plugins>
</build> </build>
<!--
Dependency repositories, with US + CN mirrors listed side by side so builds
are reasonable on either continent. Maven tries repositories in the order
they are declared — the first one that resolves an artifact wins.
IDs are deliberately distinct from the super-POM's `central` id so that
mirror rules in settings.xml (if any) don't silently redirect them. Keep
the fastest-by-default first; switch order via a local ~/.m2/settings.xml
or pass `-Paliyun-first` when building from inside China.
-->
<repositories>
<!-- Primary: Maven Central direct — fast from US/EU backbones. -->
<repository>
<id>maven-central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Fallback 1: Google Cloud's Maven Central mirror (global CDN edge). -->
<repository>
<id>google-maven-central</id>
<name>Google Maven Central Mirror</name>
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Fallback 2: Aliyun public — fast from China, full Central mirror. -->
<repository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Spring milestones / snapshots — direct from Spring (US). -->
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Aliyun Spring mirror — fallback for CN builds. -->
<repository>
<id>aliyun-spring</id>
<name>Aliyun Spring Mirror</name>
<url>https://maven.aliyun.com/repository/spring</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<!-- Plugin lookups follow the same multi-region fallback. -->
<pluginRepositories>
<pluginRepository>
<id>maven-central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>google-maven-central</id>
<name>Google Maven Central Mirror</name>
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
<!--
Profile: swap the primary repo order when building from China so Aliyun
is tried first. Activate with `mvn -Paliyun-first ...`.
-->
<profiles> <profiles>
<profile>
<id>aliyun-first</id>
<repositories>
<repository>
<id>aliyun-public-first</id>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<repository>
<id>aliyun-spring-first</id>
<url>https://maven.aliyun.com/repository/spring</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-public-first</id>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
<!-- <!--
Profile: focused test run for image / video generation features. Profile: focused test run for image / video generation features.
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`). Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).

View File

@ -24,7 +24,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class, org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app), // DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
// not the chat model. We don't use it model configuration is admin-UI driven and // not the chat model. We don't use it model configuration is admin-UI driven and
// built by AgentDashScopeChatModelBuilder. Its auto-config strictly requires // built by DashScopeChatModelBuilder. Its auto-config strictly requires
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole // spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
// ApplicationContext fail when users deploy via Docker without setting the key. // ApplicationContext fail when users deploy via Docker without setting the key.
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class, com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,

View File

@ -11,6 +11,7 @@ import vip.mate.common.result.R;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
/** /**
* RFC-090 Phase 7 REST surface for managing ACP endpoints. * RFC-090 Phase 7 REST surface for managing ACP endpoints.
@ -29,24 +30,28 @@ public class AcpEndpointController {
@Operation(summary = "List ACP endpoints") @Operation(summary = "List ACP endpoints")
@GetMapping @GetMapping
@RequireWorkspaceRole("admin")
public R<List<AcpEndpointEntity>> list() { public R<List<AcpEndpointEntity>> list() {
return R.ok(service.list()); return R.ok(service.list());
} }
@Operation(summary = "Get ACP endpoint by id") @Operation(summary = "Get ACP endpoint by id")
@GetMapping("/{id}") @GetMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> get(@PathVariable Long id) { public R<AcpEndpointEntity> get(@PathVariable Long id) {
return R.ok(service.get(id)); return R.ok(service.get(id));
} }
@Operation(summary = "Create a custom ACP endpoint") @Operation(summary = "Create a custom ACP endpoint")
@PostMapping @PostMapping
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) { public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
return R.ok(service.create(body)); return R.ok(service.create(body));
} }
@Operation(summary = "Update an ACP endpoint") @Operation(summary = "Update an ACP endpoint")
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> update(@PathVariable Long id, public R<AcpEndpointEntity> update(@PathVariable Long id,
@RequestBody AcpEndpointEntity body) { @RequestBody AcpEndpointEntity body) {
return R.ok(service.update(id, body)); return R.ok(service.update(id, body));
@ -54,6 +59,7 @@ public class AcpEndpointController {
@Operation(summary = "Delete an ACP endpoint (builtins are protected)") @Operation(summary = "Delete an ACP endpoint (builtins are protected)")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id) { public R<Void> delete(@PathVariable Long id) {
service.delete(id); service.delete(id);
return R.ok(); return R.ok();
@ -61,6 +67,7 @@ public class AcpEndpointController {
@Operation(summary = "Enable / disable an ACP endpoint") @Operation(summary = "Enable / disable an ACP endpoint")
@PutMapping("/{id}/toggle") @PutMapping("/{id}/toggle")
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> toggle(@PathVariable Long id, public R<AcpEndpointEntity> toggle(@PathVariable Long id,
@RequestParam boolean enabled) { @RequestParam boolean enabled) {
return R.ok(service.toggle(id, enabled)); return R.ok(service.toggle(id, enabled));
@ -72,6 +79,7 @@ public class AcpEndpointController {
*/ */
@Operation(summary = "Test ACP endpoint connection (initialize handshake)") @Operation(summary = "Test ACP endpoint connection (initialize handshake)")
@PostMapping("/{id}/test") @PostMapping("/{id}/test")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> test(@PathVariable Long id) { public R<Map<String, Object>> test(@PathVariable Long id) {
AcpEndpointEntity endpoint = service.get(id); AcpEndpointEntity endpoint = service.get(id);
return R.ok(tester.testEndpoint(endpoint)); return R.ok(tester.testEndpoint(endpoint));

View File

@ -20,6 +20,7 @@ import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
/** /**
* RFC-090 §4.5 / §7 unified Activity feed. * RFC-090 §4.5 / §7 unified Activity feed.
@ -74,6 +75,7 @@ public class ActivityFeedController {
*/ */
@Operation(summary = "Unified activity feed (audit + approval + tool calls)") @Operation(summary = "Unified activity feed (audit + approval + tool calls)")
@GetMapping("/feed") @GetMapping("/feed")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> feed( public R<Map<String, Object>> feed(
@RequestParam(required = false) Long workspaceId, @RequestParam(required = false) Long workspaceId,
@RequestParam(required = false) String source, @RequestParam(required = false) String source,

View File

@ -0,0 +1,313 @@
package vip.mate.agent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
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.binding.service.AgentBindingService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Agent-callable employee authoring tool.
*
* <p>Lets an agent design and persist a new specialized employee (Agent)
* from a plain-language role spec, then bind a focused capability set to
* it. Pairs with the workflow drafting tool so a single chat turn can plan
* a team of employees and chain them into a workflow:
* design roles {@link #create_employee} for each workflow drafting tool
* referencing the just-created employees.
*
* <p>Workspace is taken from {@link ChatOrigin} on the active
* {@link ToolContext}; the LLM can never write into a foreign workspace
* even if its prompt tried to forge one. Mirrors the create-then-bind
* sequence used when applying an agent template.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentAuthoringTool {
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final SkillMapper skillMapper;
private final AvailableToolService availableToolService;
private final ObjectMapper objectMapper;
/** Cap on names listed per catalog section so the tool result stays small. */
private static final int CATALOG_MAX_PER_SECTION = 200;
@Tool(description = """
Create a new specialized employee (Agent) in the current workspace from a role spec, \
and optionally bind a focused set of skills and tools to it. \
Use this when a task needs a role that does not exist yet design the role, then create it. \
Returns the new agentId (string) and a short summary. \
Leave skillNames/toolNames empty to make a generalist that inherits all globally-enabled capabilities. \
Call list_capability_catalog first to learn the exact skill and tool names you can assign. \
The created employee is enabled immediately and can be referenced by the workflow drafting tool.""")
public String create_employee(
@ToolParam(description = "Employee name, unique within the workspace, e.g. \"market-research-analyst\".")
String name,
@ToolParam(description = "One-line description of the employee's role and responsibility. Shown in pickers and used by the workflow planner to route work.")
String description,
@ToolParam(description = "System prompt that defines the employee's persona, expertise, and working style. Be specific about its specialty.")
String systemPrompt,
@ToolParam(description = "Agent type: \"react\" (single-loop reasoning, default) or \"plan_execute\" (decompose then execute). Leave empty for react.", required = false)
String agentType,
@ToolParam(description = "Optional model name override (must match an enabled model). Leave empty to use the workspace default model.", required = false)
String modelName,
@ToolParam(description = "Skills to bind, as a JSON array of skill names or a comma-separated list, e.g. [\"sql_query\",\"make_plan\"]. Empty = inherit all globally-enabled skills. Names must come from list_capability_catalog.", required = false)
String skillNames,
@ToolParam(description = "Tools to bind, as a JSON array of tool names or a comma-separated list, e.g. [\"web_search\",\"read_file\"]. Empty = inherit all globally-enabled tools. Names must come from list_capability_catalog.", required = false)
String toolNames,
@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin.workspaceId();
if (workspaceId == null || workspaceId <= 0) {
return "[error] Cannot determine the current workspace; invoke this tool within a workspace context.";
}
if (name == null || name.isBlank()) {
return "[error] Employee name is required.";
}
AgentEntity agent = new AgentEntity();
agent.setName(name.trim());
agent.setDescription(blankToNull(description));
if (systemPrompt != null && !systemPrompt.isBlank()) {
agent.setSystemPrompt(systemPrompt);
}
agent.setAgentType(normalizeAgentType(agentType));
agent.setModelName(blankToNull(modelName));
agent.setWorkspaceId(workspaceId);
agent.setCreatorUserId(parseUserId(origin.requesterId()));
AgentEntity created;
try {
created = agentService.createAgent(agent);
} catch (MateClawException e) {
// Duplicate name / blank name surface here as a friendly message
// so the planner can rename and retry instead of aborting.
return "[error] Failed to create employee: " + e.getMessage();
}
List<String> requestedSkills = parseNameList(skillNames);
List<String> requestedTools = parseNameList(toolNames);
List<String> boundSkills = bindSkills(created, workspaceId, requestedSkills);
List<String> boundTools = bindTools(created, requestedTools);
Map<String, Object> result = new LinkedHashMap<>();
result.put("agentId", String.valueOf(created.getId()));
result.put("name", created.getName());
result.put("agentType", created.getAgentType());
result.put("skillsBound", boundSkills.isEmpty() ? "(inherits global defaults)" : boundSkills);
result.put("toolsBound", boundTools.isEmpty() ? "(inherits global defaults)" : boundTools);
result.put("note", "Employee created and enabled. Reference it by name in the workflow drafting tool to chain it into a workflow.");
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
return "Employee created: id=" + created.getId() + " name=" + created.getName();
}
}
@Tool(description = """
List the capabilities you can assign when creating an employee: the enabled skill names \
and the bindable tool names in the current workspace. \
Call this before create_employee so you assign real, resolvable names rather than guessing.""")
public String list_capability_catalog(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin.workspaceId();
// Skills: builtin (global) + skills owned by this workspace, enabled only.
List<SkillEntity> skills = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getEnabled, true)
.eq(SkillEntity::getDeleted, 0)
.orderByAsc(SkillEntity::getName));
long effectiveWs = workspaceId == null ? 1L : workspaceId;
List<Map<String, String>> skillCatalog = new ArrayList<>();
for (SkillEntity s : skills) {
if (s.getName() == null || s.getName().isBlank()) continue;
boolean builtin = Boolean.TRUE.equals(s.getBuiltin());
long skillWs = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
if (!builtin && skillWs != effectiveWs) continue;
Map<String, String> m = new LinkedHashMap<>();
m.put("name", s.getName());
m.put("description", s.getDescription() == null ? "" : s.getDescription());
skillCatalog.add(m);
if (skillCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
}
// Tools: only those the binding service would accept (available == true).
List<Map<String, String>> toolCatalog = new ArrayList<>();
try {
for (AvailableToolDTO t : availableToolService.listAvailable()) {
if (t == null || !t.isAvailable() || t.getName() == null || t.getName().isBlank()) continue;
Map<String, String> m = new LinkedHashMap<>();
m.put("name", t.getName());
m.put("description", t.getDescription() == null ? "" : t.getDescription());
toolCatalog.add(m);
if (toolCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
}
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool catalog lookup failed: {}", e.getMessage());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("skills", skillCatalog);
result.put("tools", toolCatalog);
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
return "{\"skills\":[],\"tools\":[]}";
}
}
// ==================== helpers ====================
/**
* Resolve requested skill names to ids within reach of this agent
* (builtin skills are global; otherwise the skill must belong to the
* agent's workspace) and bind them. Returns the names actually bound;
* unresolved names are skipped with a warning so a single typo does not
* abort the whole hire.
*/
private List<String> bindSkills(AgentEntity agent, long workspaceId, List<String> requestedSkills) {
if (requestedSkills.isEmpty()) return List.of();
List<Long> ids = new ArrayList<>();
List<String> boundNames = new ArrayList<>();
for (String raw : requestedSkills) {
String skillName = raw.trim();
if (skillName.isEmpty()) continue;
List<SkillEntity> matches = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, skillName)
.eq(SkillEntity::getDeleted, 0));
SkillEntity chosen = matches.stream()
.filter(s -> {
if (Boolean.TRUE.equals(s.getBuiltin())) return true;
long ws = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
return ws == workspaceId;
})
.findFirst()
.orElse(null);
if (chosen == null) {
log.warn("[AgentAuthoringTool] skill '{}' not resolvable for workspace {}; skipping", skillName, workspaceId);
continue;
}
ids.add(chosen.getId());
boundNames.add(chosen.getName());
}
if (ids.isEmpty()) return List.of();
try {
// Best-effort: the employee is already persisted, so a late
// binding failure (e.g. a skill row deleted between resolve and
// bind) must not throw out of the tool and strand the caller with
// an error on top of an already-created agent. The agent simply
// keeps the default capability set instead.
agentBindingService.setSkillBindings(agent.getId(), ids);
} catch (Exception e) {
log.warn("[AgentAuthoringTool] skill binding failed for agent {}; left on global defaults: {}",
agent.getId(), e.getMessage());
return List.of();
}
return boundNames;
}
/**
* Filter requested tool names through the picker (only available == true
* names are bindable) and bind them. Returns the names actually bound.
*/
private List<String> bindTools(AgentEntity agent, List<String> requestedTools) {
if (requestedTools.isEmpty()) return List.of();
Set<String> bindable;
try {
bindable = availableToolService.listAvailable().stream()
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.collect(Collectors.toSet());
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool picker unavailable; skipping tool bind: {}", e.getMessage());
return List.of();
}
List<String> filtered = new ArrayList<>();
for (String raw : requestedTools) {
String toolName = raw == null ? "" : raw.trim();
if (toolName.isEmpty()) continue;
if (bindable.contains(toolName)) {
filtered.add(toolName);
} else {
log.warn("[AgentAuthoringTool] tool '{}' not bindable; skipping", toolName);
}
}
if (filtered.isEmpty()) return List.of();
try {
// Best-effort, same rationale as bindSkills: never throw after the
// employee has been created.
agentBindingService.setToolBindings(agent.getId(), filtered);
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool binding failed for agent {}; left on global defaults: {}",
agent.getId(), e.getMessage());
return List.of();
}
return filtered;
}
/** Parse a JSON array of strings or a comma-separated list into a name list. */
private List<String> parseNameList(String raw) {
if (raw == null || raw.isBlank()) return List.of();
String trimmed = raw.trim();
if (trimmed.startsWith("[")) {
try {
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
return parsed == null ? List.of() : parsed;
} catch (Exception ignored) {
// Fall through to comma split the model occasionally emits a
// malformed array; a comma split still recovers most names.
}
}
List<String> out = new ArrayList<>();
for (String part : trimmed.replace("[", "").replace("]", "").split(",")) {
String p = part.trim().replaceAll("^[\"']|[\"']$", "");
if (!p.isEmpty()) out.add(p);
}
return out;
}
private static String normalizeAgentType(String agentType) {
if (agentType == null || agentType.isBlank()) return "react";
String t = agentType.trim().toLowerCase();
return "plan_execute".equals(t) ? "plan_execute" : "react";
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
/** Best-effort numeric parse of the requester id for creator attribution. */
private static Long parseUserId(String requesterId) {
if (requesterId == null || requesterId.isBlank()) return null;
try {
return Long.parseLong(requesterId.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@ -15,11 +15,14 @@ import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.llm.event.ModelConfigChangedEvent; import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.memory.MemoryProperties; import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator; import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext; import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -45,14 +48,22 @@ public class AgentService {
private final MemoryRecallTracker memoryRecallTracker; private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator; private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties; private final MemoryProperties memoryProperties;
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
* to keep this a leaf dependency with no risk of a bean cycle. */
private final ConversationMapper conversationMapper;
/** Field-injected publisher for agent_lifecycle trigger events; the /** Field-injected publisher for agent_lifecycle trigger events; the
* trigger module's bridge listens and forwards into ingest. */ * trigger module's bridge listens and forwards into ingest. */
@Autowired(required = false) @Autowired(required = false)
private ApplicationEventPublisher events; private ApplicationEventPublisher events;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */ /**
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>(); * Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
* variant instead of mutating the one every other conversation shares.
* The model key is {@code ""} for the Agent / global-default model.
*/
private final Map<Long, Map<String, BaseAgent>> agentInstances = new ConcurrentHashMap<>();
// ==================== CRUD ==================== // ==================== CRUD ====================
@ -216,7 +227,7 @@ public class AgentService {
*/ */
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try { try {
return withLifecycleSync(agentId, message, conversationId, return withLifecycleSync(agentId, message, conversationId,
@ -232,7 +243,7 @@ public class AgentService {
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux // Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state. // termination so the next reactive subscriber doesn't inherit stale state.
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
@ -268,7 +279,7 @@ public class AgentService {
String requesterId, String thinkingLevel, String requesterId, String thinkingLevel,
ChatOrigin origin) { ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行 // 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
if (thinkingLevel != null && !thinkingLevel.isBlank()) { if (thinkingLevel != null && !thinkingLevel.isBlank()) {
@ -314,7 +325,7 @@ public class AgentService {
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) { public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, goal); memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try { try {
return withLifecycleSync(agentId, goal, conversationId, return withLifecycleSync(agentId, goal, conversationId,
@ -341,7 +352,7 @@ public class AgentService {
public String chatWithReplay(Long agentId, String userMessage, String conversationId, public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) { String toolCallPayload, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage); memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try { try {
return withLifecycleSync(agentId, userMessage, conversationId, return withLifecycleSync(agentId, userMessage, conversationId,
@ -369,7 +380,7 @@ public class AgentService {
String toolCallPayload, String requesterId, String toolCallPayload, String requesterId,
ChatOrigin origin) { ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage); memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> { return Flux.defer(() -> {
ChatOriginHolder.set(captured); ChatOriginHolder.set(captured);
@ -382,8 +393,20 @@ public class AgentService {
} }
public AgentState getAgentState(Long agentId) { public AgentState getAgentState(Long agentId) {
BaseAgent agent = agentInstances.get(agentId); Map<String, BaseAgent> variants = agentInstances.get(agentId);
return agent != null ? agent.getState() : AgentState.IDLE; if (variants == null || variants.isEmpty()) {
return AgentState.IDLE;
}
// An Agent may have several cached graph variants (one per pinned
// model). Report the first non-IDLE state so a turn running on any
// variant stays visible.
for (BaseAgent agent : variants.values()) {
AgentState state = agent.getState();
if (state != AgentState.IDLE) {
return state;
}
}
return AgentState.IDLE;
} }
// ==================== 缓存管理 ==================== // ==================== 缓存管理 ====================
@ -472,14 +495,63 @@ public class AgentService {
// ==================== 内部方法 ==================== // ==================== 内部方法 ====================
private BaseAgent getOrBuildAgent(Long agentId) { /**
return agentInstances.computeIfAbsent(agentId, id -> { * Resolve (and cache) the Agent graph for a conversation, honouring the
AgentEntity entity = getAgent(id); * conversation's pinned model. Conversations with no pin IM channels
if (!Boolean.TRUE.equals(entity.getEnabled())) { * before issue #183 fix, cron, sub-tasks, or rows not yet created
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName()); * resolve to the shared Agent / global-default graph.
*
* <p>Defensive normalisation: a half-populated pair (provider but no
* model, or vice versa) is treated as unpinned. Without this guard, a
* partially-cleared admin UI write could end up cached as a key like
* {@code "volcano::"} which {@link #getOrBuildAgent} would then try to
* build, only to fail at provider-resolution time on every turn.
*/
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
String provider = null;
String modelName = null;
if (conversationId != null && !conversationId.isBlank()) {
ConversationEntity conv = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
provider = blankToNull(conv.getModelProvider());
modelName = blankToNull(conv.getModelName());
// Half-populated pair treat as unpinned. Pinning requires
// a complete (provider, model) tuple see #183 follow-up
// hardening so a stale row written by an earlier broken
// admin UI release doesn't loop the cache on an invalid key.
if (provider == null || modelName == null) {
provider = null;
modelName = null;
}
} }
return agentGraphBuilder.build(entity); }
}); return getOrBuildAgent(agentId, provider, modelName);
}
/** Map empty / whitespace strings to null so the pinned-check is one branch. */
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private BaseAgent getOrBuildAgent(Long agentId) {
return getOrBuildAgent(agentId, null, null);
}
private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) {
boolean pinned = modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank();
String modelKey = pinned ? modelProvider + "::" + modelName : "";
return agentInstances
.computeIfAbsent(agentId, id -> new ConcurrentHashMap<>())
.computeIfAbsent(modelKey, key -> {
AgentEntity entity = getAgent(agentId);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
}
return agentGraphBuilder.build(entity, modelProvider, modelName);
});
} }
// ==================== StreamDelta ==================== // ==================== StreamDelta ====================

View File

@ -219,6 +219,22 @@ public class AgentToolSet {
return callbacks.size(); return callbacks.size();
} }
/**
* Resolve a mix of aliases (function name / Spring bean name / Java class simple name)
* to the {@code @Tool} function names they map to. Used to bridge persistence layers
* that key a tool by its class or bean name (e.g. {@code mate_tool.name}) onto the
* runtime callback name ({@code cb.getToolDefinition().name()}). Unknown aliases yield
* nothing.
*/
public Set<String> functionNamesFor(Set<String> aliases) {
if (aliases == null || aliases.isEmpty()) {
return Set.of();
}
return resolveAliases(aliases).stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
// ==================== Internals ==================== // ==================== Internals ====================
/** /**

View File

@ -11,6 +11,8 @@ import org.springframework.ai.content.Media;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.util.MimeType; import org.springframework.util.MimeType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.approval.ApprovalPlaceholderUtil; import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.MediaCaptionService; import vip.mate.llm.routing.MediaCaptionService;
@ -57,11 +59,17 @@ public abstract class BaseAgent {
/** /**
* Max ReAct iterations (one reasoning + action + observation step counts as one). * Max ReAct iterations (one reasoning + action + observation step counts as one).
* Default 100, hard ceiling 100 (enforced in AgentGraphBuilder so per-agent DB * Default 150, hard ceiling 150 (enforced in AgentGraphBuilder so per-agent DB
* overrides cannot exceed it). * overrides cannot exceed it).
*
* <p>Raised from 100 150 after the round-4 LLM-review smoke test, where a
* 10-model research task with browser_use + per-step verification hit the
* 100-iter cap with only 4/10 models completed. 150 gives roughly 50 %
* headroom for similar multi-step research workflows while still bounding
* a runaway agent.
*/ */
public static final int MAX_ITERATIONS_HARD_CEILING = 100; public static final int MAX_ITERATIONS_HARD_CEILING = 150;
protected int maxIterations = 100; protected int maxIterations = 150;
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */ /** 工作区活动目录(限制文件工具访问范围,为空不限制) */
protected String workspaceBasePath; protected String workspaceBasePath;
@ -116,9 +124,24 @@ public abstract class BaseAgent {
protected MultimodalRouter multimodalRouter; protected MultimodalRouter multimodalRouter;
protected MediaCaptionService mediaCaptionService; protected MediaCaptionService mediaCaptionService;
/**
* RFC 48 wired by {@link AgentGraphBuilder#build} so the agent's
* {@code buildInitialState} can inject {@code ACTIVE_GOAL} from the
* conversation's active goal row. Nullable when the goal subsystem
* is off / not wired (legacy tests with minimal builders).
*/
protected vip.mate.goal.service.GoalService goalService;
/** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */ /** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */
protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE; protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE;
/**
* Prefix of the system-role divider row a scheduled-job run writes into
* its conversation immediately before the run's user message. Used to
* (a) drop the divider when replaying history to the LLM and (b) locate
* the current run's start when isolating scheduled-job history.
*/
private static final String CRON_HEADER_PREFIX = "📋 ";
protected BaseAgent(ChatClient chatClient, ConversationService conversationService) { protected BaseAgent(ChatClient chatClient, ConversationService conversationService) {
this.chatClient = chatClient; this.chatClient = chatClient;
@ -221,6 +244,24 @@ public abstract class BaseAgent {
} }
protected List<Message> buildConversationHistory(String conversationId, String currentUserMessage) { protected List<Message> buildConversationHistory(String conversationId, String currentUserMessage) {
// ===== Scheduled-job run isolation (issue #142) =====
// A scheduled-job run is a one-shot task whose full instruction is
// passed explicitly via currentUserMessage. Its conversation the
// shared per-workspace tasks_<wsId> log, or a per-job cron_<id>
// conversation concatenates many independent runs; under concurrent
// runs their rows are not even adjacent (each startRun writes a header
// then a user row in its own transaction, and the inserts interleave).
// No positional reconstruction from that conversation is therefore
// safe. The LLM history is simply empty: the prompt is [system, task].
// The gate is an explicit ChatOrigin signal, so a normal Web or
// channel turn can never take this path.
ChatOrigin chatOrigin = ChatOriginHolder.get();
if (chatOrigin != null && chatOrigin.cronOrigin()) {
log.info("[{}] Scheduled-job run: LLM context isolated (no conversation history replayed)",
agentName);
return List.of();
}
// ===== 两阶段加载短对话全量长对话分页递进式 ===== // ===== 两阶段加载短对话全量长对话分页递进式 =====
long totalCount = conversationService.countMessages(conversationId); long totalCount = conversationService.countMessages(conversationId);
if (totalCount <= 0) { if (totalCount <= 0) {
@ -490,7 +531,7 @@ public abstract class BaseAgent {
// and bloat the prompt with scheduler metadata. // and bloat the prompt with scheduler metadata.
if ("system".equals(entity.getRole()) if ("system".equals(entity.getRole())
&& entity.getContent() != null && entity.getContent() != null
&& entity.getContent().startsWith("📋 ")) { && entity.getContent().startsWith(CRON_HEADER_PREFIX)) {
return null; return null;
} }
@ -616,7 +657,7 @@ public abstract class BaseAgent {
String role = entity.getRole(); String role = entity.getRole();
if ("system".equals(role) if ("system".equals(role)
&& entity.getContent() != null && entity.getContent() != null
&& entity.getContent().startsWith("📋 ")) return true; && entity.getContent().startsWith(CRON_HEADER_PREFIX)) return true;
if ("assistant".equals(role) if ("assistant".equals(role)
&& isApprovalPlaceholder(entity.getContent())) return true; && isApprovalPlaceholder(entity.getContent())) return true;
if ("assistant".equals(role) if ("assistant".equals(role)
@ -1136,6 +1177,15 @@ public abstract class BaseAgent {
* the primary model can't already handle. * the primary model can't already handle.
*/ */
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) { protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
// Scheduled-job run (issue #142): the task text is the explicit
// userMessageText argument. Never reconstruct it from the conversation
// a shared cron conversation under concurrent runs has no reliable
// "last user message" (another run's row may be last). Scheduled jobs
// carry no attachments, so a plain text UserMessage is exact.
ChatOrigin chatOrigin = ChatOriginHolder.get();
if (chatOrigin != null && chatOrigin.cronOrigin()) {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
try { try {
List<MessageEntity> history = conversationService.listMessages(conversationId); List<MessageEntity> history = conversationService.listMessages(conversationId);
// 倒序取最后一条 user 消息buildInitialState saveMessage 后调用所以最后一条就是当前消息 // 倒序取最后一条 user 消息buildInitialState saveMessage 后调用所以最后一条就是当前消息

View File

@ -1,33 +0,0 @@
package vip.mate.agent;
/**
* 请求级思考深度的 ThreadLocal 持有器
* <p>
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口
* <p>
* 支持的值off / low / medium / high / maxnull 表示跟随模型默认
*
* @author MateClaw Team
*/
public final class ThinkingLevelHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private ThinkingLevelHolder() {}
public static void set(String level) {
HOLDER.set(level);
}
/**
* 获取当前请求的思考级别null 表示未设置跟随模型默认
*/
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -139,7 +139,7 @@ public class AgentBindingController {
} }
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) { if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区"); throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
} }
} }
} }

View File

@ -15,8 +15,11 @@ import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.skill.acp.AcpSkillBridge; import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge; import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.lifecycle.BlockedByBindingRow;
import vip.mate.skill.lifecycle.ConfirmRequiredException;
import vip.mate.skill.model.SkillEntity; import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillRuntimeService;
@ -24,9 +27,15 @@ import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.model.AvailableToolDTO; import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService; import vip.mate.tool.service.AvailableToolService;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -41,7 +50,7 @@ import java.util.stream.Collectors;
*/ */
@Slf4j @Slf4j
@Service @Service
public class AgentBindingService { public class AgentBindingService implements AgentBindingResolver {
private final AgentSkillBindingMapper skillBindingMapper; private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper; private final AgentToolBindingMapper toolBindingMapper;
@ -112,6 +121,7 @@ public class AgentBindingService {
* 获取 Agent 绑定的 enabled skill ID 集合 * 获取 Agent 绑定的 enabled skill ID 集合
* 返回 null 表示该 agent 没有自定义绑定使用全局默认 * 返回 null 表示该 agent 没有自定义绑定使用全局默认
*/ */
@Override
public Set<Long> getBoundSkillIds(Long agentId) { public Set<Long> getBoundSkillIds(Long agentId) {
List<AgentSkillBinding> bindings = listSkillBindings(agentId); List<AgentSkillBinding> bindings = listSkillBindings(agentId);
if (bindings.isEmpty()) { if (bindings.isEmpty()) {
@ -179,6 +189,103 @@ public class AgentBindingService {
} }
} }
// ==================== Lifecycle curator support ====================
/**
* Skill ids explicitly bound to at least one enabled agent (binding row
* {@code enabled = true} AND agent row {@code enabled = true}). The
* lifecycle curator excludes these from its candidate set so it never
* silently undoes a user's explicit skill picks.
*/
public Set<Long> skillIdsBoundToEnabledAgents() {
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return Set.of();
}
return skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getEnabled, true))
.stream()
.filter(b -> b.getSkillId() != null && enabledAgentIds.contains(b.getAgentId()))
.map(AgentSkillBinding::getSkillId)
.collect(Collectors.toSet());
}
/**
* Binding-protected skills with the detail the lifecycle run report
* needs: {@code {skillId, name, agentIds, daysIdle}}. Hard-exempt skills
* (builtin / mcp / acp / pinned) are excluded since they would not be
* archival candidates regardless of bindings.
*/
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return List.of();
}
Map<Long, List<Long>> bySkill = new HashMap<>();
for (AgentSkillBinding b : skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getEnabled, true))) {
if (b.getSkillId() == null || !enabledAgentIds.contains(b.getAgentId())) {
continue;
}
bySkill.computeIfAbsent(b.getSkillId(), k -> new ArrayList<>()).add(b.getAgentId());
}
if (bySkill.isEmpty()) {
return List.of();
}
List<BlockedByBindingRow> rows = new ArrayList<>();
for (SkillEntity skill : skillMapper.selectBatchIds(bySkill.keySet())) {
if (Boolean.TRUE.equals(skill.getBuiltin()) || Boolean.TRUE.equals(skill.getPinned())) {
continue;
}
String type = skill.getSkillType();
if (type != null && List.of("builtin", "mcp", "acp").contains(type)) {
continue;
}
LocalDateTime anchor = skill.getLastActivityAt() != null
? skill.getLastActivityAt() : skill.getCreateTime();
long daysIdle = anchor == null ? 0L : Duration.between(anchor, now).toDays();
rows.add(new BlockedByBindingRow(skill.getId(), skill.getName(),
bySkill.get(skill.getId()), daysIdle));
}
return rows;
}
/**
* Enabled agents that explicitly bind {@code skillId}. Used by manual
* archive to list the agents an admin would affect before confirming.
*/
public List<ConfirmRequiredException.AgentRow> enabledAgentsBoundToSkill(Long skillId) {
if (skillId == null) {
return List.of();
}
Set<Long> agentIds = skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, skillId)
.eq(AgentSkillBinding::getEnabled, true))
.stream()
.map(AgentSkillBinding::getAgentId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (agentIds.isEmpty()) {
return List.of();
}
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.in(AgentEntity::getId, agentIds)
.eq(AgentEntity::getEnabled, true))
.stream()
.map(a -> new ConfirmRequiredException.AgentRow(a.getId(), a.getName()))
.collect(Collectors.toList());
}
/** Ids of every currently-enabled agent. */
private Set<Long> enabledAgentIds() {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getEnabled, true)
.select(AgentEntity::getId))
.stream()
.map(AgentEntity::getId)
.collect(Collectors.toSet());
}
/** /**
* Refuse to bind a skill that doesn't share the agent's workspace. * Refuse to bind a skill that doesn't share the agent's workspace.
* Skills are per-workspace installable artifacts (each workspace has * Skills are per-workspace installable artifacts (each workspace has
@ -200,11 +307,11 @@ public class AgentBindingService {
* then apply the same workspace comparison.</li> * then apply the same workspace comparison.</li>
* </ul> * </ul>
* *
* <p>Most {@code mate_skill} rows currently sit in the default workspace * <p>Builtin skills are exempt: they are global capabilities seeded
* (id=1) because skill creation doesn't yet honor the * once into the default workspace and shared with every workspace, so
* {@code X-Workspace-Id} header; the real-skill branch is therefore * any agent may bind them regardless of its own workspace. Only
* defense-in-depth right now and flips on automatically the moment * workspace-owned skills (dynamic / installed / synthesized) are
* workspace-scoped skill creation lands. ACP enforcement is live today. * tenancy-checked.
* *
* @throws MateClawException 404 if the agent or skill doesn't exist; * @throws MateClawException 404 if the agent or skill doesn't exist;
* 403 on a workspace mismatch. * 403 on a workspace mismatch.
@ -241,6 +348,12 @@ public class AgentBindingService {
throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId); throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId);
} }
} }
// Builtin skills are global shared across every workspace, so any
// agent in any workspace may bind them (same stance as MCP virtuals
// above). Only workspace-owned skills are tenancy-checked.
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return;
}
long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId(); long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
if (agentWs != skillWs) { if (agentWs != skillWs) {
@ -276,8 +389,8 @@ public class AgentBindingService {
} }
/** /**
* RFC-090 §14.2 single entry point that maps an agent's bindings to * Single entry point that maps an agent's bindings to the set of tool
* the set of tool names allowed at runtime. * names allowed at runtime.
* *
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} / * <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
* {@link #getBoundToolNames}): * {@link #getBoundToolNames}):
@ -306,13 +419,17 @@ public class AgentBindingService {
* tools and skill-expanded tools: * tools and skill-expanded tools:
* <ul> * <ul>
* <li>{@link #SYSTEM_LEVEL_TOOLS} agent-wide primitives.</li> * <li>{@link #SYSTEM_LEVEL_TOOLS} agent-wide primitives.</li>
* <li>Every currently-bindable MCP tool (any tool with * <li>Every currently-bindable MCP tool ({@code source="mcp"},
* {@code source="mcp"} and {@code available=true} in the picker). * {@code available=true} in the picker) but only when the agent
* MCP servers are administrator-level capabilities; once enabled * has not ticked any MCP tool itself. MCP servers are
* globally they should not be silently hidden from an agent that * administrator-level capabilities, so an agent that bound merely
* happens to have any other binding. To deny a specific MCP tool * a skill or a built-in tool keeps full MCP access. Once the
* to a specific agent, use the tool-guard deny path applied * operator ticks specific MCP rows, that is read as a deliberate
* upstream in {@code AgentGraphBuilder}.</li> * per-agent scope: only the ticked MCP tools stay and the rest
* are not auto-joined, so a role can be limited to a fixed MCP
* tool set. To hide a single MCP tool from an agent that ticked
* no MCP row, use the tool-guard deny path applied upstream in
* {@code AgentGraphBuilder}.</li>
* </ul> * </ul>
*/ */
public Set<String> getEffectiveToolNames(Long agentId) { public Set<String> getEffectiveToolNames(Long agentId) {
@ -355,16 +472,22 @@ public class AgentBindingService {
// (the LLM stops being able to write to LESSONS.md / MEMORY.md). // (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS); merged.addAll(SYSTEM_LEVEL_TOOLS);
// Enabled MCP server tools auto-join the allowlist for the same // MCP tools. An agent that bound only a skill or a built-in tool
// reason SYSTEM_LEVEL_TOOLS does: MCP servers are an // and ticked no MCP row keeps full access to every enabled MCP
// administrator-enabled capability, not a per-agent opt-in. Without // tool: MCP servers are an administrator-enabled capability and
// this union, an agent with any skill or built-in tool bound would // must not silently vanish just because some unrelated binding
// silently lose every MCP tool users hit this when they bound one // exists. But once the operator ticks specific MCP rows, that is a
// built-in tool, didn't tick the MCP rows, and observed "only // deliberate per-agent scope only those MCP tools (already merged
// built-in tools work". Operators who need to hide a specific MCP // via directTools above) stay, and the rest are not auto-joined, so
// tool from a specific agent still have the tool-guard deny path // a role can be limited to a fixed MCP tool set. To instead hide a
// (AgentGraphBuilder applies withDeniedToolsFiltered before this). // single MCP tool from an agent that ticked no MCP row, use the
merged.addAll(getEnabledMcpToolNames()); // tool-guard deny path applied upstream in AgentGraphBuilder.
Set<String> enabledMcpTools = getEnabledMcpToolNames();
boolean agentScopedMcpExplicitly =
directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
if (!agentScopedMcpExplicitly) {
merged.addAll(enabledMcpTools);
}
return merged; return merged;
} }
@ -391,8 +514,8 @@ public class AgentBindingService {
} }
/** /**
* RFC-090 §11 tools that exist outside the skill scope and must * Tools that exist outside the skill scope and must survive any
* survive any agent-level skill binding restriction. * agent-level skill binding restriction.
* *
* <p>Add new entries here only after verifying the tool is genuinely * <p>Add new entries here only after verifying the tool is genuinely
* agent-wide, not skill-specific. Tools added here bypass the * agent-wide, not skill-specific. Tools added here bypass the
@ -417,8 +540,17 @@ public class AgentBindingService {
"read_workspace_memory_file", "read_workspace_memory_file",
"write_workspace_memory_file", "write_workspace_memory_file",
"edit_workspace_memory_file", "edit_workspace_memory_file",
// Keyword search over the same memory files. Agent-wide like the
// CRUD primitives above a skill-bound agent must still be able
// to locate a fact by keyword instead of reading whole files.
"search_workspace_memory",
// Progressive tool disclosure meta tool that activates an
// extension-tier tool for the rest of the conversation. Must be
// agent-wide so the model can always surface hidden tools.
"enable_tool",
// Skill discovery / dispatch skills are docs, not callables; // Skill discovery / dispatch skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts. // these helpers let the LLM read SKILL.md / run scripts.
"load_skill",
"readSkillFile", "readSkillFile",
"runSkillScript", "runSkillScript",
"listSkillFiles", "listSkillFiles",
@ -433,7 +565,28 @@ public class AgentBindingService {
// delegateParallel / listAvailableAgents. Same dead-name bug. // delegateParallel / listAvailableAgents. Same dead-name bug.
"delegateToAgent", "delegateToAgent",
"delegateParallel", "delegateParallel",
// Detached async delegation spawn a sub-task that returns a
// task_id immediately, then retrieve its result in a later turn.
// Agent-wide like the synchronous delegation tools above.
"delegateAsync",
"taskOutput",
"listAvailableAgents", "listAvailableAgents",
// Persistent-goal management (RFC 48). These are agent-wide
// primitives the user can decide mid-conversation that this
// task is a multi-turn goal, and the assistant must be able to
// lock it in. Pre-fix, business agents like "数据分析师" with
// tight bindings rejected setGoal as "not in my toolset",
// observed during PR4 manual QA.
"setGoal",
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
// Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting
// tasks need it on every business agent, not just the planner,
// since context-window trims can otherwise let an agent forget
// what it has already produced and re-do work or stall.
"progress_update",
// Document / media generation agent-wide capabilities, never // Document / media generation agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were // declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever // universally visible; the new gate silently strips them whenever
@ -464,6 +617,7 @@ public class AgentBindingService {
"search", "search",
"browser_use", "browser_use",
"read_file", "read_file",
"send_file",
"write_file", "write_file",
"edit_file", "edit_file",
"execute_shell_command", "execute_shell_command",
@ -471,7 +625,36 @@ public class AgentBindingService {
"extract_document_text", "extract_document_text",
"extract_pdf_text", "extract_pdf_text",
"extract_docx_text", "extract_docx_text",
"readMateClawDoc" "readMateClawDoc",
// Wiki knowledge-base tools. These are agent-wide capabilities
// tied to whichever knowledge base is attached to the agent, and
// are never declared inside any skill manifest. Like the document
// and media generators above, the skill-binding allowlist would
// otherwise strip every wiki_* tool from any agent that has a
// skill bound so the agent could no longer read or write its
// own knowledge base ("save this result into the knowledge base"
// failed with a not-found / no-permission style error). Each tool
// degrades with a clear "no knowledge base" message when the
// agent has none attached, so advertising them unconditionally
// is safe.
"wiki_read_page",
"wiki_list_pages",
"wiki_search_pages",
"wiki_semantic_search",
"wiki_trace_source",
"wiki_create_page",
"wiki_compile_page",
"wiki_read_many",
"wiki_archive_page",
"wiki_unarchive_page",
"wiki_delete_page",
"wiki_related_pages",
"wiki_explain_relation",
"wiki_enrich_page",
"wiki_list_transformations",
"wiki_apply_transformation",
"wiki_apply_transformation_to_page",
"wiki_aggregate_transformation"
); );
private ResolvedSkill findResolvedSkillById(Long skillId) { private ResolvedSkill findResolvedSkillById(Long skillId) {
@ -596,7 +779,7 @@ public class AgentBindingService {
} }
} }
// ==================== Provider Preferences (RFC-009 PR-3) ==================== // ==================== Provider Preferences ====================
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */ /** Raw rows for the agent edit form. Sorted by sort_order ascending. */
public List<AgentProviderPreference> listProviderPreferences(Long agentId) { public List<AgentProviderPreference> listProviderPreferences(Long agentId) {
@ -614,6 +797,7 @@ public class AgentBindingService {
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the * <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
* fallback chain order per agent.</p> * fallback chain order per agent.</p>
*/ */
@Override
public List<String> getPreferredProviderIds(Long agentId) { public List<String> getPreferredProviderIds(Long agentId) {
if (agentId == null) return Collections.emptyList(); if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream() return listProviderPreferences(agentId).stream()

View File

@ -0,0 +1,53 @@
package vip.mate.agent.binding.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.skill.event.SkillRemovedEvent;
/**
* Drops {@code mate_agent_skill} rows that pointed at a now-removed skill.
*
* <p>Without this listener, deleting a skill from the skill management page
* leaves orphan binding rows behind:
* <ul>
* <li>the agent edit modal still shows a non-zero badge from
* {@code GET /agents/{id}/skills},</li>
* <li>the picker list (sourced from {@code /skills} enabled set) no longer
* contains a checkbox for that id so the user can't uncheck it, and</li>
* <li>a subsequent {@code PUT /agents/{id}/skills} payload that still
* carries the orphan id is rejected by
* {@code AgentBindingService.setSkillBindings} with
* {@code err.skill.not_found}, leaving the user with no way to clear
* the stale binding.</li>
* </ul>
*
* <p>The event is dispatched synchronously from {@code SkillService} after
* the {@code mate_skill} row deletion, so the cleanup is part of the same
* request and observable in the very next list call.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentBindingSkillRemovalListener {
private final AgentSkillBindingMapper skillBindingMapper;
@EventListener
public void onSkillRemoved(SkillRemovedEvent event) {
if (event == null || event.skillId() == null) {
return;
}
int dropped = skillBindingMapper.delete(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, event.skillId()));
if (dropped > 0) {
log.info("Cleaned {} agent-skill binding row(s) for removed skill {} (id={})",
dropped, event.skillName(), event.skillId());
}
}
}

View File

@ -1,66 +0,0 @@
package vip.mate.agent.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
* Delegates to {@link AgentGraphBuilder}'s helpers; see
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
* pattern and the {@code @Lazy} cycle break.
*/
@Component
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public AgentOpenAiCompatibleChatModelBuilder(
@Lazy AgentGraphBuilder agentGraphBuilder,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.agentGraphBuilder = agentGraphBuilder;
this.observationRegistryProvider = observationRegistryProvider;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_COMPATIBLE;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
// RFC-03 Lane B1 pass model.requestTimeoutSeconds so providers /
// models with extended-thinking p99s don't false-positive on the
// hardcoded 180s read timeout.
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
// those directly wrap with a per-request payload patcher. See
// DeepSeekV4ThinkingDecorator javadoc.
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
return new DeepSeekV4ThinkingDecorator(raw);
}
return raw;
}
}

View File

@ -33,7 +33,32 @@ public record ChatOrigin(
@Nullable Long workspaceId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable String workspaceBasePath,
@Nullable Long channelId, @Nullable Long channelId,
@Nullable ChannelTarget channelTarget @Nullable ChannelTarget channelTarget,
// True only when the agent invocation was triggered by the scheduled-job
// runner. An explicit discriminator (rather than inferring from
// requesterId/channelId) so the runtime can branch on "is this a cron
// run" without coupling to factory internals.
boolean cronOrigin,
/**
* Display name of the user that sent the inbound IM message. Used by
* the prompt-context injector so the agent's system prompt can
* personalise replies ("You are talking to {{senderName}}"). Null
* for non-IM origins (web, cron). {@code requesterId} carries the
* stable identifier; this one is purely the human-readable surface.
*/
@Nullable String senderName,
/**
* Source channel type ("feishu" / "wecom" / "dingtalk" / ...).
* Lets the agent know which platform it's responding on, e.g. to
* tailor formatting or hint at supported features.
*/
@Nullable String channelType,
/**
* Group / chat identifier for IM channels distinguishes private
* vs. group conversations. Null for 1:1 chats. Distinct from
* {@link #channelTarget()} (which targets cron / proactive sends).
*/
@Nullable String chatId
) { ) {
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
@ -41,7 +66,7 @@ public record ChatOrigin(
/** Sentinel used by AgentService default overloads where no origin is supplied. */ /** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY = public static final ChatOrigin EMPTY =
new ChatOrigin(null, null, "", null, null, null, null); new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null);
// ---------------- Factories per entry point ---------------- // ---------------- Factories per entry point ----------------
@ -51,7 +76,7 @@ public record ChatOrigin(
@Nullable String workspaceBasePath) { @Nullable String workspaceBasePath) {
return new ChatOrigin(null, conversationId, return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "", requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null); workspaceId, workspaceBasePath, null, null, false, null, "web", null);
} }
public static ChatOrigin cron(@Nullable String conversationId, public static ChatOrigin cron(@Nullable String conversationId,
@ -60,25 +85,42 @@ public record ChatOrigin(
@Nullable Long channelId, @Nullable Long channelId,
@Nullable ChannelTarget target) { @Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system", return new ChatOrigin(null, conversationId, "system",
workspaceId, workspaceBasePath, channelId, target); workspaceId, workspaceBasePath, channelId, target, true, null, null, null);
} }
// ---------------- Wither-style updates ---------------- // ---------------- Wither-style updates ----------------
public ChatOrigin withAgent(@Nullable Long newAgentId) { public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId, return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget); workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId);
} }
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) { @Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget); newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId);
} }
public ChatOrigin withConversationId(@Nullable String newConversationId) { public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId, return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget); workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId);
}
/**
* Carry the inbound message's sender display name, source channel
* type, and chat (group) id. Called by the channel-side origin
* factory so prompt-context injection can show the agent "who"
* is talking and "where".
*/
public ChatOrigin withSender(@Nullable String newSenderName,
@Nullable String newChannelType,
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId);
} }
// ---------------- Spring AI ToolContext interop ---------------- // ---------------- Spring AI ToolContext interop ----------------

View File

@ -11,7 +11,7 @@ package vip.mate.agent.context;
* call (set on entry, cleared in {@code finally}). Once written into the * call (set on entry, cleared in {@code finally}). Once written into the
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN}, * graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
* the rest of the runtime reads via the typed accessor no further ThreadLocal * the rest of the runtime reads via the typed accessor no further ThreadLocal
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}. * access. Mirrors {@link vip.mate.llm.chatmodel.ThinkingLevelHolder}.
*/ */
public final class ChatOriginHolder { public final class ChatOriginHolder {

View File

@ -26,7 +26,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* 会话历史上下文窗口管理器Hermes 风格升级版 * 会话历史上下文窗口管理器四阶段压缩升级版
* <p> * <p>
* 四阶段压缩策略 * 四阶段压缩策略
* <ol> * <ol>
@ -162,6 +162,21 @@ public class ConversationWindowManager {
/** 每个会话的摘要冷却截止时间 */ /** 每个会话的摘要冷却截止时间 */
private final ConcurrentHashMap<String, Long> summaryCooldownUntil = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Long> summaryCooldownUntil = new ConcurrentHashMap<>();
/** Per-conversation last-PTL-forced-compaction timestamp. The structured
* PTL retry path is guarded by {@link #PTL_FORCE_LLM_COOLDOWN_MS} a
* second PTL hit within the cooldown falls straight back to tail-only
* trimming. Without this, a model that keeps regenerating tool-call
* loops can drive a chain of summary-LLM calls and lock the
* conversation in a compaction storm. */
private final ConcurrentHashMap<String, Long> ptlForceCompactAt = new ConcurrentHashMap<>();
/** Cooldown window after a structured PTL compaction during which a
* follow-up PTL is downgraded to tail-only. Picked so a single ReAct
* loop that retries within seconds can't burn another summary LLM
* call, while still letting the next real conversation turn (minutes
* later) get a fresh structured pass. */
private static final long PTL_FORCE_LLM_COOLDOWN_MS = 60_000L;
// ==================== 主入口 ==================== // ==================== 主入口 ====================
/** /**
@ -259,11 +274,11 @@ public class ConversationWindowManager {
} }
int historyBudget = effectiveMax - reservedTokens; int historyBudget = effectiveMax - reservedTokens;
// 尾部保护 token 预算阈值的 20% Hermes 一致 // 尾部保护 token 预算阈值的 20%
int tailTokenBudget = (int) (triggerThreshold * 0.20); int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel, return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry); conversationId, agentId, totalTokens, spillsAtEntry, "token_threshold");
} }
/** /**
@ -298,11 +313,12 @@ public class ConversationWindowManager {
private List<Message> compactMessages(List<Message> messages, int historyBudget, private List<Message> compactMessages(List<Message> messages, int historyBudget,
int tailTokenBudget, ChatModel chatModel, int tailTokenBudget, ChatModel chatModel,
String conversationId, Long agentId, String conversationId, Long agentId,
int preTokens, long spillsAtEntry) { int preTokens, long spillsAtEntry,
String trigger) {
broadcastCompactStatus(conversationId, "start", Map.of( broadcastCompactStatus(conversationId, "start", Map.of(
"preTokens", preTokens, "preTokens", preTokens,
"messagesIn", messages.size(), "messagesIn", messages.size(),
"trigger", "token_threshold" "trigger", trigger
)); ));
// 动态计算尾部保护边界替代固定 preserveRecentPairs // 动态计算尾部保护边界替代固定 preserveRecentPairs
@ -457,7 +473,7 @@ public class ConversationWindowManager {
? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry) ? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry)
: 0L; : 0L;
Map<String, Object> boundaryMetadata = new java.util.LinkedHashMap<>(); Map<String, Object> boundaryMetadata = new java.util.LinkedHashMap<>();
boundaryMetadata.put("trigger", "token_threshold"); boundaryMetadata.put("trigger", trigger);
boundaryMetadata.put("preTokens", preTokens); boundaryMetadata.put("preTokens", preTokens);
boundaryMetadata.put("postTokens", resultTokens); boundaryMetadata.put("postTokens", resultTokens);
boundaryMetadata.put("messagesSummarized", oldMessages.size()); boundaryMetadata.put("messagesSummarized", oldMessages.size());
@ -888,6 +904,102 @@ public class ConversationWindowManager {
&& r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX); && r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
} }
/**
* Age-based compaction. Replace bodies of all tool responses older than
* the {@code keepRecentN} most recent with a one-line placeholder, while
* preserving the toolCallId and tool name so the assistant/tool pairing
* remains valid and the model still sees "I called X earlier" in history.
*
* <p>Complementary to {@link #pruneOldToolResultsForModelInput}: that pass
* targets oversized or duplicate bodies regardless of age (and may spill
* to disk); this one targets aged bodies regardless of size. Both can run
* in any order the intersection collapses to the same placeholder.
*
* <p>Spill-marker bodies retain their on-disk {@code path=} pointer
* inside the placeholder so a later {@code read_file} can still recover
* the original output. {@link #PRUNE_EXEMPT_TOOLS} (sub-agent delegations)
* bypass the pass entirely their transcripts are not replayable.
*
* @param messages full conversation in chronological order
* @param keepRecentN number of newest {@link ToolResponseMessage}s kept
* verbatim; older ones are compacted. Negative or zero
* disables the pass.
*/
public List<Message> compactAgedToolResponses(List<Message> messages, int keepRecentN) {
if (messages == null || messages.isEmpty() || keepRecentN <= 0) {
return messages;
}
List<Message> out = new ArrayList<>(messages);
int seen = 0;
int compacted = 0;
boolean anyChange = false;
for (int i = out.size() - 1; i >= 0; i--) {
if (!(out.get(i) instanceof ToolResponseMessage trm)) {
continue;
}
if (seen < keepRecentN) {
seen++;
continue;
}
seen++;
List<ToolResponseMessage.ToolResponse> newResponses =
new ArrayList<>(trm.getResponses().size());
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String body = r.responseData();
String name = r.name();
boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name);
if (exempt || body == null || body.isEmpty()) {
newResponses.add(r);
continue;
}
String placeholder = buildAgedPlaceholder(name, body);
if (placeholder.length() < body.length()) {
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, placeholder));
messageChanged = true;
compacted++;
} else {
// Body is already shorter than the placeholder would be
// collapsing it would only add tokens. Keep verbatim.
newResponses.add(r);
}
}
if (messageChanged) {
out.set(i, ToolResponseMessage.builder().responses(newResponses).build());
anyChange = true;
}
}
if (compacted > 0) {
log.info("[ConversationWindow] Aged-compacted {} tool response entries (keepRecent={}) before model request",
compacted, keepRecentN);
}
return anyChange ? out : messages;
}
/**
* Build the one-line "old tool output cleared" body. When the original
* was a spill marker, extract its {@code path=} hint so the model can
* still recover the full output via {@code read_file} on demand.
*/
static String buildAgedPlaceholder(String toolName, String body) {
String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName;
if (body != null && body.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
int idx = body.indexOf(" path=");
if (idx >= 0) {
int end = body.indexOf('\n', idx);
String path = (end > 0 ? body.substring(idx + 6, end) : body.substring(idx + 6)).trim();
if (!path.isEmpty()) {
return "[Old tool output cleared — '" + safeName
+ "' result was spilled to " + path
+ "; use read_file on that path if you still need it.]";
}
}
}
return "[Old tool output cleared — '" + safeName
+ "' can be called again if its result is needed.]";
}
/** /**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符 * Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
* <p>Spill-marker responses are left untouched so their on-disk pointer * <p>Spill-marker responses are left untouched so their on-disk pointer
@ -907,10 +1019,10 @@ public class ConversationWindowManager {
} }
String data = r.responseData(); String data = r.responseData();
if (data != null && data.length() > 500) { if (data != null && data.length() > 500) {
String head = data.substring(0, 200); String marker = "\n...[trimmed " + data.length() + " chars; "
String tail = data.substring(data.length() - 200); + StructuredTruncator.FIDELITY_NOTE + "]...\n";
newResponses.add(new ToolResponseMessage.ToolResponse( newResponses.add(new ToolResponseMessage.ToolResponse(
r.id(), r.name(), head + "\n...[trimmed " + data.length() + " chars]...\n" + tail)); r.id(), r.name(), StructuredTruncator.truncate(data, 200, 200, marker)));
changed = true; changed = true;
} else { } else {
newResponses.add(r); newResponses.add(r);
@ -1081,9 +1193,9 @@ public class ConversationWindowManager {
String text = msg.getText(); String text = msg.getText();
if (text != null && text.length() > CONTENT_MAX) { if (text != null && text.length() > CONTENT_MAX) {
text = text.substring(0, CONTENT_HEAD) String marker = "\n...[truncated " + text.length() + " chars; "
+ "\n...[截断 " + text.length() + " 字符]...\n" + StructuredTruncator.FIDELITY_NOTE + "]...\n";
+ text.substring(text.length() - CONTENT_TAIL); text = StructuredTruncator.truncate(text, CONTENT_HEAD, CONTENT_TAIL, marker);
} }
sb.append(role).append(": ").append(text != null ? text : "").append("\n\n"); sb.append(role).append(": ").append(text != null ? text : "").append("\n\n");
@ -1114,6 +1226,118 @@ public class ConversationWindowManager {
// ==================== PTL 紧急压缩 ==================== // ==================== PTL 紧急压缩 ====================
/**
* Structured PTL (Prompt Too Long) recovery reuses the full
* {@link #compactMessages} pipeline (pair-safe boundary, soft/hard
* trim, MemoryProvider hook, LLM summary, anchor of the first user
* goal) under a forced-tight history budget so the retry actually fits.
* <p>
* Differences vs the {@link #compactForRetry(List)} fallback:
* <ul>
* <li>Preserves the original user goal via anchor instead of dropping
* it with the head long tasks lose context every PTL otherwise.</li>
* <li>Pair-safe cuts, so the retry doesn't break a
* {@code AssistantMessage.tool_calls} / {@code ToolResponseMessage}
* cluster and 400 the provider a second time.</li>
* <li>Runs through summary generation so semantic continuity (user
* preferences, completed steps) survives the trim.</li>
* <li>Tags the persisted boundary row with
* {@code trigger=prompt_too_long} so the summary is retrievable
* via the same {@code mate_conversation_summary} schema as a
* normal token-threshold compaction.</li>
* </ul>
* <p>
* A 60s cooldown ({@link #PTL_FORCE_LLM_COOLDOWN_MS}) downgrades the
* second-and-subsequent PTL hit on one conversation to tail-only, so
* a model stuck in a tool-call retry loop can't drag the summary LLM
* along with it.
*
* @param messages Current history that overflowed the model window.
* @param chatModel Used for the summary generation step.
* @param conversationId Cooldown / cache key.
* @param agentId Drives the {@code MemoryProvider.onPreCompress}
* hook. Nullable the hook is a no-op when null.
* @return Compacted history with summary + anchor + tail, or the
* {@link #compactForRetry(List)} tail-only fallback when the
* cooldown is active or the structured pass produces no
* reduction. {@code null} when the input is too small to
* compact (matches the legacy contract).
*/
public List<Message> compactForRetry(List<Message> messages,
ChatModel chatModel,
String conversationId,
Long agentId) {
if (messages == null || messages.size() <= 2) {
return null;
}
// Sweep the cooldown map on every PTL entry. The summaryCache sweep
// already covers normal-compaction traffic via fitToWindow; without
// this call here, a conversation that only ever hits PTL never
// releases its ptlForceCompactAt entry.
evictExpiredEntries();
// Race-safe claim: compute is atomic per key, so two concurrent
// PTL hits on the same conv can't both pass the cooldown check.
// The {@code claimed} flag is set inside the atomic block so we can
// distinguish "this call's stamp won" from "previous call's stamp
// happened to equal our now" (Windows clock has 15 ms granularity —
// identity-on-timestamp would misfire for back-to-back invocations).
long now = System.currentTimeMillis();
final boolean[] claimed = {false};
ptlForceCompactAt.compute(conversationId, (k, prev) -> {
if (prev != null && now - prev < PTL_FORCE_LLM_COOLDOWN_MS) {
claimed[0] = false;
return prev;
}
claimed[0] = true;
return now;
});
if (!claimed[0]) {
long prevStamp = ptlForceCompactAt.getOrDefault(conversationId, now);
long remainingMs = Math.max(0L, PTL_FORCE_LLM_COOLDOWN_MS - (now - prevStamp));
log.warn("[ConversationWindow] PTL cooldown active for conv={} (remaining {} ms), falling back to tail-only",
conversationId, remainingMs);
broadcastCompactStatus(conversationId, "ptl_cooldown_skipped", Map.of(
"trigger", "prompt_too_long",
"cooldownRemainingMs", remainingMs));
return compactForRetry(messages);
}
int currentTokens = TokenEstimator.estimateTokens(messages);
// Force the history budget into the bottom quartile of current size
// but never under 2k so the post-trim window still has room for
// summary + anchor + a couple of recent turns. Tail budget is one
// quarter of that so the recent window doesn't dominate.
int forcedBudget = Math.max(2000, currentTokens / 4);
int forcedTailBudget = forcedBudget / 4;
log.warn("[ConversationWindow] PTL forced compaction: messages={}, currentTokens={}, forcedBudget={}, forcedTail={}",
messages.size(), currentTokens, forcedBudget, forcedTailBudget);
// Note: no separate "ptl_start" broadcast the inner compactMessages
// call broadcasts "start" with trigger="prompt_too_long" in its
// payload, which is sufficient differentiation for the frontend
// (one event per compaction, with the trigger field carrying the
// semantic distinction).
// Spill count is the manager's private view of toolResultStorage
// computed inside the manager so callers don't need to touch the
// storage SPI.
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
List<Message> compacted = compactMessages(messages, forcedBudget, forcedTailBudget,
chatModel, conversationId, agentId, currentTokens, spillsAtEntry,
"prompt_too_long");
if (compacted == messages || TokenEstimator.estimateTokens(compacted) >= currentTokens) {
log.warn("[ConversationWindow] PTL structured compaction had no effect for conv={}, falling back to tail-only",
conversationId);
return compactForRetry(messages);
}
return compacted;
}
/** /**
* PTL (Prompt Too Long) 恢复用的紧急压缩 * PTL (Prompt Too Long) 恢复用的紧急压缩
* 不调用 LLM 摘要直接丢弃较旧消息只保留最近 4 * 不调用 LLM 摘要直接丢弃较旧消息只保留最近 4
@ -1155,6 +1379,8 @@ public class ConversationWindowManager {
private void evictExpiredEntries() { private void evictExpiredEntries() {
summaryCache.entrySet().removeIf(entry -> entry.getValue().isExpired(CACHE_TTL_MS)); summaryCache.entrySet().removeIf(entry -> entry.getValue().isExpired(CACHE_TTL_MS));
long ptlCutoff = System.currentTimeMillis() - PTL_FORCE_LLM_COOLDOWN_MS;
ptlForceCompactAt.entrySet().removeIf(entry -> entry.getValue() < ptlCutoff);
} }
record CachedSummary(String summary, long createdAt) { record CachedSummary(String summary, long createdAt) {

View File

@ -46,6 +46,27 @@ public final class RuntimeContextInjector {
* 构建运行时上下文消息i18n 版本 * 构建运行时上下文消息i18n 版本
*/ */
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) { public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) {
return buildContextMessage(workspaceBasePath, i18n, null);
}
/**
* Build the runtime-context message and (when {@code origin} is non-null
* and carries IM channel context) append a short "who is talking, where,
* via what channel" block so the agent's system prompt can personalise
* its reply. Same cache discipline as the simpler overloads the block
* stays well under the spring-ai user-cache threshold (1024 chars).
*
* <p>The sender block is suppressed when:
* <ul>
* <li>{@code origin} is null or {@link ChatOrigin#EMPTY}</li>
* <li>the origin carries no IM context (web / cron) both produce
* a null {@code channelType} or {@code "web"}</li>
* </ul>
* Web and cron callers thus see exactly the same prompt as before.
*/
public static String buildContextMessage(String workspaceBasePath,
vip.mate.i18n.I18nService i18n,
ChatOrigin origin) {
LocalDateTime now = LocalDateTime.now(ZONE); LocalDateTime now = LocalDateTime.now(ZONE);
String dateStr = now.format(DATE_FMT); String dateStr = now.format(DATE_FMT);
String timeStr = now.format(TIME_FMT); String timeStr = now.format(TIME_FMT);
@ -67,6 +88,37 @@ public final class RuntimeContextInjector {
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories."); sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
} }
} }
appendSenderBlockIfPresent(sb, origin);
return sb.toString(); return sb.toString();
} }
/**
* Append a sender / channel / chat block when the origin carries
* meaningful IM context. Format is intentionally one line per
* fact so it's both LLM-readable and easy to log-grep.
*/
private static void appendSenderBlockIfPresent(StringBuilder sb, ChatOrigin origin) {
if (origin == null || origin == ChatOrigin.EMPTY) return;
String channelType = origin.channelType();
// Only inject for real IM channels web / null / cron should
// see the previous prompt verbatim so their cache hit rate
// and existing eval baselines don't shift.
if (channelType == null || channelType.isBlank()
|| "web".equalsIgnoreCase(channelType)
|| origin.cronOrigin()) {
return;
}
sb.append("\n[system-context] Channel: ").append(channelType);
if (origin.senderName() != null && !origin.senderName().isBlank()) {
sb.append("\n[system-context] Sender: ").append(origin.senderName());
}
if (origin.requesterId() != null && !origin.requesterId().isBlank()) {
sb.append(" (id=").append(origin.requesterId()).append(')');
}
if (origin.chatId() != null && !origin.chatId().isBlank()) {
sb.append("\n[system-context] Chat: ").append(origin.chatId())
.append(" (group conversation — multiple users may follow up)");
}
}
} }

View File

@ -0,0 +1,175 @@
package vip.mate.agent.context;
/**
* Boundary-aware text truncation.
*
* <p>Character-count truncation that lands inside a JSON value or string literal
* leaves the model a fragment like {@code {"name":"serv} — a shape that invites it
* to "repair" the structure by fabricating the omitted fields. When the input
* looks like JSON, this utility snaps each head/tail cut point to the nearest
* complete structural boundary (immediately after a {@code ,}, {@code &#125;} or
* {@code ]} that is not inside a string), so a retained fragment always ends and
* begins between elements rather than in the middle of one.
*
* <p>Non-JSON input falls back to a plain character cut, and boundary snapping is
* only applied when it costs less than half the requested budget so callers can
* use this unconditionally without ever losing more than a plain cut would.
*/
public final class StructuredTruncator {
private StructuredTruncator() {
}
private static final int[] NO_BOUNDARIES = new int[0];
/**
* Standard fidelity directive appended to truncation markers so the model
* treats omitted content as unknown rather than reconstructable.
*/
public static final String FIDELITY_NOTE =
"Do NOT infer or fabricate omitted content; retrieve the full data (e.g. read_file) "
+ "or tell the user the result is incomplete.";
/**
* Head-only slice: the first {@code maxHeadChars} characters, snapped back to
* a JSON boundary when one sits within the kept region. Returns the input
* unchanged when it is already short enough.
*/
public static String headSlice(String text, int maxHeadChars) {
if (text == null || maxHeadChars <= 0 || text.length() <= maxHeadChars) {
return text;
}
int[] bounds = boundaries(text);
int end = snapDown(bounds, maxHeadChars);
// Reject a boundary that throws away more than half the budget.
if (end < maxHeadChars / 2) {
end = maxHeadChars;
}
return text.substring(0, end);
}
/**
* Head + marker + tail truncation. {@code headBudget} / {@code tailBudget} are
* upper bounds on each retained side; {@code marker} is inserted between them.
* The cut points snap to JSON boundaries when the input is JSON-like and the
* snap is cheap; otherwise plain character cuts are used. The result never
* exceeds {@code headBudget + marker.length() + tailBudget}.
*
* @return the input unchanged when it already fits both budgets
*/
public static String truncate(String text, int headBudget, int tailBudget, String marker) {
if (text == null) {
return null;
}
if (headBudget < 0) {
headBudget = 0;
}
if (tailBudget < 0) {
tailBudget = 0;
}
int len = text.length();
if (len <= headBudget + tailBudget) {
return text;
}
String mk = marker == null ? "" : marker;
int[] bounds = boundaries(text);
int headEnd = snapDown(bounds, headBudget);
if (headEnd < headBudget / 2) {
// No usable boundary near the head budget plain cut.
headEnd = headBudget;
}
int floor = len - tailBudget;
int tailStart = snapUp(bounds, floor);
if (tailStart > floor + tailBudget / 2) {
// Nearest boundary is so far forward the tail would shrink by half plain cut.
tailStart = floor;
}
if (tailStart <= headEnd) {
// Snapping collapsed the two regions into each other plain, non-overlapping cut.
headEnd = Math.min(headBudget, len);
tailStart = Math.max(len - tailBudget, headEnd);
}
return text.substring(0, headEnd) + mk + text.substring(tailStart);
}
/**
* Indices (in ascending order) at which the text may be split without
* severing a JSON token. A boundary index {@code i} marks the position
* immediately after a {@code ,}, {@code &#125;} or {@code ]} that is not
* inside a string literal. Returns an empty array when the input does not
* look like JSON, which makes both snap helpers fall back to plain cuts.
*/
private static int[] boundaries(String text) {
int len = text.length();
int start = 0;
while (start < len && Character.isWhitespace(text.charAt(start))) {
start++;
}
if (start >= len) {
return NO_BOUNDARIES;
}
char first = text.charAt(start);
if (first != '{' && first != '[') {
return NO_BOUNDARIES;
}
int[] buf = new int[16];
int n = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < len; i++) {
char c = text.charAt(i);
if (inString) {
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
inString = false;
}
continue;
}
if (c == '"') {
inString = true;
} else if (c == ',' || c == '}' || c == ']') {
if (n == buf.length) {
int[] grown = new int[buf.length * 2];
System.arraycopy(buf, 0, grown, 0, n);
buf = grown;
}
buf[n++] = i + 1;
}
}
if (n == buf.length) {
return buf;
}
int[] out = new int[n];
System.arraycopy(buf, 0, out, 0, n);
return out;
}
/** Largest boundary {@code <= limit}, or 0 when none exists. */
private static int snapDown(int[] bounds, int limit) {
int best = 0;
for (int b : bounds) {
if (b > limit) {
break;
}
best = b;
}
return best;
}
/** Smallest boundary {@code >= floor}, or {@link Integer#MAX_VALUE} when none exists. */
private static int snapUp(int[] bounds, int floor) {
for (int b : bounds) {
if (b >= floor) {
return b;
}
}
return Integer.MAX_VALUE;
}
}

View File

@ -270,7 +270,7 @@ public class AgentController {
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) { private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) { if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区"); throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
} }
} }

View File

@ -17,6 +17,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
/** /**
* REST surface for managing live sub-agents: * REST surface for managing live sub-agents:
@ -86,6 +87,7 @@ public class SubagentController {
*/ */
@Operation(summary = "Interrupt a running sub-agent") @Operation(summary = "Interrupt a running sub-agent")
@PostMapping("/{subagentId}/interrupt") @PostMapping("/{subagentId}/interrupt")
@RequireGlobalAdmin
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) { public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth); SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
boolean ok = registry.interrupt(subagentId); boolean ok = registry.interrupt(subagentId);
@ -106,6 +108,7 @@ public class SubagentController {
*/ */
@Operation(summary = "Set sub-agent spawn-pause for a conversation") @Operation(summary = "Set sub-agent spawn-pause for a conversation")
@PostMapping("/spawn-pause") @PostMapping("/spawn-pause")
@RequireGlobalAdmin
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) { public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
Object parentObj = body == null ? null : body.get("parentConversationId"); Object parentObj = body == null ? null : body.get("parentConversationId");
String parent = parentObj == null ? null : parentObj.toString(); String parent = parentObj == null ? null : parentObj.toString();
@ -128,12 +131,17 @@ public class SubagentController {
} }
/** /**
* List the sub-agents currently active under {@code parentConversationId}. * List the sub-agents currently active in the delegation tree rooted at
* The query parameter is mandatory: returning all subagents process-wide * {@code parentConversationId} the user-facing conversation. Returns the
* would let any logged-in user enumerate other tenants' delegation trees. * whole tree (direct children plus deeper descendants), so a multi-level
* delegation is fully visible. The query parameter is mandatory: returning
* all subagents process-wide would let any logged-in user enumerate other
* tenants' delegation trees. Tenant isolation is enforced on this root
* conversation, which the caller owns.
*/ */
@Operation(summary = "List active sub-agents under a parent conversation") @Operation(summary = "List active sub-agents in a conversation's delegation tree")
@GetMapping("/active") @GetMapping("/active")
@RequireGlobalAdmin
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId, public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
Authentication auth) { Authentication auth) {
if (parentConversationId == null || parentConversationId.isBlank()) { if (parentConversationId == null || parentConversationId.isBlank()) {
@ -143,7 +151,7 @@ public class SubagentController {
if (!conversationService.isConversationOwner(parentConversationId, username)) { if (!conversationService.isConversationOwner(parentConversationId, username)) {
throw new MateClawException(403, "not the owner of conversation " + parentConversationId); throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
} }
List<Map<String, Object>> snapshot = registry.snapshot(parentConversationId).stream() List<Map<String, Object>> snapshot = registry.snapshotTree(parentConversationId).stream()
.map(this::toResponseDto) .map(this::toResponseDto)
.toList(); .toList();
return R.ok(Map.of("subagents", snapshot)); return R.ok(Map.of("subagents", snapshot));
@ -163,6 +171,8 @@ public class SubagentController {
dto.put("subagentId", rec.subagentId()); dto.put("subagentId", rec.subagentId());
dto.put("parentConversationId", rec.parentConversationId()); dto.put("parentConversationId", rec.parentConversationId());
dto.put("childConversationId", rec.childConversationId()); dto.put("childConversationId", rec.childConversationId());
dto.put("parentSubagentId", rec.parentSubagentId());
dto.put("depth", rec.depth());
dto.put("agentId", rec.agentId()); dto.put("agentId", rec.agentId());
dto.put("goal", rec.goal()); dto.put("goal", rec.goal());
dto.put("startedAt", rec.startedAt()); dto.put("startedAt", rec.startedAt());

View File

@ -89,10 +89,16 @@ public class SubagentHeartbeat {
if (rec.status().compareAndSet("running", "stale")) { if (rec.status().compareAndSet("running", "stale")) {
Map<String, Object> payload = new LinkedHashMap<>(); Map<String, Object> payload = new LinkedHashMap<>();
payload.put("subagentId", rec.subagentId()); payload.put("subagentId", rec.subagentId());
payload.put("parentSubagentId", rec.parentSubagentId());
payload.put("depth", rec.depth());
payload.put("cycles", sc); payload.put("cycles", sc);
payload.put("lastTool", currentTool != null ? currentTool : ""); payload.put("lastTool", currentTool != null ? currentTool : "");
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt()); payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
streamTracker.broadcastObject(rec.parentConversationId(), "subagent_stale", payload); // Broadcast to the root (human-facing) conversation so the event
// reaches the stream the user is watching at any tree depth.
String target = rec.rootConversationId() != null
? rec.rootConversationId() : rec.parentConversationId();
streamTracker.broadcastObject(target, "subagent_stale", payload);
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})", log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
rec.subagentId(), sc, limit); rec.subagentId(), sc, limit);
} }

View File

@ -53,7 +53,14 @@ public class SubagentRegistry {
AtomicReference<String> lastSeenTool, AtomicReference<String> lastSeenTool,
AtomicInteger staleCount, AtomicInteger staleCount,
AtomicLong firstApiCallAt, AtomicLong firstApiCallAt,
Disposable disposable Disposable disposable,
// Tree identity: parentSubagentId is null for first-level children
// (spawned by the root agent); depth is 1 for first-level, 2 for a
// grandchild, etc. rootConversationId is the human-facing stream the
// whole tree reports into, used for UI-facing broadcasts at any depth.
String parentSubagentId,
int depth,
String rootConversationId
) {} ) {}
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>(); private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
@ -77,6 +84,16 @@ public class SubagentRegistry {
* children spawn within the same millisecond. * children spawn within the same millisecond.
*/ */
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) { public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
return register(parentConvId, childConvId, agentId, goal, d, null, 1, parentConvId);
}
/**
* Register a sub-agent with full tree identity. {@code parentSubagentId} is
* null for first-level children; {@code depth} is 1-based; {@code rootConvId}
* is the human-facing conversation the whole tree reports into.
*/
public String register(String parentConvId, String childConvId, Long agentId, String goal,
Disposable d, String parentSubagentId, int depth, String rootConvId) {
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix(); String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
active.put(sid, new SubagentRecord( active.put(sid, new SubagentRecord(
sid, sid,
@ -93,7 +110,10 @@ public class SubagentRegistry {
new AtomicReference<>(null), new AtomicReference<>(null),
new AtomicInteger(0), new AtomicInteger(0),
new AtomicLong(0), new AtomicLong(0),
d)); d,
parentSubagentId,
depth,
rootConvId != null ? rootConvId : parentConvId));
return sid; return sid;
} }
@ -120,9 +140,13 @@ public class SubagentRegistry {
} }
/** /**
* Snapshot of all sub-agents whose parent matches {@code parentConvId}. * Snapshot of all sub-agents whose <em>immediate</em> parent matches
* Filtering at the registry boundary prevents callers from accidentally * {@code parentConvId}. Filtering at the registry boundary prevents callers
* surfacing other tenants' subagents in API responses. * from accidentally surfacing other tenants' subagents in API responses.
*
* <p>Note: this returns only direct children. To list a whole delegation
* tree (including grandchildren whose immediate parent is a child
* conversation), use {@link #snapshotTree(String)}.
*/ */
public List<SubagentRecord> snapshot(String parentConvId) { public List<SubagentRecord> snapshot(String parentConvId) {
if (parentConvId == null) return List.of(); if (parentConvId == null) return List.of();
@ -131,6 +155,20 @@ public class SubagentRegistry {
.toList(); .toList();
} }
/**
* Snapshot of the entire delegation tree rooted at {@code rootConvId} the
* human-facing conversation. Every sub-agent at any depth carries the same
* {@code rootConversationId}, so this returns direct children and all deeper
* descendants. Tenant isolation must be enforced on {@code rootConvId} by
* the caller (it is the conversation the user owns).
*/
public List<SubagentRecord> snapshotTree(String rootConvId) {
if (rootConvId == null) return List.of();
return active.values().stream()
.filter(r -> rootConvId.equals(r.rootConversationId()))
.toList();
}
public void unregister(String subagentId) { public void unregister(String subagentId) {
if (subagentId == null) return; if (subagentId == null) return;
active.remove(subagentId); active.remove(subagentId);

View File

@ -9,8 +9,9 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.agent.AssistantThinkingRelay;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import vip.mate.llm.chatmodel.ReasoningContentCache;
import reactor.core.Disposable; import reactor.core.Disposable;
@ -179,23 +180,43 @@ public class NodeStreamingChatHelper {
} }
/** /**
* RFC-009 Phase 4 map an {@link ErrorType} to the matching pool * Map an {@link ErrorType} to the matching pool
* {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for * {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for
* HARD failures (AUTH / BILLING / MODEL_NOT_FOUND). Returns {@code null} * provider-wide HARD failures (AUTH / BILLING). Returns {@code null} for
* for SOFT errors and benign types those keep the provider in-pool and * SOFT errors, benign types, and model-scoped errors those keep the
* are handled by {@link vip.mate.llm.failover.ProviderHealthTracker}'s * provider in-pool.
* cooldown instead. *
* <p>{@code MODEL_NOT_FOUND} is deliberately excluded: it means the
* provider rejected one specific model id, not that the provider is
* unusable. Evicting the whole provider would needlessly take its other
* models offline. SOFT errors are absorbed by
* {@link vip.mate.llm.failover.ProviderHealthTracker}'s cooldown instead.</p>
*/ */
private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) { private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) {
if (type == null) return null; if (type == null) return null;
return switch (type) { return switch (type) {
case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR; case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR;
case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING; case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING;
case MODEL_NOT_FOUND -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.MODEL_NOT_FOUND;
default -> null; default -> null;
}; };
} }
/**
* True when {@code type} reflects the <i>provider's own health</i> (auth,
* billing, rate limit, server error, empty response) rather than something
* specific to the requested model or prompt. Only provider-level failures
* should feed pool eviction and the consecutive-failure cooldown tracker
* a {@code MODEL_NOT_FOUND} / {@code CLIENT_ERROR} / {@code PROMPT_TOO_LONG}
* says nothing about whether the provider's other models still work.
*/
private static boolean isProviderLevelFailure(ErrorType type) {
if (type == null) return false;
return switch (type) {
case NONE, PROMPT_TOO_LONG, CLIENT_ERROR, THINKING_BLOCK_ERROR, MODEL_NOT_FOUND -> false;
default -> true;
};
}
/** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */ /** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */
private boolean inPool(String providerId) { private boolean inPool(String providerId) {
return providerPool == null || providerId == null || providerPool.contains(providerId); return providerPool == null || providerId == null || providerPool.contains(providerId);
@ -313,11 +334,22 @@ public class NodeStreamingChatHelper {
*/ */
private static final int CONTENT_REPEAT_CHECK_INTERVAL = 200; private static final int CONTENT_REPEAT_CHECK_INTERVAL = 200;
private static final int MAX_RETRIES = 5; /**
* Maximum retry attempts for SERVER_ERROR / transient network failures.
* Total LLM calls per turn = MAX_RETRIES + 1 (attempt 0 is the initial,
* attempts 1..MAX_RETRIES are the retries). Bumped from 5 to 10 in
* commit 1dd99b68 so sustained wiki batch load can ride out provider
* flaps without surfacing the error.
*
* <p>Package-private so {@code LaneDPerformanceFixesTest} can stay in
* sync without a magic number when this value changes again, the
* test follows automatically.
*/
static final int MAX_RETRIES = 10;
// RATE_LIMIT: fail fast to failover chain staying on the same // RATE_LIMIT: fail fast to failover chain staying on the same
// provider during a rate-limit window wastes time without recovery. // provider during a rate-limit window wastes time without recovery.
// SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal). // SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal).
private static final int MAX_RETRIES_RATE_LIMIT = 2; static final int MAX_RETRIES_RATE_LIMIT = 2;
private static final long BACKOFF_BASE_MS = 3000; private static final long BACKOFF_BASE_MS = 3000;
private static final long BACKOFF_CAP_MS = 60_000; private static final long BACKOFF_CAP_MS = 60_000;
@ -429,7 +461,12 @@ public class NodeStreamingChatHelper {
// Reactor Netty wraps the raw socket cause in WebClientRequestException; // Reactor Netty wraps the raw socket cause in WebClientRequestException;
// surface that wrapper too so retries fire even when the cause chain // surface that wrapper too so retries fire even when the cause chain
// string is "WebClientRequestException ...; nested ... SSLException". // string is "WebClientRequestException ...; nested ... SSLException".
|| msg.contains("WebClientRequestException")) { || msg.contains("WebClientRequestException")
// SiliconFlow and some other providers return "network connection error"
// in the response body when their backend is under high load or the
// upstream connection to the model server is disrupted. This is a
// transient server-side failure classify as retryable.
|| msg.contains("network connection error")) {
return ErrorType.SERVER_ERROR; return ErrorType.SERVER_ERROR;
} }
return ErrorType.UNKNOWN; return ErrorType.UNKNOWN;
@ -521,15 +558,23 @@ public class NodeStreamingChatHelper {
removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage()); removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage());
break; break;
} }
// RFC-009 P3.2: BILLING / MODEL_NOT_FOUND provider-side hard failures // BILLING provider-side hard failure (out of credit). Won't change
// that won't change on retry. Skip to fallback chain (a different // on retry and affects every model on the provider, so evict it and
// provider may have credits, or the model name may be valid there). // hand off to the fallback chain (a different provider may have credits).
if (lastResult.errorType() == ErrorType.BILLING if (lastResult.errorType() == ErrorType.BILLING) {
|| lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) { log.warn("[{}] Primary billing failure — skipping same-model retries, handing off to fallback chain", phase);
log.warn("[{}] Primary error={} — skipping same-model retries, handing off to fallback chain",
phase, lastResult.errorType());
recordPrimary(false); recordPrimary(false);
removeFromPool(primaryProviderId, lastResult.errorType(), lastResult.errorMessage()); removeFromPool(primaryProviderId, ErrorType.BILLING, lastResult.errorMessage());
break;
}
// MODEL_NOT_FOUND the provider rejected this specific model id. The
// provider itself is healthy, so do NOT evict it from the pool or
// record a provider-level failure: that would take its sibling models
// down too. Just skip same-model retries and hand off to the fallback
// chain a different provider may recognize the model name.
if (lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) {
log.warn("[{}] Primary model not found — handing off to fallback chain "
+ "(provider kept available for its other models)", phase);
break; break;
} }
// CLIENT_ERROR (400 Bad Request): 不重试参数/格式错误重试也不会变 // CLIENT_ERROR (400 Bad Request): 不重试参数/格式错误重试也不会变
@ -560,18 +605,32 @@ public class NodeStreamingChatHelper {
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return lastResult; return lastResult;
} }
// Any other non-null errored result with a classified type that doStreamCall // RATE_LIMIT / SERVER_ERROR past their retry budget are provider-level
// chose NOT to retry (i.e. UNKNOWN, or RATE_LIMIT/SERVER_ERROR past MAX_RETRIES) // failures: the same model will not recover within this turn, but a
// must exit otherwise we silently spin through attempts and waste seconds // different provider can. Break to the fallback chain instead of
// per turn on unrecoverable errors like DashScope's "url error" / unknown model. // returning recordPrimary(false) runs once at the post-loop provider
// health check below, and if every fallback also fails the chain
// walker re-surfaces this same error to the caller.
if (lastResult.errorType() == ErrorType.RATE_LIMIT
|| lastResult.errorType() == ErrorType.SERVER_ERROR) {
log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain",
phase, lastResult.errorType());
break;
}
// Any other non-null errored result (e.g. UNKNOWN) that doStreamCall
// chose NOT to retry must exit otherwise we silently spin through
// attempts and waste seconds per turn on unrecoverable errors like
// DashScope's "url error" / unknown model.
recordPrimary(false); recordPrimary(false);
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return lastResult; return lastResult;
} }
// lastResult == null 表示需要重试 // lastResult == null 表示需要重试
} }
// If we exhausted the retry loop without a verdict, primary effectively failed. // If we exhausted the retry loop without a verdict, primary effectively
if (!primarySkipped && lastResult != null && lastResult.errorType() != ErrorType.NONE) { // failed. Only count it against provider health for provider-level errors
// a MODEL_NOT_FOUND break above must not nudge the provider toward cooldown.
if (!primarySkipped && lastResult != null && isProviderLevelFailure(lastResult.errorType())) {
recordPrimary(false); recordPrimary(false);
} }
@ -621,11 +680,17 @@ public class NodeStreamingChatHelper {
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return fallbackResult; return fallbackResult;
} }
if (healthTracker != null) healthTracker.recordFailure(entry.providerId()); // Only provider-level failures count toward the cooldown tracker. A
// null result is a retryable soft failure; a MODEL_NOT_FOUND result is
// model-scoped and must not penalise an otherwise-healthy provider.
if (healthTracker != null
&& (fallbackResult == null || isProviderLevelFailure(fallbackResult.errorType()))) {
healthTracker.recordFailure(entry.providerId());
}
if (fallbackResult != null) { if (fallbackResult != null) {
// RFC-009 Phase 4: HARD errors evict from the pool so later // HARD errors (auth / billing) evict the provider from the pool so
// walks skip this provider outright. SOFT errors keep it // later walks skip it outright. SOFT and model-scoped errors keep it
// in-pool and let the tracker's cooldown absorb the blip. // in-pool absorbed by the tracker's cooldown or simply retried.
removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage()); removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage());
lastResult = fallbackResult; // remember most recent to report if the whole chain fails lastResult = fallbackResult; // remember most recent to report if the whole chain fails
} }
@ -724,7 +789,7 @@ public class NodeStreamingChatHelper {
boolean broadcast, int attempt) { boolean broadcast, int attempt) {
if (attempt > 0) { if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS); long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
// 加入 jitter 防止雷群效应Hermes 风格 // 加入 jitter 防止雷群效应
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2)); delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, BACKOFF_CAP_MS); delay = Math.min(delay, BACKOFF_CAP_MS);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}", log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
@ -765,7 +830,7 @@ public class NodeStreamingChatHelper {
AtomicInteger cacheWriteTokens = new AtomicInteger(0); AtomicInteger cacheWriteTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅 // thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除参考 Hermes 思路 // 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍
// agent 不替模型审核输出退化 max_tokens + max_iterations 兜底 // agent 不替模型审核输出退化 max_tokens + max_iterations 兜底
// 仅保留 thinking-only 这条体积兜底处理 volcengine-plan provider // 仅保留 thinking-only 这条体积兜底处理 volcengine-plan provider
// thinking 通道堆字符不出 content 的死循环生产 trace c1eefa45 // thinking 通道堆字符不出 content 的死循环生产 trace c1eefa45
@ -873,7 +938,7 @@ public class NodeStreamingChatHelper {
thinkingAccum.append(thinkingDelta); thinkingAccum.append(thinkingDelta);
// thinkingLevel=off 时不广播 thinking模型仍可能产生但前端不展示 // thinkingLevel=off 时不广播 thinking模型仍可能产生但前端不展示
boolean suppressThinking = "off".equalsIgnoreCase( boolean suppressThinking = "off".equalsIgnoreCase(
vip.mate.agent.ThinkingLevelHolder.get()); vip.mate.llm.chatmodel.ThinkingLevelHolder.get());
if (broadcast && !suppressThinking) { if (broadcast && !suppressThinking) {
broadcastDelta(conversationId, "thinking_delta", thinkingDelta); broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
} }
@ -1142,6 +1207,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns.
cacheReasoningContent(fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1171,6 +1240,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns. The cache replays real values instead of empty strings.
cacheReasoningContent(fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1204,6 +1277,24 @@ public class NodeStreamingChatHelper {
return builder.build(); return builder.build();
} }
/**
* Store reasoning content in the cache for cross-turn replay.
* Only caches when there are tool calls (MiMo requires reasoning_content
* specifically on assistant messages with tool_calls).
*/
private static void cacheReasoningContent(String fullThinking,
List<AssistantMessage.ToolCall> toolCalls) {
if (fullThinking == null || fullThinking.isBlank()) return;
if (toolCalls == null || toolCalls.isEmpty()) return;
List<String> ids = toolCalls.stream()
.map(AssistantMessage.ToolCall::id)
.filter(id -> id != null && !id.isEmpty())
.toList();
if (!ids.isEmpty()) {
ReasoningContentCache.store(ids, fullThinking);
}
}
/** /**
* Record token / cache usage to the optional metrics aggregator. * Record token / cache usage to the optional metrics aggregator.
* Called only from successful assembly paths ({@link #assembleResult} * Called only from successful assembly paths ({@link #assembleResult}
@ -1454,6 +1545,11 @@ public class NodeStreamingChatHelper {
if (msg.contains("rate_limit") || msg.contains("429")) return "Rate limit exceeded, please retry later"; if (msg.contains("rate_limit") || msg.contains("429")) return "Rate limit exceeded, please retry later";
if (msg.contains("timeout") || msg.contains("Timeout")) return "Request timeout, please retry"; if (msg.contains("timeout") || msg.contains("Timeout")) return "Request timeout, please retry";
if (msg.contains("502") || msg.contains("503") || msg.contains("504")) return "Model service temporarily unavailable"; if (msg.contains("502") || msg.contains("503") || msg.contains("504")) return "Model service temporarily unavailable";
// SiliconFlow and similar providers surface "network connection error" when their
// backend is under high load or the upstream model connection is disrupted.
// Treat this as a transient failure so the user gets a retry-oriented message.
if (combined.contains("network connection error"))
return "Model service network error, please retry in a moment";
// 截断过长的原始消息 // 截断过长的原始消息
return msg.length() > 100 ? msg.substring(0, 100) + "..." : msg; return msg.length() > 100 ? msg.substring(0, 100) + "..." : msg;
} }

View File

@ -88,7 +88,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId); log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId); Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
Optional<OverAllState> result = compiledGraph.invoke(inputs); // Fresh thread per invocation so graph state never carries over
// between calls. The CompiledGraph is cached and shared; without a
// unique threadId, consecutive sync runs (e.g. back-to-back cron
// executions) inherit the prior run's accumulated messages and
// counters. Mirrors the streaming paths, which already do this.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
return result return result
.flatMap(s -> s.<String>value(FINAL_ANSWER)) .flatMap(s -> s.<String>value(FINAL_ANSWER))
@ -147,7 +154,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
if (toolCallPayload != null && !toolCallPayload.isEmpty()) { if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
inputs.put(FORCED_TOOL_CALL, toolCallPayload); inputs.put(FORCED_TOOL_CALL, toolCallPayload);
} }
Optional<OverAllState> result = compiledGraph.invoke(inputs); // Fresh thread per invocation see chat() for rationale.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
return result return result
.flatMap(s -> s.<String>value(FINAL_ANSWER)) .flatMap(s -> s.<String>value(FINAL_ANSWER))
@ -491,7 +501,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用 // 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用
// maxIterations<=0 表示软上限解除 LLM 自己决定何时收尾加分要短路 // maxIterations<=0 表示软上限解除 LLM 自己决定何时收尾加分要短路
// 否则 thinking-on 会把"无限"误算成 5变成"5 步就停" // 否则 thinking-on 会把"无限"误算成 5变成"5 步就停"
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get(); String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel); boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
int effectiveMaxIterations = (maxIterations <= 0) int effectiveMaxIterations = (maxIterations <= 0)
? 0 ? 0
@ -537,6 +547,29 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
origin = origin.withConversationId(conversationId) origin = origin.withConversationId(conversationId)
.withWorkspace(origin.workspaceId(), workspaceBasePath); .withWorkspace(origin.workspaceId(), workspaceBasePath);
inputs.put(CHAT_ORIGIN, origin); inputs.put(CHAT_ORIGIN, origin);
// RFC 48 inject active goal snapshot for GoalEvaluationNode.
// The node + dispatcher both bail out when ACTIVE_GOAL is absent,
// so this is a no-op for conversations without a bound goal.
// GOAL_EVALUATED_THIS_RUN explicitly seeded so the FinalAnswer
// GoalEvaluation conditional edge sees a clean false on each new
// chat invocation (RFC 48 §6.3 exhaustsBudgetAndStopsLooping
// depends on this every new chat is a fresh evaluation pass).
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
try {
vip.mate.goal.model.GoalEntity active =
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
}
}
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
return inputs; return inputs;
} }

View File

@ -0,0 +1,47 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATED_THIS_RUN;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
/**
* Decides whether to re-enter the reasoning loop with an injected
* follow-up prompt or terminate the graph run.
*
* <p>Both targets are passed in by the builder so the same class serves
* the ReAct graph (followup -&gt; {@code REASONING_NODE}, terminal -&gt;
* {@code END}) and the Plan-Execute graph (followup -&gt;
* {@code PLAN_GENERATION_NODE}, terminal -&gt; {@code END}) without
* branching on graph type at runtime.
*/
@Slf4j
@RequiredArgsConstructor
public class GoalEvaluationDispatcher implements EdgeAction {
/** Where to re-enter the loop when GoalEvaluationNode injected a followup. */
private final String followupTarget;
/** Where to go on the normal terminal path (typically {@code END}). */
private final String terminalTarget;
@Override
public String apply(OverAllState state) {
// Re-enter the loop only when a followup was injected AND this was not a
// terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key
// strategy and is never cleared by the reasoning nodes, so after a
// run-to-completion loop it can linger true; goalEvaluatedThisRun (set
// true on every terminal branch completed / exhausted / skip /
// continue-without-followup) is the authoritative end-of-run signal.
boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false));
boolean terminal = Boolean.TRUE.equals(state.value(GOAL_EVALUATED_THIS_RUN, false));
if (followup && !terminal) {
log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
return followupTarget;
}
return terminalTarget;
}
}

View File

@ -10,6 +10,7 @@ import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ApprovalWorkflowService;
@ -163,21 +164,21 @@ public class ToolExecutionExecutor {
static String truncateToolResult(String result, int maxChars) { static String truncateToolResult(String result, int maxChars) {
if (result == null || result.length() <= maxChars) return result; if (result == null || result.length() <= maxChars) return result;
int rawLen = result.length(); int rawLen = result.length();
// 检测尾部 2000 字符是否含错误模式 // Detect an error pattern in the trailing 2000 chars and bias toward the tail when present.
String tailRegion = result.substring(Math.max(0, rawLen - 2000)); String tailRegion = result.substring(Math.max(0, rawLen - 2000));
boolean errorDetected = ERROR_TAIL_PATTERN.matcher(tailRegion).find(); boolean errorDetected = ERROR_TAIL_PATTERN.matcher(tailRegion).find();
double headRatio = errorDetected ? 0.2 : 0.4; double headRatio = errorDetected ? 0.2 : 0.4;
if (errorDetected) { if (errorDetected) {
log.info("[ToolExecutor] Error pattern detected in tail, preserving 80% tail (headRatio=0.2)"); log.info("[ToolExecutor] Error pattern detected in tail, preserving 80% tail (headRatio=0.2)");
} }
String marker = "\n\n...[TRUNCATED: original " + rawLen + " chars, middle omitted. "
+ StructuredTruncator.FIDELITY_NOTE + "]...\n\n";
int headLen = (int) (maxChars * headRatio); int headLen = (int) (maxChars * headRatio);
int tailLen = maxChars - headLen - 80; int tailLen = maxChars - headLen - marker.length();
if (tailLen <= 0) tailLen = maxChars / 2; if (tailLen <= 0) tailLen = maxChars / 2;
log.info("[ToolExecutor] Truncated tool result from {} to {} chars (headRatio={})", log.info("[ToolExecutor] Truncated tool result from {} to {} chars (headRatio={})",
rawLen, maxChars, headRatio); rawLen, maxChars, headRatio);
return result.substring(0, headLen) return StructuredTruncator.truncate(result, headLen, tailLen, marker);
+ "\n\n... [结果已截断,原始 " + rawLen + " 字符,保留首尾关键片段] ...\n\n"
+ result.substring(rawLen - tailLen);
} }
private final Map<String, ToolCallback> toolCallbackMap; private final Map<String, ToolCallback> toolCallbackMap;

View File

@ -2,6 +2,7 @@ package vip.mate.agent.graph.executor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -230,16 +231,16 @@ public class ToolResultStorage {
if (body == null || body.length() <= maxChars) { if (body == null || body.length() <= maxChars) {
return body; return body;
} }
int markerBudget = 120; String marker = "\n\n... [tool result compacted for model context: tool="
int available = Math.max(200, maxChars - markerBudget); + toolName + ", original_chars=" + body.length() + ". "
+ StructuredTruncator.FIDELITY_NOTE + "] ...\n\n";
int available = Math.max(200, maxChars - marker.length());
int headLen = Math.max(100, (int) (available * 0.45)); int headLen = Math.max(100, (int) (available * 0.45));
int tailLen = Math.max(100, available - headLen); int tailLen = Math.max(100, available - headLen);
if (headLen + tailLen >= body.length()) { if (headLen + tailLen >= body.length()) {
return body; return body;
} }
String marker = "\n\n... [tool result compacted for model context: tool=" return StructuredTruncator.truncate(body, headLen, tailLen, marker);
+ toolName + ", original_chars=" + body.length() + "] ...\n\n";
return body.substring(0, headLen) + marker + body.substring(body.length() - tailLen);
} }
private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) { private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) {
@ -251,14 +252,16 @@ public class ToolResultStorage {
} }
private String buildPreview(String fullResult, String toolName, Path spillFile) { private String buildPreview(String fullResult, String toolName, Path spillFile) {
int previewLen = Math.min(props.getPreviewHeadChars(), fullResult.length()); // Snap the preview to a complete JSON element so the model never sees a value
String head = fullResult.substring(0, previewLen); // severed mid-token (which invites it to fabricate the omitted fields).
String head = StructuredTruncator.headSlice(fullResult, props.getPreviewHeadChars());
return SPILL_MARKER_PREFIX return SPILL_MARKER_PREFIX
+ " tool=" + toolName + " tool=" + toolName
+ " full_chars=" + fullResult.length() + " full_chars=" + fullResult.length()
+ " path=" + spillFile.toAbsolutePath() + " path=" + spillFile.toAbsolutePath()
+ "\n[Preview — first " + previewLen + " of " + fullResult.length() + "\n[Preview — first " + head.length() + " of " + fullResult.length()
+ " chars. Use read_file with the path above to retrieve the rest.]\n" + " chars. The preview is INCOMPLETE: use read_file with the path above to "
+ "retrieve the full result. Do NOT infer or fabricate the omitted content.]\n"
+ head + head
+ "\n…[truncated]"; + "\n…[truncated]";
} }

View File

@ -2,6 +2,8 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState; import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction; import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
@ -28,6 +30,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
@Slf4j @Slf4j
public class ActionNode implements NodeAction { public class ActionNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** Function name of the explicit skill-load tool, mirrored from SkillLoadTool. */
private static final String LOAD_SKILL_TOOL = "load_skill";
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
private static final String ENABLE_TOOL = "enable_tool";
private final ToolExecutionExecutor executor; private final ToolExecutionExecutor executor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker; private final vip.mate.channel.web.ChatStreamTracker streamTracker;
@ -133,6 +143,98 @@ public class ActionNode implements NodeAction {
output.forcedToolCall(""); output.forcedToolCall("");
} }
// Pin skills the model loaded this run so the next reasoning turn's
// catalog ranks them first and the model stops re-loading the same
// skill it already pulled into message history. Tools cannot mutate
// graph state directly, so the load is detected here from the tool
// calls and merged into LOADED_SKILLS (read-merge-write, REPLACE key).
Set<String> requestedSkills = extractLoadedSkillNames(toolCalls);
if (!requestedSkills.isEmpty()) {
Set<String> merged = new LinkedHashSet<>(accessor.loadedSkills());
if (merged.addAll(requestedSkills)) {
output.loadedSkills(Set.copyOf(merged));
}
}
// Same mechanism for enable_tool: record the activated extension tools so
// ReasoningNode's next turn adds them back to the advertised callbacks.
Set<String> enabledTools = extractEnabledToolNames(toolCalls);
if (!enabledTools.isEmpty()) {
Set<String> merged = new LinkedHashSet<>(accessor.enabledExtensionTools());
if (merged.addAll(enabledTools)) {
output.enabledExtensionTools(Set.copyOf(merged));
}
}
return output.build(); return output.build();
} }
/**
* Extract the {@code toolName} argument of every {@code enable_tool} call in
* this batch. Like {@link #extractLoadedSkillNames}, an unknown name is
* harmless: the reasoning-node split only activates names that resolve to an
* extension-tier tool actually in the agent's set.
*/
static Set<String> extractEnabledToolNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}
Set<String> names = new LinkedHashSet<>();
for (AssistantMessage.ToolCall tc : toolCalls) {
if (tc == null || !ENABLE_TOOL.equals(tc.name())) {
continue;
}
String name = parseStringArg(tc.arguments(), "toolName", "tool_name", "name");
if (name != null && !name.isBlank()) {
names.add(name.trim());
}
}
return names;
}
/**
* Extract the {@code skillName} argument of every {@code load_skill} call in
* this batch. The names are used only to bias catalog ordering, so an
* unparseable or unknown name is harmless (it simply never matches a
* visible skill) failures are swallowed rather than aborting the batch.
*/
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}
Set<String> names = new LinkedHashSet<>();
for (AssistantMessage.ToolCall tc : toolCalls) {
if (tc == null || !LOAD_SKILL_TOOL.equals(tc.name())) {
continue;
}
String name = parseStringArg(tc.arguments(), "skillName", "skill_name", "name");
if (name != null && !name.isBlank()) {
names.add(name.trim());
}
}
return names;
}
/**
* Read the first present, non-null string value among {@code keys} from a
* tool-call arguments JSON object. Returns null on malformed JSON or when
* none of the keys are present.
*/
private static String parseStringArg(String argumentsJson, String... keys) {
if (argumentsJson == null || argumentsJson.isBlank()) {
return null;
}
try {
JsonNode node = OBJECT_MAPPER.readTree(argumentsJson);
for (String key : keys) {
JsonNode value = node.get(key);
if (value != null && !value.isNull()) {
return value.asText();
}
}
return null;
} catch (Exception e) {
return null;
}
}
} }

View File

@ -0,0 +1,334 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.service.GoalEvaluationService;
import vip.mate.goal.service.GoalFollowupService;
import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GraphFlavor;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
*
* <p>Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so
* upstream finishReason / evidence checks are already authoritative. The
* node:
* <ol>
* <li>Bails out for the "this turn shouldn't count" finishReasons
* (evidence_insufficient, stopped, error_fallback, return_direct,
* max_iterations_reached, plus awaiting_approval).</li>
* <li>Otherwise calls the evaluator, persists the
* agent/eval LLM-call deltas + score + gap via GoalService.</li>
* <li>Decides completed / exhausted / followup / continue. Completed
* and exhausted update {@code mate_agent_goal.status} ONLY they
* never touch FINISH_REASON, since the graph's own terminal status
* is independent of goal status.</li>
* <li>On followup, sets GOAL_FOLLOWUP_PROMPT and clears whichever
* graph-specific state would otherwise short-circuit the re-entry
* pass (clear set depends on the constructor-time GraphFlavor).</li>
* </ol>
*/
@Slf4j
public class GoalEvaluationNode implements NodeAction {
private final GoalEvaluationService evaluationService;
private final GoalFollowupService followupService;
private final GoalService goalService;
private final GoalProperties properties;
private final ConversationWindowManager windowManager; // unused PR2, kept for PR5
private final ConversationService conversationService; // unused PR2, kept for PR5
private final GraphFlavor flavor;
public GoalEvaluationNode(GoalEvaluationService evaluationService,
GoalFollowupService followupService,
GoalService goalService,
GoalProperties properties,
ConversationWindowManager windowManager,
ConversationService conversationService,
GraphFlavor flavor) {
this.evaluationService = evaluationService;
this.followupService = followupService;
this.goalService = goalService;
this.properties = properties;
this.windowManager = windowManager;
this.conversationService = conversationService;
this.flavor = flavor;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
// Master kill switch node stays inert until PR5 flips this.
if (!properties.isEnabled()) {
return Map.of();
}
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
Optional<Object> goalOpt = accessor.activeGoal();
if (goalOpt.isEmpty()) {
return Map.of();
}
// Re-entry guard the FinalAnswerGoalEvaluation conditional edge
// also checks this, but defence in depth pays for itself here.
if (accessor.goalEvaluatedThisRun()) {
return Map.of();
}
// Every skip path below emits a goal_evaluated event with a reason
// so the frontend can flip the breathing-halo state back off. The
// chat composable's `message_complete` handler optimistically sets
// evaluating=true; without a balancing event the ring would stay
// in that state forever after e.g. a max-iterations turn.
Long goalIdForEvents = (goalOpt.get() instanceof GoalEntity ge) ? ge.getId() : null;
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
// determines whether this turn counts. Plan-Execute usually doesn't
// set finishReason on the happy path, so we only enforce these
// exit conditions in REACT mode + the universal awaiting_approval
// gate that both flavors share.
if (flavor == GraphFlavor.REACT) {
String fr = accessor.finishReason();
if (FinishReason.EVIDENCE_INSUFFICIENT.getValue().equals(fr)
|| FinishReason.STOPPED.getValue().equals(fr)
|| FinishReason.ERROR_FALLBACK.getValue().equals(fr)
|| FinishReason.RETURN_DIRECT.getValue().equals(fr)
|| FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(fr)) {
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
.build();
}
}
if (accessor.awaitingApproval()) {
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "awaiting_approval")))
.build();
}
Object goalObj = goalOpt.get();
if (!(goalObj instanceof GoalEntity goal)) {
log.warn("[GoalEvaluationNode] ACTIVE_GOAL is not a GoalEntity: {}", goalObj.getClass());
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(null, "non_goal_entity")))
.build();
}
String terminal = accessor.terminalAnswer();
if (terminal.isEmpty()) {
log.warn("[GoalEvaluationNode] terminalAnswer empty (flavor={}); skipping evaluation", flavor);
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goal.getId(), "empty_terminal_answer")))
.build();
}
// Build a thin recent-messages slice for the evaluator prompt.
List<Message> recent = accessor.messages();
int max = properties.getEvaluatorContextMessages();
if (recent.size() > max) {
recent = recent.subList(recent.size() - max, recent.size());
}
// Evaluator + persistence wrapped together: the just-emitted final
// answer is the user-visible thing and must NOT be lost just because
// a provider timeout or DB hiccup happens on the way to the
// bookkeeping write. On any failure we mark the run as evaluated
// (so the conditional edge above won't loop us back) and route to
// the normal terminal path the user still sees their answer; the
// goal stays in whatever state it was before this turn.
GoalEvaluationResult result;
GoalEntity refreshed;
try {
result = evaluationService.evaluate(goal, recent, terminal);
// Bill only the NEW agent LLM calls since the last accounted point.
// The run-to-completion loop evaluates multiple times per graph run
// while LLM_CALL_COUNT keeps growing, so passing the cumulative value
// raw would re-bill earlier calls on every pass and exhaust the
// goal's LLM budget prematurely. The followup branch advances the
// accounted marker; terminal branches don't (the run ends there).
int agentLlmDelta = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount());
int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
refreshed = goalService.getById(goal.getId());
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] evaluator/persist failed for goal={} — skipping this pass: {}",
goal.getId(), t.toString());
return MateClawStateAccessor.output()
.goalEvaluationResult(GoalEvaluationResult.fallback("node_exception").toMap())
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goal.getId(), "evaluator_or_persist_failed")))
.build();
}
// Decision branches. Each terminal write is wrapped so a DB hiccup
// (e.g. optimistic-lock conflict exceeding retries, memory sync
// failure on completion) does not propagate into the chat graph
// and abort the streamed answer the user already sees.
try {
if (result.completed() || result.score() >= 0.95) {
goalService.markCompleted(refreshed.getId(), result);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score()))))
.build();
}
if (goalService.isBudgetExhausted(refreshed)) {
String reason = goalService.exhaustionReason(refreshed);
goalService.markExhausted(refreshed.getId(), reason);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_exhausted", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"turnsUsed", refreshed.getTurnsUsed(),
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
"reason", reason))))
.build();
}
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
refreshed.getId(), t.toString());
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
.build();
}
int followupCountThisRun = accessor.goalFollowupCount();
Optional<String> followup;
try {
followup = followupService.maybeBuildFollowup(refreshed, result);
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}",
refreshed.getId(), t.toString());
followup = Optional.empty();
}
// Per-run safety net: cap the autonomous self-continuation loop so a
// single user message can't drive an unbounded number of steps or
// approach the graph recursion limit. When the cap is hit we fall
// through to the terminal "continue, no followup" path the goal stays
// active and the cross-message turn / LLM budget (or the user) carries
// it on.
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
if (followup.isPresent() && perRunCapReached) {
log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run",
followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId());
}
if (followup.isPresent() && !perRunCapReached) {
try {
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] recordFollowupInjected failed — emitting followup anyway: {}",
t.toString());
// Continue: the in-memory state-machine path still works
// even if the audit row could not be written.
}
MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalFollowupInjected(true)
.goalFollowupPrompt(followup.get())
.goalFollowupCount(followupCountThisRun + 1)
// Advance the LLM-billing marker to the current cumulative
// count so the NEXT evaluation in this run charges only its
// own delta (see agentLlmDelta above).
.goalAccountedLlmCallCount(accessor.llmCallCount())
// Deliberately NOT setting goalEvaluatedThisRun(true): leaving
// it false lets the NEXT answer be re-evaluated, turning the
// old single-step behaviour into run-to-completion. The loop
// is bounded by the per-run cap above plus the turn / LLM
// budgets; the dispatcher treats any terminal pass
// (goalEvaluatedThisRun == true) as END even if this flag
// lingers true under the REPLACE key strategy.
.needsToolCall(false)
.events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"prompt", followup.get()))));
if (flavor == GraphFlavor.REACT) {
// ReAct: append the followup as a fresh user message via the
// MESSAGES APPEND strategy. ReasoningNode picks it up on its
// next call without any followup-specific logic on its side.
out.clearFinalAnswer()
.clearFinishReason()
.messages(List.of((Message) new UserMessage(followup.get())));
} else {
// Plan-Execute: wipe the wider mid-pass + terminal state.
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
// preserved the next PlanGeneration pass needs them.
out.clearFinalAnswer()
.clearFinishReason()
.clearPlanFinalSummary()
.clearPlanDirectAnswer()
.clearPlanId()
.clearPlanSteps()
.clearPlanValid()
.clearNeedsPlanning()
.clearCurrentStepIndex()
.clearCurrentStepTitle()
.clearCurrentStepResult()
.clearCompletedResults()
.clearFinalSummaryThinking()
.clearCurrentStepThinking();
}
return out.build();
}
// Continue but no follow-up just record the evaluation event.
// (helper below avoids needing a custom() factory on GraphEventPublisher.)
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_evaluated", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score(),
"gap", result.gap() == null ? "" : result.gap()))))
.build();
}
/** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());
}
/**
* Builds a goal_evaluated event for skip paths so the frontend can
* unconditionally flip its "evaluating" flag off after every turn that
* has an active goal even when the evaluator never ran. The reason
* field lets us tell apart "normal continue" from "skipped because of
* max iterations" in logs / future telemetry without ambiguity.
*/
private static GraphEventPublisher.GraphEvent skippedEvent(Long goalId, String reason) {
return goalEvent("goal_evaluated", Map.of(
"goalId", goalId == null ? "" : String.valueOf(goalId),
"skipped", true,
"reason", reason == null ? "" : reason));
}
}

View File

@ -47,18 +47,33 @@ public class LimitExceededNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper; private final NodeStreamingChatHelper streamingHelper;
/** Optional i18n service; nullable so legacy/tests without Spring context still work. */ /** Optional i18n service; nullable so legacy/tests without Spring context still work. */
private final I18nService i18n; private final I18nService i18n;
/**
* Optional ledger loader. When set, the conversation's progress snapshot
* (done / in-progress / pending) is appended to the LLM's context so the
* "graceful wrap-up" answer can be honest about which steps actually
* finished and which were still pending when the iteration cap hit.
* Null in legacy/test constructors the wrap behaves as before.
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor, public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper) { NodeStreamingChatHelper streamingHelper) {
this(chatModel, observationProcessor, streamingHelper, null); this(chatModel, observationProcessor, streamingHelper, null, null);
} }
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor, public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper, I18nService i18n) { NodeStreamingChatHelper streamingHelper, I18nService i18n) {
this(chatModel, observationProcessor, streamingHelper, i18n, null);
}
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper, I18nService i18n,
vip.mate.agent.progress.ProgressLedgerService progressLedgerService) {
this.chatModel = chatModel; this.chatModel = chatModel;
this.observationProcessor = observationProcessor; this.observationProcessor = observationProcessor;
this.streamingHelper = streamingHelper; this.streamingHelper = streamingHelper;
this.i18n = i18n; this.i18n = i18n;
this.progressLedgerService = progressLedgerService;
} }
/** /**
@ -66,7 +81,7 @@ public class LimitExceededNode implements NodeAction {
*/ */
@Deprecated @Deprecated
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) { public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
this(chatModel, observationProcessor, null, null); this(chatModel, observationProcessor, null, null, null);
} }
@Override @Override
@ -100,6 +115,25 @@ public class LimitExceededNode implements NodeAction {
contextForLLM = i18n != null ? i18n.msg("agent.limit_exceeded.empty_context") : "(no tool results)"; contextForLLM = i18n != null ? i18n.msg("agent.limit_exceeded.empty_context") : "(no tool results)";
} }
// Prepend the conversation's progress ledger snapshot when available
// so the wrap-up answer can be honest about partial completion ("4/10
// models researched, 6 still pending") rather than vaguely describing
// "what I tried". Without this, hitting the iteration cap on a
// 10-step task produces a useless catch-all message observed in
// round-4 of the LLM-review smoke test.
String ledgerSnapshot = null;
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
try {
ledgerSnapshot = progressLedgerService.load(conversationId).renderSnapshot();
} catch (Exception e) {
log.warn("[LimitExceededNode] Failed to load progress ledger for {}: {}",
conversationId, e.getMessage());
}
}
if (ledgerSnapshot != null) {
contextForLLM = ledgerSnapshot + "\n\n---\n\n" + contextForLLM;
}
// 构建 prompt // 构建 prompt
String systemPrompt = SYSTEM_TEMPLATE.replace("{maxIterations}", String.valueOf(maxIterations)); String systemPrompt = SYSTEM_TEMPLATE.replace("{maxIterations}", String.valueOf(maxIterations));
String userPrompt = USER_TEMPLATE String userPrompt = USER_TEMPLATE

View File

@ -72,7 +72,7 @@ public class ObservationNode implements NodeAction {
// 合并为单条观察记录 // 合并为单条观察记录
String combinedObservation = String.join("\n---\n", processedObservations); String combinedObservation = String.join("\n---\n", processedObservations);
// Budget Pressure WarningHermes 风格接近上限时注入警告到工具结果中 // Budget Pressure Warning接近上限时注入警告到工具结果中
// LLM 下一轮 reasoning 时能看到从而主动收束而非被硬性截断 // LLM 下一轮 reasoning 时能看到从而主动收束而非被硬性截断
if (maxIterations > 0) { if (maxIterations > 0) {
int progress = (int) ((double) nextIteration / maxIterations * 100); int progress = (int) ((double) nextIteration / maxIterations * 100);

View File

@ -18,7 +18,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.ThinkingLevelHolder; import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector; import vip.mate.agent.context.RuntimeContextInjector;
@ -72,7 +72,59 @@ public class ReasoningNode implements NodeAction {
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384; private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384;
/** /**
* Hermes-agent style enforcement clause appended to every ReasoningNode * DashScope's native chat API caps {@code max_tokens} at 8192 and returns a
* 400 {@code InvalidParameter} ("Range of max_tokens should be [1, 8192]")
* for anything larger. The failover layer misclassifies that 400 as
* "model not found" and silently switches to a different provider, so the
* per-call ceiling must be clamped to this value for DashScope-backed
* models keeping {@link #DEFAULT_MAX_OUTPUT_TOKENS} for every other
* provider that does accept the larger budget.
*/
private static final int DASHSCOPE_MAX_OUTPUT_TOKENS = 8192;
/**
* Max times to re-prompt the model when it returns a completely empty turn
* (no tool call, no content, no thinking) before accepting termination.
* A blank turn is otherwise treated as a final answer and ends the run; on
* long multi-step tasks that surfaces as the agent quitting mid-way.
*/
private static final int MAX_EMPTY_COMPLETION_RETRIES = 2;
/**
* Number of newest tool-response messages kept verbatim in the model
* input; older ones have their bodies collapsed to a one-line "old
* output cleared" placeholder while keeping the toolCallId / tool name
* so the assistant/tool pairing remains valid. The latest few results
* are what the model is reasoning over right now beyond that, the
* content is history and re-call (or read_file on the spill path) is
* cheaper than carrying every previous body forward across iterations.
*/
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
private static final String EMPTY_COMPLETION_NUDGE =
"Your previous turn was empty. If the task is not yet complete, continue now "
+ "with the next concrete step — call a tool or write the next part. If every "
+ "required step is already done, output the final answer to the user now.";
/**
* A turn carrying no tool call, no content, and no thinking is not a usable
* answer it would route to the final-answer branch as an empty string and
* terminate the run. Fatal / prompt-too-long / partial results are handled by
* their own branches and must not be misread as "empty".
*/
static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) {
if (result == null || result.hasToolCalls() || result.hasFatalError()
|| result.isPromptTooLong() || result.partial()) {
return false;
}
boolean noContent = result.text() == null || result.text().isBlank();
boolean noThinking = result.thinking() == null || result.thinking().isBlank();
return noContent && noThinking;
}
/**
* Tool-use enforcement clause appended to every ReasoningNode
* system prompt. Treats narration ("I will now …") as a protocol violation * system prompt. Treats narration ("I will now …") as a protocol violation
* to prevent the recurring failure mode where a model says it will call a * to prevent the recurring failure mode where a model says it will call a
* tool but emits the description as final_answer text instead. * tool but emits the description as final_answer text instead.
@ -87,10 +139,42 @@ public class ReasoningNode implements NodeAction {
+ "- 如果上一次工具调用因 args JSON 截断max_tokens 超限)失败,\n" + "- 如果上一次工具调用因 args JSON 截断max_tokens 超限)失败,\n"
+ " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n" + " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n"
+ "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n" + "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n"
+ " 才输出无 tool_call 的纯文字回答。\n"; + " 才输出无 tool_call 的纯文字回答。\n\n"
+ "## 进度跟踪(多步任务强制规则,不可绕过)\n\n"
+ "**触发条件**:用户的任务包含 ≥3 个可枚举子目标 — 比如\n"
+ "\"调研 10 个模型\"\"逐节起草报告\"\"批量生成 N 份文档\"\n"
+ "\"依次调用 N 个 API\"\"对每个文件执行同一操作\"等。\n\n"
+ "**必须做的事**\n"
+ "1. **第一轮回复就用并行 tool_calls 批量注册全部子目标为 `pending`**\n"
+ " 一条回复里 N 个 `progress_update` 同时发出(不要串行)。\n"
+ " 例:要调研 10 个模型,第一轮就发 10 个 `progress_update(stepKey=\"model_xxx\", status=\"pending\")`。\n"
+ "2. **每开始一个子目标**前发 `progress_update(同 stepKey, status=\"in_progress\")`。\n"
+ "3. **每完成一个子目标**后立即发 `progress_update(同 stepKey, status=\"done\")`。\n"
+ "4. **无法继续**时发 `progress_update(同 stepKey, status=\"blocked\", note=\"具体原因\")`。\n\n"
+ "**为什么必须**\n"
+ "- 系统在你**每一次推理前**注入一份 \"## 当前任务进度\" 快照。\n"
+ " 这是你**唯一可信**的\"已完成清单\"——比你记忆里的步骤更权威,因为上下文窗口\n"
+ " 会被裁剪,老的工具调用记录会消失,但 ledger 不会。\n"
+ "- 不维护 ledger 的后果(实测):\n"
+ " · 上下文裁剪后忘记自己做过的步骤,重复执行已完成项 → 浪费迭代预算\n"
+ " · 漏做项目 → 任务不完整 → 撞 max_iterations 还没干完\n"
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
private final ChatModel chatModel; private final ChatModel chatModel;
private final List<ToolCallback> toolCallbacks; private final List<ToolCallback> toolCallbacks;
/**
* Full agent tool set, used for the per-turn disclosure split. Null in the
* legacy {@code (ChatModel, List)} path that path falls back to
* {@link #toolCallbacks} verbatim with no split.
*/
private final AgentToolSet toolSet;
/**
* Splits tools into core + already-enabled extensions per
* {@code ENABLED_EXTENSION_TOOLS}. Null disables the split (advertise the
* full {@link #toolCallbacks}).
*/
private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService;
private final String reasoningEffort; private final String reasoningEffort;
/** /**
* PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts * PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts
@ -105,6 +189,22 @@ public class ReasoningNode implements NodeAction {
private final int maxOutputTokens; private final int maxOutputTokens;
/** Wiki 相关性注入可选null 时跳过) */ /** Wiki 相关性注入可选null 时跳过) */
private final vip.mate.wiki.service.WikiContextService wikiContextService; private final vip.mate.wiki.service.WikiContextService wikiContextService;
/**
* Renders the {@code ## Skills} catalog each turn so its ordering reacts to
* skills loaded this run (load_skill pins). Null in legacy / test
* constructors when null, no catalog segment is appended.
*/
private final vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer;
/**
* Loads the per-conversation progress ledger each reasoning step so a
* compact snapshot can be injected into {@code nonHistoryPrefix}
* surviving message-window trims so the agent never loses track of
* "what is already done" on long multi-step tasks. Null in legacy /
* test constructors; when null the snapshot block is suppressed and
* the prompt is identical to pre-feature behavior.
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper, NodeStreamingChatHelper streamingHelper,
@ -148,8 +248,66 @@ public class ReasoningNode implements NodeAction {
ConversationWindowManager conversationWindowManager, ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens, ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService) { vip.mate.wiki.service.WikiContextService wikiContextService) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService, null);
}
/**
* Primary constructor with the runtime {@link vip.mate.skill.runtime.SkillCatalogRenderer}.
* The catalog is rendered each turn (ordered by skills loaded this run)
* instead of being baked into the system prompt, so the prompt-cache prefix
* stays stable and load_skill pins float to the top.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService,
skillCatalogRenderer, null);
}
/**
* Backward-compatible delegate for callers built before the
* {@link vip.mate.agent.progress.ProgressLedgerService} was wired in
* passes {@code null} so the progress snapshot block is suppressed.
* New call sites should use the 13-arg primary constructor below.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer,
vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, null);
}
/**
* Primary constructor with the {@link vip.mate.agent.progress.ProgressLedgerService}.
* When non-null, a compact snapshot of the conversation's progress ledger
* is appended to {@code nonHistoryPrefix} each turn so the agent retains
* its "what is already done" view across message-window trims.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer,
vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService,
vip.mate.agent.progress.ProgressLedgerService progressLedgerService) {
this.chatModel = chatModel; this.chatModel = chatModel;
this.toolSet = toolSet;
this.toolCallbacks = toolSet.callbacks(); this.toolCallbacks = toolSet.callbacks();
this.toolDisclosureService = toolDisclosureService;
this.reasoningEffort = reasoningEffort; this.reasoningEffort = reasoningEffort;
this.supportsReasoningEffort = supportsReasoningEffort; this.supportsReasoningEffort = supportsReasoningEffort;
this.streamingHelper = streamingHelper; this.streamingHelper = streamingHelper;
@ -157,6 +315,8 @@ public class ReasoningNode implements NodeAction {
this.streamTracker = streamTracker; this.streamTracker = streamTracker;
this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS; this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
this.wikiContextService = wikiContextService; this.wikiContextService = wikiContextService;
this.skillCatalogRenderer = skillCatalogRenderer;
this.progressLedgerService = progressLedgerService;
} }
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
@ -181,7 +341,9 @@ public class ReasoningNode implements NodeAction {
@Deprecated @Deprecated
public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) { public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) {
this.chatModel = chatModel; this.chatModel = chatModel;
this.toolSet = null;
this.toolCallbacks = toolCallbacks; this.toolCallbacks = toolCallbacks;
this.toolDisclosureService = null;
this.reasoningEffort = null; this.reasoningEffort = null;
this.supportsReasoningEffort = false; this.supportsReasoningEffort = false;
this.streamingHelper = null; this.streamingHelper = null;
@ -189,6 +351,8 @@ public class ReasoningNode implements NodeAction {
this.streamTracker = null; this.streamTracker = null;
this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS; this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
this.wikiContextService = null; this.wikiContextService = null;
this.skillCatalogRenderer = null;
this.progressLedgerService = null;
} }
@Override @Override
@ -237,17 +401,16 @@ public class ReasoningNode implements NodeAction {
// ======= 构建 Prompt ======= // ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt(); String systemPrompt = accessor.systemPrompt();
// RFC-049 follow-up: append a tool-use enforcement clause to every // Append a tool-use enforcement clause to every ReasoningNode call.
// ReasoningNode call. Without this, models (especially DeepSeek thinking // Without it, some models (notably DeepSeek thinking and Claude Opus)
// and Claude Opus) tend to "narrate" emit a final_answer like "现在 // tend to "narrate" emit a final_answer like "现在直接生成立项材料
// 直接生成立项材料 docx" instead of actually calling renderDocx, which // docx" instead of actually calling renderDocx, which makes the
// makes the graph silently terminate at final_answer_node with the // graph silently terminate at final_answer_node with the narration
// narration as the user-facing reply. // as the user-facing reply.
// //
// Pattern adopted from hermes-agent's TOOL_USE_ENFORCEMENT_GUIDANCE // Appended at runtime rather than woven into the AgentEntity-stored
// (`/agent/prompt_builder.py:179-191`). Appended to systemPrompt rather // prompt so it stays out of the user-editable agent UI but is still
// than woven into the AgentEntity-stored prompt so it stays out of the // always-on for the runtime LLM.
// user-editable agent UI but is still always-on at runtime.
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT; systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
List<Message> messages = accessor.messages(); List<Message> messages = accessor.messages();
@ -328,26 +491,76 @@ public class ReasoningNode implements NodeAction {
} }
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, ""); String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
List<Message> promptMessages = new ArrayList<>(); String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
promptMessages.add(new SystemMessage(systemPrompt)); String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
// Wiki 相关性注入根据用户消息提取相关页面摘要 // Build the non-history prefix ONCE. The PTL retry branch below
if (wikiContextService != null) { // reuses this list verbatim so the retried prompt has exactly the
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, ""); // same system / runtime context / wiki injection as the original
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, ""); // the previous tail-only retry path silently dropped the wiki
// segment which led to "answer regressed after compaction"
// complaints on long sessions.
List<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg,
accessor.chatOrigin());
// Append the runtime-rendered skill catalog as a SEPARATE SystemMessage
// right after the skeleton system prompt. Keeping it out of the baked
// prompt keeps the stable prefix's prompt-cache hash intact, while
// re-rendering each turn lets skills loaded this run (load_skill) pin
// to the top of the catalog. Reused verbatim by the PTL retry branch.
if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
if (skillCatalog != null && !skillCatalog.isBlank()) {
nonHistoryPrefix.add(1, new SystemMessage(skillCatalog));
}
}
// Inject the conversation's progress-ledger snapshot as a separate
// SystemMessage. Sits in nonHistoryPrefix (never trimmed) so the
// agent always sees its own "what's done / what's pending" record
// even after the message-window trim above drops the tool-call
// history that produced those done entries. Suppressed when the
// ledger column is empty so short single-turn questions stay
// prompt-cache-friendly.
//
// Past iteration ~10, also emit a stale-reminder SystemMessage when
// the ledger looks abandoned (empty after many turns, or no
// progress_update in >90s). This pushes the model back to the
// ledger discipline before it drifts into the "I'm doing the work
// but never marking it" failure mode observed in round-4 of the
// LLM-review smoke test.
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
try { try {
Long parsedAgentId = Long.parseLong(agentIdStr); vip.mate.agent.progress.ProgressLedger ledger =
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg); progressLedgerService.load(conversationId);
if (wikiRelevant != null && !wikiRelevant.isBlank()) { String snapshot = ledger.renderSnapshot();
promptMessages.add(new UserMessage(wikiRelevant)); if (snapshot != null) {
nonHistoryPrefix.add(new SystemMessage(snapshot));
} }
} catch (NumberFormatException ignored) { String staleReminder = ledger.renderStaleReminder(
// agentId 无法解析时跳过 wiki 注入 accessor.iterationCount(), java.time.Instant.now());
if (staleReminder != null) {
nonHistoryPrefix.add(new SystemMessage(staleReminder));
log.info("[ReasoningNode] Injected stale-ledger reminder at iter {} for conv {}",
accessor.iterationCount(), conversationId);
}
} catch (Exception e) {
// Never let a ledger-side failure break the reasoning step.
log.warn("[ReasoningNode] Failed to load progress ledger for {}: {}",
conversationId, e.getMessage());
} }
} }
if (conversationWindowManager != null) { if (conversationWindowManager != null) {
// Age-based compaction first: drop the body of tool responses
// older than the K most recent into a one-line placeholder that
// keeps the toolCallId / tool name (so the assistant/tool pair
// stays valid) and, for spilled bodies, preserves the on-disk
// path so read_file can still recover the original. Without
// this, even spilled previews (~1-2 KB each) accumulate across
// 30+ tool calls and bloat the prompt the model sees every turn.
messages = conversationWindowManager.compactAgedToolResponses(
messages, KEEP_RECENT_TOOL_RESPONSES);
// Pass conversationId + workspaceBasePath so oversized older // Pass conversationId + workspaceBasePath so oversized older
// tool results can be spilled to the workspace spill directory // tool results can be spilled to the workspace spill directory
// (preserving the full body for read_file recovery) instead of // (preserving the full body for read_file recovery) instead of
@ -355,6 +568,7 @@ public class ReasoningNode implements NodeAction {
messages = conversationWindowManager.pruneOldToolResultsForModelInput( messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath); messages, conversationId, workspaceBasePath);
} }
List<Message> promptMessages = new ArrayList<>(nonHistoryPrefix);
promptMessages.addAll(messages); promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置 // 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
@ -362,7 +576,15 @@ public class ReasoningNode implements NodeAction {
log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}", log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}",
ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort); ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort);
ChatOptions options = buildChatOptions(effectiveReasoning); // Progressive disclosure: advertise only core tools plus the extensions
// enabled this run, computed fresh each turn from ENABLED_EXTENSION_TOOLS
// so an enable_tool call earlier in this loop takes effect immediately.
// Falls back to the full tool set when no disclosure service is wired.
List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null)
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks()
: toolCallbacks;
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
Prompt prompt = new Prompt(promptMessages, options); Prompt prompt = new Prompt(promptMessages, options);
@ -372,7 +594,7 @@ public class ReasoningNode implements NodeAction {
// PTL compact retry 会再 +1 // PTL compact retry 会再 +1
int nextLlmCallCount = accessor.llmCallCount() + 1; int nextLlmCallCount = accessor.llmCallCount() + 1;
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}", log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}",
promptMessages.size(), toolCallbacks.size(), promptMessages.size(), activeCallbacks.size(),
accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount); accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount);
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning", GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
@ -397,19 +619,34 @@ public class ReasoningNode implements NodeAction {
try { try {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning"); result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
// PTL 处理压缩后重试 // PTL 处理结构化压缩后重试复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把
// wiki 段一起丢掉重试后的 prompt 比原始更短少一层信息
if (result.isPromptTooLong() && conversationWindowManager != null) { if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry"); log.warn("[ReasoningNode] Prompt too long, attempting STRUCTURED compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
// MateClawStateAccessor.agentId() returns String per state
// schema; the ConversationWindowManager hook expects Long
// (nullable onPreCompress is a no-op when null).
Long agentIdLong = null;
if (!agentIdStr.isEmpty()) {
try {
agentIdLong = Long.parseLong(agentIdStr);
} catch (NumberFormatException ignored) {
// Same fallback as the non-history prefix builder above.
}
}
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
messages, chatModel, conversationId, agentIdLong);
if (compactedMessages != null && compactedMessages.size() < messages.size()) { if (compactedMessages != null && compactedMessages.size() < messages.size()) {
List<Message> retryPromptMessages = new ArrayList<>(); // Reuse the SAME non-history prefix wiki/runtime context preserved.
retryPromptMessages.add(new SystemMessage(systemPrompt)); List<Message> retryPromptMessages = new ArrayList<>(nonHistoryPrefix);
retryPromptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
retryPromptMessages.addAll(compactedMessages); retryPromptMessages.addAll(compactedMessages);
Prompt retryPrompt = new Prompt(retryPromptMessages, options); Prompt retryPrompt = new Prompt(retryPromptMessages, options);
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages", log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
messages.size(), compactedMessages.size()); messages.size(), compactedMessages.size());
// compact retry 是第 2 LLM 调用先递增再调用
nextLlmCallCount++; nextLlmCallCount++;
pushPhase(conversationId, "reasoning", Map.of( pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(), "iteration", accessor.iterationCount(),
@ -421,6 +658,28 @@ public class ReasoningNode implements NodeAction {
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry"); log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
} }
} }
// Empty-completion guard: a turn with no tool call, no content, and
// no thinking is not a real answer. Under heavy message-window
// trimming on long multi-step tasks the model occasionally emits a
// blank turn; the final-answer branch would then treat it as "done"
// (finalAnswer="") and end the run prematurely (observed: a 10-item
// research task stopping at item 2). Re-prompt it to continue
// bounded, so a model that genuinely has nothing left still
// terminates cleanly through the normal empty-answer path below.
int emptyRetries = 0;
while (emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && isEmptyCompletion(result)) {
emptyRetries++;
log.warn("[ReasoningNode] Empty LLM completion (no tool call / content / thinking); "
+ "nudging to continue (retry {}/{}), conv={}",
emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId);
List<Message> nudgedMessages = new ArrayList<>(promptMessages);
nudgedMessages.add(new UserMessage(EMPTY_COMPLETION_NUDGE));
Prompt nudgePrompt = new Prompt(nudgedMessages, options);
nextLlmCallCount++;
result = streamingHelper.streamCall(
chatModel, nudgePrompt, conversationId, "reasoning_empty_retry");
}
} catch (CancellationException ce) { } catch (CancellationException ce) {
// "调用已发出但尚未产出内容时用户停止" streamHelper CancellationException // "调用已发出但尚未产出内容时用户停止" streamHelper CancellationException
// 返回空 finalAnswer + STOPPED FinalAnswerNode STOPPED 语义处理 // 返回空 finalAnswer + STOPPED FinalAnswerNode STOPPED 语义处理
@ -660,6 +919,56 @@ public class ReasoningNode implements NodeAction {
return out; return out;
} }
/**
* Build the part of the Prompt that does not depend on history messages:
* system prompt, workspace runtime context, and (when wiring permits) the
* wiki relevant-pages snippet. Extracted so the initial Prompt assembly
* and the PTL retry path can share one source of truth historically
* these were two parallel code paths and the retry one silently dropped
* the wiki injection.
* <p>
* {@code systemPrompt} is consumed as-is; the upstream callsite has
* already appended the tool-use enforcement clause, so this helper must
* NOT re-append it (doing so would duplicate the clause on every retry).
*
* @param systemPrompt Fully-built system prompt (with tool-use
* enforcement already appended upstream).
* @param workspaceBasePath Active workspace directory; passed to
* {@link RuntimeContextInjector}.
* @param agentIdStr Agent ID as carried in graph state parsed
* to {@code Long} only when non-empty and
* numeric; otherwise the wiki segment is
* skipped (matches the pre-refactor behavior).
* @param userMsg Current user message used by
* {@code WikiContextService} to score
* relevance.
*/
// Package-private so ReasoningNodePtlPromptTest can directly assert on
// the wiki / runtime-context layout; the production callsites inside
// this class call it via {@code this.buildNonHistoryPrefix(...)} so
// narrowing the visibility doesn't change behavior.
List<Message> buildNonHistoryPrefix(String systemPrompt,
String workspaceBasePath,
String agentIdStr,
String userMsg,
vip.mate.agent.context.ChatOrigin chatOrigin) {
List<Message> prefix = new ArrayList<>();
prefix.add(new SystemMessage(systemPrompt));
prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
if (wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
prefix.add(new UserMessage(wikiRelevant));
}
} catch (NumberFormatException ignored) {
// agentId not numeric skip wiki injection (matches prior behavior).
}
}
return prefix;
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) { private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || !StringUtils.hasText(conversationId)) { if (streamTracker == null || !StringUtils.hasText(conversationId)) {
return; return;
@ -673,12 +982,12 @@ public class ReasoningNode implements NodeAction {
* - AnthropicChatModel AnthropicChatOptions支持 extended thinking * - AnthropicChatModel AnthropicChatOptions支持 extended thinking
* - 其他OpenAI/DashScope OpenAiChatOptions支持 reasoningEffort * - 其他OpenAI/DashScope OpenAiChatOptions支持 reasoningEffort
*/ */
private ChatOptions buildChatOptions(String effectiveReasoning) { private ChatOptions buildChatOptions(String effectiveReasoning, List<ToolCallback> activeCallbacks) {
// Anthropic 协议模型AnthropicChatModelMiniMax 也用此协议但不支持 thinking // Anthropic 协议模型AnthropicChatModelMiniMax 也用此协议但不支持 thinking
if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) { if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) {
org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder = org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder =
org.springframework.ai.anthropic.AnthropicChatOptions.builder() org.springframework.ai.anthropic.AnthropicChatOptions.builder()
.toolCallbacks(toolCallbacks) .toolCallbacks(activeCallbacks)
.internalToolExecutionEnabled(false); .internalToolExecutionEnabled(false);
// 仅对真正的 Claude 模型启用 extended thinkingMiniMax 等走 Anthropic 协议但不支持 // 仅对真正的 Claude 模型启用 extended thinkingMiniMax 等走 Anthropic 协议但不支持
@ -712,9 +1021,19 @@ public class ReasoningNode implements NodeAction {
// 始终使用 OpenAiChatOptions而非 ToolCallingChatOptions // 始终使用 OpenAiChatOptions而非 ToolCallingChatOptions
// 因为 ToolCallingChatOptions 会丢失 OpenAI 特有参数streamUsage // 因为 ToolCallingChatOptions 会丢失 OpenAI 特有参数streamUsage
// 导致 Kimi OpenAI 兼容 API 响应异常或提前截断 // 导致 Kimi OpenAI 兼容 API 响应异常或提前截断
// DashScope rejects max_tokens above its 8192 ceiling with a 400 that
// the failover layer misreads as "model not found"; clamp so a
// DashScope-backed model never overflows the provider limit.
int effectiveMaxTokens = maxOutputTokens;
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model",
effectiveMaxTokens, DASHSCOPE_MAX_OUTPUT_TOKENS);
effectiveMaxTokens = DASHSCOPE_MAX_OUTPUT_TOKENS;
}
OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder() OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbacks) .toolCallbacks(activeCallbacks)
.maxTokens(maxOutputTokens); .maxTokens(effectiveMaxTokens);
if (StringUtils.hasText(effectiveReasoning)) { if (StringUtils.hasText(effectiveReasoning)) {
oaiBuilder.reasoningEffort(effectiveReasoning); oaiBuilder.reasoningEffort(effectiveReasoning);
} }

View File

@ -14,6 +14,7 @@ import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.OpenAiChatOptions;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.prompt.PromptLoader; import vip.mate.agent.prompt.PromptLoader;
@ -120,7 +121,8 @@ public class SummarizingNode implements NodeAction {
log.warn("[SummarizingNode] Summarization LLM call failed: {}, using raw observations as fallback", log.warn("[SummarizingNode] Summarization LLM call failed: {}, using raw observations as fallback",
result.errorMessage()); result.errorMessage());
String fallback = observationText.length() > 500 String fallback = observationText.length() > 500
? observationText.substring(0, 500) + "...[摘要生成失败,已截断]" ? StructuredTruncator.headSlice(observationText.toString(), 500)
+ "\n...[摘要生成失败,仅保留原始观察的前部片段;数据不完整,请勿编造、补全或重新编号缺失内容]"
: observationText.toString(); : observationText.toString();
AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback); AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback);
return MateClawStateAccessor.output() return MateClawStateAccessor.output()

View File

@ -1,6 +1,7 @@
package vip.mate.agent.graph.observation; package vip.mate.agent.graph.observation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.config.GraphObservationProperties; import vip.mate.config.GraphObservationProperties;
import java.util.List; import java.util.List;
@ -94,12 +95,11 @@ public class ObservationProcessor {
int headLen = (int) (available * effectiveHeadRatio); int headLen = (int) (available * effectiveHeadRatio);
int tailLen = available - headLen; int tailLen = available - headLen;
String head = text.substring(0, headLen); String result = StructuredTruncator.truncate(text, headLen, tailLen, marker);
String tail = text.substring(originalLen - tailLen);
log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})", log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})",
originalLen, head.length() + tail.length(), maxLen, effectiveHeadRatio); originalLen, result.length(), maxLen, effectiveHeadRatio);
return head + marker + tail; return result;
} }
/** /**

View File

@ -319,6 +319,24 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
origin = origin.withConversationId(conversationId) origin = origin.withConversationId(conversationId)
.withWorkspace(origin.workspaceId(), workspaceBasePath); .withWorkspace(origin.workspaceId(), workspaceBasePath);
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin); inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
// RFC 48 inject active goal snapshot for GoalEvaluationNode.
// Mirrors StateGraphReActAgent.buildInitialState exactly.
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
try {
vip.mate.goal.model.GoalEntity active =
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
}
}
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
return inputs; return inputs;
} }

View File

@ -115,6 +115,20 @@ public class PlanGenerationNode implements NodeAction {
public Map<String, Object> apply(OverAllState state) throws Exception { public Map<String, Object> apply(OverAllState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state); PlanStateAccessor accessor = new PlanStateAccessor(state);
String goal = accessor.goal(); String goal = accessor.goal();
// Goal follow-up injection: GoalEvaluationNode requested a re-plan
// pass with extra guidance. The mid-pass plan state was wiped by
// the previous node, so we run the normal planning flow but
// append the follow-up prompt to the user goal so the planner
// sees "do these original objectives + this next step the
// evaluator just asked for".
String followupPrompt = state.value(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
if (!followupPrompt.isEmpty()) {
log.info("[PlanGeneration] Goal follow-up active, augmenting goal with {} chars of guidance",
followupPrompt.length());
goal = goal + "\n\n[Follow-up guidance]\n" + followupPrompt;
}
String systemPrompt = accessor.systemPrompt(); String systemPrompt = accessor.systemPrompt();
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown"); String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
String conversationId = accessor.conversationId(); String conversationId = accessor.conversationId();
@ -148,7 +162,11 @@ public class PlanGenerationNode implements NodeAction {
List<Message> promptMessages = new ArrayList<>(); List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(PLANNING_PROMPT)); promptMessages.add(new SystemMessage(PLANNING_PROMPT));
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, ""); String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath))); vip.mate.agent.context.ChatOrigin chatOrigin =
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
promptMessages.add(new UserMessage(
RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
// Advertise available tools so the LLM can recognize when an action is possible, // Advertise available tools so the LLM can recognize when an action is possible,
// but do NOT force "any tool usage implies multi-step" single-hop tool use // but do NOT force "any tool usage implies multi-step" single-hop tool use

View File

@ -27,6 +27,7 @@ import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.graph.executor.ToolExecutionExecutor; import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.planning.service.PlanningService; import vip.mate.planning.service.PlanningService;
import vip.mate.skill.runtime.SkillCatalogRenderer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -60,6 +61,12 @@ public class StepExecutionNode implements NodeAction {
private final String reasoningEffort; private final String reasoningEffort;
private final NodeStreamingChatHelper streamingHelper; private final NodeStreamingChatHelper streamingHelper;
private final long stepWallClockTimeoutMs; private final long stepWallClockTimeoutMs;
/**
* Renders the {@code ## Skills} catalog at runtime. Null in legacy / test
* constructors when null, no catalog segment is appended (the Plan path's
* pre-disclosure behavior of baking it into the system prompt is gone).
*/
private final SkillCatalogRenderer skillCatalogRenderer;
/** /**
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}. * Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
@ -92,7 +99,20 @@ public class StepExecutionNode implements NodeAction {
ConversationWindowManager conversationWindowManager) { ConversationWindowManager conversationWindowManager) {
this(chatModel, toolSet, executor, planningService, streamTracker, this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager, reasoningEffort, streamingHelper, conversationWindowManager,
STEP_WALL_CLOCK_TIMEOUT_MS); null, STEP_WALL_CLOCK_TIMEOUT_MS);
}
/** Production constructor with the runtime skill-catalog renderer. */
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
SkillCatalogRenderer skillCatalogRenderer) {
this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager,
skillCatalogRenderer, STEP_WALL_CLOCK_TIMEOUT_MS);
} }
/** Test-friendly overload — production callers use the default timeout. */ /** Test-friendly overload — production callers use the default timeout. */
@ -103,6 +123,19 @@ public class StepExecutionNode implements NodeAction {
String reasoningEffort, NodeStreamingChatHelper streamingHelper, String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager, ConversationWindowManager conversationWindowManager,
long stepWallClockTimeoutMs) { long stepWallClockTimeoutMs) {
this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager,
null, stepWallClockTimeoutMs);
}
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
SkillCatalogRenderer skillCatalogRenderer,
long stepWallClockTimeoutMs) {
this.chatModel = chatModel; this.chatModel = chatModel;
this.toolSet = toolSet; this.toolSet = toolSet;
this.executor = executor; this.executor = executor;
@ -111,6 +144,7 @@ public class StepExecutionNode implements NodeAction {
this.conversationWindowManager = conversationWindowManager; this.conversationWindowManager = conversationWindowManager;
this.reasoningEffort = reasoningEffort; this.reasoningEffort = reasoningEffort;
this.streamingHelper = streamingHelper; this.streamingHelper = streamingHelper;
this.skillCatalogRenderer = skillCatalogRenderer;
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs; this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
} }
@ -494,8 +528,18 @@ public class StepExecutionNode implements NodeAction {
8. 每一步最多做一个必要的检查和一个必要的执行不要无意义循环 8. 每一步最多做一个必要的检查和一个必要的执行不要无意义循环
"""; """;
messages.add(new SystemMessage(enhancedSystemPrompt)); messages.add(new SystemMessage(enhancedSystemPrompt));
// 注入运行时上下文当前时间 + 工作目录 // Runtime skill catalog (rendered here instead of baked into the system
messages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath))); // prompt). The Plan path never pins per-run loads, so render with an
// empty loaded set this reproduces the pre-disclosure DB ordering.
if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of());
if (skillCatalog != null && !skillCatalog.isBlank()) {
messages.add(new SystemMessage(skillCatalog));
}
}
// 注入运行时上下文当前时间 + 工作目录 + 发起者上下文
messages.add(new UserMessage(
RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, accessor.chatOrigin())));
// Layer 2: Working context对话历史 + 步骤结果的受控长度摘要 // Layer 2: Working context对话历史 + 步骤结果的受控长度摘要
String workingContext = accessor.workingContext(); String workingContext = accessor.workingContext();

View File

@ -107,6 +107,17 @@ public final class PlanStateAccessor {
return state.value(MateClawStateKeys.TRACE_ID, ""); return state.value(MateClawStateKeys.TRACE_ID, "");
} }
/**
* The {@link vip.mate.agent.context.ChatOrigin} forwarded into graph
* state by {@code MateClawStateAccessor.OutputBuilder.chatOrigin}.
* Returns {@link vip.mate.agent.context.ChatOrigin#EMPTY} when nothing
* was injected (legacy callers / non-channel entry points).
*/
public vip.mate.agent.context.ChatOrigin chatOrigin() {
return state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
}
// ===== 会话消息复用 MateClawStateKeys.MESSAGES===== // ===== 会话消息复用 MateClawStateKeys.MESSAGES=====
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@ -235,8 +246,10 @@ public final class PlanStateAccessor {
NodeStreamingChatHelper.StreamResult result) { NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0); int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0); int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens()); map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this; return this;
} }

View File

@ -228,6 +228,26 @@ public final class MateClawStateAccessor {
return state.<ChatOrigin>value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY); return state.<ChatOrigin>value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY);
} }
// ===== Skill progressive disclosure =====
/**
* Skills loaded via {@code load_skill} so far this run. Empty when none
* have been loaded (the common first-iteration case).
*/
@SuppressWarnings("unchecked")
public Set<String> loadedSkills() {
return state.<Set<String>>value(LOADED_SKILLS).orElse(Set.of());
}
/**
* Extension tools activated via {@code enable_tool} so far this run. Empty
* when none have been enabled (the common case).
*/
@SuppressWarnings("unchecked")
public Set<String> enabledExtensionTools() {
return state.<Set<String>>value(ENABLED_EXTENSION_TOOLS).orElse(Set.of());
}
// ===== Token Usage ===== // ===== Token Usage =====
public int promptTokens() { public int promptTokens() {
@ -246,6 +266,67 @@ public final class MateClawStateAccessor {
return state.value(RUNTIME_PROVIDER_ID, ""); return state.value(RUNTIME_PROVIDER_ID, "");
} }
// ===== Persistent goal accessors =====
/**
* Active goal snapshot or empty. The injected object is the
* {@code vip.mate.goal.model.GoalEntity}; we reference it by Object
* here to avoid pulling the goal package into core graph state.
*/
public Optional<Object> activeGoal() {
return state.<Object>value(ACTIVE_GOAL);
}
public boolean hasActiveGoal() {
return state.<Object>value(ACTIVE_GOAL).isPresent();
}
public boolean goalEvaluatedThisRun() {
return state.value(GOAL_EVALUATED_THIS_RUN, false);
}
public boolean goalFollowupInjected() {
return state.value(GOAL_FOLLOWUP_INJECTED, false);
}
public String goalFollowupPrompt() {
return state.value(GOAL_FOLLOWUP_PROMPT, "");
}
/** Auto-followups already injected in this graph run (0 at run start). */
public int goalFollowupCount() {
return state.value(GOAL_FOLLOWUP_COUNT, 0);
}
/** Cumulative agent LLM calls already billed to the goal this run (0 at run start). */
public int goalAccountedLlmCallCount() {
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
}
/**
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
* Plan-Execute writes to {@code PlanStateKeys.FINAL_SUMMARY} (long
* path) or {@code PlanStateKeys.DIRECT_ANSWER} (short path). The
* GoalEvaluationNode reads whichever is populated without having to
* know which graph it's inside.
*/
public String terminalAnswer() {
String fa = state.value(FINAL_ANSWER, "");
if (!fa.isEmpty()) {
return fa;
}
// Avoid a direct compile-time reference to PlanStateKeys (the plan
// sub-package depends on core graph state); use the string keys
// verbatim. Mismatches would surface as terminalAnswer() returning
// empty in tests the v3 TerminalAnswerTest pins exactly that.
String summary = state.value("final_summary", "");
if (!summary.isEmpty()) {
return summary;
}
return state.value("direct_answer", "");
}
// ===== 输出构建器 ===== // ===== 输出构建器 =====
/** /**
@ -423,6 +504,16 @@ public final class MateClawStateAccessor {
return put(CHAT_ORIGIN, origin); return put(CHAT_ORIGIN, origin);
} }
// ---- Skill progressive disclosure ----
public OutputBuilder loadedSkills(Set<String> names) {
return put(LOADED_SKILLS, names);
}
// ---- Tool progressive disclosure ----
public OutputBuilder enabledExtensionTools(Set<String> names) {
return put(ENABLED_EXTENSION_TOOLS, names);
}
// ---- Token Usage ---- // ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */ /** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
@ -435,6 +526,97 @@ public final class MateClawStateAccessor {
return this; return this;
} }
// ---- Persistent goal ----
public OutputBuilder goalEvaluationResult(Map<String, Object> result) {
return put(GOAL_EVALUATION_RESULT, result);
}
public OutputBuilder goalFollowupInjected(boolean injected) {
return put(GOAL_FOLLOWUP_INJECTED, injected);
}
public OutputBuilder goalFollowupPrompt(String prompt) {
return put(GOAL_FOLLOWUP_PROMPT, prompt);
}
public OutputBuilder goalEvaluatedThisRun(boolean v) {
return put(GOAL_EVALUATED_THIS_RUN, v);
}
public OutputBuilder goalFollowupCount(int n) {
return put(GOAL_FOLLOWUP_COUNT, n);
}
public OutputBuilder goalAccountedLlmCallCount(int n) {
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
}
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
* immediately re-terminate via the existing final text. */
public OutputBuilder clearFinalAnswer() {
return put(FINAL_ANSWER, "");
}
/** Wipe FINISH_REASON for the same reason as clearFinalAnswer(). */
public OutputBuilder clearFinishReason() {
return put(FINISH_REASON, "");
}
/** Plan-Execute follow-up: clear the terminal-side plan summary so
* the next PlanGeneration pass starts clean. Identifier is the
* string literal "final_summary" to avoid a compile-time link to
* the plan sub-package from core graph state. */
public OutputBuilder clearPlanFinalSummary() {
return put("final_summary", "");
}
public OutputBuilder clearPlanDirectAnswer() {
return put("direct_answer", "");
}
/** Plan-Execute follow-up: wipe the mid-pass plan state so the next
* PlanGenerationNode pass re-derives everything from scratch. */
public OutputBuilder clearPlanId() {
return put("plan_id", null);
}
public OutputBuilder clearPlanSteps() {
return put("plan_steps", List.of());
}
public OutputBuilder clearPlanValid() {
return put("plan_valid", false);
}
public OutputBuilder clearNeedsPlanning() {
return put("needs_planning", true);
}
public OutputBuilder clearCurrentStepIndex() {
return put("current_step_index", 0);
}
public OutputBuilder clearCurrentStepTitle() {
return put("current_step_title", "");
}
public OutputBuilder clearCurrentStepResult() {
return put("current_step_result", "");
}
public OutputBuilder clearCompletedResults() {
return put("completed_results", List.of());
}
public OutputBuilder clearFinalSummaryThinking() {
return put("final_summary_thinking", "");
}
public OutputBuilder clearCurrentStepThinking() {
return put("current_step_thinking", "");
}
public Map<String, Object> build() { public Map<String, Object> build() {
return map; return map;
} }

View File

@ -168,6 +168,69 @@ public final class MateClawStateKeys {
/** Source references observed from successful tool results during this run. */ /** Source references observed from successful tool results during this run. */
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger"; public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
// ===== Persistent goal cross-turn objective lock-in =====
/**
* Active goal snapshot bound to the conversation; null when no goal.
* Injected by {@code buildInitialState} from {@code GoalService.findActiveByConversation}.
* Read by GoalEvaluationNode + its dispatcher.
*/
public static final String ACTIVE_GOAL = "active_goal";
/**
* Map snapshot of the latest evaluation pass (score/gap/decision/...).
* Written by GoalEvaluationNode; consumed by the SSE accumulator for
* the {@code goal_evaluated} event payload.
*/
public static final String GOAL_EVALUATION_RESULT = "goal_evaluation_result";
/**
* True when GoalEvaluationNode injected a follow-up prompt and the
* dispatcher should re-enter the reasoning loop (or PlanGeneration in
* the Plan-Execute graph) instead of terminating to END.
*/
public static final String GOAL_FOLLOWUP_INJECTED = "goal_followup_injected";
/**
* Follow-up user-message text to append to MESSAGES on graph re-entry.
* ReasoningNode (or PlanGenerationNode) reads this on its way in,
* appends to MESSAGES, then clears the value so the second pass
* cannot double-inject.
*/
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
/**
* Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets
* this true only when it ENDS the run (completed / exhausted / skip /
* continue-without-followup). The FinalAnswerNodeGoalEvaluation edge skips
* re-entering once it's true. The followup branch deliberately leaves it
* false so the self-continuation loop can re-evaluate the next answer; that
* loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus
* the goal's turn / LLM-call budgets.
*/
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
/**
* Number of auto-followups already injected in THIS graph run (one user
* turn). Bounds the self-continuation loop per single message independent
* of the goal's cross-turn turn_budget so one message can't drive an
* unbounded number of autonomous steps or exhaust the graph recursion
* limit. Implicitly 0 at the start of each graph invocation.
*/
public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count";
/**
* Cumulative agent LLM-call count already billed to the goal in THIS graph
* run. The run-to-completion loop evaluates multiple times per run while
* {@link #LLM_CALL_COUNT} keeps growing; recording only
* (current accounted) on each pass avoids re-billing earlier calls and
* exhausting the goal's LLM budget prematurely. Implicitly 0 at run start.
*/
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
/** Graph-node identifier for the GoalEvaluationNode. */
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
// ===== RFC-063r: ChatOrigin propagation through the StateGraph ===== // ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
/** /**
@ -179,4 +242,32 @@ public final class MateClawStateKeys {
* workspace context. * workspace context.
*/ */
public static final String CHAT_ORIGIN = "chat_origin"; public static final String CHAT_ORIGIN = "chat_origin";
// ===== Skill progressive disclosure (REPLACE strategy) =====
/**
* Names of skills explicitly loaded via the {@code load_skill} tool during
* this graph run. Stored as a {@code Set<String>} and used to pin recently
* loaded skills to the top of the runtime skill catalog so a multi-iteration
* loop stops re-loading the same skill it already pulled into message
* history. ActionNode reads the prior value and writes back the merged set
* (read-merge-write under the REPLACE strategy).
* <p>
* MUST be registered in both the ReAct and Plan-Execute KeyStrategyFactory
* blocks or the framework will drop it on multi-node merges, leaving the
* catalog ranker blind to in-run loads.
*/
public static final String LOADED_SKILLS = "loaded_skills";
/**
* Function names of extension-tier tools activated via {@code enable_tool}
* during this run. Stored as a {@code Set<String>}; ReasoningNode adds these
* back to the active tool callbacks on its next turn so an enabled extension
* tool becomes callable within the same ReAct loop. ActionNode reads the
* prior value and writes back the merged set (read-merge-write under REPLACE).
* <p>
* MUST be registered in both KeyStrategyFactory blocks (see
* {@link #LOADED_SKILLS}).
*/
public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools";
} }

View File

@ -0,0 +1,35 @@
package vip.mate.agent.progress;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* A single step inside a conversation's {@link ProgressLedger}.
*
* <p>{@code key} is the stable identifier the agent picks (e.g. {@code
* "model_gpt55"} for "research GPT-5.5" or {@code "step_pptx"} for "generate
* the slide deck"). The same key on subsequent updates overwrites the entry
* in place so the model can advance one step from {@code PENDING}
* {@code IN_PROGRESS} {@code DONE} without producing duplicates.
*
* <p>{@code note} is optional and capped at a few hundred characters when
* rendered into the snapshot; the field itself isn't length-limited because
* the underlying column is LONGTEXT and a model that wants to dump rich
* context shouldn't be silently truncated at the schema layer.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProgressEntry {
private String key;
private String label;
private ProgressStatus status;
private String note;
private Instant updatedAt;
}

View File

@ -0,0 +1,187 @@
package vip.mate.agent.progress;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Read-only view over a conversation's progress entries with a renderer that
* turns the map into a compact markdown snapshot for system-prompt injection.
*
* <p>The snapshot is grouped by status (done in-progress pending
* blocked) and stays short on purpose: the agent reads it on every turn, so
* spending more than ~200 tokens on it would defeat the very context
* pressure this ledger exists to relieve.
*/
public final class ProgressLedger {
/** Hard cap on the snapshot's note suffix so a rambling note can't bloat every turn. */
private static final int NOTE_PREVIEW_CHARS = 120;
private final Map<String, ProgressEntry> entries;
public ProgressLedger(Map<String, ProgressEntry> entries) {
this.entries = entries != null ? entries : new LinkedHashMap<>();
}
public static ProgressLedger empty() {
return new ProgressLedger(new LinkedHashMap<>());
}
public boolean isEmpty() {
return entries.isEmpty();
}
public int size() {
return entries.size();
}
public Map<String, ProgressEntry> asMap() {
return entries;
}
/**
* @return the most recent {@code updatedAt} across all entries, or empty
* when the ledger is empty / all entries lack a timestamp.
*/
public Optional<Instant> mostRecentUpdate() {
Instant max = null;
for (ProgressEntry e : entries.values()) {
Instant t = e.getUpdatedAt();
if (t != null && (max == null || t.isAfter(max))) {
max = t;
}
}
return Optional.ofNullable(max);
}
/** Iteration before which no stale reminder is ever issued — too early to judge. */
private static final int STALE_WARMUP_ITERATIONS = 10;
/** Iteration past which an empty ledger triggers a "you should register steps" reminder. */
private static final int EMPTY_LEDGER_NUDGE_ITERATIONS = 15;
/** Wall-clock gap that flips a non-empty ledger from "fresh" to "stale". */
private static final long STALE_GAP_SECONDS = 90;
/**
* Build a stale-reminder string for injection into the model's context
* when the ledger appears to be falling behind the actual reasoning
* progress. Returns {@code null} when the ledger is being maintained
* normally so the caller can skip the injection.
*
* <p>Trigger heuristics derived from round-4 of the LLM-review smoke
* test, where the model stopped calling {@code progress_update} after
* the first 30s and silently fell out of the ledger discipline:
*
* <ul>
* <li><strong>Warm-up</strong>: {@code currentIteration < 10} never
* remind, the model is still setting up the task.</li>
* <li><strong>Empty ledger</strong>: {@code currentIteration 15} and
* no entries at all likely a multi-step task being executed
* without any ledger discipline.</li>
* <li><strong>Stale updates</strong>: ledger has entries, but the
* most recent {@code updatedAt} is &gt; 90 s ago ledger is no
* longer tracking the real work.</li>
* </ul>
*
* @param currentIteration the agent's current ReAct iteration count
* @param now the reference instant for staleness ("now");
* injected for testability
*/
public String renderStaleReminder(int currentIteration, Instant now) {
if (currentIteration < STALE_WARMUP_ITERATIONS) {
return null;
}
if (entries.isEmpty()) {
if (currentIteration < EMPTY_LEDGER_NUDGE_ITERATIONS) {
return null;
}
return "## ⚠️ 进度账本是空的(已运行 " + currentIteration + " 轮)\n\n"
+ "你正在进行一个看起来需要拆解的多步任务,但还没有调用 `progress_update`。\n"
+ "**立即用一条并行 tool_calls 回复批量注册所有 pending 步骤**,否则上下文\n"
+ "窗口被裁剪后,你会忘记自己做过的工作并重复执行。";
}
Optional<Instant> lastUpdate = mostRecentUpdate();
if (lastUpdate.isEmpty()) {
return null;
}
long gap = java.time.Duration.between(lastUpdate.get(), now).getSeconds();
if (gap < STALE_GAP_SECONDS) {
return null;
}
int done = (int) entries.values().stream()
.filter(e -> e.getStatus() == ProgressStatus.DONE).count();
int inProgress = (int) entries.values().stream()
.filter(e -> e.getStatus() == ProgressStatus.IN_PROGRESS).count();
return "## ⚠️ 进度账本已 " + gap + " 秒未更新\n\n"
+ "你已运行 " + currentIteration + " 轮,但 progress_update 已经 "
+ gap + " 秒(约 " + (gap / 60) + " 分钟)没被调用过。\n"
+ "当前账本:" + done + " done / " + inProgress + " in_progress / "
+ (entries.size() - done - inProgress) + " pending。\n\n"
+ "**立即做以下一件事**(不要再 read_file 或 browser_use先更新账本\n"
+ "- 把已经完成的子步骤切到 `done`(如果你能看到工作区文件已生成)\n"
+ "- 把正在做的步骤切到 `in_progress`\n"
+ "- 有阻塞切到 `blocked` + 写明原因\n"
+ "不维护账本会导致重复工作 / 漏做项目 / 撞迭代上限。";
}
/**
* @return a compact, model-readable progress snapshot, or {@code null}
* when the ledger is empty so the caller can skip injection
* entirely (no "(empty)" placeholder noise).
*/
public String renderSnapshot() {
if (entries.isEmpty()) {
return null;
}
List<ProgressEntry> done = bucket(ProgressStatus.DONE);
List<ProgressEntry> inProgress = bucket(ProgressStatus.IN_PROGRESS);
List<ProgressEntry> pending = bucket(ProgressStatus.PENDING);
List<ProgressEntry> blocked = bucket(ProgressStatus.BLOCKED);
StringBuilder sb = new StringBuilder(256);
sb.append("## 当前任务进度(执行参考,权威记录)\n\n");
appendBucket(sb, "✅ 已完成", done);
appendBucket(sb, "🔄 进行中", inProgress);
appendBucket(sb, "⏳ 待办", pending);
appendBucket(sb, "⛔ 受阻", blocked);
sb.append("\n请基于此进度继续推进已完成的步骤不要重复执行。")
.append("完成新步骤后调用 `progress_update` 工具更新本账本。");
return sb.toString();
}
private List<ProgressEntry> bucket(ProgressStatus status) {
List<ProgressEntry> out = new ArrayList<>();
for (ProgressEntry e : entries.values()) {
if (e.getStatus() == status) {
out.add(e);
}
}
return out;
}
private void appendBucket(StringBuilder sb, String header, Collection<ProgressEntry> items) {
if (items.isEmpty()) {
return;
}
sb.append(header).append(" (").append(items.size()).append("):\n");
for (ProgressEntry e : items) {
String label = e.getLabel() != null && !e.getLabel().isBlank() ? e.getLabel() : e.getKey();
sb.append("- ").append(label).append(" [`").append(e.getKey()).append("`]");
String note = e.getNote();
if (note != null && !note.isBlank()) {
String trimmed = note.length() > NOTE_PREVIEW_CHARS
? note.substring(0, NOTE_PREVIEW_CHARS) + ""
: note;
sb.append("").append(trimmed);
}
sb.append('\n');
}
sb.append('\n');
}
}

View File

@ -0,0 +1,157 @@
package vip.mate.agent.progress;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loader / writer for the per-conversation progress ledger persisted as a
* JSON blob on {@code mate_conversation.progress_ledger} (see V100 migration).
*
* <p>The service is the only component that touches the JSON column directly.
* Callers above it work with {@link ProgressLedger} (immutable view) or plain
* {@code Map<String, ProgressEntry>}.
*
* <p>Failure mode: a malformed JSON value never throws back at the caller
* the runtime would rather render no snapshot than crash the reasoning loop
* over a corrupted ledger column. Parse failures are logged at warn level so
* the operator notices on a long-running deployment.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ProgressLedgerService {
/** Map<stepKey, ProgressEntry> — LinkedHashMap preserves insertion order in the rendered snapshot. */
private static final TypeReference<LinkedHashMap<String, ProgressEntry>> LEDGER_TYPE =
new TypeReference<>() {};
/**
* Per-conversation mutex for the load-mutate-save sequence inside
* {@link #upsert}. Without this guard, a single agent turn that issues
* N parallel {@code progress_update} tool calls (observed: 12 calls in
* one batch when the model pre-registered every step at task start)
* collapses to last-writer-wins, losing every entry but one defeating
* the whole point of the ledger. Different conversations stay
* uncontended; only intra-conversation writes serialise.
*
* <p>Entries are computed on demand and never explicitly removed; even
* with thousands of long-running conversations the map stays bounded by
* the active conversation set, and any leak is a {@code Object} per
* conversation id small enough to ignore relative to the rest of the
* per-conv state already held in memory.
*/
private final ConcurrentHashMap<String, Object> upsertLocks = new ConcurrentHashMap<>();
private final ConversationMapper conversationMapper;
private final ObjectMapper objectMapper;
/**
* @return the conversation's ledger, never null an empty map when the
* column is NULL or unparseable.
*/
public ProgressLedger load(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return ProgressLedger.empty();
}
return parse(loadLedgerJson(conversationId));
}
/**
* Read the raw JSON column for one conversation, or {@code null} when
* the row or column is empty. Protected so concurrency tests can
* subclass and back the service with an in-memory map without having
* to mock the Mybatis-Plus wrapper internals.
*/
protected String loadLedgerJson(String conversationId) {
ConversationEntity row = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.select(ConversationEntity::getProgressLedger));
return row != null ? row.getProgressLedger() : null;
}
/**
* Write the raw JSON column for one conversation. Protected for the
* same reason as {@link #loadLedgerJson}.
*/
protected void saveLedgerJson(String conversationId, String json) {
conversationMapper.update(null,
new LambdaUpdateWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.set(ConversationEntity::getProgressLedger, json));
}
/**
* Upsert one entry on the ledger atomically (load mutate save).
*
* @return the updated ledger so callers can render a fresh snapshot
* without a second DB roundtrip.
*/
public ProgressLedger upsert(String conversationId, String key, String label,
ProgressStatus status, String note) {
if (conversationId == null || conversationId.isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("step key is required");
}
if (status == null) {
throw new IllegalArgumentException("status is required");
}
// Serialise the load-mutate-save sequence per conversation. Without
// this, two parallel @Tool calls on the same conversation race: both
// read the same starting state, each adds its own entry, and the
// last save() drops the other's entry. Observed in production: a
// 12-entry pre-registration collapsed to 8 because four sibling
// tool calls landed in the same window.
Object mutex = upsertLocks.computeIfAbsent(conversationId, k -> new Object());
synchronized (mutex) {
ProgressLedger ledger = load(conversationId);
Map<String, ProgressEntry> map = ledger.asMap();
ProgressEntry existing = map.get(key);
String effectiveLabel = (label != null && !label.isBlank())
? label
: (existing != null ? existing.getLabel() : key);
map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now()));
persist(conversationId, map);
return new ProgressLedger(map);
}
}
private ProgressLedger parse(String json) {
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return ProgressLedger.empty();
}
try {
LinkedHashMap<String, ProgressEntry> map = objectMapper.readValue(json, LEDGER_TYPE);
return new ProgressLedger(map);
} catch (Exception e) {
log.warn("Failed to parse progress ledger JSON, treating as empty: {}", e.getMessage());
return ProgressLedger.empty();
}
}
private void persist(String conversationId, Map<String, ProgressEntry> map) {
try {
String json = objectMapper.writeValueAsString(map);
saveLedgerJson(conversationId, json);
} catch (Exception e) {
// Surface to caller so the tool can return an error message to
// the LLM rather than silently dropping the update.
throw new IllegalStateException(
"Failed to persist progress ledger for " + conversationId + ": " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,52 @@
package vip.mate.agent.progress;
import java.util.Locale;
/**
* Status of a single step in the conversation-scoped progress ledger.
*
* <p>Kept deliberately small four states cover the workflow patterns we
* see in long multi-step agent tasks (research one item at a time, draft a
* document section by section, etc.) without inviting bikeshedding on
* intermediate states. The wire form is the lowercase enum name; the tool's
* {@code status} parameter accepts case-insensitive input.
*/
public enum ProgressStatus {
/** Step is known to be needed but not yet started. */
PENDING,
/** Currently being worked on. */
IN_PROGRESS,
/** Finished and verified by the agent. */
DONE,
/** Cannot continue — note must explain why so the user / next pass can intervene. */
BLOCKED;
public String wireValue() {
return name().toLowerCase(Locale.ROOT);
}
/**
* Parse a model-supplied status string. Tolerates case differences,
* hyphens, and spaces (the model often writes "in progress" or
* "in-progress" both map to {@link #IN_PROGRESS}).
*
* @return the matching status, or {@code null} when no match is found so
* the caller can surface a structured error back to the LLM.
*/
public static ProgressStatus parse(String raw) {
if (raw == null) {
return null;
}
String normalised = raw.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_');
for (ProgressStatus s : values()) {
if (s.name().equals(normalised)) {
return s;
}
}
return null;
}
}

View File

@ -19,7 +19,7 @@ import java.util.stream.Collectors;
/** /**
* Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry}) * Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry})
* with agent metadata so the admin Backstage UI can render one card per * with agent metadata so the admin Live view can render one card per
* working agent without making the frontend traverse three independent * working agent without making the frontend traverse three independent
* services. * services.
* *
@ -84,6 +84,9 @@ public class AgentRuntimeAggregator {
String subagentId, String subagentId,
String parentConversationId, String parentConversationId,
String childConversationId, String childConversationId,
String rootConversationId,
String parentSubagentId,
int depth,
Long agentId, Long agentId,
String agentName, String agentName,
String agentIcon, String agentIcon,
@ -186,6 +189,9 @@ public class AgentRuntimeAggregator {
rec.subagentId(), rec.subagentId(),
rec.parentConversationId(), rec.parentConversationId(),
rec.childConversationId(), rec.childConversationId(),
rec.rootConversationId(),
rec.parentSubagentId(),
rec.depth(),
rec.agentId(), rec.agentId(),
ag == null ? null : ag.getName(), ag == null ? null : ag.getName(),
ag == null ? null : ag.getIcon(), ag == null ? null : ag.getIcon(),

View File

@ -18,16 +18,17 @@ import vip.mate.workspace.conversation.ConversationService;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
/** /**
* Admin-only Backstage surface: the global view of every in-flight agent * Admin-only live runtime surface: the global view of every in-flight agent
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck * turn plus the controls to friendly-stop, force-recycle, or sweep stuck
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation * runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
* owner-scoped this controller is intentionally cross-tenant for the * owner-scoped this controller is intentionally cross-tenant for the
* operator role. * operator role.
*/ */
@Slf4j @Slf4j
@Tag(name = "Agent Runtime (Backstage)") @Tag(name = "Agent Runtime (Live)")
@RestController @RestController
@RequestMapping("/api/v1/admin/agent-runtime") @RequestMapping("/api/v1/admin/agent-runtime")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -42,6 +43,7 @@ public class AgentRuntimeController {
@Operation(summary = "Snapshot of every in-flight agent turn") @Operation(summary = "Snapshot of every in-flight agent turn")
@GetMapping("/snapshot") @GetMapping("/snapshot")
@RequireGlobalAdmin
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) { public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
return R.ok(aggregator.snapshot()); return R.ok(aggregator.snapshot());
@ -49,6 +51,7 @@ public class AgentRuntimeController {
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint") @Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
@PostMapping("/runs/{conversationId}/stop") @PostMapping("/runs/{conversationId}/stop")
@RequireGlobalAdmin
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId, public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
Authentication auth) { Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
@ -59,6 +62,7 @@ public class AgentRuntimeController {
@Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored") @Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored")
@PostMapping("/runs/{conversationId}/recycle") @PostMapping("/runs/{conversationId}/recycle")
@RequireGlobalAdmin
public R<Map<String, Object>> recycle(@PathVariable String conversationId, public R<Map<String, Object>> recycle(@PathVariable String conversationId,
Authentication auth) { Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
@ -72,6 +76,7 @@ public class AgentRuntimeController {
@Operation(summary = "Interrupt one sub-agent (admin override of ownership check)") @Operation(summary = "Interrupt one sub-agent (admin override of ownership check)")
@PostMapping("/subagents/{subagentId}/interrupt") @PostMapping("/subagents/{subagentId}/interrupt")
@RequireGlobalAdmin
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId, public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
Authentication auth) { Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
@ -87,6 +92,7 @@ public class AgentRuntimeController {
*/ */
@Operation(summary = "Recycle every run currently flagged as stuck") @Operation(summary = "Recycle every run currently flagged as stuck")
@PostMapping("/sweep") @PostMapping("/sweep")
@RequireGlobalAdmin
public R<Map<String, Object>> sweep(Authentication auth) { public R<Map<String, Object>> sweep(Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(); AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();

View File

@ -525,6 +525,90 @@ public class ApprovalWorkflowService implements ApplicationRunner {
} }
} }
// ==================== Global pending query (admin/notification surface) ====================
/**
* Default cap for {@link #listPendingFromDb(int)} when callers don't specify
* one, so a runaway pending table cannot drown the notification panel.
*/
public static final int DEFAULT_PENDING_LIST_LIMIT = 200;
/**
* Hard ceiling regardless of caller-requested limit.
*/
public static final int MAX_PENDING_LIST_LIMIT = 500;
/**
* Return every {@code PENDING} approval row, newest first, capped at {@code limit}.
* Reads from {@code mate_tool_approval} directly so restart / recovery edge
* cases cannot leave the in-memory map and the DB out of sync from a caller's
* perspective.
*
* <p>Payload shape matches {@link ApprovalService#getPendingByConversation},
* so the same frontend renderer can consume both surfaces.
*/
public List<Map<String, Object>> listPendingFromDb(int limit) {
int effectiveLimit = limit <= 0 ? DEFAULT_PENDING_LIST_LIMIT
: Math.min(limit, MAX_PENDING_LIST_LIMIT);
List<ToolApprovalEntity> rows;
try {
rows = approvalMapper.selectList(
new LambdaQueryWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getStatus, "PENDING")
.orderByDesc(ToolApprovalEntity::getCreatedAt)
.last("LIMIT " + effectiveLimit)
);
} catch (Exception e) {
log.warn("[ApprovalWorkflow] listPendingFromDb failed: {}", e.getMessage());
return List.of();
}
return rows.stream().map(this::toPendingPayload).toList();
}
/**
* Count of pending approvals in {@code mate_tool_approval}. Used by the
* notification summary endpoint; cheap enough to call on every poll.
*/
public long countPendingFromDb() {
try {
Long n = approvalMapper.selectCount(
new LambdaQueryWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getStatus, "PENDING")
);
return n == null ? 0L : n;
} catch (Exception e) {
log.warn("[ApprovalWorkflow] countPendingFromDb failed: {}", e.getMessage());
return 0L;
}
}
private Map<String, Object> toPendingPayload(ToolApprovalEntity entity) {
java.util.LinkedHashMap<String, Object> entry = new java.util.LinkedHashMap<>();
entry.put("pendingId", entity.getPendingId());
entry.put("conversationId", entity.getConversationId());
entry.put("agentId", entity.getAgentId());
entry.put("toolName", entity.getToolName());
entry.put("toolArguments", entity.getToolArguments() != null ? entity.getToolArguments() : "");
entry.put("status", "pending");
entry.put("createdAt", entity.getCreatedAt() != null ? entity.getCreatedAt().toString() : null);
if (entity.getFindingsJson() != null) {
entry.put("findingsJson", entity.getFindingsJson());
}
if (entity.getMaxSeverity() != null) {
entry.put("maxSeverity", entity.getMaxSeverity());
}
if (entity.getSummary() != null) {
entry.put("summary", entity.getSummary());
}
if (entity.getChannelType() != null) {
entry.put("channelType", entity.getChannelType());
}
if (entity.getRequesterName() != null) {
entry.put("requesterName", entity.getRequesterName());
}
return entry;
}
// ---------- shared two-phase machinery ---------- // ---------- shared two-phase machinery ----------
private ResolveOutcome performResolve(String pendingId, String userId, private ResolveOutcome performResolve(String pendingId, String userId,

View File

@ -11,6 +11,7 @@ import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService; import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import java.util.List; import java.util.List;
@ -35,12 +36,14 @@ public class AuthController {
@Operation(summary = "获取用户列表") @Operation(summary = "获取用户列表")
@GetMapping("/users") @GetMapping("/users")
@RequireGlobalAdmin
public R<List<UserEntity>> listUsers() { public R<List<UserEntity>> listUsers() {
return R.ok(authService.listUsers()); return R.ok(authService.listUsers());
} }
@Operation(summary = "创建用户") @Operation(summary = "创建用户")
@PostMapping("/users") @PostMapping("/users")
@RequireGlobalAdmin
public R<UserEntity> createUser(@RequestBody UserEntity user) { public R<UserEntity> createUser(@RequestBody UserEntity user) {
return R.ok(authService.createUser(user)); return R.ok(authService.createUser(user));
} }

View File

@ -1,6 +1,7 @@
package vip.mate.auth.model; package vip.mate.auth.model;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -20,7 +21,12 @@ public class UserEntity {
/** 用户名 */ /** 用户名 */
private String username; private String username;
/** 密码BCrypt加密 */ /**
* 密码BCrypt加密WRITE_ONLY: accepted from request bodies (login / user
* creation) but never serialized into a response, so the bcrypt hash cannot
* leak via endpoints that return UserEntity (e.g. GET /auth/users).
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; private String password;
/** 昵称 */ /** 昵称 */

View File

@ -417,7 +417,10 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
* - dm_policy / group_policy控制私聊/群聊是否开放 * - dm_policy / group_policy控制私聊/群聊是否开放
* - allow_from用户白名单 * - allow_from用户白名单
* - deny_message拒绝时的提示消息 * - deny_message拒绝时的提示消息
* - require_mention群聊中是否需要 @机器人 * <p>
* Note: {@code require_mention} is honored by individual channel adapters
* (Feishu reads the SDK's mentions field, etc.) rather than at this layer,
* because reliable mention detection is platform-specific.
*/ */
protected boolean checkAccess(ChannelMessage message) { protected boolean checkAccess(ChannelMessage message) {
boolean isDM = isDirectMessage(message); boolean isDM = isDirectMessage(message);
@ -433,18 +436,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
return false; return false;
} }
// 2. 群聊中检查 require_mention需要 @机器人才响应 // 2. 检查 allow_from 白名单
// 如果已设置 botPrefixshouldProcess() 已处理此处处理 configJson 中的 require_mention
if (!isDM && getConfigBoolean("require_mention", false)) {
String botPrefix = channelEntity.getBotPrefix();
if (botPrefix == null || botPrefix.isBlank()) {
// 设置了 require_mention 但没有 botPrefix无法判断 mention放行
log.debug("[{}] require_mention=true but no botPrefix configured, allowing", getChannelType());
}
// 如果有 botPrefixshouldProcess() 已经过滤过非 mention 消息此处放行
}
// 3. 检查 allow_from 白名单
List<String> allowFrom = getConfigList("allow_from"); List<String> allowFrom = getConfigList("allow_from");
if (!allowFrom.isEmpty()) { if (!allowFrom.isEmpty()) {
if (!allowFrom.contains(message.getSenderId())) { if (!allowFrom.contains(message.getSenderId())) {

View File

@ -142,6 +142,24 @@ public interface ChannelAdapter {
vip.mate.channel.notification.ApprovalNotificationService.staticBuildText(notice)); vip.mate.channel.notification.ApprovalNotificationService.staticBuildText(notice));
} }
/**
* Does this adapter deliver approval decisions through an
* interactive card (button click card.action callback) rather
* than the text-command flow ({@code /approve <id>} / {@code /deny <id>})?
*
* <p>Controls {@code ChannelMessageRouter}'s "non-approval message →
* auto-cancel pending" heuristic. The heuristic was designed for
* the text flow where the user is expected to type
* {@code /approve} and anything else is an implicit "I changed my
* mind". With interactive cards the user clicks a button, and
* unrelated chat messages during the wait window must NOT
* auto-cancel the pending. Default false (text flow); WeCom +
* Feishu (when card dispatcher is wired) override to true.
*/
default boolean usesInteractiveApprovalCards() {
return false;
}
// ==================== 主动推送 ==================== // ==================== 主动推送 ====================
/** /**
@ -257,4 +275,24 @@ public interface ChannelAdapter {
? ChannelHealth.up(getChannelType(), null, java.time.Instant.now()) ? ChannelHealth.up(getChannelType(), null, java.time.Instant.now())
: ChannelHealth.outOfService(getChannelType(), null); : ChannelHealth.outOfService(getChannelType(), null);
} }
// ==================== Lifecycle hooks ====================
/**
* Fires after the router has successfully delivered the agent's reply
* for the given inbound message. Channels that want to acknowledge
* completion (e.g. Feishu adds a reaction on the user's original
* message) override this; the default is a no-op so the router can
* call it unconditionally without checking adapter type.
*
* <p>Called only on the happy path error replies, approval-pending
* branches, and stream exceptions skip this hook.
*
* <p>Implementations MUST be cheap and non-blocking; they run on the
* router's processing thread. Use a background thread for any
* platform API call.
*/
default void onAgentCompleted(ChannelMessage inboundMessage) {
// no-op; opt-in per adapter
}
} }

View File

@ -37,7 +37,13 @@ public class ChannelChatOriginFactory {
/* workspaceId */ channel.getWorkspaceId(), /* workspaceId */ channel.getWorkspaceId(),
/* workspaceBasePath */ workspaceBasePath, /* workspaceBasePath */ workspaceBasePath,
/* channelId */ channel.getId(), /* channelId */ channel.getId(),
/* channelTarget */ target); /* channelTarget */ target,
/* cronOrigin */ false,
/* senderName */ message.getSenderName(),
/* channelType */ message.getChannelType() != null
? message.getChannelType()
: channel.getChannelType(),
/* chatId */ message.getChatId());
} }
/** /**

View File

@ -75,6 +75,57 @@ public class ChannelManager {
*/ */
private final vip.mate.channel.wecom.WeComKeepaliveScheduler weComKeepaliveScheduler; private final vip.mate.channel.wecom.WeComKeepaliveScheduler weComKeepaliveScheduler;
/**
* Feishu SDK-backed media uploader. Wired into
* {@link vip.mate.channel.feishu.FeishuChannelAdapter} so every
* outbound image / file / audio / video flows through
* {@code oapi-sdk} multipart and the per-platform size policy
* instead of hand-rolled HTTP.
*/
private final vip.mate.channel.feishu.FeishuMediaUploader feishuMediaUploader;
/**
* Channel-shared scrubber that converts agent-emitted
* {@code /api/v1/files/generated/{id}} URLs into native channel
* attachments. Same instance is also injected into WeCom in a
* follow-up patch; today only Feishu consumes it.
*/
private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber;
/**
* Feishu CardKit streaming-card manager. Drives
* {@link vip.mate.channel.feishu.FeishuChannelAdapter}'s
* {@code processStream} so the receiver sees text appearing
* character-by-character instead of waiting for the full reply.
*/
private final vip.mate.channel.feishu.FeishuStreamingCardManager feishuStreamingCardManager;
/**
* Feishu interactive-card dispatcher. Drives
* {@code FeishuChannelAdapter.sendApprovalNotice} (button-card render)
* and routes inbound {@code P2CardActionTrigger} events to the right
* card kind's handler (e.g. tool-guard approve / deny).
*/
private final vip.mate.channel.feishu.cards.FeishuCardDispatcher feishuCardDispatcher;
/**
* Per-channelId SDK {@link com.lark.oapi.Client} cache. Injected into
* {@link vip.mate.channel.feishu.FeishuChannelAdapter} so inbound
* file/image/audio/video downloads go through
* {@code client.im().v1().messageResource().get(...)} instead of the
* legacy hand-rolled HTTP path token refresh, retries, and domain
* switching are then handled by the SDK.
*/
private final vip.mate.channel.feishu.FeishuClientFactory feishuClientFactory;
/**
* Speech-to-text service used by the Feishu adapter to transcribe
* inbound voice messages. WeCom and DingTalk get ASR text directly
* from their webhooks; Feishu does not, so the adapter has to call
* STT itself before the agent can reason about the message.
*/
private final vip.mate.stt.SttService sttService;
/** /**
* Distributed leader election. Channels whose adapter reports * Distributed leader election. Channels whose adapter reports
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so * {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
@ -1140,7 +1191,9 @@ public class ChannelManager {
return switch (type) { return switch (type) {
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper); case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper); case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,

View File

@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.ResolveOutcome; import vip.mate.approval.ResolveOutcome;
import vip.mate.approval.PendingApproval; import vip.mate.approval.PendingApproval;
@ -487,6 +488,47 @@ public class ChannelMessageRouter {
return null; return null;
} }
/**
* Identity gate shared by /approve and /deny (group-chat safety): only the
* original human requester may resolve a pending. Agent/cron ("system") and
* unattributed (null) approvals are fail-closed in IM any group member
* could otherwise approve OR deny/cancel a guarded action and must be
* handled from the admin console. Sends the rejection notice + logs and
* returns {@code false} when the caller is not authorized.
*/
private boolean approvalResolveAuthorized(PendingApproval pending, ChannelMessage message,
ChannelAdapter adapter, String replyTarget) {
String originalRequester = pending.getUserId();
boolean systemOriginated = originalRequester == null || "system".equals(originalRequester);
if (systemOriginated || !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, systemOriginated
? "⚠️ 该审批由系统/定时任务发起,请在管理端处理。"
: "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval resolve rejected: sender={} != requester={} (systemOriginated={})",
adapter.getChannelType(), message.getSenderId(), originalRequester, systemOriginated);
return false;
}
return true;
}
/**
* When an /approve or /deny command carries an explicit short pendingId
* (e.g. "/deny a1b2c3"), verify it matches the conversation's current
* pending before resolving otherwise a stale or copy-pasted id would
* silently act on the wrong pending. Sends the mismatch notice and returns
* {@code true} (caller must abort) when the ids don't line up.
*/
private boolean pendingIdMismatch(String userText, PendingApproval pending,
ChannelAdapter adapter, String replyTarget) {
String shortId = extractShortId(userText);
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
return true;
}
return false;
}
// ==================== 消息处理 route 逻辑 + 审批拦截层 ==================== // ==================== 消息处理 route 逻辑 + 审批拦截层 ====================
/** /**
@ -509,20 +551,12 @@ public class ChannelMessageRouter {
String replyTarget = resolveReplyTarget(message); String replyTarget = resolveReplyTarget(message);
if (isApproveCommand(userText)) { if (isApproveCommand(userText)) {
// pendingId 校验如果命令包含 shortId验证是否匹配当前 pending // pendingId 校验approve / deny 共用命令带 shortId 时必须匹配当前 pending
String shortId = extractShortId(userText); if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
return; return;
} }
// 身份校验只有原始请求者可以审批群聊安全 // 身份校验approve / deny 共用同一道门禁群聊安全 + system/null fail-closed
String originalRequester = pending.getUserId(); if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
if (originalRequester != null && !"system".equals(originalRequester)
&& !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval rejected: sender={} != requester={}",
adapter.getChannelType(), message.getSenderId(), originalRequester);
return; return;
} }
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically. // Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
@ -541,9 +575,24 @@ public class ChannelMessageRouter {
return; return;
} else if (isDenyCommand(userText)) { } else if (isDenyCommand(userText)) {
// pendingId 校验 approve 一致命令带 shortId 时必须匹配当前 pending
// 否则 /deny <其它ID> 会错误地拒绝当前 conversation pending
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
return;
}
// 身份校验deny approve 共用门禁否则群里任意成员可拒绝/取消他人的
// pendingsystem/null 发起的审批也会被任意人 deny取消审批 placeholder
// 写入 denied 状态这类审批改到管理端处理
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
return;
}
// Deny via IM: workflow.resolve owns the full state-machine transition. // Deny via IM: workflow.resolve owns the full state-machine transition.
ResolveOutcome denyOutcome = approvalService.resolve( ResolveOutcome denyOutcome = approvalService.resolve(
pending.getPendingId(), message.getSenderId(), "denied"); pending.getPendingId(), message.getSenderId(), "denied");
if (denyOutcome.isAlreadyResolved()) {
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
return;
}
conversationService.removeApprovalPlaceholders(conversationId); conversationService.removeApprovalPlaceholders(conversationId);
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName(); String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
persistAndBroadcastApprovalHint(conversationId, denyHint, persistAndBroadcastApprovalHint(conversationId, denyHint,
@ -554,8 +603,23 @@ public class ChannelMessageRouter {
denyOutcome.messagesRewritten()); denyOutcome.messagesRewritten());
return; return;
} else if (adapter.usesInteractiveApprovalCards()) {
// Channel approves via button-clicks on an interactive
// card, NOT via /approve text. A casual follow-up
// message from the user during the wait window MUST
// NOT auto-cancel the pending the button click is
// the canonical decision path. Treat the new message
// as a fresh turn; the pending stays alive until the
// user clicks Approve / Deny, the GC TTL expires, or
// the workflow explicitly resolves it.
log.info("[{}] Non-approval message while pending exists; channel uses card buttons so NOT auto-cancelling pendingId={}",
adapter.getChannelType(), pending.getPendingId());
// Fall through to process the new message normally.
} else { } else {
// Non-approval message while a pending exists treat as implicit deny. // Non-approval message while a pending exists treat as implicit deny.
// Text-command channels rely on this: the user is told
// "type /approve <id>" and anything else is an implicit
// change of mind.
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied"); approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId); conversationService.removeApprovalPlaceholders(conversationId);
String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。"; String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。";
@ -569,8 +633,40 @@ public class ChannelMessageRouter {
} }
// ======= 审批拦截层结束 ======= // ======= 审批拦截层结束 =======
// 确保会话存在workspace 感知 // Ensure the conversation exists, seeded with the agent's
conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId()); // currently-configured default model so per-conversation model
// selection works for IM channels too (issue #183).
//
// Two-part behaviour, both inside getOrCreateSharedConversation:
// 1. Brand-new conversation write defaultModelName so the
// very first turn picks the right model; user can later
// switch via the admin UI (updateConversationModel) and the
// override sticks.
// 2. Pre-existing conversation with model still null (legacy
// rows created before #183 fix) backfill once, then leave
// alone. Already-pinned conversations are never overwritten.
//
// We pass provider=null because AgentEntity doesn't carry a
// provider field the downstream ProviderChatModelFactory
// resolves provider from the model name. The seed logic in
// ConversationService treats (null, name) as no-seed (both
// fields must be non-blank to take effect), which is the
// correct defensive behaviour: we only pin when we have a
// complete (provider, model) pair from the admin UI.
String agentDefaultModel = null;
try {
AgentEntity agentEntity = agentService.getAgent(agentId);
agentDefaultModel = agentEntity.getModelName();
} catch (Exception e) {
// Agent deleted / disabled mid-flight don't block message
// intake. Downstream agentService.chatStructuredStream will
// surface the real error to the user.
log.debug("[{}] Could not load agent {} for model-seed lookup: {}",
adapter.getChannelType(), agentId, e.getMessage());
}
conversationService.getOrCreateSharedConversation(
conversationId, agentId, channelEntity.getWorkspaceId(),
null, agentDefaultModel);
// 更新渠道会话存储用于主动推送 // 更新渠道会话存储用于主动推送
String replyTarget = resolveReplyTarget(message); String replyTarget = resolveReplyTarget(message);
@ -696,6 +792,15 @@ public class ChannelMessageRouter {
// 语音回复异步 TTS 合成并追加发送先文本后语音不阻塞 // 语音回复异步 TTS 合成并追加发送先文本后语音不阻塞
maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity); maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity);
// Per-channel completion ack (e.g. Feishu reaction).
// No-op for adapters that haven't overridden the hook.
try {
adapter.onAgentCompleted(message);
} catch (Exception hookErr) {
log.debug("[{}] onAgentCompleted hook failed (non-fatal): {}",
adapter.getChannelType(), hookErr.getMessage());
}
} }
} }
} finally { } finally {
@ -813,6 +918,12 @@ public class ChannelMessageRouter {
maybeGenerateVoiceReply(message, streamingAdapter, replyTarget, maybeGenerateVoiceReply(message, streamingAdapter, replyTarget,
conversationId, finalContent, channelEntity); conversationId, finalContent, channelEntity);
} }
try {
streamingAdapter.onAgentCompleted(message);
} catch (Exception hookErr) {
log.debug("[{}] onAgentCompleted hook failed (non-fatal): {}",
channelType, hookErr.getMessage());
}
return saved != null ? saved.getId() : null; return saved != null ? saved.getId() : null;
} }

View File

@ -0,0 +1,17 @@
package vip.mate.channel.cards;
/**
* Thrown when a channel-specific card payload would exceed a platform-
* imposed size limit (e.g. WeCom's 1024-byte {@code button.key},
* Feishu's 30 KB interactive content cap).
*
* <p>Caught by adapters so they can fall back to the
* {@code AbstractChannelAdapter} text-approval path instead of letting
* the whole approval flow drop. Lives in the generic {@code channel/cards}
* package so every channel implementation shares one type.
*/
public class CardOversizedException extends RuntimeException {
public CardOversizedException(String message) {
super(message);
}
}

View File

@ -43,7 +43,7 @@ public class ChannelController {
private final ChannelVerifierRegistry verifierRegistry; private final ChannelVerifierRegistry verifierRegistry;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("admin")
@Operation(summary = "获取渠道列表") @Operation(summary = "获取渠道列表")
@GetMapping @GetMapping
public R<List<ChannelEntity>> list( public R<List<ChannelEntity>> list(
@ -52,7 +52,7 @@ public class ChannelController {
return R.ok(channelService.listChannelsByWorkspace(wsId)); return R.ok(channelService.listChannelsByWorkspace(wsId));
} }
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("admin")
@Operation(summary = "按类型获取渠道列表") @Operation(summary = "按类型获取渠道列表")
@GetMapping("/type/{channelType}") @GetMapping("/type/{channelType}")
public R<List<ChannelEntity>> listByType(@PathVariable String channelType, public R<List<ChannelEntity>> listByType(@PathVariable String channelType,
@ -61,7 +61,7 @@ public class ChannelController {
return R.ok(channelService.listChannelsByTypeAndWorkspace(channelType, wsId)); return R.ok(channelService.listChannelsByTypeAndWorkspace(channelType, wsId));
} }
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("admin")
@Operation(summary = "获取渠道详情") @Operation(summary = "获取渠道详情")
@GetMapping("/{id}") @GetMapping("/{id}")
public R<ChannelEntity> get(@PathVariable Long id, public R<ChannelEntity> get(@PathVariable Long id,
@ -140,7 +140,7 @@ public class ChannelController {
return R.ok(channelManager.getStatus()); return R.ok(channelManager.getStatus());
} }
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("admin")
@Operation(summary = "获取指定渠道的实时健康状态(真连接状态,前端绿点应该绑这个)") @Operation(summary = "获取指定渠道的实时健康状态(真连接状态,前端绿点应该绑这个)")
@GetMapping("/{id}/health") @GetMapping("/{id}/health")
public R<Map<String, Object>> health(@PathVariable Long id, public R<Map<String, Object>> health(@PathVariable Long id,
@ -236,7 +236,7 @@ public class ChannelController {
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) { private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) { if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区"); throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
} }
} }
} }

View File

@ -0,0 +1,214 @@
package vip.mate.channel.feishu;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class FeishuCardFormatter {
enum ContentFormat { JSON, MARKDOWN, LONG_TEXT, PLAIN_TEXT }
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int JSON_MAX_LEN = 32_000;
private static final Pattern HEADER = Pattern.compile("(?m)^#{1,6}\\s");
private static final Pattern TABLE_SEP = Pattern.compile("(?m)^\\|[\\s|:-]+\\|\\s*$");
private static final Pattern JSON_CODE_BLOCK =
Pattern.compile("(?s)```(?:json)?\\s*([\\[{][\\s\\S]*?[\\]}])\\s*```");
private FeishuCardFormatter() {}
static ContentFormat detect(String content) {
if (content == null || content.isBlank()) return ContentFormat.PLAIN_TEXT;
String s = content.trim();
if ((s.startsWith("{") || s.startsWith("[")) && s.length() <= JSON_MAX_LEN) {
try {
JsonNode node = MAPPER.readTree(s);
if (node.isObject() && !node.isEmpty()) return ContentFormat.JSON;
if (node.isArray() && node.size() > 0 && node.get(0).isObject()) return ContentFormat.JSON;
} catch (Exception ignored) {}
}
if (s.contains("```")) {
Matcher cm = JSON_CODE_BLOCK.matcher(s);
while (cm.find()) {
String extracted = cm.group(1).strip();
if (extracted.length() <= JSON_MAX_LEN) {
try {
JsonNode node = MAPPER.readTree(extracted);
if (node.isObject() && !node.isEmpty()) return ContentFormat.JSON;
if (node.isArray() && node.size() > 0 && node.get(0).isObject()) return ContentFormat.JSON;
} catch (Exception ignored) {}
}
}
return ContentFormat.MARKDOWN;
}
if (HEADER.matcher(s).find()) return ContentFormat.MARKDOWN;
if (TABLE_SEP.matcher(s).find()) return ContentFormat.MARKDOWN;
if (bulletCount(s) >= 2) return ContentFormat.MARKDOWN;
if (s.length() > 300 && s.contains("\n\n")) return ContentFormat.LONG_TEXT;
return ContentFormat.PLAIN_TEXT;
}
private static long bulletCount(String s) {
return s.lines()
.filter(line -> {
String t = line.stripLeading();
return t.startsWith("- ") || t.startsWith("* ")
|| t.matches("^\\d+\\.\\s.*");
})
.count();
}
// ==================== 渲染层 ====================
/** Default markdown card header used when the channel doesn't override it. */
static final String DEFAULT_MARKDOWN_HEADER = "AI 助手";
static Map<String, Object> render(String content, ContentFormat format) {
return render(content, format, DEFAULT_MARKDOWN_HEADER);
}
/**
* Renders the card for the given content/format pair. {@code markdownHeader}
* controls the title shown above markdown cards pass null or blank to
* suppress the header entirely. JSON and long-text/plain-text layouts ignore
* the header (they have never carried one).
*/
static Map<String, Object> render(String content, ContentFormat format, String markdownHeader) {
return switch (format) {
case JSON -> renderJson(content);
case MARKDOWN -> renderMarkdown(content, markdownHeader);
case LONG_TEXT, PLAIN_TEXT -> renderLongText(content);
};
}
private static Map<String, Object> renderMarkdown(String content, String headerText) {
Map<String, Object> header = (headerText == null || headerText.isBlank())
? null
: Map.of("title", Map.of("tag", "plain_text", "content", headerText));
return cardOf(
header,
List.of(Map.of(
"tag", "div",
"text", Map.of("tag", "lark_md", "content", content)
))
);
}
private static Map<String, Object> renderLongText(String content) {
return cardOf(
null,
List.of(Map.of(
"tag", "div",
"text", Map.of("tag", "plain_text", "content", content)
))
);
}
private static Map<String, Object> renderJson(String content) {
try {
JsonNode node = MAPPER.readTree(content);
if (node.isObject()) return renderJsonObject(node);
if (node.isArray()) return renderJsonArray(node);
} catch (Exception ignored) {}
Matcher rm = JSON_CODE_BLOCK.matcher(content);
while (rm.find()) {
String extracted = rm.group(1).strip();
try {
JsonNode node = MAPPER.readTree(extracted);
if (node.isObject()) return renderJsonObject(node);
if (node.isArray()) return renderJsonArray(node);
} catch (Exception ignored) {}
}
return renderLongText(content);
}
private static Map<String, Object> renderJsonObject(JsonNode node) {
List<Object> elements = new ArrayList<>();
node.fields().forEachRemaining(entry -> {
String key = entry.getKey();
String value = entry.getValue().isTextual()
? entry.getValue().asText()
: entry.getValue().toString();
elements.add(Map.of(
"tag", "column_set",
"flex_mode", "none",
"columns", List.of(
Map.of("tag", "column", "width", "weighted", "weight", 1,
"elements", List.of(Map.of("tag", "div",
"text", Map.of("tag", "plain_text", "content", key)))),
Map.of("tag", "column", "width", "weighted", "weight", 2,
"elements", List.of(Map.of("tag", "div",
"text", Map.of("tag", "plain_text", "content", value))))
)
));
});
return cardOf(null, elements);
}
private static Map<String, Object> renderJsonArray(JsonNode array) {
JsonNode first = array.get(0);
List<String> fields = new ArrayList<>();
first.fieldNames().forEachRemaining(fields::add);
return fields.size() <= 4
? renderJsonTable(array, fields)
: renderJsonList(array);
}
private static Map<String, Object> renderJsonTable(JsonNode array, List<String> fields) {
List<Map<String, Object>> columns = fields.stream()
.<Map<String, Object>>map(name -> Map.of("name", name, "display_name", name))
.toList();
List<Map<String, Object>> rows = new ArrayList<>();
for (JsonNode item : array) {
Map<String, Object> row = new LinkedHashMap<>();
for (String field : fields) {
JsonNode val = item.get(field);
row.put(field, val == null ? "" : (val.isTextual() ? val.asText() : val.toString()));
}
rows.add(row);
}
Map<String, Object> table = new LinkedHashMap<>();
table.put("tag", "table");
table.put("columns", columns);
table.put("rows", rows);
table.put("page_size", 10);
table.put("row_height", "low");
return cardOf(null, List.of(table));
}
private static Map<String, Object> renderJsonList(JsonNode array) {
List<Object> elements = new ArrayList<>();
for (JsonNode item : array) {
StringBuilder sb = new StringBuilder();
item.fields().forEachRemaining(e -> {
String val = e.getValue().isTextual() ? e.getValue().asText() : e.getValue().toString();
sb.append("**").append(e.getKey()).append("**: ").append(val).append("\n");
});
elements.add(Map.of(
"tag", "div",
"text", Map.of("tag", "lark_md", "content", sb.toString().trim())
));
}
return cardOf(null, elements);
}
private static Map<String, Object> cardOf(Map<String, Object> header, List<?> elements) {
Map<String, Object> card = new LinkedHashMap<>();
card.put("schema", "2.0");
card.put("config", Map.of("wide_screen_mode", true));
if (header != null) card.put("header", header);
card.put("body", Map.of("elements", elements));
return card;
}
}

View File

@ -0,0 +1,160 @@
package vip.mate.channel.feishu;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lark.oapi.Client;
import com.lark.oapi.core.enums.BaseUrlEnum;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Per-channelId cache of {@link Client} instances backed by Feishu's
* official {@code oapi-sdk}. The SDK manages the
* {@code tenant_access_token} lifecycle internally callers never
* touch tokens.
*
* <p><b>Invalidation</b>: when a Feishu channel row is mutated
* (credential rotation, domain switch, deletion), {@code ChannelService}
* calls {@link #evict(Long)} to drop the stale client; the next
* {@link #client(Long)} call rebuilds from current config.
*
* <p>Each cache entry also carries a {@code fingerprint} (appId +
* secret hash + domain). A subtle in-place edit that misses the
* eviction hook is still caught on next lookup: the fingerprint
* mismatch forces a rebuild.
*
* <p>Why a dedicated factory rather than building on the adapter's
* existing hand-rolled HTTP path: the SDK handles multipart uploads,
* CardKit streaming, contact lookups, calendar/docx, reactions, and
* message updates uniformly the adapter's hand-rolled
* {@code HttpClient} only covers basic message send. Every new send
* path in this codebase should go through this factory and the SDK.
* The adapter's pre-existing hand-rolled paths stay untouched
* (surgical principle) two token caches coexisting is a negligible
* memory cost.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FeishuClientFactory {
private static final String CHANNEL_TYPE = "feishu";
private final ChannelMapper channelMapper;
private final ObjectMapper objectMapper;
private final ConcurrentHashMap<Long, Cached> cache = new ConcurrentHashMap<>();
/** Cache entry — fingerprint guards against missed-eviction in-place edits. */
private record Cached(String fingerprint, Client client) {}
/**
* Get (building lazily if absent) the SDK client for the given
* Feishu channel.
*
* @throws IllegalArgumentException if the channel does not exist
* or is not of type {@code feishu}
* @throws IllegalStateException if the channel row is missing
* {@code app_id} or {@code app_secret}
*/
public Client client(Long channelId) {
if (channelId == null) {
throw new IllegalArgumentException("channelId must not be null");
}
ChannelEntity ch = channelMapper.selectById(channelId);
if (ch == null) {
throw new IllegalArgumentException("Channel not found: " + channelId);
}
if (!CHANNEL_TYPE.equals(ch.getChannelType())) {
throw new IllegalArgumentException(
"Channel " + channelId + " is type=" + ch.getChannelType()
+ ", not " + CHANNEL_TYPE);
}
Map<String, Object> cfg = parseConfig(ch.getConfigJson());
String appId = asString(cfg.get("app_id"));
String appSecret = asString(cfg.get("app_secret"));
String domain = asStringOr(cfg.get("domain"), "feishu");
if (appId == null || appSecret == null) {
throw new IllegalStateException(
"Feishu channel " + channelId + " missing app_id / app_secret");
}
String fp = fingerprint(appId, appSecret, domain);
Cached existing = cache.get(channelId);
if (existing != null && existing.fingerprint().equals(fp)) {
return existing.client();
}
Client built = build(appId, appSecret, domain);
cache.put(channelId, new Cached(fp, built));
if (existing != null) {
log.info("[feishu-client-factory] Rebuilt client for channel {} (config changed)", channelId);
} else {
log.info("[feishu-client-factory] Built client for channel {} (domain={})", channelId, domain);
}
return built;
}
/**
* Drop the cached client for {@code channelId}. Idempotent safe
* to call for non-feishu channels (no-op) and for channels with no
* cached client. Called by {@code ChannelService} on every
* Feishu channel update / delete / toggle.
*/
public void evict(Long channelId) {
if (channelId == null) return;
if (cache.remove(channelId) != null) {
log.info("[feishu-client-factory] Evicted client for channel {}", channelId);
}
}
/** Test hook — visible for assertion. */
int cachedCount() {
return cache.size();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private Client build(String appId, String appSecret, String domain) {
BaseUrlEnum baseUrl = "lark".equalsIgnoreCase(domain)
? BaseUrlEnum.LarkSuite
: BaseUrlEnum.FeiShu;
return Client.newBuilder(appId, appSecret)
.openBaseUrl(baseUrl)
.build();
}
private static String fingerprint(String appId, String appSecret, String domain) {
return appId + '|' + Objects.hash(appSecret) + '|' + domain.toLowerCase();
}
private Map<String, Object> parseConfig(String configJson) {
if (configJson == null || configJson.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(configJson, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[feishu-client-factory] Failed to parse configJson: {}", e.getMessage());
return Map.of();
}
}
private static String asString(Object v) {
if (v == null) return null;
String s = v.toString().trim();
return s.isEmpty() ? null : s;
}
private static String asStringOr(Object v, String fallback) {
String s = asString(v);
return s == null ? fallback : s;
}
}

View File

@ -0,0 +1,342 @@
package vip.mate.channel.feishu;
import com.lark.oapi.Client;
import com.lark.oapi.service.im.v1.model.CreateFileReq;
import com.lark.oapi.service.im.v1.model.CreateFileReqBody;
import com.lark.oapi.service.im.v1.model.CreateFileResp;
import com.lark.oapi.service.im.v1.model.CreateImageReq;
import com.lark.oapi.service.im.v1.model.CreateImageReqBody;
import com.lark.oapi.service.im.v1.model.CreateImageResp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.media.ImageCompressor;
import vip.mate.channel.media.MediaSizeDecision;
import vip.mate.channel.media.MediaSource;
import vip.mate.channel.media.MediaUploadException;
import vip.mate.channel.media.MediaUploadRequest;
import vip.mate.channel.media.MediaUploadResult;
import vip.mate.channel.media.MediaUploader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
/**
* Feishu implementation of {@link MediaUploader}. Routes through the
* {@code oapi-sdk} so {@code tenant_access_token}, multipart framing,
* retries, and domain (feishu / lark) are all handled by the SDK
* no hand-rolled HTTP for any new send path.
*
* <p>Flow:
* <ol>
* <li>Resolve the {@link MediaSource} into bytes (in-memory for
* URL/Path sources are normalised the same way the SDK needs).</li>
* <li>Consult {@link FeishuSizePolicy}. If the decision rejects,
* throw {@link MediaUploadException}. If it downgrades, switch
* to the file endpoint and carry the user-facing note on the
* result.</li>
* <li>If still {@code image} and oversized but under hard ceiling,
* run {@link ImageCompressor} so the original payload fits.</li>
* <li>Stage to a temp file (the SDK signatures take
* {@code java.io.File}, not streams), invoke the right SDK
* endpoint, then delete the temp file in {@code finally}.</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FeishuMediaUploader implements MediaUploader {
/**
* Cap on the bytes a {@link MediaSource.RemoteUrl} fetch may yield.
* Same as Feishu's hard {@link FeishuSizePolicy#FILE_MAX_BYTES file
* ceiling} beyond this the size policy will reject anyway, so no
* point downloading more.
*/
private static final long REMOTE_FETCH_MAX_BYTES = FeishuSizePolicy.FILE_MAX_BYTES;
/** Connection + per-request fetch timeout for {@link MediaSource.RemoteUrl}. */
private static final Duration REMOTE_FETCH_TIMEOUT = Duration.ofSeconds(30);
/**
* Extension SDK {@code file_type} for the file endpoint. Anything
* not on this list maps to {@code stream} (the SDK's catch-all).
* Image extensions are intentionally absent images go through
* the image endpoint, not file.
*/
private static final Map<String, String> EXT_TO_FILE_TYPE = Map.of(
"pdf", "pdf",
"doc", "doc",
"docx", "doc",
"xls", "xls",
"xlsx", "xls",
"ppt", "ppt",
"pptx", "ppt",
"mp4", "mp4",
"opus", "opus"
);
private final FeishuClientFactory clientFactory;
private final FeishuSizePolicy sizePolicy;
/** Lazily-built HTTP client for {@link MediaSource.RemoteUrl} fetches. */
private volatile HttpClient httpClient;
@Override
public String channelType() {
return "feishu";
}
@Override
public MediaUploadResult upload(MediaUploadRequest request) throws MediaUploadException {
byte[] bytes = resolveBytes(request.source(), request.fileName());
// ---- Apply size policy. May reject, downgrade, or pass.
MediaSizeDecision decision = sizePolicy.evaluate(
bytes.length, request.mediaType(), request.contentType());
if (decision.rejected()) {
throw new MediaUploadException(decision.rejectReason());
}
String effectiveType = decision.finalMediaType();
// ---- For images, give the compressor a chance before upload
if ("image".equals(effectiveType) && bytes.length > FeishuSizePolicy.IMAGE_MAX_BYTES / 2) {
// Pre-emptive compression when the image is in the upper
// half of the 10 MB window keeps a margin against
// transient size growth from JPEG re-encoding.
bytes = ImageCompressor.compressIfNeeded(bytes, request.fileName(), FeishuSizePolicy.IMAGE_MAX_BYTES);
if (bytes.length > FeishuSizePolicy.IMAGE_MAX_BYTES) {
// Compression couldn't get under fall through to the
// file endpoint instead of failing outright.
log.warn("[feishu-upload] {} image still {}KB after compression — downgrading to file",
request.fileName(), bytes.length / 1024);
effectiveType = "file";
decision = MediaSizeDecision.downgradeTo("file",
"图片压缩后仍超过 10MB已转为文件形式发送");
}
}
Path tempFile = null;
try {
tempFile = stageToTempFile(bytes, request.fileName());
File asFile = tempFile.toFile();
Client client = clientFactory.client(request.channelId());
String mediaId = switch (effectiveType) {
case "image" -> uploadImage(client, asFile);
case "audio", "video", "file" -> uploadFile(
client, asFile, effectiveType, request.fileName(),
request.contentType(), request.durationMillis());
default -> throw new MediaUploadException(
"Unsupported mediaType: " + effectiveType);
};
return new MediaUploadResult(mediaId, effectiveType, decision.downgradeNote());
} catch (MediaUploadException e) {
throw e;
} catch (Exception e) {
throw new MediaUploadException(
"Feishu upload failed for " + request.fileName() + ": " + e.getMessage(), e);
} finally {
if (tempFile != null) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignore) {
// tmpdir cleanup is best-effort
}
}
}
}
// ------------------------------------------------------------------
// SDK endpoint calls
// ------------------------------------------------------------------
private String uploadImage(Client client, File file) throws Exception {
CreateImageReq req = CreateImageReq.newBuilder()
.createImageReqBody(CreateImageReqBody.newBuilder()
.imageType("message")
.image(file)
.build())
.build();
CreateImageResp resp = client.im().v1().image().create(req);
if (!resp.success()) {
throw new MediaUploadException(formatSdkError("im.image.create", resp.getCode(), resp.getMsg()));
}
return resp.getData().getImageKey();
}
private String uploadFile(Client client, File file, String effectiveType, String fileName,
String contentType, Integer durationMillis) throws Exception {
String fileType = resolveFileType(effectiveType, fileName, contentType);
CreateFileReqBody.Builder body = CreateFileReqBody.newBuilder()
.fileType(fileType)
.fileName(fileName)
.file(file);
if (durationMillis != null && durationMillis > 0) {
body.duration(durationMillis);
}
CreateFileReq req = CreateFileReq.newBuilder()
.createFileReqBody(body.build())
.build();
CreateFileResp resp = client.im().v1().file().create(req);
if (!resp.success()) {
throw new MediaUploadException(formatSdkError("im.file.create", resp.getCode(), resp.getMsg()));
}
return resp.getData().getFileKey();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
/**
* Map (effectiveType, fileName, contentType) SDK {@code file_type}
* accepted by the {@code /im/v1/files} endpoint. The SDK enforces a
* closed set; unknown values are rejected with code 230003.
*/
private static String resolveFileType(String effectiveType, String fileName, String contentType) {
if ("audio".equals(effectiveType)) {
return "opus";
}
if ("video".equals(effectiveType)) {
return "mp4";
}
// effectiveType == "file" derive from extension, fall back to stream
String ext = extensionOf(fileName);
if (ext != null) {
String mapped = EXT_TO_FILE_TYPE.get(ext);
if (mapped != null) return mapped;
}
// Some content-types carry a hint (e.g. application/pdf)
if (contentType != null) {
String ct = contentType.toLowerCase(Locale.ROOT);
if (ct.contains("pdf")) return "pdf";
if (ct.contains("msword") || ct.contains("wordprocessing")) return "doc";
if (ct.contains("excel") || ct.contains("spreadsheet")) return "xls";
if (ct.contains("powerpoint") || ct.contains("presentation")) return "ppt";
}
return "stream";
}
private static String extensionOf(String fileName) {
if (fileName == null) return null;
int dot = fileName.lastIndexOf('.');
if (dot < 0 || dot == fileName.length() - 1) return null;
return fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private byte[] resolveBytes(MediaSource source, String fileName) throws MediaUploadException {
try {
return switch (source) {
case MediaSource.Bytes b -> b.data();
case MediaSource.LocalPath p -> Files.readAllBytes(p.path());
case MediaSource.RemoteUrl u -> fetchRemote(u.url());
};
} catch (MediaUploadException e) {
throw e;
} catch (IOException e) {
throw new MediaUploadException(
"Failed to read media source for " + fileName + ": " + e.getMessage(), e);
}
}
private byte[] fetchRemote(String url) throws MediaUploadException {
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(REMOTE_FETCH_TIMEOUT)
.GET()
.build();
HttpResponse<InputStream> resp = httpClient()
.send(req, HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() / 100 != 2) {
throw new MediaUploadException(
"Remote fetch " + url + " returned HTTP " + resp.statusCode());
}
try (InputStream is = resp.body()) {
return readCapped(is, REMOTE_FETCH_MAX_BYTES, url);
}
} catch (MediaUploadException e) {
throw e;
} catch (Exception e) {
throw new MediaUploadException(
"Failed to fetch remote media " + url + ": " + e.getMessage(), e);
}
}
/**
* Read at most {@code cap} bytes from {@code in}. Throws if the
* stream still has more we never load oversized remote payloads
* into memory, since the size policy would reject them anyway.
*/
private static byte[] readCapped(InputStream in, long cap, String urlForError) throws IOException, MediaUploadException {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8 * 1024];
long total = 0;
int n;
while ((n = in.read(buf)) > 0) {
total += n;
if (total > cap) {
throw new MediaUploadException(
"Remote media " + urlForError + " exceeds " + cap + " bytes — aborted partial read");
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
private HttpClient httpClient() {
HttpClient c = this.httpClient;
if (c != null) return c;
synchronized (this) {
c = this.httpClient;
if (c == null) {
c = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
this.httpClient = c;
}
}
return c;
}
private static Path stageToTempFile(byte[] bytes, String fileName) throws IOException {
// Preserve extension so the SDK and Feishu server can sniff
// content type. Strip path separators from the suggested name.
String safeName = fileName == null ? "upload" : fileName.replaceAll("[/\\\\]", "_");
Path tmp = Files.createTempFile("feishu-upload-", "-" + safeName);
Files.copy(new java.io.ByteArrayInputStream(bytes), tmp, StandardCopyOption.REPLACE_EXISTING);
return tmp;
}
private static String formatSdkError(String op, int code, String msg) {
return op + " failed (code=" + code + ", msg=" + msg + ")";
}
/** Reserved for future inference if request.contentType is null and source is a path. */
@SuppressWarnings("unused")
private static String sniffContentType(Path path) {
try {
String type = Files.probeContentType(path);
if (type != null) return type;
} catch (IOException ignore) {
// fall through
}
return URLConnection.guessContentTypeFromName(path.getFileName().toString());
}
}

View File

@ -0,0 +1,107 @@
package vip.mate.channel.feishu;
import org.springframework.stereotype.Component;
import vip.mate.channel.media.MediaSizeDecision;
import vip.mate.channel.media.MediaSizePolicy;
import java.util.Locale;
import java.util.Set;
/**
* Feishu's per-media-type ceilings, encoded as a {@link MediaSizePolicy}.
*
* <p>Sources (Feishu official OpenAPI docs):
* <ul>
* <li>{@code /open-apis/im/v1/images} image payload max <b>10 MB</b></li>
* <li>{@code /open-apis/im/v1/files} file payload max <b>30 MB</b>;
* {@code file_type} {opus, mp4, pdf, doc, xls, ppt, stream}.
* Anything outside that set must be sent as {@code stream}.</li>
* <li>Voice msgtype only accepts {@code opus} audio. Other audio
* MIMEs (mp3, wav, ) cannot render as a native voice bubble
* the only way to deliver them is as a downgraded file.</li>
* <li>Video msgtype only accepts {@code mp4}. Same downgrade rule.</li>
* </ul>
*
* <p>The 30 MB file ceiling is a hard limit even after a downgrade
* to {@code file} the payload cannot exceed it. So a 50 MB video gets
* rejected outright; a 40 MB image likewise.
*/
@Component
public class FeishuSizePolicy implements MediaSizePolicy {
/** Image ceiling — Feishu {@code /im/v1/images} accepts up to 10 MB. */
public static final long IMAGE_MAX_BYTES = 10L * 1024 * 1024;
/** File / audio / video ceiling — Feishu {@code /im/v1/files} accepts up to 30 MB. */
public static final long FILE_MAX_BYTES = 30L * 1024 * 1024;
/** Audio MIMEs that render as native voice bubbles. Anything else → file. */
private static final Set<String> VOICE_SUPPORTED_MIMES = Set.of(
"audio/opus", "audio/ogg", "audio/ogg;codecs=opus"
);
/** Video MIMEs that render as native video bubbles. Anything else → file. */
private static final Set<String> VIDEO_SUPPORTED_MIMES = Set.of(
"video/mp4"
);
@Override
public String channelType() {
return "feishu";
}
@Override
public MediaSizeDecision evaluate(long fileSize, String mediaType, String contentType) {
String type = mediaType == null ? "file" : mediaType.toLowerCase(Locale.ROOT);
String mime = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT).trim();
// ---- Hard reject: nothing on Feishu carries > 30 MB
if (fileSize > FILE_MAX_BYTES) {
double mb = fileSize / 1024.0 / 1024.0;
return MediaSizeDecision.reject(type, String.format(
Locale.ROOT,
"文件大小 %.2fMB 超过飞书 30MB 上限,无法发送。请压缩或拆分后再发。",
mb));
}
// ---- Image: 10 MB hard, oversized file
if ("image".equals(type)) {
if (fileSize > IMAGE_MAX_BYTES) {
double mb = fileSize / 1024.0 / 1024.0;
return MediaSizeDecision.downgradeTo("file", String.format(
Locale.ROOT,
"图片 %.2fMB 超过飞书 10MB 限制,已转为文件形式发送",
mb));
}
return MediaSizeDecision.pass("image");
}
// ---- Audio: opus-only for voice bubble, else file
if ("audio".equals(type)) {
if (!mime.isEmpty() && !isVoiceSupported(mime)) {
return MediaSizeDecision.downgradeTo("file",
"语音格式 " + mime + " 不支持(飞书原生语音仅支持 opus已转为文件形式发送");
}
return MediaSizeDecision.pass("audio");
}
// ---- Video: mp4-only for video bubble, else file
if ("video".equals(type)) {
if (!mime.isEmpty() && !VIDEO_SUPPORTED_MIMES.contains(mime)) {
return MediaSizeDecision.downgradeTo("file",
"视频格式 " + mime + " 不支持(飞书原生视频仅支持 mp4已转为文件形式发送");
}
return MediaSizeDecision.pass("video");
}
// ---- Plain file already passed the 30 MB gate above
return MediaSizeDecision.pass("file");
}
private static boolean isVoiceSupported(String mime) {
for (String supported : VOICE_SUPPORTED_MIMES) {
if (mime.startsWith(supported)) return true;
}
return false;
}
}

View File

@ -0,0 +1,440 @@
package vip.mate.channel.feishu;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lark.oapi.Client;
import com.lark.oapi.service.cardkit.v1.model.ContentCardElementReq;
import com.lark.oapi.service.cardkit.v1.model.ContentCardElementReqBody;
import com.lark.oapi.service.cardkit.v1.model.ContentCardElementResp;
import com.lark.oapi.service.cardkit.v1.model.CreateCardReq;
import com.lark.oapi.service.cardkit.v1.model.CreateCardReqBody;
import com.lark.oapi.service.cardkit.v1.model.CreateCardResp;
import com.lark.oapi.service.cardkit.v1.model.SettingsCardReq;
import com.lark.oapi.service.cardkit.v1.model.SettingsCardReqBody;
import com.lark.oapi.service.cardkit.v1.model.SettingsCardResp;
import com.lark.oapi.service.im.v1.model.CreateMessageReq;
import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;
import com.lark.oapi.service.im.v1.model.CreateMessageResp;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Streaming-card lifecycle for the Feishu CardKit v1 API.
*
* <p>Flow per stream:
* <ol>
* <li>{@link #createAndDeliver} build a {@code streaming_mode=true}
* schema-2.0 card via {@code cardkit/v1/card.create}, then send
* it to the user via {@code im/v1/message.create} as an
* {@code interactive} message referencing the new {@code card_id}.
* Returns a session key used by subsequent calls.</li>
* <li>{@link #appendContent} accumulate delta text and, when the
* throttle window permits or the caller forces a flush, push the
* current accumulator to the card's markdown element via
* {@code cardkit/v1/cardElement.content} with a monotonic
* sequence number.</li>
* <li>{@link #finishCard} push the final full content one last
* time, then turn off {@code streaming_mode} via
* {@code cardkit/v1/card.settings} so the receiver UI stops
* showing the typing animation.</li>
* <li>{@link #failCard} append an error marker to whatever was
* accumulated, then close streaming the same way.</li>
* </ol>
*
* <p>Designed mirror-image to {@code DingTalkAICardManager}: same
* create/append/finish/fail shape, same per-session throttling,
* same activeSessions map for hand-off between threads. The
* implementation is end-to-end {@code oapi-sdk} no hand-rolled HTTP.
*
* <p>The four SDK call sites are {@code protected} so unit tests can
* subclass and verify session/throttle behavior without booting a real
* Feishu credential or hitting the network.
*/
@Slf4j
@Component
public class FeishuStreamingCardManager {
/** Throttle window for {@link #appendContent}, ms — matches DingTalk AICard. */
static final long THROTTLE_INTERVAL_MS = 500;
/**
* Markdown element id baked into the initial streaming card.
* Content-update calls reference this id. Public so tests can assert.
*/
public static final String STREAM_ELEMENT_ID = "stream_md";
/** Default text shown when the card is first created, before any delta arrives. */
public static final String DEFAULT_INITIAL_TEXT = "🤔 思考中...";
private final FeishuClientFactory clientFactory;
private final ObjectMapper objectMapper;
/** sessionKey → CardSession. sessionKey is an opaque UUID handed back to the caller. */
private final ConcurrentHashMap<String, CardSession> activeSessions = new ConcurrentHashMap<>();
public FeishuStreamingCardManager(FeishuClientFactory clientFactory, ObjectMapper objectMapper) {
this.clientFactory = clientFactory;
this.objectMapper = objectMapper;
}
// ------------------------------------------------------------------
// Session state
// ------------------------------------------------------------------
/** Terminal-state CAS guard — at most one of {finishCard, failCard} wins per session. */
enum Status { STREAMING, FINISHED, FAILED }
/**
* One in-flight streaming card. State is mutated by a single Reactor
* thread per session (the one consuming the {@code Flux}), so all
* mutable fields are either {@code volatile} (visibility across the
* eventual terminal call) or guarded by the session monitor.
*/
static final class CardSession {
final String sessionKey;
final Long channelId;
final String cardId;
final String messageId;
final StringBuilder accumulated = new StringBuilder();
final AtomicInteger sequence = new AtomicInteger(0);
final AtomicReference<Status> status = new AtomicReference<>(Status.STREAMING);
/**
* Time of the most recent flush. Initialised to a value well in
* the past so the very first {@link #appendContent} always
* flushes the receiver sees the first token instantly instead
* of waiting up to {@link #THROTTLE_INTERVAL_MS} for the second.
*/
volatile long lastFlushMs = Long.MIN_VALUE / 2;
CardSession(String sessionKey, Long channelId, String cardId, String messageId) {
this.sessionKey = sessionKey;
this.channelId = channelId;
this.cardId = cardId;
this.messageId = messageId;
}
boolean isStreaming() {
return status.get() == Status.STREAMING;
}
}
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/**
* Build the streaming card, push it as an interactive message, and
* register an in-memory session.
*
* @param channelId mate_channel row id picks SDK client
* @param receiveIdType one of {@code open_id} / {@code chat_id} /
* {@code email} / {@code union_id} /
* {@code user_id}
* @param receiveId the chat or user id to receive the card
* @param initialText bubble text shown before the first delta;
* null {@link #DEFAULT_INITIAL_TEXT}
* @return sessionKey for subsequent calls, or null on failure
*/
public String createAndDeliver(Long channelId, String receiveIdType,
String receiveId, String initialText) {
if (channelId == null || receiveIdType == null || receiveId == null) {
log.warn("[feishu-stream] createAndDeliver missing required arg(s)");
return null;
}
String firstText = (initialText == null || initialText.isBlank())
? DEFAULT_INITIAL_TEXT
: initialText;
try {
Client client = clientFactory.client(channelId);
String cardId = sdkCreateCard(client, firstText);
if (cardId == null) {
return null;
}
String messageId = sdkSendInteractiveMessage(client, receiveIdType, receiveId, cardId);
if (messageId == null) {
// Card built but couldn't deliver best effort close so the
// server-side card isn't orphaned in streaming mode forever.
tryCloseStreamingSilently(client, cardId);
return null;
}
String sessionKey = UUID.randomUUID().toString();
CardSession session = new CardSession(sessionKey, channelId, cardId, messageId);
activeSessions.put(sessionKey, session);
log.info("[feishu-stream] Card created: sessionKey={}, cardId={}, messageId={}",
sessionKey, abbrev(cardId), abbrev(messageId));
return sessionKey;
} catch (Exception e) {
log.error("[feishu-stream] createAndDeliver failed: {}", e.getMessage(), e);
return null;
}
}
/**
* Append delta text to the running session. May flush immediately
* (force) or wait for the next throttle window.
*
* <p>No-op when {@code sessionKey} is unknown or the session has
* already reached a terminal status keeps the caller's
* {@code doOnNext} loop simple ("just push every chunk").
*/
public void appendContent(String sessionKey, String contentDelta, boolean forceFlush) {
CardSession session = activeSessions.get(sessionKey);
if (session == null || !session.isStreaming()) {
return;
}
if (contentDelta != null && !contentDelta.isEmpty()) {
synchronized (session) {
session.accumulated.append(contentDelta);
}
}
long now = currentTimeMs();
if (!forceFlush && now - session.lastFlushMs < THROTTLE_INTERVAL_MS) {
return;
}
flush(session, now);
}
/**
* Push final content and turn off streaming mode. Idempotent
* a second call is a no-op. After return, the sessionKey is no
* longer known to the manager.
*/
public void finishCard(String sessionKey, String finalContent) {
CardSession session = activeSessions.get(sessionKey);
if (session == null) return;
if (!session.status.compareAndSet(Status.STREAMING, Status.FINISHED)) {
return;
}
try {
replaceAccumulated(session, finalContent != null ? finalContent : "");
flush(session, currentTimeMs());
closeStreaming(session);
} finally {
activeSessions.remove(sessionKey);
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}",
sessionKey, finalContent == null ? 0 : finalContent.length());
}
}
/**
* Mark the session failed. The current accumulator gets an error
* suffix; the card is closed so the typing animation stops.
* Idempotent.
*/
public void failCard(String sessionKey, String errorMessage) {
CardSession session = activeSessions.get(sessionKey);
if (session == null) return;
if (!session.status.compareAndSet(Status.STREAMING, Status.FAILED)) {
return;
}
try {
String tail;
synchronized (session) {
if (session.accumulated.length() == 0) {
tail = "⚠️ 处理失败:" + safe(errorMessage);
} else {
tail = session.accumulated + "\n\n⚠ " + safe(errorMessage);
}
session.accumulated.setLength(0);
session.accumulated.append(tail);
}
flush(session, currentTimeMs());
closeStreaming(session);
} finally {
activeSessions.remove(sessionKey);
log.warn("[feishu-stream] Card failed: sessionKey={}, error={}", sessionKey, errorMessage);
}
}
// ------------------------------------------------------------------
// Inspection helpers (tests / metrics)
// ------------------------------------------------------------------
/** Visible for tests / metrics — number of in-flight sessions. */
public int activeSessionCount() {
return activeSessions.size();
}
/** Visible for tests — direct session lookup. */
CardSession sessionFor(String sessionKey) {
return activeSessions.get(sessionKey);
}
// ------------------------------------------------------------------
// Internal flush + SDK seams
// ------------------------------------------------------------------
private void flush(CardSession session, long now) {
String snapshot;
synchronized (session) {
snapshot = session.accumulated.toString();
}
int seq = session.sequence.incrementAndGet();
try {
Client client = clientFactory.client(session.channelId);
sdkPushElementContent(client, session.cardId, STREAM_ELEMENT_ID, snapshot, seq);
session.lastFlushMs = now;
} catch (Exception e) {
log.warn("[feishu-stream] flush failed: sessionKey={}, seq={}, err={}",
session.sessionKey, seq, e.getMessage());
}
}
private void closeStreaming(CardSession session) {
int seq = session.sequence.incrementAndGet();
try {
Client client = clientFactory.client(session.channelId);
sdkCloseStreamingMode(client, session.cardId, seq);
} catch (Exception e) {
log.warn("[feishu-stream] closeStreaming failed: sessionKey={}, err={}",
session.sessionKey, e.getMessage());
}
}
private void tryCloseStreamingSilently(Client client, String cardId) {
try {
sdkCloseStreamingMode(client, cardId, 1);
} catch (Exception ignore) {
// best-effort already in an error path
}
}
private void replaceAccumulated(CardSession session, String content) {
synchronized (session) {
session.accumulated.setLength(0);
session.accumulated.append(content);
}
}
// ------------------------------------------------------------------
// SDK seams (overridable in tests)
// ------------------------------------------------------------------
/** Build a streaming-mode schema-2.0 card. Returns the new card_id or null. */
protected String sdkCreateCard(Client client, String initialText) throws Exception {
String cardJson = objectMapper.writeValueAsString(buildInitialCardJson(initialText));
CreateCardReq req = CreateCardReq.newBuilder()
.createCardReqBody(CreateCardReqBody.newBuilder()
.type("card_json")
.data(cardJson)
.build())
.build();
CreateCardResp resp = client.cardkit().v1().card().create(req);
if (!resp.success() || resp.getData() == null) {
log.warn("[feishu-stream] card.create failed: code={}, msg={}", resp.getCode(), resp.getMsg());
return null;
}
return resp.getData().getCardId();
}
/** Send the freshly-built card as an interactive message. Returns message_id or null. */
protected String sdkSendInteractiveMessage(Client client, String receiveIdType,
String receiveId, String cardId) throws Exception {
Map<String, Object> content = Map.of(
"type", "card",
"data", Map.of("card_id", cardId)
);
CreateMessageReq req = CreateMessageReq.newBuilder()
.receiveIdType(receiveIdType)
.createMessageReqBody(CreateMessageReqBody.newBuilder()
.receiveId(receiveId)
.msgType("interactive")
.content(objectMapper.writeValueAsString(content))
.build())
.build();
CreateMessageResp resp = client.im().v1().message().create(req);
if (!resp.success() || resp.getData() == null) {
log.warn("[feishu-stream] interactive message send failed: code={}, msg={}",
resp.getCode(), resp.getMsg());
return null;
}
return resp.getData().getMessageId();
}
/** Push the latest accumulator snapshot to the streaming element. */
protected void sdkPushElementContent(Client client, String cardId, String elementId,
String content, int sequence) throws Exception {
ContentCardElementReq req = ContentCardElementReq.newBuilder()
.cardId(cardId)
.elementId(elementId)
.contentCardElementReqBody(ContentCardElementReqBody.newBuilder()
.content(content)
.uuid(UUID.randomUUID().toString())
.sequence(sequence)
.build())
.build();
ContentCardElementResp resp = client.cardkit().v1().cardElement().content(req);
if (!resp.success()) {
log.warn("[feishu-stream] cardElement.content failed: cardId={}, seq={}, code={}, msg={}",
abbrev(cardId), sequence, resp.getCode(), resp.getMsg());
}
}
/** Flip streaming_mode=false so the receiving UI stops the typing animation. */
protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) throws Exception {
Map<String, Object> settings = Map.of(
"config", Map.of("streaming_mode", false)
);
SettingsCardReq req = SettingsCardReq.newBuilder()
.cardId(cardId)
.settingsCardReqBody(SettingsCardReqBody.newBuilder()
.settings(objectMapper.writeValueAsString(settings))
.uuid(UUID.randomUUID().toString())
.sequence(sequence)
.build())
.build();
SettingsCardResp resp = client.cardkit().v1().card().settings(req);
if (!resp.success()) {
log.warn("[feishu-stream] card.settings (close) failed: cardId={}, code={}, msg={}",
abbrev(cardId), resp.getCode(), resp.getMsg());
}
}
// ------------------------------------------------------------------
// Test seams + tiny helpers
// ------------------------------------------------------------------
/** Overridable so tests can pin time without involving Clock + reflection. */
protected long currentTimeMs() {
return System.currentTimeMillis();
}
/** Visible for tests. The "schema 2.0 streaming card" baseline. */
Map<String, Object> buildInitialCardJson(String initialText) {
// LinkedHashMap deterministic JSON order, easier to log-grep
Map<String, Object> config = new LinkedHashMap<>();
config.put("streaming_mode", true);
config.put("update_multi", true);
Map<String, Object> element = new LinkedHashMap<>();
element.put("tag", "markdown");
element.put("element_id", STREAM_ELEMENT_ID);
element.put("content", initialText);
Map<String, Object> body = new LinkedHashMap<>();
body.put("elements", List.of(element));
Map<String, Object> card = new LinkedHashMap<>();
card.put("schema", "2.0");
card.put("config", config);
card.put("body", body);
return card;
}
private static String abbrev(String s) {
if (s == null || s.length() <= 12) return s == null ? "" : s;
return s.substring(0, 12) + "";
}
private static String safe(String s) {
return s == null ? "" : s;
}
}

View File

@ -0,0 +1,97 @@
package vip.mate.channel.feishu.cards;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Routing-only dispatcher for Feishu interactive cards.
*
* <p>Maintains a single index keyed by {@link FeishuCardKind#actionPrefix}
* the inbound {@code P2CardActionTrigger} payload carries
* {@code action.value.action} (a string we put there during render),
* and the dispatcher picks the handler whose prefix matches.
*
* <p>Card kinds <i>must</i> use disjoint prefixes; collision throws at
* registration time. Mirror image of {@code WeComCardDispatcher}
* same shape, same disjoint-prefix invariant but parameterised on
* Feishu's {@code action.value} discriminator rather than WeCom's
* {@code template_card_event.task_id} prefix.
*
* <p>Outbound rendering today has a single direct caller
* ({@code FeishuChannelAdapter.sendApprovalNotice}) which always wants
* the tool-guard kind, so no outbound discriminator is needed yet.
* Adding more outbound kinds: introduce a second index keyed by
* {@code metadata.message_type} the way WeCom does.
*/
@Slf4j
@Component
public class FeishuCardDispatcher {
/** {@code action.value.action} prefix → kind. */
private final Map<String, FeishuCardKind> byActionPrefix = new HashMap<>();
/** {@code name} → kind, for outbound lookup by callers that know the kind name. */
private final Map<String, FeishuCardKind> byName = new HashMap<>();
private final ToolGuardCardKindFactory toolGuardFactory;
public FeishuCardDispatcher(ToolGuardCardKindFactory toolGuardFactory) {
this.toolGuardFactory = toolGuardFactory;
registerKinds();
}
private void registerKinds() {
// Currently single kind. Add lines here as new card kinds land.
// Order doesn't matter disjoint-prefix invariant prevents ambiguity.
register(toolGuardFactory.create());
}
private void register(FeishuCardKind kind) {
if (byActionPrefix.containsKey(kind.actionPrefix())) {
throw new IllegalStateException(
"duplicate card kind for actionPrefix '" + kind.actionPrefix()
+ "': existing=" + byActionPrefix.get(kind.actionPrefix()).name()
+ ", new=" + kind.name());
}
if (byName.containsKey(kind.name())) {
throw new IllegalStateException(
"duplicate card kind name '" + kind.name() + "'");
}
byActionPrefix.put(kind.actionPrefix(), kind);
byName.put(kind.name(), kind);
log.info("[feishu-cards] Registered card kind: name={} actionPrefix={}",
kind.name(), kind.actionPrefix());
}
/** Look up a card kind by its registered name (outbound). */
public Optional<FeishuCardKind> lookupByName(String name) {
if (name == null || name.isBlank()) return Optional.empty();
return Optional.ofNullable(byName.get(name));
}
/**
* Look up a card kind by the inbound {@code action.value.action}
* string's prefix. O(N) over registered kinds (N is small
* currently 1).
*/
public Optional<FeishuCardKind> lookupByAction(String action) {
if (action == null || action.isBlank()) return Optional.empty();
for (Map.Entry<String, FeishuCardKind> e : byActionPrefix.entrySet()) {
if (action.startsWith(e.getKey())) {
return Optional.of(e.getValue());
}
}
return Optional.empty();
}
/** Visible for tests / logs. */
public List<String> registeredKindNames() {
return List.copyOf(byName.keySet());
}
}

View File

@ -0,0 +1,48 @@
package vip.mate.channel.feishu.cards;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
import vip.mate.channel.feishu.FeishuChannelAdapter;
/**
* Process an inbound {@code P2CardActionTrigger} event for one kind of
* interactive card (e.g. a tool-guard approval card).
*
* <p>Schema-2.0 cards update in-place via the {@code
* P2CardActionTriggerResponse} the handler returns Feishu uses
* {@code response.card} as the new card body and surfaces
* {@code response.toast} as a transient popup. The async
* {@code PATCH /im/v1/messages/{id}} path is a silent no-op for V2
* cards; do NOT use it.
*
* <p>Implementations must:
* <ol>
* <li>Validate the click decode the button value, look up the
* pending business object, identity-check the clicker against
* the original requester.</li>
* <li>Inject any agent-side follow-up (e.g. a synthetic
* {@code /approve <id>} message via {@code adapter.injectSyntheticMessage(...)})
* so the router runs its canonical resolve + replay logic.</li>
* <li>Build and return a {@link P2CardActionTriggerResponse} with
* the resolved-state card body. Must complete inside Feishu's
* response window (~3 seconds before timeout).</li>
* </ol>
*
* <p>Returning {@code null} is allowed Feishu leaves the original
* card untouched in that case (use sparingly, only when the event
* shouldn't acknowledge visibly).
*/
@FunctionalInterface
public interface FeishuCardHandler {
/**
* @param adapter the live Feishu adapter (provides
* {@code injectSyntheticMessage}, SDK client, etc.)
* @param data the parsed {@code P2CardActionTriggerData} payload
* contains the operator, action.value, the card
* token, and {@code context} (containing the
* {@code open_message_id} of the original card)
* @return the response Feishu should use to update the card, or
* null to leave it unchanged
*/
P2CardActionTriggerResponse handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data);
}

View File

@ -0,0 +1,45 @@
package vip.mate.channel.feishu.cards;
import vip.mate.channel.cards.CardOversizedException;
/**
* Description of one kind of interactive Feishu card the dispatcher
* knows how to route its outbound render path plus its inbound click
* handler.
*
* <p>Disjoint-prefix invariant on {@link #actionPrefix} the inbound
* dispatcher picks a handler by matching the prefix of the button
* {@code value.action} string, so two card kinds MUST NOT share a
* prefix (the dispatcher rejects collisions at registration time).
* Kept as a simple record so adding a new kind is just: implement
* renderer/handler, register a new {@code FeishuCardKind} in
* {@link FeishuCardDispatcher#registerKinds()}.
*
* @param name short human-readable label for logs
* @param actionPrefix matches the prefix of inbound
* {@code action.value.action} (drives the inbound
* {@code handle} dispatch). E.g.
* {@code "tg_approval."} for tool-guard buttons.
* @param renderer converts a pending business object (e.g.
* {@link vip.mate.channel.notification.ApprovalNotice})
* into a Feishu interactive-card payload Map.
* Throws {@link CardOversizedException} to signal
* "this kind can't render now, fall back to text".
* @param handler processes an inbound {@code P2CardActionTrigger}
* event for this kind.
*/
public record FeishuCardKind(
String name,
String actionPrefix,
FeishuCardRenderer renderer,
FeishuCardHandler handler
) {
public FeishuCardKind {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("FeishuCardKind.name must not be blank");
}
if (actionPrefix == null || actionPrefix.isBlank()) {
throw new IllegalArgumentException("FeishuCardKind.actionPrefix must not be blank");
}
}
}

View File

@ -0,0 +1,28 @@
package vip.mate.channel.feishu.cards;
import vip.mate.channel.cards.CardOversizedException;
import vip.mate.channel.notification.ApprovalNotice;
import java.util.Map;
/**
* Build a Feishu interactive-card payload Map from a business object.
*
* <p>Implementations may throw {@link CardOversizedException} to signal
* the caller to fall back to a non-card path (e.g. text approval
* notice on the {@code AbstractChannelAdapter} default). Anything
* else surfaces as a bug.
*
* <p>Currently parameterised on {@link ApprovalNotice} since tool-guard
* is the only card kind in this PR; future kinds (poll cards, info-
* request cards, etc.) will likely take a different input or accept
* {@code Object} and self-cast.
*/
@FunctionalInterface
public interface FeishuCardRenderer {
/**
* Build the Schema-2.0 interactive-card body Map ready to drop into
* {@code im/v1/messages.create} with {@code msg_type=interactive}.
*/
Map<String, Object> render(ApprovalNotice notice) throws CardOversizedException;
}

View File

@ -0,0 +1,115 @@
package vip.mate.channel.feishu.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.channel.cards.CardOversizedException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Encode / decode the button {@code value} field on a tool-guard
* approval card.
*
* <p>Feishu Schema-2.0 buttons carry a free-form JSON object as
* {@code value}. The server echoes it back inside the inbound
* {@code P2CardActionTrigger}'s {@code action.value}. We pack just
* enough to recover the pending approval (the {@code pendingId}
* alone is enough mateclaw's {@code ApprovalService.getPending}
* resolves the rest, including the original requester). Sender / chat
* context is intentionally not packed the inbound handler runs in-
* process and can look it up synchronously.
*
* <p>Feishu does not publish an explicit byte ceiling on the value
* field but interactive-content as a whole is capped at ~30 KB.
* {@link #MAX_VALUE_BYTES} keeps our share well below that so the rest
* of the card body fits even when the tool name is verbose.
*/
public final class ToolGuardButtonValue {
/** Discriminator action prefix shared with {@link ToolGuardCardKindFactory}. */
public static final String ACTION_PREFIX = "tg_approval.";
public static final String ACTION_APPROVE = ACTION_PREFIX + "approve";
public static final String ACTION_DENY = ACTION_PREFIX + "deny";
/** Soft cap on the serialised value payload (well below Feishu's overall ~30 KB cap). */
public static final int MAX_VALUE_BYTES = 2048;
public enum Action {
APPROVE(ACTION_APPROVE),
DENY(ACTION_DENY);
public final String wireValue;
Action(String v) { this.wireValue = v; }
public static Action fromWire(String v) {
if (v == null) return null;
if (ACTION_APPROVE.equals(v)) return APPROVE;
if (ACTION_DENY.equals(v)) return DENY;
return null;
}
}
public record Decoded(Action action, String pendingId, String toolName, String severity) {}
private final ObjectMapper objectMapper;
public ToolGuardButtonValue(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* Build the Map that goes into the Schema-2.0 button's {@code value}
* field. LinkedHashMap so the JSON serialisation order is stable
* makes byte-length predictable and snapshot-testable.
*
* @throws CardOversizedException when the resulting JSON would
* exceed {@link #MAX_VALUE_BYTES}; caller falls back to text
*/
public Map<String, Object> encode(Action action, String pendingId, String toolName, String severity) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("action", action.wireValue);
payload.put("rid", pendingId);
payload.put("tool", toolName == null ? "" : toolName);
payload.put("sev", severity == null ? "" : severity);
// Size-check via a one-off JSON serialisation so we surface the
// overflow at render time rather than letting Feishu reject the
// whole interactive message at send time.
try {
String json = objectMapper.writeValueAsString(payload);
int bytes = json.getBytes(StandardCharsets.UTF_8).length;
if (bytes > MAX_VALUE_BYTES) {
throw new CardOversizedException(
"tool_guard button.value payload " + bytes + " bytes > limit " + MAX_VALUE_BYTES);
}
} catch (CardOversizedException e) {
throw e;
} catch (Exception e) {
throw new CardOversizedException("failed to serialise button.value: " + e.getMessage());
}
return payload;
}
/**
* Decode the {@code action.value} echoed back by Feishu on click.
* Returns null if the payload is malformed or the action
* unrecognised. Callers should treat null as "ignore this event".
*/
public Decoded decode(Map<String, Object> value) {
if (value == null || value.isEmpty()) return null;
Action action = Action.fromWire(asString(value.get("action")));
if (action == null) return null;
String pendingId = asString(value.get("rid"));
if (pendingId == null || pendingId.isBlank()) return null;
return new Decoded(
action,
pendingId,
asString(value.getOrDefault("tool", "")),
asString(value.getOrDefault("sev", "")));
}
private static String asString(Object o) {
return o == null ? null : o.toString();
}
}

View File

@ -0,0 +1,288 @@
package vip.mate.channel.feishu.cards.tool_guard;
import com.lark.oapi.event.cardcallback.model.CallBackAction;
import com.lark.oapi.event.cardcallback.model.CallBackCard;
import com.lark.oapi.event.cardcallback.model.CallBackContext;
import com.lark.oapi.event.cardcallback.model.CallBackOperator;
import com.lark.oapi.event.cardcallback.model.CallBackToast;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.PendingApproval;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.feishu.cards.FeishuCardHandler;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Process an inbound {@code P2CardActionTrigger} for the tool-guard
* approval card.
*
* <p><b>Schema-2.0 update protocol</b>: the card update must travel
* back to Feishu via the {@code P2CardActionTriggerResponse} we
* return async {@code PATCH /im/v1/messages/{id}} is silent no-op
* on V2 cards. We build a {@link CallBackCard} carrying the resolved
* card JSON and a {@link CallBackToast} for the transient "已批准" /
* "🚫 已拒绝" popup.
*
* <p><b>Replay protocol</b>: instead of calling
* {@code approvalWorkflowService.resolve(...)} directly, we inject a
* synthetic {@code /approve <pendingId>} message into the router. This
* reuses the canonical text-approve path that the router already
* tested: {@code resolveAndConsume + replayApprovedToolCall} so the
* approved tool actually re-runs through {@code ToolExecutionExecutor}
* and the agent picks the next step. The button click and the
* {@code /approve} text command thus take exactly the same code path,
* preventing the two from drifting.
*
* <p><b>Step ordering</b>:
* <ol>
* <li>Decode {@code action.value} null check</li>
* <li>Look up {@code PendingApproval} by id</li>
* <li>Identity check: clicker (open_id) vs original requester</li>
* <li>Inject synthetic {@code /approve} or {@code /deny} command
* router does the resolve + replay</li>
* <li>Build {@link P2CardActionTriggerResponse} with the resolved
* card JSON + toast and return it</li>
* </ol>
*
* <p>All steps must complete inside Feishu's response window. Steps
* 13 are O(ms) DB lookups; step 4 enqueues to the router but does
* not block on execution; step 5 just serialises a Map.
*/
@Slf4j
public class ToolGuardCardHandler implements FeishuCardHandler {
private final ApprovalService approvalService;
private final ToolGuardButtonValue buttonValue;
public ToolGuardCardHandler(ApprovalService approvalService,
ToolGuardButtonValue buttonValue) {
this.approvalService = approvalService;
this.buttonValue = buttonValue;
}
@Override
public P2CardActionTriggerResponse handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data) {
if (data == null) {
log.warn("[feishu-toolguard] handle called with null data");
return null;
}
CallBackAction action = data.getAction();
CallBackOperator operator = data.getOperator();
CallBackContext context = data.getContext();
String messageId = context != null ? context.getOpenMessageId() : null;
String clickerOpenId = operator != null ? operator.getOpenId() : null;
// ---- 1. Decode button value
ToolGuardButtonValue.Decoded decoded = action != null
? buttonValue.decode(action.getValue())
: null;
if (decoded == null) {
log.warn("[feishu-toolguard] Could not decode action.value (messageId={}, clicker={})",
abbrev(messageId), abbrev(clickerOpenId));
return null;
}
String pendingId = decoded.pendingId();
ToolGuardButtonValue.Action act = decoded.action();
// ---- 2. Look up pending approval
Optional<PendingApproval> opt = approvalService.getPending(pendingId);
if (opt.isEmpty() || !"pending".equals(opt.get().getStatus())) {
log.info("[feishu-toolguard] Pending {} not found / already resolved (action={}, clicker={})",
pendingId, act, abbrev(clickerOpenId));
return buildExpiredResponse(decoded.toolName());
}
PendingApproval pending = opt.get();
// ---- 3. Identity check (fail-closed)
// Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against. A guarded-tool card landing in
// a group chat would otherwise let ANY member click Approve and run the
// tool. Those approvals must be resolved from the admin console instead,
// so only an exact requester==clicker match is authorized here.
String originalRequester = pending.getUserId();
boolean authorized = originalRequester != null
&& !"system".equals(originalRequester)
&& originalRequester.equals(clickerOpenId);
if (!authorized) {
log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerOpenId), abbrev(originalRequester), pendingId);
return buildUnauthorizedResponse(decoded.toolName(), originalRequester);
}
// ---- 4. Inject synthetic /approve | /deny router runs the
// canonical resolve + replay path. Mirror of WeCom's
// button-card handling so the two channels share one
// resolve code path.
String commandText = (act == ToolGuardButtonValue.Action.APPROVE ? "/approve " : "/deny ")
+ pendingId;
ChannelMessage synthetic = buildSynthetic(commandText, clickerOpenId, pending, data);
try {
adapter.injectSyntheticMessage(synthetic);
log.info("[feishu-toolguard] Injected '{}' for pending={}, clicker={}",
act == ToolGuardButtonValue.Action.APPROVE ? "/approve" : "/deny",
pendingId, abbrev(clickerOpenId));
} catch (Exception e) {
// Returning the resolved-state card to Feishu without the
// router seeing the click leaves the agent stuck. Log and
// surface a failure toast the user will see "未生效"
// popup and the buttons stay clickable.
log.error("[feishu-toolguard] Failed to inject synthetic command for pending={}: {}",
pendingId, e.getMessage(), e);
return buildErrorResponse("⚠️ 审批未生效,请重试或联系运维");
}
// ---- 5. Build the resolved-state response
return buildResolvedResponse(decoded.toolName(), act, clickerOpenId);
}
// ------------------------------------------------------------------
// Response builders assemble P2CardActionTriggerResponse{toast,card}
// ------------------------------------------------------------------
private static P2CardActionTriggerResponse buildResolvedResponse(
String toolName, ToolGuardButtonValue.Action act, String clickerOpenId) {
boolean approve = act == ToolGuardButtonValue.Action.APPROVE;
String title = approve ? "✅ 已批准" : "🚫 已拒绝";
String template = approve ? "green" : "red";
String desc = "**工具**: `" + (toolName == null ? "" : toolName) + "`\n"
+ "**操作者**: " + abbrev(clickerOpenId);
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(title, desc, template);
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast(approve ? "info" : "warning", title));
resp.setCard(wrapCard(card));
return resp;
}
private static P2CardActionTriggerResponse buildUnauthorizedResponse(
String toolName, String originalRequester) {
String requesterLabel = originalRequester == null ? "原请求者" : abbrev(originalRequester);
String desc = "**工具**: `" + (toolName == null ? "" : toolName) + "`\n"
+ "**原请求者**: " + requesterLabel + "\n*仅原请求者可批准 / 拒绝该操作*";
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(
"❌ 仅原请求者可审批", desc, "grey");
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast("warning", "仅原请求者可审批"));
resp.setCard(wrapCard(card));
return resp;
}
private static P2CardActionTriggerResponse buildExpiredResponse(String toolName) {
String desc = "**工具**: `" + (toolName == null ? "" : toolName) + "`\n"
+ "*该审批已过期或已被处理*";
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(
"⌛ 审批已失效", desc, "grey");
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast("warning", "审批已失效"));
resp.setCard(wrapCard(card));
return resp;
}
private static P2CardActionTriggerResponse buildErrorResponse(String message) {
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast("error", message));
// Leave card null original card stays clickable so the user can retry.
return resp;
}
private static CallBackToast buildToast(String type, String content) {
CallBackToast toast = new CallBackToast();
toast.setType(type);
toast.setContent(content);
return toast;
}
private static CallBackCard wrapCard(Map<String, Object> cardJson) {
CallBackCard cb = new CallBackCard();
// Feishu callback-response validator only accepts Schema 1.0
// inline cards with type="raw" type="card_json" + Schema 2.0
// body returns 200672 "卡片内容格式错误" even though the same
// Schema 2.0 body works fine on cardkit/v1 card.create and on
// im/v1 message.create msg_type=interactive. Two different
// server-side validators, only one of which has been upgraded
// for Schema 2.0. QwenPaw's production Feishu adapter uses the
// same type="raw" approach for callback updates.
cb.setType("raw");
cb.setData(cardJson);
return cb;
}
// ------------------------------------------------------------------
// Synthetic message construction (mirror of WeCom pattern)
// ------------------------------------------------------------------
/**
* Build a {@link ChannelMessage} that looks like the clicker just
* typed "/approve &lt;pendingId&gt;" (or /deny) in the same chat.
* The router's existing approve / deny gate picks it up and runs
* the canonical resolveAndConsume + replay path.
*
* <p><b>conversationId matching is critical</b>: the router routes
* the synthetic by {@code buildConversationId(message)}
* {@code feishu:<chatId-or-senderId>} and looks up pending under
* that key. If the synthetic's conversationId doesn't match the
* pending's own {@code conversationId}, the router treats the
* message as a regular query and the LLM sees "/approve xxxxx"
* as user text.
*
* <p>Feishu's card-callback {@code context.openChatId} is populated
* even for 1:1 bot chats, but the original inbound-message handler
* stores 1:1 chats with {@code chatId=null} (so
* {@code buildConversationId} falls back to senderId). To stay
* consistent we ignore {@code openChatId} and derive the chatId
* from {@code pending.conversationId} the source of truth that
* was used to register the pending in the first place.
*/
private static ChannelMessage buildSynthetic(String commandText, String clickerOpenId,
PendingApproval pending,
P2CardActionTriggerData data) {
// pending.conversationId looks like "feishu:<scope>" where
// <scope> is either ou_xxx (1:1 chat derived from senderId)
// or oc_xxx (group chat derived from chatId). Reverse the
// scope back into the right chatId field so buildConversationId
// reproduces the exact same key.
String convId = pending.getConversationId();
String scope = (convId != null && convId.startsWith("feishu:"))
? convId.substring("feishu:".length())
: null;
boolean isGroup = scope != null && scope.startsWith("oc_");
String chatId = isGroup ? scope : null;
String replyToken = isGroup ? scope : clickerOpenId;
return ChannelMessage.builder()
.channelType("feishu")
.senderId(clickerOpenId)
.senderName(clickerOpenId)
.chatId(chatId)
.content(commandText)
.contentType("text")
.contentParts(List.of())
.inputMode("text")
.timestamp(LocalDateTime.now())
.replyToken(replyToken)
.rawPayload(Map.of(
"feishu_button_click", true,
"feishu_pending_id", pending.getPendingId()
))
.build();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private static String abbrev(String s) {
if (s == null || s.isBlank()) return "";
if (s.length() <= 12) return s;
return s.substring(0, 12) + "";
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.channel.feishu.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.approval.ApprovalService;
import vip.mate.channel.feishu.cards.FeishuCardKind;
/**
* Spring-managed factory that produces the tool-guard card kind for
* {@link vip.mate.channel.feishu.cards.FeishuCardDispatcher}.
*
* <p>Plain {@code @Component} so the dispatcher can constructor-inject
* it. Each call to {@link #create()} returns a freshly constructed
* {@link FeishuCardKind}; the dispatcher caches the result and queries
* it for life of the JVM.
*/
@Component("feishuToolGuardCardKindFactory")
public class ToolGuardCardKindFactory {
/**
* Kind name used by callers that look up the kind by name for
* outbound render (today only {@code FeishuChannelAdapter.sendApprovalNotice}).
*/
public static final String KIND_NAME = "tool_guard_approval";
/** Discriminator prefix on inbound {@code action.value.action}. */
public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX;
private final ApprovalService approvalService;
private final ObjectMapper objectMapper;
public ToolGuardCardKindFactory(ApprovalService approvalService,
ObjectMapper objectMapper) {
this.approvalService = approvalService;
this.objectMapper = objectMapper;
}
public FeishuCardKind create() {
ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper);
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue);
// Handler no longer needs ApprovalWorkflowService the canonical
// resolve + replay path runs via a synthetic /approve|/deny
// message injected back into the router.
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonValue);
return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler);
}
}

View File

@ -0,0 +1,197 @@
package vip.mate.channel.feishu.cards.tool_guard;
import vip.mate.channel.cards.CardOversizedException;
import vip.mate.channel.feishu.cards.FeishuCardRenderer;
import vip.mate.channel.notification.ApprovalNotice;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Build the Feishu Schema-2.0 button-card payload from an
* {@link ApprovalNotice}.
*
* <p>Card structure (Schema 2.0 interactive card):
* <pre>
* {
* "schema": "2.0",
* "header": {"title": {"tag": "plain_text", "content": "🛡️ 工具审批"}, "template": "orange"},
* "body": {
* "elements": [
* {"tag": "markdown", "content": "&lt;tool / risk / args summary&gt;"},
* {"tag": "action", "actions": [
* {"tag": "button", "text": {...}, "type": "primary", "value": &lt;approve payload&gt;},
* {"tag": "button", "text": {...}, "type": "danger", "value": &lt;deny payload&gt;}
* ]}
* ]
* }
* }
* </pre>
*
* <p>If either button.value would exceed the size ceiling, the encoder
* throws {@link CardOversizedException} and the calling adapter falls
* back to the {@link vip.mate.channel.AbstractChannelAdapter} text-
* approval path.
*/
public class ToolGuardCardRenderer implements FeishuCardRenderer {
private final ToolGuardButtonValue buttonValue;
public ToolGuardCardRenderer(ToolGuardButtonValue buttonValue) {
this.buttonValue = buttonValue;
}
@Override
public Map<String, Object> render(ApprovalNotice notice) throws CardOversizedException {
String pendingId = notice.pendingId();
String toolName = nullSafe(notice.toolName(), "tool");
String severity = nullSafe(notice.maxSeverity(), "MEDIUM");
// Encode button values FIRST so a size overflow throws before
// we build any cosmetic body.
Map<String, Object> approveValue = buttonValue.encode(
ToolGuardButtonValue.Action.APPROVE, pendingId, toolName, severity);
Map<String, Object> denyValue = buttonValue.encode(
ToolGuardButtonValue.Action.DENY, pendingId, toolName, severity);
// Header
Map<String, Object> title = new LinkedHashMap<>();
title.put("tag", "plain_text");
title.put("content", "🛡️ 工具审批");
Map<String, Object> header = new LinkedHashMap<>();
header.put("template", severityToTemplate(severity));
header.put("title", title);
// Markdown summary
Map<String, Object> markdown = new LinkedHashMap<>();
markdown.put("tag", "markdown");
markdown.put("content", buildSummaryMarkdown(notice, toolName, severity));
// Schema 1.0 button row {tag:"action", actions:[buttons]}.
// We use Schema 1.0 throughout (not Schema 2.0) so the callback
// response can update this same card without a schema-version
// mismatch error. Schema 2.0 is supported by im/v1/message.create
// BUT the callback response validator only accepts Schema 1.0
// inline (type="raw") once we commit to Schema 1.0 here the
// resolved-state card update lands cleanly. QwenPaw's
// production Feishu integration uses the same Schema 1.0 path.
Map<String, Object> approveBtn = new LinkedHashMap<>();
approveBtn.put("tag", "button");
approveBtn.put("text", plainText("批准"));
approveBtn.put("type", "primary");
approveBtn.put("value", approveValue);
Map<String, Object> denyBtn = new LinkedHashMap<>();
denyBtn.put("tag", "button");
denyBtn.put("text", plainText("拒绝"));
denyBtn.put("type", "danger");
denyBtn.put("value", denyValue);
Map<String, Object> actionRow = new LinkedHashMap<>();
actionRow.put("tag", "action");
actionRow.put("actions", List.of(approveBtn, denyBtn));
Map<String, Object> config = new LinkedHashMap<>();
config.put("wide_screen_mode", true);
// Schema 1.0 layout elements at root, no "schema" / "body" wrapper.
Map<String, Object> card = new LinkedHashMap<>();
card.put("config", config);
card.put("header", header);
card.put("elements", List.of(markdown, actionRow));
return card;
}
/**
* Build the resolved-state card for the {@code
* P2CardActionTriggerResponse.card} payload <b>Schema 1.0</b>
* inline format ({@code config / header / elements} all at root,
* no {@code "schema"} field, no {@code "body"} nesting).
*
* <p><b>Why Schema 1.0 here</b>: the Feishu callback-response
* validator is the legacy validator and rejects Schema 2.0 cards
* with error code 200672 "卡片内容格式错误". This is different from
* {@code im/v1/message.create msg_type=interactive} and
* {@code cardkit/v1 card.create}, both of which DO accept Schema
* 2.0. So we keep the original approval card (sent via message
* create) in Schema 2.0 for the column_set button layout, but the
* resolved-state update has to be Schema 1.0. QwenPaw's production
* Feishu integration uses the same split.
*
* <p>Caller passes the resulting Map to a {@code CallBackCard}
* with {@code type="raw"} (NOT {@code card_json}).
*
* @param title headline like "✅ 已批准 by 张三"
* @param desc optional detail line (markdown)
* @param template "green" / "red" / "grey" / "blue" header colour
*/
public static Map<String, Object> buildResolvedCard(String title, String desc, String template) {
Map<String, Object> titleObj = new LinkedHashMap<>();
titleObj.put("tag", "plain_text");
titleObj.put("content", title == null ? "" : title);
Map<String, Object> header = new LinkedHashMap<>();
header.put("template", template == null ? "grey" : template);
header.put("title", titleObj);
Map<String, Object> markdown = new LinkedHashMap<>();
markdown.put("tag", "markdown");
markdown.put("content", desc == null ? "" : desc);
Map<String, Object> config = new LinkedHashMap<>();
config.put("wide_screen_mode", true);
// Schema 1.0 layout elements at root, no schema/body wrapper.
Map<String, Object> card = new LinkedHashMap<>();
card.put("config", config);
card.put("header", header);
card.put("elements", List.of(markdown));
return card;
}
private static String buildSummaryMarkdown(ApprovalNotice notice, String toolName, String severity) {
StringBuilder sb = new StringBuilder();
sb.append("**工具**: `").append(toolName).append("`\n");
sb.append("**风险等级**: ").append(severityLabel(severity)).append("\n");
if (notice.summary() != null && !notice.summary().isBlank()) {
sb.append("**摘要**: ").append(notice.summary()).append("\n");
}
if (notice.argumentsPreview() != null && !notice.argumentsPreview().isBlank()) {
String args = notice.argumentsPreview();
if (args.length() > 200) args = args.substring(0, 200) + "";
sb.append("**参数**: `").append(args).append("`");
}
return sb.toString();
}
private static String severityLabel(String severity) {
return switch (severity.toUpperCase()) {
case "CRITICAL" -> "🔴 CRITICAL";
case "HIGH" -> "🟠 HIGH";
case "MEDIUM" -> "🟡 MEDIUM";
case "LOW" -> "🔵 LOW";
case "INFO" -> "⚪ INFO";
default -> severity;
};
}
private static String severityToTemplate(String severity) {
return switch (severity.toUpperCase()) {
case "CRITICAL", "HIGH" -> "orange";
case "MEDIUM" -> "yellow";
case "LOW", "INFO" -> "blue";
default -> "orange";
};
}
private static Map<String, Object> plainText(String content) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("tag", "plain_text");
m.put("content", content);
return m;
}
private static String nullSafe(String v, String fallback) {
return (v == null || v.isBlank()) ? fallback : v;
}
}

View File

@ -0,0 +1,211 @@
package vip.mate.channel.feishu.tool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lark.oapi.Client;
import com.lark.oapi.service.calendar.v4.model.CalendarEvent;
import com.lark.oapi.service.calendar.v4.model.ListCalendarEventReq;
import com.lark.oapi.service.calendar.v4.model.ListCalendarEventResp;
import com.lark.oapi.service.docx.v1.model.CreateDocumentReq;
import com.lark.oapi.service.docx.v1.model.CreateDocumentReqBody;
import com.lark.oapi.service.docx.v1.model.CreateDocumentResp;
import com.lark.oapi.service.docx.v1.model.RawContentDocumentReq;
import com.lark.oapi.service.docx.v1.model.RawContentDocumentResp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Component;
import vip.mate.channel.feishu.FeishuClientFactory;
import vip.mate.channel.tool.ChannelToolCallback;
import vip.mate.channel.tool.ChannelToolContext;
import vip.mate.channel.tool.ChannelToolDescriptor;
import vip.mate.channel.tool.ChannelToolProvider;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* First concrete {@link ChannelToolProvider} exposes a representative
* subset of Feishu's OpenAPI as Agent tools:
*
* <ul>
* <li>{@code feishu_calendar_list_events} (read)
* {@code calendar/v4 calendarEvent.list}</li>
* <li>{@code feishu_doc_read} (read)
* {@code docx/v1 document.rawContent}</li>
* <li>{@code feishu_doc_create} (write)
* {@code docx/v1 document.create}</li>
* </ul>
*
* <p>Both reads ship default-enabled; the write ships default-disabled
* and {@code ChannelToolService} seeds a HIGH-severity guard rule so a
* call falls into {@code NEEDS_APPROVAL} via {@code DbRuleGuardian}.
*
* <p>Tool I/O uses JSON input is the tool's argument JSON string,
* output is a compact JSON result (success or {@code "error"} key).
* Returning a structured object rather than free text keeps the LLM
* downstream branch-friendly.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FeishuChannelToolProvider implements ChannelToolProvider {
private final FeishuClientFactory clientFactory;
private final ObjectMapper objectMapper;
@Override
public String channelType() {
return "feishu";
}
@Override
public List<ChannelToolDescriptor> describeTools() {
return FeishuToolCatalog.descriptors();
}
@Override
public List<ToolCallback> createTools(ChannelToolContext context) {
Long channelId = context.channelId();
List<ToolCallback> out = new ArrayList<>(3);
for (ChannelToolDescriptor d : FeishuToolCatalog.descriptors()) {
out.add(new ChannelToolCallback(
d.name(), d.description(), d.inputSchema(),
input -> dispatch(d.name(), channelId, input)));
}
return out;
}
/** Route by tool name. Kept in one place so the descriptor catalog drives the surface. */
private String dispatch(String toolName, Long channelId, String input) {
try {
Client client = clientFactory.client(channelId);
return switch (toolName) {
case FeishuToolCatalog.TOOL_LIST_EVENTS -> handleListEvents(client, input);
case FeishuToolCatalog.TOOL_DOC_READ -> handleDocRead(client, input);
case FeishuToolCatalog.TOOL_DOC_CREATE -> handleDocCreate(client, input);
default -> errorJson("Unknown Feishu tool: " + toolName);
};
} catch (Exception e) {
log.warn("[feishu-tool] {} failed: {}", toolName, e.getMessage());
return errorJson(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
}
}
// ------------------------------------------------------------------
// Handlers
// ------------------------------------------------------------------
private String handleListEvents(Client client, String input) throws Exception {
JsonNode args = objectMapper.readTree(input == null || input.isBlank() ? "{}" : input);
String calendarId = textArg(args, "calendar_id");
if (calendarId == null) return errorJson("calendar_id is required");
ListCalendarEventReq.Builder req = ListCalendarEventReq.newBuilder().calendarId(calendarId);
String startTime = textArg(args, "start_time");
String endTime = textArg(args, "end_time");
Integer pageSize = intArg(args, "page_size");
if (startTime != null) req.startTime(startTime);
if (endTime != null) req.endTime(endTime);
if (pageSize != null) req.pageSize(pageSize);
ListCalendarEventResp resp = client.calendar().v4().calendarEvent().list(req.build());
if (!resp.success() || resp.getData() == null) {
return errorJson("calendar list failed: code=" + resp.getCode() + ", msg=" + resp.getMsg());
}
CalendarEvent[] items = resp.getData().getItems();
Map<String, Object> result = new LinkedHashMap<>();
result.put("count", items == null ? 0 : items.length);
// Compact representation title + start + end keeps the LLM context tight.
List<Map<String, Object>> events = new ArrayList<>();
if (items != null) {
for (CalendarEvent ev : items) {
Map<String, Object> e = new LinkedHashMap<>();
e.put("event_id", ev.getEventId());
e.put("summary", ev.getSummary());
if (ev.getStartTime() != null) e.put("start", ev.getStartTime().getTimestamp());
if (ev.getEndTime() != null) e.put("end", ev.getEndTime().getTimestamp());
events.add(e);
}
}
result.put("events", events);
return objectMapper.writeValueAsString(result);
}
private String handleDocRead(Client client, String input) throws Exception {
JsonNode args = objectMapper.readTree(input == null || input.isBlank() ? "{}" : input);
String documentId = textArg(args, "document_id");
if (documentId == null) return errorJson("document_id is required");
RawContentDocumentReq req = RawContentDocumentReq.newBuilder().documentId(documentId).build();
RawContentDocumentResp resp = client.docx().v1().document().rawContent(req);
if (!resp.success() || resp.getData() == null) {
return errorJson("doc read failed: code=" + resp.getCode() + ", msg=" + resp.getMsg());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("document_id", documentId);
result.put("content", resp.getData().getContent());
return objectMapper.writeValueAsString(result);
}
private String handleDocCreate(Client client, String input) throws Exception {
JsonNode args = objectMapper.readTree(input == null || input.isBlank() ? "{}" : input);
String title = textArg(args, "title");
if (title == null) return errorJson("title is required");
String folderToken = textArg(args, "folder_token");
CreateDocumentReqBody.Builder body = CreateDocumentReqBody.newBuilder().title(title);
if (folderToken != null && !folderToken.isBlank()) {
body.folderToken(folderToken);
}
CreateDocumentReq req = CreateDocumentReq.newBuilder()
.createDocumentReqBody(body.build())
.build();
CreateDocumentResp resp = client.docx().v1().document().create(req);
if (!resp.success() || resp.getData() == null || resp.getData().getDocument() == null) {
return errorJson("doc create failed: code=" + resp.getCode() + ", msg=" + resp.getMsg());
}
String docId = resp.getData().getDocument().getDocumentId();
Map<String, Object> result = new LinkedHashMap<>();
result.put("document_id", docId);
result.put("revision_id", resp.getData().getDocument().getRevisionId());
result.put("title", title);
// The URL is constructed client-side; SDK doesn't return it.
return objectMapper.writeValueAsString(result);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private String textArg(JsonNode args, String key) {
if (args == null) return null;
JsonNode n = args.get(key);
if (n == null || n.isNull()) return null;
String v = n.asText("");
return v.isBlank() ? null : v;
}
private Integer intArg(JsonNode args, String key) {
if (args == null) return null;
JsonNode n = args.get(key);
if (n == null || n.isNull()) return null;
if (n.canConvertToInt()) return n.intValue();
try {
return Integer.parseInt(n.asText());
} catch (NumberFormatException e) {
return null;
}
}
private String errorJson(String message) {
try {
return objectMapper.writeValueAsString(Map.of("error", message));
} catch (Exception e) {
return "{\"error\":\"" + message.replace("\"", "\\\"") + "\"}";
}
}
}

View File

@ -0,0 +1,99 @@
package vip.mate.channel.feishu.tool;
import vip.mate.channel.tool.ChannelToolDescriptor;
import java.util.List;
/**
* Static catalog of Feishu channel-native tools. Kept separate from
* the provider so the descriptors can be queried by tests / docs
* without instantiating the provider (which would drag in the SDK
* client factory).
*
* <p>Tool naming: the base name registered here is the "human"
* identifier. {@code ChannelToolService} appends {@code _c<channelId>}
* before registering with {@code ToolRegistry}, so the actual name an
* Agent sees is e.g. {@code feishu_doc_create_c2055137662148763649}.
*
* <p>Initial set covers the most useful read + a representative write
* per resource family the rest land as follow-ups:
* <ul>
* <li><b>feishu_calendar_list_events</b> read; default-on</li>
* <li><b>feishu_doc_read</b> read; default-on</li>
* <li><b>feishu_doc_create</b> write; default-off, approval-gated</li>
* </ul>
*/
public final class FeishuToolCatalog {
public static final String TOOL_LIST_EVENTS = "feishu_calendar_list_events";
public static final String TOOL_DOC_READ = "feishu_doc_read";
public static final String TOOL_DOC_CREATE = "feishu_doc_create";
private FeishuToolCatalog() {}
public static List<ChannelToolDescriptor> descriptors() {
return List.of(
new ChannelToolDescriptor(
TOOL_LIST_EVENTS,
"List Feishu calendar events",
"List events on a Feishu calendar within a time window. "
+ "Required: calendar_id (the user's primary calendar id is usually returned by "
+ "the calendar.primary endpoint). Optional: start_time (UNIX seconds string), "
+ "end_time (UNIX seconds string), page_size (1-1000, default 100).",
eventsListSchema(),
/* mutating */ false, /* enabledByDefault */ true),
new ChannelToolDescriptor(
TOOL_DOC_READ,
"Read a Feishu Doc as plain text",
"Fetch a Feishu Doc's raw plain-text content. Required: document_id "
+ "(the {documentId} segment in the URL https://x.feishu.cn/docx/{documentId}). "
+ "Returns the raw concatenated text — no formatting / images / tables.",
docReadSchema(),
/* mutating */ false, /* enabledByDefault */ true),
new ChannelToolDescriptor(
TOOL_DOC_CREATE,
"Create a new Feishu Doc",
"Create an empty Feishu Doc. Required: title (string). Optional: folder_token "
+ "(target folder; empty string = root). Returns the new doc's "
+ "{document_id, url}. NOTE: only the bot's app sees the new doc until "
+ "you explicitly share it — pass owner_open_id later via a permission "
+ "tool to grant access. This is a write tool and triggers an approval.",
docCreateSchema(),
/* mutating */ true, /* enabledByDefault */ false)
);
}
// ------------------------------------------------------------------
// JSON Schemas kept as constants so the descriptor is pure data
// ------------------------------------------------------------------
private static String eventsListSchema() {
return "{"
+ "\"type\":\"object\","
+ "\"properties\":{"
+ "\"calendar_id\":{\"type\":\"string\",\"description\":\"target calendar id\"},"
+ "\"start_time\":{\"type\":\"string\",\"description\":\"UNIX seconds, lower bound\"},"
+ "\"end_time\":{\"type\":\"string\",\"description\":\"UNIX seconds, upper bound\"},"
+ "\"page_size\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":1000,\"default\":100}"
+ "},\"required\":[\"calendar_id\"]}";
}
private static String docReadSchema() {
return "{"
+ "\"type\":\"object\","
+ "\"properties\":{"
+ "\"document_id\":{\"type\":\"string\",\"description\":\"the {documentId} segment of the doc URL\"}"
+ "},\"required\":[\"document_id\"]}";
}
private static String docCreateSchema() {
return "{"
+ "\"type\":\"object\","
+ "\"properties\":{"
+ "\"title\":{\"type\":\"string\",\"description\":\"document title\"},"
+ "\"folder_token\":{\"type\":\"string\",\"description\":\"target folder token; empty = root\"}"
+ "},\"required\":[\"title\"]}";
}
}

View File

@ -0,0 +1,103 @@
package vip.mate.channel.media;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.tool.document.GeneratedFileCache;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
/**
* Channel-agnostic scanner that finds {@code /api/v1/files/generated/{id}}
* URLs in agent output, looks each id up in {@link GeneratedFileCache},
* and rewrites the URL so the IM bubble shows a meaningful surface
* (file name marker or "retry" hint) while collecting the bytes for the
* adapter to upload as a native attachment.
*
* <p>Cache miss has two causes (both surfaced with the same retry hint
* so the user just resubmits):
* <ol>
* <li>The LLM hallucinated a UUID-shaped string without ever calling
* a render tool. {@link GeneratedFileCache#put} logs every real
* put, so its absence here is proof the file was never generated
* this turn.</li>
* <li>The 10-min cache entry expired before the IM client got around
* to clicking, or was wiped on JVM restart.</li>
* </ol>
* Without this rewrite, IM clients tap a markdown link that returns
* 404, save the HTML 404 body as the requested file extension, then
* report "file is corrupted" to support.
*
* <p>Originally lived as a private method on {@code WeComChannelAdapter};
* extracted here so Feishu / DingTalk / future channels share one
* implementation and one set of log conventions.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GeneratedFileScrubber {
private final GeneratedFileCache cache;
/**
* One attachment hit produced by {@link #scrub}.
*
* @param bytes raw file content (from cache)
* @param fileName original file name (kept for upload + display)
* @param mimeType MIME from cache entry used to decide
* {@code image} vs {@code file} on upload
* @param mediaType coarse classification: {@code "image"} when
* {@code mimeType} starts with {@code image/},
* otherwise {@code "file"}
*/
public record AttachmentHit(byte[] bytes, String fileName, String mimeType, String mediaType) {}
/**
* Result of scrubbing one text block.
*
* @param rewrittenText same text with each generated-URL replaced
* by either a {@code "📎 filename"} marker
* (cache hit) or a retry warning (cache miss)
* @param attachments one entry per cache hit, in document order
*/
public record ScrubResult(String rewrittenText, List<AttachmentHit> attachments) {}
/**
* Scan {@code text} for generated-file URLs and produce a
* {@link ScrubResult}. Returns the input unchanged (and an empty
* attachment list) when {@code text} is null/empty or contains no
* matches.
*/
public ScrubResult scrub(String text) {
if (text == null || text.isEmpty()) {
return new ScrubResult(text, List.of());
}
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(text);
if (!m.find()) {
return new ScrubResult(text, List.of());
}
StringBuilder out = new StringBuilder();
List<AttachmentHit> hits = new ArrayList<>();
m.reset();
while (m.find()) {
String id = m.group(1);
GeneratedFileCache.Entry entry = cache.get(id).orElse(null);
if (entry != null) {
String mediaType = isImageMime(entry.mimeType()) ? "image" : "file";
hits.add(new AttachmentHit(entry.bytes(), entry.filename(), entry.mimeType(), mediaType));
m.appendReplacement(out, Matcher.quoteReplacement("📎 " + entry.filename()));
} else {
log.warn("[generated-file-scrubber] cache miss for id={} — likely LLM skipped the render tool and wrote a fake URL", id);
m.appendReplacement(out, Matcher.quoteReplacement(GeneratedFileCache.MISSING_REFERENCE_NOTICE));
}
}
m.appendTail(out);
return new ScrubResult(out.toString(), hits);
}
private static boolean isImageMime(String mimeType) {
return mimeType != null && mimeType.toLowerCase().startsWith("image/");
}
}

View File

@ -0,0 +1,164 @@
package vip.mate.channel.media;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Iterator;
/**
* Channel-agnostic image compressor shrinks an image to fit a
* platform's per-image byte ceiling.
*
* <p>Strategy (in order):
* <ol>
* <li>If already under {@code maxBytes}, return as-is.</li>
* <li>Decode convert to RGB (drops alpha; flattens against white).</li>
* <li>Re-encode JPEG at progressively lower quality
* ({@code qualitySteps}); accept the first encoding that fits.</li>
* <li>If still too big, downscale by progressively smaller factors
* ({@code scaleSteps}) at quality 0.5; accept first that fits.</li>
* <li>If nothing fits, return the smallest variant we produced
* the caller's size policy will then reject or further downgrade.</li>
* </ol>
*
* <p>Pure static utility no Spring, no platform knowledge. Callers
* pass in their platform's limit (Feishu image 10 MB, WeCom image
* 1.9 MB safe-margin under the 2 MB hard cap, etc.).
*/
@Slf4j
public final class ImageCompressor {
/** Default JPEG quality steps used by {@link #compressIfNeeded(byte[], String, long)}. */
public static final float[] DEFAULT_QUALITY_STEPS = {0.85f, 0.70f, 0.50f, 0.30f};
/** Default downscale factors used by {@link #compressIfNeeded(byte[], String, long)}. */
public static final double[] DEFAULT_SCALE_STEPS = {0.75, 0.50, 0.25};
private ImageCompressor() {}
/**
* Compress with the default quality and scale ladders.
*
* @param imageBytes original encoded image (PNG / JPEG / GIF / )
* @param fileName original file name kept for log clarity only
* @param maxBytes target ceiling; bytes returned will be at most
* this size unless every step still exceeds it
*/
public static byte[] compressIfNeeded(byte[] imageBytes, String fileName, long maxBytes) {
return compressIfNeeded(imageBytes, fileName, maxBytes, DEFAULT_QUALITY_STEPS, DEFAULT_SCALE_STEPS);
}
/**
* Compress with caller-supplied quality and scale ladders. Useful
* when a platform's limit is tight enough that the defaults
* leave little headroom and a custom ladder converges faster.
*/
public static byte[] compressIfNeeded(byte[] imageBytes, String fileName, long maxBytes,
float[] qualitySteps, double[] scaleSteps) {
if (imageBytes == null || imageBytes.length == 0) {
return imageBytes;
}
if (imageBytes.length <= maxBytes) {
return imageBytes;
}
log.info("[image-compress] {}: original {}KB > limit {}KB",
fileName, imageBytes.length / 1024, maxBytes / 1024);
try {
BufferedImage img = ImageIO.read(new ByteArrayInputStream(imageBytes));
if (img == null) {
log.warn("[image-compress] {}: ImageIO could not decode; returning original", fileName);
return imageBytes;
}
BufferedImage rgbImg = toRgb(img);
for (float quality : qualitySteps) {
byte[] compressed = writeJpeg(rgbImg, quality);
if (compressed.length <= maxBytes) {
log.info("[image-compress] {}: compressed to {}KB (quality={})",
fileName, compressed.length / 1024, quality);
return compressed;
}
}
int w = rgbImg.getWidth();
int h = rgbImg.getHeight();
byte[] smallest = null;
for (double scale : scaleSteps) {
BufferedImage resized = resize(rgbImg, (int) (w * scale), (int) (h * scale));
byte[] compressed = writeJpeg(resized, 0.50f);
smallest = compressed;
if (compressed.length <= maxBytes) {
log.info("[image-compress] {}: resized to {}x{}, {}KB",
fileName, (int) (w * scale), (int) (h * scale), compressed.length / 1024);
return compressed;
}
}
log.warn("[image-compress] {}: could not shrink below {}KB; returning smallest ({}KB)",
fileName, maxBytes / 1024, smallest != null ? smallest.length / 1024 : 0);
return smallest != null ? smallest : imageBytes;
} catch (Exception e) {
log.error("[image-compress] {}: failed ({}); returning original", fileName, e.getMessage());
return imageBytes;
}
}
private static BufferedImage toRgb(BufferedImage img) {
if (img.getType() == BufferedImage.TYPE_INT_RGB) {
return img;
}
BufferedImage rgb = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g = rgb.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, img.getWidth(), img.getHeight());
g.drawImage(img, 0, 0, null);
g.dispose();
return rgb;
}
private static byte[] writeJpeg(BufferedImage img, float quality) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
if (!writers.hasNext()) {
throw new IllegalStateException("No JPEG ImageWriter available");
}
ImageWriter writer = writers.next();
try {
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality);
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
writer.setOutput(ios);
writer.write(null, new IIOImage(img, null, null), param);
}
} finally {
writer.dispose();
}
return baos.toByteArray();
}
private static BufferedImage resize(BufferedImage img, int newWidth, int newHeight) {
BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = resized.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setColor(Color.WHITE);
g.fillRect(0, 0, newWidth, newHeight);
g.drawImage(img, 0, 0, newWidth, newHeight, null);
g.dispose();
return resized;
}
}

View File

@ -0,0 +1,42 @@
package vip.mate.channel.media;
/**
* Decision returned by {@link MediaSizePolicy#evaluate}.
*
* <p>Three terminal cases, exposed as separate boolean flags so the
* caller can branch without inspecting which fields are populated:
* <ul>
* <li><b>pass</b> {@code rejected=false}, {@code downgraded=false}.
* Upload as-is; {@code finalMediaType} matches request.</li>
* <li><b>downgraded</b> {@code rejected=false}, {@code downgraded=true}.
* Still upload, but as {@code finalMediaType} (e.g. an oversized
* image is uploaded as {@code file}). Append {@code downgradeNote}
* to the message body so the user knows why.</li>
* <li><b>rejected</b> {@code rejected=true}. Do not upload at all;
* surface {@code rejectReason} as the message body. Even
* {@code file} cannot carry it.</li>
* </ul>
*
* <p>Generalised from WeCom's {@code WeComUploadLimitDecision} so every
* channel that needs platform-specific size rules can implement
* {@link MediaSizePolicy} without re-inventing the result shape.
*/
public record MediaSizeDecision(
String finalMediaType,
boolean rejected,
String rejectReason,
boolean downgraded,
String downgradeNote) {
public static MediaSizeDecision pass(String mediaType) {
return new MediaSizeDecision(mediaType, false, null, false, null);
}
public static MediaSizeDecision reject(String mediaType, String reason) {
return new MediaSizeDecision(mediaType, true, reason, false, null);
}
public static MediaSizeDecision downgradeTo(String newMediaType, String note) {
return new MediaSizeDecision(newMediaType, false, null, true, note);
}
}

View File

@ -0,0 +1,30 @@
package vip.mate.channel.media;
/**
* SPI for "what does this channel platform accept at what size".
*
* <p>One implementation per channel type. Encodes the platform's
* per-media-type byte ceilings and any "wrong MIME → downgrade to file"
* rules. Kept independent of {@link MediaUploader} so the policy can be
* unit-tested in pure isolation and reused by any caller that wants to
* pre-validate before commit (e.g. an admin UI showing "this file is
* too big for WeCom" up-front).
*
* <p>Pure function no platform credentials, no network I/O.
*/
public interface MediaSizePolicy {
/** Channel type this policy serves, matching {@link MediaUploader#channelType()}. */
String channelType();
/**
* Decide whether to accept, downgrade, or reject the given upload.
*
* @param fileSize payload size in bytes
* @param mediaType requested type {@code image} / {@code file} /
* {@code audio} / {@code video}
* @param contentType MIME (may be null; policies that don't care
* about MIME ignore it)
*/
MediaSizeDecision evaluate(long fileSize, String mediaType, String contentType);
}

View File

@ -0,0 +1,46 @@
package vip.mate.channel.media;
import java.nio.file.Path;
/**
* Sealed input source for {@link MediaUploadRequest}.
*
* <p>Exactly one of the three variants carries the payload. Concrete
* {@link MediaUploader} implementations decide how to normalize each
* variant to whatever shape the platform SDK requires (the Feishu SDK,
* for instance, takes {@link java.io.File}, so bytes/url variants are
* staged through a temp file).
*/
public sealed interface MediaSource permits MediaSource.Bytes, MediaSource.LocalPath, MediaSource.RemoteUrl {
/** In-memory bytes — typical for content produced by an agent tool. */
record Bytes(byte[] data) implements MediaSource {
public Bytes {
if (data == null || data.length == 0) {
throw new IllegalArgumentException("MediaSource.Bytes payload must be non-empty");
}
}
}
/** Already-on-disk file — typical for skill scripts that write to a workspace path. */
record LocalPath(Path path) implements MediaSource {
public LocalPath {
if (path == null) {
throw new IllegalArgumentException("MediaSource.LocalPath path must not be null");
}
}
}
/**
* Remote HTTP(S) URL uploader fetches it before handing the bytes
* to the platform SDK. Implementations may choose to enforce a
* size cap on the fetched body to protect memory.
*/
record RemoteUrl(String url) implements MediaSource {
public RemoteUrl {
if (url == null || url.isBlank()) {
throw new IllegalArgumentException("MediaSource.RemoteUrl url must be non-blank");
}
}
}
}

View File

@ -0,0 +1,22 @@
package vip.mate.channel.media;
/**
* Thrown by {@link MediaUploader#upload(MediaUploadRequest)} when the
* upload cannot complete credential issues, oversize rejection,
* platform API failure, or local I/O while staging the payload.
*
* <p>Distinct from {@link IllegalArgumentException} (caller's fault)
* and unchecked runtime errors (programmer bugs). Catching code is
* expected to log and fall back to a textual notice to the user
* rather than crash the adapter's send loop.
*/
public class MediaUploadException extends Exception {
public MediaUploadException(String message) {
super(message);
}
public MediaUploadException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,48 @@
package vip.mate.channel.media;
/**
* Channel-agnostic request to upload one media asset.
*
* <p>Required: {@code channelId}, {@code source}, {@code fileName},
* {@code mediaType}. The remaining fields are best-effort hints
* platform SDKs that need them will use them, others ignore.
*
* @param channelId identifies which channel-row credentials to use
* when the uploader needs to authenticate
* @param source the payload (bytes / on-disk path / remote URL)
* @param fileName file name with extension (used by platform SDKs
* and shown to end users in IM file bubbles)
* @param mediaType one of {@code image} / {@code file} /
* {@code audio} / {@code video}. Decides which
* platform endpoint the uploader picks and how
* the receiving IM client renders the bubble.
* May be downgraded by a {@link MediaSizePolicy}.
* @param contentType MIME type (e.g. {@code image/png},
* {@code audio/opus}); used by both the size
* policy and the platform SDK
* @param durationMillis playback duration in ms for audio/video; some
* SDKs surface it on the receiver UI; nullable
*/
public record MediaUploadRequest(
Long channelId,
MediaSource source,
String fileName,
String mediaType,
String contentType,
Integer durationMillis) {
public MediaUploadRequest {
if (channelId == null) {
throw new IllegalArgumentException("MediaUploadRequest.channelId must not be null");
}
if (source == null) {
throw new IllegalArgumentException("MediaUploadRequest.source must not be null");
}
if (fileName == null || fileName.isBlank()) {
throw new IllegalArgumentException("MediaUploadRequest.fileName must be non-blank");
}
if (mediaType == null || mediaType.isBlank()) {
throw new IllegalArgumentException("MediaUploadRequest.mediaType must be non-blank");
}
}
}

View File

@ -0,0 +1,37 @@
package vip.mate.channel.media;
/**
* Result of a successful {@link MediaUploader#upload} call.
*
* @param mediaId platform-side identifier Feishu
* {@code image_key} / {@code file_key};
* DingTalk {@code mediaId} / {@code downloadCode};
* WeCom {@code media_id}. Callers store this on
* the {@code MessageContentPart} and feed it to
* the platform's message-send call.
* @param finalMediaType the media type actually uploaded under may
* differ from {@link MediaUploadRequest#mediaType()}
* if a {@link MediaSizePolicy} downgraded it
* (e.g. oversized image file)
* @param downgradeNote optional user-visible note explaining a
* downgrade; null when no downgrade happened
*/
public record MediaUploadResult(
String mediaId,
String finalMediaType,
String downgradeNote) {
public MediaUploadResult {
if (mediaId == null || mediaId.isBlank()) {
throw new IllegalArgumentException("MediaUploadResult.mediaId must be non-blank");
}
if (finalMediaType == null || finalMediaType.isBlank()) {
throw new IllegalArgumentException("MediaUploadResult.finalMediaType must be non-blank");
}
}
/** Convenience for the common "no downgrade" path. */
public static MediaUploadResult of(String mediaId, String mediaType) {
return new MediaUploadResult(mediaId, mediaType, null);
}
}

View File

@ -0,0 +1,41 @@
package vip.mate.channel.media;
/**
* SPI for "upload one media asset to a channel platform".
*
* <p>Implemented once per channel type (Feishu / WeCom / DingTalk / ).
* The adapter for that channel injects all matching beans and routes
* by {@link #channelType()}. Pure boundary contract the SPI knows
* nothing about adapters, message routing, or downstream send logic;
* it just turns {@link MediaUploadRequest} into a platform-side
* {@link MediaUploadResult#mediaId() mediaId}.
*
* <p>Implementations are expected to:
* <ol>
* <li>Consult their paired {@link MediaSizePolicy} first; if the
* decision rejects, throw {@link MediaUploadException} with the
* reason so the caller can surface it to the user.</li>
* <li>Apply any necessary downgrade ({@code image} {@code file})
* before calling the platform endpoint, and propagate the note
* on the returned {@link MediaUploadResult}.</li>
* <li>Avoid leaking temp files staged for SDK calls always clean
* up in a {@code finally} block.</li>
* </ol>
*/
public interface MediaUploader {
/**
* Channel type this uploader serves, matching
* {@code ChannelAdapter.getChannelType()} (e.g. {@code "feishu"}).
*/
String channelType();
/**
* Upload the media and return the platform identifier that can be
* referenced in a subsequent send-message call.
*
* @throws MediaUploadException on size rejection, credential issues,
* platform API failure, or local I/O while staging
*/
MediaUploadResult upload(MediaUploadRequest request) throws MediaUploadException;
}

View File

@ -0,0 +1,249 @@
package vip.mate.channel.qq;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* QQ Bot "scan-to-bind" registration service.
* <p>
* Drives the QQ Open Platform Lite bind portal:
* <pre>
* POST {portal}/lite/create_bind_task body {key} {task_id}
* POST {portal}/lite/poll_bind_result body {task_id} {status, bot_appid?, bot_encrypt_secret?, user_openid?}
* </pre>
* <p>
* The {@code key} is a base64-encoded 256-bit random AES key generated locally
* the portal uses it to AES-256-GCM-encrypt {@code client_secret} so the
* plaintext never travels in the clear. Decryption happens here, after which
* the session exposes {@code clientId} / {@code clientSecret} to the SPI
* provider.
* <p>
* Sessions live in memory (ConcurrentHashMap) with a 12-minute TTL the
* QR code itself expires after ~5 min on the portal side, the extra buffer
* is for late polls. A background worker polls the portal every 2s until
* a terminal state, capped at 6 min wall-clock to avoid thread leaks.
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QQAppRegistrationService {
/** QQ Open Platform portal host (overridable for proxies / test envs). */
private static final String PORTAL_HOST =
System.getenv().getOrDefault("QQ_BIND_PORTAL_HOST", "q.qq.com");
/** Vendor source tag forwarded to the portal in the QR URL. */
private static final String PORTAL_SOURCE = "mateclaw";
/** Portal path that hosts the user-facing scan landing page. */
private static final String PORTAL_CONNECT_PATH = "/qqbot/openclaw/connect.html";
private static final long POLL_INTERVAL_MS = 2_000L;
private static final long POLL_REQUEST_TIMEOUT_MS = 10_000L;
private static final long INIT_REQUEST_TIMEOUT_MS = 15_000L;
private static final long SESSION_TTL_MS = 12 * 60_000L;
private static final long WORKER_MAX_RUNTIME_MS = 6 * 60_000L;
/** Portal status codes (bind portal returns numeric codes, not strings). */
private static final int PORTAL_STATUS_PENDING = 1;
private static final int PORTAL_STATUS_COMPLETED = 2;
private static final int PORTAL_STATUS_EXPIRED = 3;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ConcurrentHashMap<String, RegistrationSession> sessions = new ConcurrentHashMap<>();
/**
* Kick off a new bind session. Returns immediately with the QR URL set;
* polling for completion happens in a background worker.
*/
public RegistrationSession begin() throws Exception {
evictExpiredSessions();
String aesKey = QQBindCrypto.generateKey();
Map<?, ?> response = postJson("/lite/create_bind_task", Map.of("key", aesKey), INIT_REQUEST_TIMEOUT_MS);
Integer retcode = response.get("retcode") instanceof Number n ? n.intValue() : null;
if (retcode == null || retcode != 0) {
throw new IllegalStateException(
"create_bind_task failed: retcode=" + retcode + ", msg=" + response.get("msg"));
}
Object dataObj = response.get("data");
if (!(dataObj instanceof Map<?, ?> data)) {
throw new IllegalStateException("create_bind_task returned no data");
}
String taskId = data.get("task_id") instanceof String s ? s : null;
if (taskId == null || taskId.isBlank()) {
throw new IllegalStateException("create_bind_task returned empty task_id");
}
String sessionId = UUID.randomUUID().toString();
RegistrationSession session = new RegistrationSession(sessionId);
session.qrcodeUrl = buildConnectUrl(taskId);
session.status = Status.WAITING;
sessions.put(sessionId, session);
Thread worker = new Thread(() -> pollUntilTerminal(session, taskId, aesKey),
"qq-register-" + sessionId.substring(0, 8));
worker.setDaemon(true);
worker.start();
log.info("[qq-register] session {} started (task_id suffix=...{})",
sessionId, taskId.length() > 6 ? taskId.substring(taskId.length() - 6) : taskId);
return session;
}
public RegistrationSession getSession(String sessionId) {
evictExpiredSessions();
return sessions.get(sessionId);
}
private void pollUntilTerminal(RegistrationSession session, String taskId, String aesKey) {
long startMs = System.currentTimeMillis();
while (true) {
if (System.currentTimeMillis() - startMs > WORKER_MAX_RUNTIME_MS) {
session.status = Status.EXPIRED;
session.errorMessage = "polling worker timed out";
session.lastUpdateMs = System.currentTimeMillis();
log.warn("[qq-register] session {} timed out after {} ms",
session.sessionId, WORKER_MAX_RUNTIME_MS);
return;
}
try {
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
try {
Map<?, ?> response = postJson("/lite/poll_bind_result",
Map.of("task_id", taskId), POLL_REQUEST_TIMEOUT_MS);
Integer retcode = response.get("retcode") instanceof Number n ? n.intValue() : null;
if (retcode == null || retcode != 0) {
log.debug("[qq-register] poll non-zero retcode={}, msg={} (will retry)",
retcode, response.get("msg"));
continue;
}
Object dataObj = response.get("data");
if (!(dataObj instanceof Map<?, ?> data)) {
continue;
}
int portalStatus = data.get("status") instanceof Number n ? n.intValue() : 0;
session.lastUpdateMs = System.currentTimeMillis();
switch (portalStatus) {
case PORTAL_STATUS_COMPLETED -> {
String appId = data.get("bot_appid") instanceof String s ? s
: (data.get("bot_appid") != null ? data.get("bot_appid").toString() : null);
String encryptedSecret = data.get("bot_encrypt_secret") instanceof String s ? s : null;
String userOpenid = data.get("user_openid") instanceof String s ? s : null;
if (appId == null || encryptedSecret == null) {
session.status = Status.DENIED;
session.errorMessage = "portal returned completed without credentials";
log.warn("[qq-register] session {} completed but missing credentials", session.sessionId);
return;
}
try {
session.clientSecret = QQBindCrypto.decryptSecret(encryptedSecret, aesKey);
} catch (Exception e) {
session.status = Status.DENIED;
session.errorMessage = "failed to decrypt client_secret: " + e.getMessage();
log.error("[qq-register] session {} decrypt failed: {}",
session.sessionId, e.getMessage());
return;
}
session.clientId = appId;
session.userOpenid = userOpenid;
session.status = Status.CONFIRMED;
log.info("[qq-register] session {} confirmed, appId={}", session.sessionId, appId);
return;
}
case PORTAL_STATUS_EXPIRED -> {
session.status = Status.EXPIRED;
log.info("[qq-register] session {} expired", session.sessionId);
return;
}
case PORTAL_STATUS_PENDING -> {
// keep polling
}
default -> log.debug("[qq-register] session {} unknown portal status: {}",
session.sessionId, portalStatus);
}
} catch (Exception e) {
log.debug("[qq-register] poll attempt failed (will retry): {}", e.getMessage());
}
}
}
private String buildConnectUrl(String taskId) {
String encoded = URLEncoder.encode(taskId, StandardCharsets.UTF_8);
return "https://" + PORTAL_HOST + PORTAL_CONNECT_PATH
+ "?task_id=" + encoded + "&_wv=2&source=" + PORTAL_SOURCE;
}
private Map<?, ?> postJson(String path, Map<String, ?> body, long timeoutMs) throws Exception {
String json = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://" + PORTAL_HOST + path))
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.timeout(Duration.ofMillis(timeoutMs))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("portal " + path + " HTTP " + response.statusCode()
+ ": " + response.body());
}
return objectMapper.readValue(response.body(), Map.class);
}
private void evictExpiredSessions() {
long cutoff = System.currentTimeMillis() - SESSION_TTL_MS;
Iterator<Map.Entry<String, RegistrationSession>> it = sessions.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().createdAtMs < cutoff) it.remove();
}
}
public enum Status {
WAITING, CONFIRMED, EXPIRED, DENIED
}
public static class RegistrationSession {
public final String sessionId;
final long createdAtMs = System.currentTimeMillis();
public volatile Status status = Status.WAITING;
public volatile String qrcodeUrl;
public volatile String qrcodeImgDataUri;
/** Decrypted bot app_id (filled on confirmed). */
public volatile String clientId;
/** Decrypted bot client_secret (filled on confirmed). */
public volatile String clientSecret;
/** OpenID of the user who scanned (filled on confirmed). */
public volatile String userOpenid;
public volatile String errorMessage;
public volatile long lastUpdateMs = System.currentTimeMillis();
RegistrationSession(String sessionId) {
this.sessionId = sessionId;
}
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.channel.qq;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES-256-GCM helpers for the QQ scan-to-bind onboarding flow.
*
* <p>The bind portal encrypts the bot {@code client_secret} with a key
* supplied by this server, so the plaintext secret never travels in the
* clear. Ciphertext layout returned by the portal is:
*
* <pre>base64( IV(12 bytes) ciphertext(N bytes) AuthTag(16 bytes) )</pre>
*/
final class QQBindCrypto {
private static final int KEY_BYTES = 32;
private static final int IV_BYTES = 12;
private static final int TAG_BITS = 128;
private static final SecureRandom RANDOM = new SecureRandom();
private QQBindCrypto() {}
/** Generate a fresh 256-bit AES key, base64-encoded. */
static String generateKey() {
byte[] key = new byte[KEY_BYTES];
RANDOM.nextBytes(key);
return Base64.getEncoder().encodeToString(key);
}
/**
* Decrypt an AES-256-GCM ciphertext produced by the bind portal.
*
* @param encryptedBase64 base64-encoded {@code IV ciphertext tag}
* @param keyBase64 base64 AES key (same one passed to create_bind_task)
* @return decrypted UTF-8 plaintext
*/
static String decryptSecret(String encryptedBase64, String keyBase64) throws Exception {
byte[] key = Base64.getDecoder().decode(keyBase64);
byte[] raw = Base64.getDecoder().decode(encryptedBase64);
if (raw.length < IV_BYTES + (TAG_BITS / 8)) {
throw new IllegalArgumentException("ciphertext too short");
}
byte[] iv = new byte[IV_BYTES];
System.arraycopy(raw, 0, iv, 0, IV_BYTES);
byte[] ciphertextWithTag = new byte[raw.length - IV_BYTES];
System.arraycopy(raw, IV_BYTES, ciphertextWithTag, 0, ciphertextWithTag.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_BITS, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
byte[] plaintext = cipher.doFinal(ciphertextWithTag);
return new String(plaintext, java.nio.charset.StandardCharsets.UTF_8);
}
}

View File

@ -424,7 +424,7 @@ public class QQChannelAdapter extends AbstractChannelAdapter {
switch (op) { switch (op) {
case OP_HELLO -> handleHello((Map<String, Object>) data, ws); case OP_HELLO -> handleHello((Map<String, Object>) data, ws);
case OP_DISPATCH -> handleDispatch(eventType, (Map<String, Object>) data); case OP_DISPATCH -> handleDispatch(eventType, data);
case OP_HEARTBEAT_ACK -> log.trace("[qq] Heartbeat ACK received"); case OP_HEARTBEAT_ACK -> log.trace("[qq] Heartbeat ACK received");
case OP_RECONNECT -> { case OP_RECONNECT -> {
log.info("[qq] Server requested reconnect"); log.info("[qq] Server requested reconnect");
@ -515,34 +515,51 @@ public class QQChannelAdapter extends AbstractChannelAdapter {
/** /**
* 处理 DISPATCH 事件 * 处理 DISPATCH 事件
* <p>
* `data` payload shape varies by event: object for messages/READY, empty string for RESUMED.
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void handleDispatch(String eventType, Map<String, Object> data) { private void handleDispatch(String eventType, Object data) {
if (eventType == null || data == null) return; if (eventType == null) return;
switch (eventType) { switch (eventType) {
case "RESUMED" -> {
// RESUMED carries no payload (`d` is an empty string); don't read from it.
reconnectAttempts = 0;
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
log.info("[qq] RESUMED successfully");
}
case "READY" -> { case "READY" -> {
sessionId = (String) data.get("session_id"); if (!(data instanceof Map<?, ?> map)) {
log.warn("[qq] READY event has non-object data: {}", data);
return;
}
Map<String, Object> readyData = (Map<String, Object>) map;
sessionId = (String) readyData.get("session_id");
reconnectAttempts = 0; reconnectAttempts = 0;
quickDisconnectCount = 0; quickDisconnectCount = 0;
connectionState.set(ConnectionState.CONNECTED); connectionState.set(ConnectionState.CONNECTED);
lastError = null; lastError = null;
log.info("[qq] READY received, session_id={}", sessionId); log.info("[qq] READY received, session_id={}", sessionId);
} }
case "RESUMED" -> { case "C2C_MESSAGE_CREATE" -> dispatchMessage("c2c", eventType, data);
reconnectAttempts = 0; case "GROUP_AT_MESSAGE_CREATE" -> dispatchMessage("group", eventType, data);
connectionState.set(ConnectionState.CONNECTED); case "AT_MESSAGE_CREATE" -> dispatchMessage("guild", eventType, data);
lastError = null; case "DIRECT_MESSAGE_CREATE" -> dispatchMessage("dm", eventType, data);
log.info("[qq] RESUMED successfully");
}
case "C2C_MESSAGE_CREATE" -> handleMessageEvent("c2c", data);
case "GROUP_AT_MESSAGE_CREATE" -> handleMessageEvent("group", data);
case "AT_MESSAGE_CREATE" -> handleMessageEvent("guild", data);
case "DIRECT_MESSAGE_CREATE" -> handleMessageEvent("dm", data);
default -> log.debug("[qq] Unhandled event: {}", eventType); default -> log.debug("[qq] Unhandled event: {}", eventType);
} }
} }
@SuppressWarnings("unchecked")
private void dispatchMessage(String messageType, String eventType, Object data) {
if (!(data instanceof Map<?, ?> map)) {
log.warn("[qq] {} event has non-object data, skipping: {}", eventType, data);
return;
}
handleMessageEvent(messageType, (Map<String, Object>) map);
}
/** /**
* 处理消息事件C2C / Group / Guild / DM * 处理消息事件C2C / Group / Guild / DM
*/ */

View File

@ -0,0 +1,73 @@
package vip.mate.channel.qrcode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.qq.QQAppRegistrationService;
import vip.mate.channel.qrcode.util.QrCodeImageEncoder;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* QR-code auth provider for the QQ Bot scan-to-bind flow.
*
* <p>Wraps {@link QQAppRegistrationService} to expose its session model
* through the unified {@link ChannelQRCodeAuthProvider} contract. On
* {@code status=confirmed}, the credentials surface as {@code app_id} and
* {@code client_secret} matching the keys the QQ channel adapter reads
* from {@code configJson}.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QQQRCodeAuthProvider implements ChannelQRCodeAuthProvider {
private final QQAppRegistrationService service;
@Override
public String channelType() {
return "qq";
}
@Override
public Map<String, Object> begin(Map<String, String> params) throws Exception {
QQAppRegistrationService.RegistrationSession session = service.begin();
return Map.of("session_id", session.sessionId);
}
@Override
public Map<String, Object> pollStatus(String sessionId) {
QQAppRegistrationService.RegistrationSession session = service.getSession(sessionId);
if (session == null) {
return Map.of("status", "expired", "error", "session not found or expired");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", session.status.name().toLowerCase());
if (session.qrcodeUrl != null) {
body.put("qrcode_url", session.qrcodeUrl);
if (session.qrcodeImgDataUri == null) {
try {
session.qrcodeImgDataUri = QrCodeImageEncoder.toDataUri(session.qrcodeUrl);
} catch (Exception e) {
log.warn("[qq-register] QR encode failed: {}", e.getMessage());
}
}
if (session.qrcodeImgDataUri != null) {
body.put("qrcode_img", session.qrcodeImgDataUri);
}
}
if (session.status == QQAppRegistrationService.Status.CONFIRMED) {
// Key names mirror configJson fields that QQChannelAdapter reads.
body.put("app_id", session.clientId);
body.put("client_secret", session.clientSecret);
if (session.userOpenid != null) {
body.put("user_openid", session.userOpenid);
}
}
if (session.errorMessage != null) {
body.put("error", session.errorMessage);
}
return body;
}
}

View File

@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import vip.mate.channel.feishu.FeishuClientFactory;
import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper; import vip.mate.channel.repository.ChannelMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
@ -30,6 +32,20 @@ public class ChannelService {
private final ChannelMapper channelMapper; private final ChannelMapper channelMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/**
* Cache-eviction hook for the Feishu SDK client. {@link ObjectProvider}
* defers the lookup so this service stays usable in test contexts
* that don't load the Feishu beans, and so a future cycle (Feishu
* components transitively depending on this service) cannot crash
* Spring's eager constructor wiring.
*/
private final ObjectProvider<FeishuClientFactory> feishuClientFactoryProvider;
/**
* Reconcile hook for channel-native tools. {@link ObjectProvider}
* for the same reasons as above service still works in test
* contexts that don't load the channel-tool subsystem.
*/
private final ObjectProvider<vip.mate.channel.tool.ChannelToolService> channelToolServiceProvider;
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/** /**
@ -121,6 +137,7 @@ public class ChannelService {
channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson())); channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson()));
} }
channelMapper.updateById(channel); channelMapper.updateById(channel);
invalidateChannelCaches(channel.getId(), channel.getChannelType());
log.info("Updated channel: {}", existing.getName()); log.info("Updated channel: {}", existing.getName());
return channel; return channel;
} }
@ -131,6 +148,7 @@ public class ChannelService {
public void deleteChannel(Long id) { public void deleteChannel(Long id) {
ChannelEntity channel = getChannel(id); ChannelEntity channel = getChannel(id);
channelMapper.deleteById(id); channelMapper.deleteById(id);
invalidateChannelCaches(id, channel.getChannelType());
log.info("Deleted channel: {}", channel.getName()); log.info("Deleted channel: {}", channel.getName());
} }
@ -141,10 +159,39 @@ public class ChannelService {
ChannelEntity channel = getChannel(id); ChannelEntity channel = getChannel(id);
channel.setEnabled(enabled); channel.setEnabled(enabled);
channelMapper.updateById(channel); channelMapper.updateById(channel);
invalidateChannelCaches(id, channel.getChannelType());
log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled"); log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled");
return channel; return channel;
} }
/**
* Drop any cached per-channel SDK clients / tool registrations
* after a mutation. Today this only matters for Feishu (whose
* {@link FeishuClientFactory} caches a client per channelId);
* RFC 47's channel-tool reconcile service will hook in here too.
*/
private void invalidateChannelCaches(Long channelId, String channelType) {
if ("feishu".equals(channelType)) {
FeishuClientFactory factory = feishuClientFactoryProvider.getIfAvailable();
if (factory != null) {
factory.evict(channelId);
}
}
// Trigger an immediate channel-tool reconcile on this node so
// newly-enabled / -disabled / -reconfigured channels' tool sets
// align before the next reconcile tick. Other nodes catch up
// within ChannelToolService.RECONCILE_INTERVAL_SECONDS.
vip.mate.channel.tool.ChannelToolService cts =
channelToolServiceProvider.getIfAvailable();
if (cts != null) {
try {
cts.syncNow();
} catch (Exception e) {
log.debug("[ChannelService] syncNow() failed (non-fatal): {}", e.getMessage());
}
}
}
private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) { private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) {
Map<String, Object> incoming = parseConfig(incomingConfigJson); Map<String, Object> incoming = parseConfig(incomingConfigJson);
Map<String, Object> existing = parseConfig(existingConfigJson); Map<String, Object> existing = parseConfig(existingConfigJson);

View File

@ -0,0 +1,64 @@
package vip.mate.channel.tool;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.function.Function;
/**
* Convenience {@link ToolCallback} that wraps a {@code (name,
* description, schema, handler)} tuple saves channel-tool providers
* from having to spell out the whole {@code ToolCallback} interface
* for every handler.
*
* <p>Identical in spirit to the skill-runtime wrapper but kept in the
* channel-tool domain so cross-domain refactors don't accidentally
* couple the two.
*/
public class ChannelToolCallback implements ToolCallback {
private final ToolDefinition definition;
private final Function<String, String> handler;
public ChannelToolCallback(String name,
String description,
String inputSchema,
Function<String, String> handler) {
this.definition = ToolDefinition.builder()
.name(name)
.description(description)
.inputSchema(inputSchema)
.build();
this.handler = handler;
}
@Override
public ToolDefinition getToolDefinition() {
return definition;
}
@Override
public String call(String toolInput) {
return handler.apply(toolInput);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
return handler.apply(toolInput);
}
/**
* Build a new callback that is identical in every way except it
* carries the supplied {@code actualName}. Used by
* {@link ChannelToolService} to apply the {@code _c<channelId>}
* suffix without having the provider know about per-instance names.
*/
public ToolCallback renamed(String actualName) {
return new ChannelToolCallback(
actualName,
definition.description(),
definition.inputSchema(),
handler);
}
}

View File

@ -0,0 +1,45 @@
package vip.mate.channel.tool;
import java.util.Map;
/**
* Context handed to {@link ChannelToolProvider#createTools(ChannelToolContext)}
* when materialising tools for one specific {@code mate_channel} row.
*
* <p>{@link #channelId()} alone is enough for the provider to call its
* SDK-client factory (e.g. {@code FeishuClientFactory.client(channelId)});
* the remaining fields are convenience for handlers that want to
* default any "who should I attribute this to" or "which app are we
* acting on behalf of" parameters.
*
* @param channelId {@code mate_channel.id} the only required field
* @param channelName display name of the channel row (for log clarity)
* @param channelType {@code mate_channel.channel_type} matches
* {@link ChannelToolProvider#channelType()}
* @param agentId the {@code mate_agent.id} this channel routes
* inbound messages to, may be null when the channel
* is unbound
* @param config parsed {@code mate_channel.config_json} as a map
* (e.g. {@code app_id}, {@code app_secret},
* {@code domain}). Provided so handlers don't each
* re-parse the JSON.
*/
public record ChannelToolContext(
Long channelId,
String channelName,
String channelType,
Long agentId,
Map<String, Object> config) {
public ChannelToolContext {
if (channelId == null) {
throw new IllegalArgumentException("ChannelToolContext.channelId must not be null");
}
if (channelType == null || channelType.isBlank()) {
throw new IllegalArgumentException("ChannelToolContext.channelType must be non-blank");
}
if (config == null) {
config = Map.of();
}
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.channel.tool;
/**
* Static description of one channel-native tool what
* {@link ChannelToolProvider#describeTools()} returns. Used by
* {@link ChannelToolService} to upsert {@code mate_tool} rows and by
* the admin UI to render the tool in the Channel group before any
* specific channel instance is materialised.
*
* <p>"Static" here means: no credentials needed, no network call
* pure metadata about a tool that this channel type COULD expose if
* connected. The per-instance materialisation (binding the tool to a
* specific {@code mate_channel} row + its SDK client) is the second
* step performed by {@link ChannelToolProvider#createTools}.
*
* @param name tool base name (e.g.
* {@code "feishu_calendar_create_event"}).
* Per-instance materialisation prefixes
* {@code _c<channelId>} to keep the actual
* registered tool name stable across CRUD
* see {@link ChannelToolService}.
* @param displayName human-readable label for UI
* @param description short description LLM uses to decide whether
* to call this tool. Long-form usage notes
* belong in a companion skill package.
* @param inputSchema JSON Schema string describing the tool's args
* @param mutating {@code true} for write operations. The tool
* row defaults to {@code enabled=false} and
* {@link ChannelToolService} seeds a DB rule
* so that calls hit Guard / approval before
* executing.
* @param enabledByDefault should the {@code mate_tool} row be created
* with {@code enabled=true}? Forced to
* {@code false} when {@link #mutating()}.
*/
public record ChannelToolDescriptor(
String name,
String displayName,
String description,
String inputSchema,
boolean mutating,
boolean enabledByDefault) {
public ChannelToolDescriptor {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("ChannelToolDescriptor.name must be non-blank");
}
if (description == null || description.isBlank()) {
throw new IllegalArgumentException("ChannelToolDescriptor.description must be non-blank");
}
if (inputSchema == null || inputSchema.isBlank()) {
throw new IllegalArgumentException("ChannelToolDescriptor.inputSchema must be non-blank");
}
// Mutating tools always start disabled regardless of caller intent.
if (mutating && enabledByDefault) {
enabledByDefault = false;
}
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.channel.tool;
import org.springframework.ai.tool.ToolCallback;
import java.util.List;
/**
* SPI implemented once per channel type that exposes platform-native
* capabilities (e.g. Feishu calendar / docx, WeCom approval, DingTalk
* task) as Agent tools avoiding the need for each user to also
* configure a separate MCP server with duplicate credentials.
*
* <p>Implementations are plain Spring beans. {@link ChannelToolService}
* collects every {@code ChannelToolProvider} bean at startup, indexed
* by {@link #channelType()}; channel CRUD then reconciles per
* {@code mate_channel} row by calling {@link #describeTools()} (for
* the static catalog DB upsert) and {@link #createTools} (for the
* per-instance ToolCallback materialisation).
*
* <p>Implementations must NOT touch credentials in
* {@link #describeTools()} that path is invoked even when no channel
* is configured. Credential access is restricted to
* {@link #createTools(ChannelToolContext)} which receives an already-
* validated context.
*/
public interface ChannelToolProvider {
/**
* Channel type this provider serves matches
* {@code ChannelAdapter.getChannelType()} (e.g. {@code "feishu"}).
* Used by {@link ChannelToolService} for routing.
*/
String channelType();
/**
* Static catalogue of every tool this channel type COULD expose.
* Pure metadata no credentials, no I/O. Called once at startup
* to seed {@code mate_tool} rows for the admin UI.
*/
List<ChannelToolDescriptor> describeTools();
/**
* Materialise per-instance {@link ToolCallback} instances for the
* given channel row. Invoked by {@link ChannelToolService} during
* reconcile; the returned callbacks are registered into
* {@code ToolRegistry} via
* {@code ToolRegistry.registerPluginTool(...)}.
*
* <p>Each returned callback's {@code getToolDefinition().name()}
* must match a descriptor in {@link #describeTools()} (the service
* will rename to the per-instance actual name).
*/
List<ToolCallback> createTools(ChannelToolContext context);
}

View File

@ -0,0 +1,459 @@
package vip.mate.channel.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.guard.engine.ToolGuardRuleRegistry;
import vip.mate.tool.guard.model.ToolGuardRuleEntity;
import vip.mate.tool.guard.repository.ToolGuardRuleMapper;
import vip.mate.tool.model.ToolEntity;
import vip.mate.tool.repository.ToolMapper;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Node-local reconciler that keeps every {@link ChannelToolProvider}'s
* tool callbacks registered in {@link ToolRegistry} in sync with the
* currently-enabled {@code mate_channel} rows whose type has a
* registered provider.
*
* <p>Three trigger points, all idempotent:
* <ol>
* <li><b>Startup</b> {@link ApplicationRunner} runs once per node
* after the Spring context is ready</li>
* <li><b>Periodic (60s)</b> picks up changes made by another node
* (config rotated, channel enabled / disabled), per RFC v3
* "reconcile-driven, not adapter-lifecycle-driven"</li>
* <li><b>Local CRUD</b> {@code ChannelService} calls
* {@link #syncNow()} after every channel mutation so the local
* node aligns instantly (other nodes wait at most one tick)</li>
* </ol>
*
* <p>Decoupled from {@code ChannelManager} / adapter lifecycle / leader
* election: tools are pure OpenAPI calls keyed by channelId and never
* need the WebSocket or the leader lease. RFC §4.1 makes this explicit.
*
* <p>Tool names get a stable {@code _c<channelId>} suffix
* unconditionally the channelId is immutable for the channel's
* lifetime, so the registered tool name never drifts even when other
* channels of the same type are added / deleted (RFC v5 §4.3).
*/
@Slf4j
@Component
public class ChannelToolService {
/** Periodic reconcile cadence — RFC default. */
static final long RECONCILE_INTERVAL_SECONDS = 60;
/** Suffix prefix appended before the channelId on the actual tool name. */
public static final String INSTANCE_SUFFIX_PREFIX = "_c";
private final List<ChannelToolProvider> providerBeans;
private final ChannelMapper channelMapper;
private final ToolMapper toolMapper;
private final ToolRegistry toolRegistry;
private final ObjectMapper objectMapper;
private final ToolGuardRuleMapper guardRuleMapper;
private final ToolGuardRuleRegistry guardRuleRegistry;
/** Indexed at startup: channelType → provider. */
private Map<String, ChannelToolProvider> providersByType = Map.of();
/** Node-local registration state: channelId → list of actual tool names registered. */
private final ConcurrentHashMap<Long, List<String>> registered = new ConcurrentHashMap<>();
/** channelId → the {@code update_time} value last seen on reconcile (config-change detection). */
private final ConcurrentHashMap<Long, LocalDateTime> registeredUpdateTime = new ConcurrentHashMap<>();
/** Daemon scheduler for the periodic tick. */
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "channel-tool-reconcile");
t.setDaemon(true);
return t;
});
public ChannelToolService(List<ChannelToolProvider> providerBeans,
ChannelMapper channelMapper,
ToolMapper toolMapper,
ToolRegistry toolRegistry,
ObjectMapper objectMapper,
ToolGuardRuleMapper guardRuleMapper,
ToolGuardRuleRegistry guardRuleRegistry) {
this.providerBeans = providerBeans != null ? providerBeans : List.of();
this.channelMapper = channelMapper;
this.toolMapper = toolMapper;
this.toolRegistry = toolRegistry;
this.objectMapper = objectMapper;
this.guardRuleMapper = guardRuleMapper;
this.guardRuleRegistry = guardRuleRegistry;
}
@PostConstruct
void index() {
providersByType = providerBeans.stream()
.collect(Collectors.toUnmodifiableMap(
ChannelToolProvider::channelType,
p -> p,
(a, b) -> {
log.warn("[channel-tool] Duplicate ChannelToolProvider for type {} ({} vs {}); keeping first",
a.channelType(), a.getClass().getSimpleName(), b.getClass().getSimpleName());
return a;
}));
if (providersByType.isEmpty()) {
log.info("[channel-tool] No ChannelToolProvider beans wired — reconcile loop will be a no-op");
} else {
log.info("[channel-tool] Registered providers: {}", providersByType.keySet());
}
// Schedule the periodic tick. Startup reconcile runs separately
// via the ApplicationRunner bean below so the Spring context
// is fully ready (including any provider's transitive deps).
scheduler.scheduleAtFixedRate(this::tickQuietly,
RECONCILE_INTERVAL_SECONDS, RECONCILE_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
/**
* Initial reconcile after Spring is fully ready. Spring runs every
* {@link ApplicationRunner} bean after all {@code @PostConstruct}
* hooks but before serving traffic.
*/
@org.springframework.context.annotation.Bean
ApplicationRunner channelToolStartupReconcile() {
return args -> {
log.info("[channel-tool] Startup reconcile");
tickQuietly();
};
}
@PreDestroy
void shutdown() {
scheduler.shutdown();
}
/**
* Trigger an immediate reconcile from this node. Called by
* {@code ChannelService} after channel CRUD so the local node
* aligns within the same request; other nodes catch up at the
* next periodic tick.
*/
public void syncNow() {
tickQuietly();
}
private void tickQuietly() {
try {
reconcile();
} catch (Exception e) {
log.warn("[channel-tool] reconcile failed (will retry next tick): {}", e.getMessage(), e);
}
}
/**
* Diff the local registration against the expected set (enabled
* channels of types we have providers for) and apply just the
* deltas unregister stale, register new, rebuild on config change.
* Idempotent.
*/
synchronized void reconcile() {
if (providersByType.isEmpty()) {
return;
}
List<ChannelEntity> enabled = channelMapper.selectList(
new LambdaQueryWrapper<ChannelEntity>().eq(ChannelEntity::getEnabled, true));
Map<Long, ChannelEntity> desired = new HashMap<>();
for (ChannelEntity ch : enabled) {
if (providersByType.containsKey(ch.getChannelType())) {
desired.put(ch.getId(), ch);
}
}
// 0. Cross-process orphan sweep catches rows left over from channels
// that were deleted while this node was down (the unregister loop below
// only sees channels this process once registered, so without the sweep
// pre-existing orphans live forever).
sweepOrphans(desired.keySet());
// 1. Unregister channels no longer in the desired set
for (Long goneId : new ArrayList<>(registered.keySet())) {
if (!desired.containsKey(goneId)) {
unregisterChannel(goneId);
deleteToolRows(goneId);
}
}
// 2. Register / re-register changed or new channels
for (ChannelEntity ch : desired.values()) {
LocalDateTime seen = registeredUpdateTime.get(ch.getId());
if (seen != null && seen.equals(ch.getUpdateTime())) {
continue; // already registered + config unchanged
}
// Config changed drop the stale callbacks before re-registering
// so handler closures don't keep stale config.
unregisterChannel(ch.getId());
try {
registerChannel(ch);
} catch (Exception e) {
log.warn("[channel-tool] register failed for channel {} ({}): {}",
ch.getId(), ch.getChannelType(), e.getMessage());
}
}
}
private void registerChannel(ChannelEntity ch) {
ChannelToolProvider provider = providersByType.get(ch.getChannelType());
if (provider == null) return;
List<ChannelToolDescriptor> descriptors = provider.describeTools();
if (descriptors == null || descriptors.isEmpty()) {
log.debug("[channel-tool] Provider {} returned no descriptors; skipping channel {}",
provider.channelType(), ch.getId());
return;
}
Map<String, String> nameMap = upsertToolRows(ch, descriptors);
seedGuardRules(ch, descriptors, nameMap);
ChannelToolContext context = new ChannelToolContext(
ch.getId(), ch.getName(), ch.getChannelType(), ch.getAgentId(),
parseConfig(ch.getConfigJson()));
List<ToolCallback> callbacks;
try {
callbacks = provider.createTools(context);
} catch (Exception e) {
log.warn("[channel-tool] createTools failed for channel {} ({}): {}",
ch.getId(), provider.channelType(), e.getMessage());
return;
}
if (callbacks == null) return;
List<String> actualNames = new ArrayList<>();
for (ToolCallback cb : callbacks) {
String baseName = cb.getToolDefinition().name();
String actualName = nameMap.getOrDefault(baseName, baseName + INSTANCE_SUFFIX_PREFIX + ch.getId());
ToolCallback renamed = (cb instanceof ChannelToolCallback ctc)
? ctc.renamed(actualName)
: cb; // legacy callback (any other ToolCallback impl) registers under its own name
toolRegistry.registerPluginTool(renamed, () -> isToolRowEnabled(actualName));
actualNames.add(actualName);
}
registered.put(ch.getId(), actualNames);
registeredUpdateTime.put(ch.getId(), ch.getUpdateTime());
log.info("[channel-tool] Registered {} tool(s) for channel {} ({})",
actualNames.size(), ch.getId(), ch.getChannelType());
}
private void unregisterChannel(Long channelId) {
registeredUpdateTime.remove(channelId);
List<String> names = registered.remove(channelId);
if (names != null && !names.isEmpty()) {
names.forEach(toolRegistry::unregisterPluginTool);
log.info("[channel-tool] Unregistered {} tool(s) for channel {}", names.size(), channelId);
}
}
/**
* Upsert {@code mate_tool} rows for each descriptor; returns the
* baseName actual-name map the caller uses when renaming the
* provider's callbacks. DB-level uniqueness on {@code mate_tool.name}
* (introduced in V100) makes this safe under concurrent reconcile
* from multiple nodes.
*/
private Map<String, String> upsertToolRows(ChannelEntity ch, List<ChannelToolDescriptor> descriptors) {
Map<String, String> nameMap = new HashMap<>();
for (ChannelToolDescriptor d : descriptors) {
String actualName = d.name() + INSTANCE_SUFFIX_PREFIX + ch.getId();
nameMap.put(d.name(), actualName);
ToolEntity existing = toolMapper.selectOne(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getName, actualName));
if (existing == null) {
ToolEntity row = new ToolEntity();
row.setName(actualName);
row.setDisplayName(d.displayName() + " (" + ch.getName() + ")");
row.setDescription(d.description());
row.setToolType("channel");
row.setParamsSchema(d.inputSchema());
row.setEnabled(d.enabledByDefault());
row.setBuiltin(false);
row.setChannelId(ch.getId());
try {
toolMapper.insert(row);
} catch (org.springframework.dao.DuplicateKeyException race) {
// Another node beat us to the insert that's the
// whole point of the uk_mate_tool_name unique index.
log.debug("[channel-tool] tool row {} already inserted by another node", actualName);
}
} else {
// Refresh metadata that may have evolved between releases
// (description rewrites, schema updates) without clobbering
// the user's enable / disable preference.
boolean dirty = false;
String newDisplay = d.displayName() + " (" + ch.getName() + ")";
if (!newDisplay.equals(existing.getDisplayName())) { existing.setDisplayName(newDisplay); dirty = true; }
if (!d.description().equals(existing.getDescription())) { existing.setDescription(d.description()); dirty = true; }
if (!d.inputSchema().equals(existing.getParamsSchema())) { existing.setParamsSchema(d.inputSchema()); dirty = true; }
if (existing.getChannelId() == null) { existing.setChannelId(ch.getId()); dirty = true; }
if (!"channel".equals(existing.getToolType())) { existing.setToolType("channel"); dirty = true; }
if (dirty) toolMapper.updateById(existing);
}
}
return nameMap;
}
/**
* Seed one HIGH-severity DB rule per mutating descriptor so the
* tool's invocation gets evaluated by {@code DbRuleGuardian}
* {@code NEEDS_APPROVAL}. The rule pattern is {@code ".*"} so
* every invocation matches; the severity is what drives the
* approval decision, not pattern specificity.
*
* <p>Idempotent: a stable {@code rule_id} per (tool, channel) +
* {@code ON DUPLICATE KEY UPDATE}-style upsert keeps re-reconcile
* safe. Triggers a registry reload so the new rule is immediately
* visible to the next invocation.
*/
private void seedGuardRules(ChannelEntity ch, List<ChannelToolDescriptor> descriptors, Map<String, String> nameMap) {
String legacyName = "Channel write tool — approval required";
String channelScopedName = legacyName + " (" + ch.getName() + ")";
boolean changed = false;
for (ChannelToolDescriptor d : descriptors) {
if (!d.mutating()) continue;
String actualName = nameMap.get(d.name());
if (actualName == null) continue;
String ruleId = "channel_tool:" + actualName;
ToolGuardRuleEntity existing = guardRuleMapper.selectOne(
new LambdaQueryWrapper<ToolGuardRuleEntity>().eq(ToolGuardRuleEntity::getRuleId, ruleId));
if (existing != null) {
// One-time migration: rename rows that still carry the original
// hardcoded label so the UI can tell channels apart. User-edited
// names (anything other than the legacy literal) are preserved.
if (legacyName.equals(existing.getName())) {
existing.setName(channelScopedName);
guardRuleMapper.updateById(existing);
changed = true;
}
continue;
}
ToolGuardRuleEntity row = new ToolGuardRuleEntity();
row.setRuleId(ruleId);
row.setName(channelScopedName);
row.setDescription("Auto-seeded approval gate for channel-native write tool " + actualName);
row.setToolName(actualName);
row.setParamName("args");
row.setCategory("SENSITIVE_FILE_ACCESS");
row.setSeverity("HIGH");
row.setDecision("NEEDS_APPROVAL");
row.setPattern(".*"); // every invocation matches
row.setRemediation("Confirm the requested change is intended, then approve.");
row.setBuiltin(false);
row.setEnabled(true);
row.setPriority(100);
try {
guardRuleMapper.insert(row);
changed = true;
log.info("[channel-tool] Seeded approval rule for write tool {}", actualName);
} catch (org.springframework.dao.DuplicateKeyException race) {
// Another node beat us to it the existing row is fine.
} catch (Exception e) {
log.warn("[channel-tool] Failed to seed guard rule for {}: {}", actualName, e.getMessage());
}
}
if (changed) {
try {
guardRuleRegistry.reload();
} catch (Exception e) {
log.debug("[channel-tool] guard rule reload failed (non-fatal): {}", e.getMessage());
}
}
}
/**
* Reverse reconciliation: drop any channel-scoped {@code mate_tool} row
* whose {@code channel_id} is not in the live set, then drop any seeded
* {@code mate_tool_guard_rule} whose target tool no longer exists. Closes
* the gap left by {@link #reconcile()}'s unregister loop, which only sees
* channels this process registered itself orphans from channels deleted
* while the node was down survived previously.
*/
private void sweepOrphans(Set<Long> liveChannelIds) {
List<ToolEntity> channelTools = toolMapper.selectList(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getToolType, "channel"));
List<Long> staleToolIds = channelTools.stream()
.filter(t -> t.getChannelId() == null || !liveChannelIds.contains(t.getChannelId()))
.map(ToolEntity::getId)
.toList();
if (!staleToolIds.isEmpty()) {
toolMapper.delete(new LambdaQueryWrapper<ToolEntity>().in(ToolEntity::getId, staleToolIds));
log.info("[channel-tool] Swept {} orphan mate_tool row(s)", staleToolIds.size());
}
Set<String> liveChannelToolNames = new HashSet<>();
for (ToolEntity t : channelTools) {
if (t.getChannelId() != null && liveChannelIds.contains(t.getChannelId())) {
liveChannelToolNames.add(t.getName());
}
}
List<ToolGuardRuleEntity> seededRules = guardRuleMapper.selectList(
new LambdaQueryWrapper<ToolGuardRuleEntity>().likeRight(ToolGuardRuleEntity::getRuleId, "channel_tool:"));
List<Long> staleRuleIds = seededRules.stream()
.filter(r -> !liveChannelToolNames.contains(r.getToolName()))
.map(ToolGuardRuleEntity::getId)
.toList();
if (!staleRuleIds.isEmpty()) {
guardRuleMapper.delete(new LambdaQueryWrapper<ToolGuardRuleEntity>().in(ToolGuardRuleEntity::getId, staleRuleIds));
try {
guardRuleRegistry.reload();
} catch (Exception e) {
log.debug("[channel-tool] guard rule reload after sweep failed (non-fatal): {}", e.getMessage());
}
log.info("[channel-tool] Swept {} orphan mate_tool_guard_rule row(s)", staleRuleIds.size());
}
}
private void deleteToolRows(Long channelId) {
int deleted = toolMapper.delete(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getChannelId, channelId));
if (deleted > 0) {
log.info("[channel-tool] Deleted {} mate_tool row(s) for channel {}", deleted, channelId);
}
}
private boolean isToolRowEnabled(String actualName) {
ToolEntity row = toolMapper.selectOne(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getName, actualName));
return row != null && Boolean.TRUE.equals(row.getEnabled());
}
private Map<String, Object> parseConfig(String configJson) {
if (configJson == null || configJson.isBlank()) return Map.of();
try {
return objectMapper.readValue(configJson, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[channel-tool] Failed to parse configJson: {}", e.getMessage());
return Map.of();
}
}
// ---- test inspection ----
int registeredChannelCount() { return registered.size(); }
Map<String, ChannelToolProvider> providersByTypeForTest() { return providersByType; }
}

View File

@ -520,6 +520,11 @@ public class ChatController {
AtomicBoolean finalized = new AtomicBoolean(false); AtomicBoolean finalized = new AtomicBoolean(false);
try { try {
conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId); conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId);
// Pin the model the user picked for this conversation so later
// turns (and the runtime model resolver) honour it independently
// of every other conversation.
conversationService.updateConversationModel(conversationId,
request.getModelProvider(), request.getModelName());
List<MessageContentPart> requestParts = normalizeRequestParts(request); List<MessageContentPart> requestParts = normalizeRequestParts(request);
String promptText = buildPromptText(message, requestParts); String promptText = buildPromptText(message, requestParts);
conversationService.saveMessage(conversationId, "user", message, requestParts); conversationService.saveMessage(conversationId, "user", message, requestParts);
@ -932,7 +937,7 @@ public class ChatController {
String username = auth != null ? auth.getName() : "anonymous"; String username = auth != null ? auth.getName() : "anonymous";
// 权限校验已认证用户需验证会话归属匿名用户permitAll直接放行 // 权限校验已认证用户需验证会话归属匿名用户permitAll直接放行
if (auth != null && !conversationService.isConversationOwner(conversationId, username)) { if (auth != null && !conversationService.isConversationOwner(conversationId, username)) {
return R.fail("无权操作该会话"); return R.fail(403, "无权操作该会话");
} }
boolean stopped = streamTracker.requestStop(conversationId); boolean stopped = streamTracker.requestStop(conversationId);
@ -975,7 +980,7 @@ public class ChatController {
Authentication auth) { Authentication auth) {
String username = auth != null ? auth.getName() : "anonymous"; String username = auth != null ? auth.getName() : "anonymous";
if (auth != null && !conversationService.isConversationOwner(conversationId, username)) { if (auth != null && !conversationService.isConversationOwner(conversationId, username)) {
return R.fail("无权操作该会话"); return R.fail(403, "无权操作该会话");
} }
if (!streamTracker.isRunning(conversationId)) { if (!streamTracker.isRunning(conversationId)) {
@ -1024,7 +1029,7 @@ public class ChatController {
String username = auth != null ? auth.getName() : null; String username = auth != null ? auth.getName() : null;
if (username == null) { if (username == null) {
return R.fail("未登录,请先登录"); return R.fail(401, "未登录,请先登录");
} }
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId);
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts()); conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts());
@ -1047,7 +1052,7 @@ public class ChatController {
// 校验会话归属会话可能尚未创建此时允许上传后续 stream/chat 会创建并绑定用户 // 校验会话归属会话可能尚未创建此时允许上传后续 stream/chat 会创建并绑定用户
if (conversationService.conversationExists(conversationId) if (conversationService.conversationExists(conversationId)
&& !conversationService.isConversationOwner(conversationId, username)) { && !conversationService.isConversationOwner(conversationId, username)) {
return R.fail("无权操作该会话"); return R.fail(403, "无权操作该会话");
} }
if (file.isEmpty()) { if (file.isEmpty()) {
return R.fail("上传文件不能为空"); return R.fail("上传文件不能为空");
@ -1151,6 +1156,14 @@ public class ChatController {
private Long lastEventId; private Long lastEventId;
/** 思考深度off / low / medium / high / maxnull 表示跟随 Agent 默认 */ /** 思考深度off / low / medium / high / maxnull 表示跟随 Agent 默认 */
private String thinkingLevel; private String thinkingLevel;
/**
* Provider id of the model the user picked for this conversation.
* Paired with {@link #modelName}; null means "no per-conversation
* override use the agent / global default".
*/
private String modelProvider;
/** Model id the user picked for this conversation. See {@link #modelProvider}. */
private String modelName;
} }
/** /**
@ -1424,6 +1437,29 @@ public class ChatController {
if (savedAssistant.getRuntimeProvider() != null && !savedAssistant.getRuntimeProvider().isBlank()) { if (savedAssistant.getRuntimeProvider() != null && !savedAssistant.getRuntimeProvider().isBlank()) {
payload.put("runtimeProvider", savedAssistant.getRuntimeProvider()); payload.put("runtimeProvider", savedAssistant.getRuntimeProvider());
} }
// Surface the server-authoritative segments timeline. The live SSE
// path builds metadata.segments from streamed deltas only, so
// server-side annotations added at persist time (e.g. the
// 'superseded' marker the SegmentSupersedeDetector writes onto
// pre-tool model claims that the actual tool result replaced)
// never reach the in-memory message until a page reload triggers
// a refetch via /messages. Inlining them in the done payload lets
// the client merge the markers onto its local segments by id
// without an extra HTTP round-trip.
String rawMetadata = savedAssistant.getMetadata();
if (rawMetadata != null && !rawMetadata.isBlank()) {
try {
Map<String, Object> parsed = objectMapper.readValue(rawMetadata,
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
Object segs = parsed.get("segments");
if (segs instanceof java.util.List<?> list && !list.isEmpty()) {
payload.put("segments", segs);
}
} catch (Exception ignored) {
// Best-effort: malformed metadata just means the client falls
// back to its existing "wait for reload" reconcile path.
}
}
} }
if (promptTokens > 0) payload.put("promptTokens", promptTokens); if (promptTokens > 0) payload.put("promptTokens", promptTokens);
if (completionTokens > 0) payload.put("completionTokens", completionTokens); if (completionTokens > 0) payload.put("completionTokens", completionTokens);
@ -1982,6 +2018,7 @@ public class ChatController {
synchronized String toMetadataJson() { synchronized String toMetadataJson() {
finalizeToolCalls(); finalizeToolCalls();
finalizeRunningSegments("thinking", "content", "tool_call"); finalizeRunningSegments("thinking", "content", "tool_call");
SegmentSupersedeDetector.markSuperseded(segments);
try { try {
Map<String, Object> metadata = new LinkedHashMap<>(); Map<String, Object> metadata = new LinkedHashMap<>();
if (!toolCalls.isEmpty()) { if (!toolCalls.isEmpty()) {

View File

@ -394,6 +394,11 @@ public class ChatStreamTracker {
|| "error".equals(eventName) || "error".equals(eventName)
|| "tool_approval_requested".equals(eventName) || "tool_approval_requested".equals(eventName)
|| "phase".equals(eventName) || "phase".equals(eventName)
// Plan lifecycle events from a child agent: flush buffered
// tool calls first so the parent timeline preserves order.
|| "plan_created".equals(eventName)
|| "plan_step_started".equals(eventName)
|| "plan_step_completed".equals(eventName)
|| "done".equals(eventName); || "done".equals(eventName);
} }
@ -453,7 +458,18 @@ public class ChatStreamTracker {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
if (state != null && state.done) { if (state != null && state.done) {
stopHeartbeat(conversationId); stopHeartbeat(conversationId);
runs.put(conversationId, new RunState(conversationId)); RunState nextState = new RunState(conversationId);
int carried = 0;
QueuedInput queued;
while ((queued = state.messageQueue.poll()) != null) {
nextState.messageQueue.offer(queued);
carried++;
}
runs.put(conversationId, nextState);
if (carried > 0) {
log.info("[ChatStreamTracker] Carried {} queued message(s) into next run: {}",
carried, conversationId);
}
} else if (state != null) { } else if (state != null) {
// Reuse path: when complete() early-returns due to activeFluxCount > 0 // Reuse path: when complete() early-returns due to activeFluxCount > 0
// (approval replay / interrupt / any leaked flux increment), the RunState // (approval replay / interrupt / any leaked flux increment), the RunState
@ -1448,17 +1464,64 @@ public class ChatStreamTracker {
/** 已完成的 RunState 保留时间5 分钟) */ /** 已完成的 RunState 保留时间5 分钟) */
private static final long DONE_RETENTION_MS = 5 * 60 * 1000; private static final long DONE_RETENTION_MS = 5 * 60 * 1000;
/** RunState 最大存活时间30 分钟,防止挂起的流永远占内存) */
private static final long MAX_LIFETIME_MS = 30 * 60 * 1000; /**
* RunState 最长无活动时间 wall-clock {@code MAX_LIFETIME_MS=30min}
* 切换到 inactivity-based 后默认 30 min hermes-agent
* {@code gateway_timeout=1800s} 同口径只要 agent 还在持续产事件
* tool call / content delta / phase transition / progress_update
* 就一直活下去墙钟跑 1 小时 2 小时都可以只有真正"完全静默 ≥ N 分钟"
* 才视为卡死并强制清理
*
* <p>修复的背景round-6 10-LLM 横评任务实际跑了 47 min全程都在
* tool call但旧的 wall-clock 30 min 死线在 iter 128 / 8 of 10
* 就把 RunState 清掉了 SSE 流死UI 空白用户以为任务挂了换成
* inactivity 那种长任务永远不会被误清而真正卡死的 agent无活动
* 5+ 分钟会按时清理可通过 property
* {@code mateclaw.sse.idle-timeout-minutes} 调整
*/
@org.springframework.beans.factory.annotation.Value("${mateclaw.sse.idle-timeout-minutes:30}")
private int idleTimeoutMinutes = 30;
/**
* Test hook backdates the {@code lastEventAt} timestamp on an
* existing RunState so {@link #cleanupStaleRuns()} can be exercised
* deterministically without sleeping for minutes. Package-private on
* purpose; production callers go through {@link #broadcast} which
* stamps the field forward.
*/
void backdateLastEventForTesting(String conversationId, long lastEventAt) {
RunState state = runs.get(conversationId);
if (state != null) {
state.lastEventAt = lastEventAt;
}
}
/** Test hook — true when a RunState row exists for the conversation. */
boolean hasRunStateForTesting(String conversationId) {
return runs.containsKey(conversationId);
}
/** Test hook — exposes the configurable timeout for assertion. */
int idleTimeoutMinutesForTesting() {
return idleTimeoutMinutes;
}
/** Test hook — override the timeout in pure-unit tests that bypass Spring. */
void setIdleTimeoutMinutesForTesting(int minutes) {
this.idleTimeoutMinutes = minutes;
}
/** /**
* 定期清理过期的 RunState防止内存泄漏 * 定期清理过期的 RunState防止内存泄漏
* - 已完成超过 5 分钟的 移除 * - 已完成超过 {@link #DONE_RETENTION_MS} 移除
* - 存活超过 30 分钟的无论是否完成 强制移除 * - {@link RunState#lastEventAt} 算起静默超过
* {@link #idleTimeoutMinutes} 分钟的 强制移除视为卡死
*/ */
@org.springframework.scheduling.annotation.Scheduled(fixedRate = 600_000) @org.springframework.scheduling.annotation.Scheduled(fixedRate = 600_000)
public void cleanupStaleRuns() { public void cleanupStaleRuns() {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long idleThresholdMs = (long) idleTimeoutMinutes * 60_000L;
int evicted = 0; int evicted = 0;
var iterator = runs.entrySet().iterator(); var iterator = runs.entrySet().iterator();
@ -1466,6 +1529,7 @@ public class ChatStreamTracker {
var entry = iterator.next(); var entry = iterator.next();
RunState state = entry.getValue(); RunState state = entry.getValue();
long age = now - state.createdAt; long age = now - state.createdAt;
long idleMs = now - state.lastEventAt;
boolean shouldEvict = false; boolean shouldEvict = false;
String reason = null; String reason = null;
@ -1473,12 +1537,35 @@ public class ChatStreamTracker {
if (state.done && age > DONE_RETENTION_MS) { if (state.done && age > DONE_RETENTION_MS) {
shouldEvict = true; shouldEvict = true;
reason = "completed and expired"; reason = "completed and expired";
} else if (age > MAX_LIFETIME_MS) { } else if (idleMs > idleThresholdMs) {
shouldEvict = true; shouldEvict = true;
reason = "exceeded max lifetime (" + (age / 1000) + "s)"; reason = "idle for " + (idleMs / 1000) + "s (threshold "
+ idleTimeoutMinutes + "min); total wall-clock age "
+ (age / 1000) + "s";
} }
if (shouldEvict) { if (shouldEvict) {
// Flush any accumulated assistant content/segments BEFORE we
// dispose the run mirrors {@link #onShutdown()} so an idle-
// timeout eviction doesn't leave the conversation with only
// the user message and no assistant trace (the round-6
// failure mode: SSE evicted mid-stream, UI refresh saw blank
// because doOnComplete never fired for the disposed Flux).
// Skip on completed runs they already saved via the normal
// doOnComplete path.
if (!state.done) {
Runnable cb = state.emergencySaveCallback;
if (cb != null) {
try {
cb.run();
log.info("[SSE] Emergency-saved state for conversation={} before eviction",
entry.getKey());
} catch (Exception ex) {
log.warn("[SSE] Emergency save failed for conversation={}: {}",
entry.getKey(), ex.getMessage());
}
}
}
// 先清理资源再移除 // 先清理资源再移除
stopHeartbeat(entry.getKey()); stopHeartbeat(entry.getKey());
Disposable d = state.disposable; Disposable d = state.disposable;
@ -1563,7 +1650,7 @@ public class ChatStreamTracker {
} }
} }
// ===== Runtime snapshot surface (admin Backstage) ===== // ===== Runtime snapshot surface (admin Live view) =====
/** /**
* Bind the resolved agent + owner to the active run so the runtime * Bind the resolved agent + owner to the active run so the runtime
@ -1603,7 +1690,7 @@ public class ChatStreamTracker {
) {} ) {}
/** /**
* Snapshot every active run. Used by the admin Backstage to render the * Snapshot every active run. Used by the admin Live view to render the
* global "what are my agents doing right now" view. Returned list is a * global "what are my agents doing right now" view. Returned list is a
* defensive copy callers may freely sort / filter it. * defensive copy callers may freely sort / filter it.
*/ */
@ -1640,7 +1727,7 @@ public class ChatStreamTracker {
} }
/** /**
* Force a wedged run to terminate. Used by the admin Backstage's * Force a wedged run to terminate. Used by the admin Live view's
* "End it" action when the friendly stop has been observed not to take * "End it" action when the friendly stop has been observed not to take
* effect (model wedged in a tool call beyond the timeout). Sequence * effect (model wedged in a tool call beyond the timeout). Sequence
* matches what {@link #onShutdown()} does for individual runs. * matches what {@link #onShutdown()} does for individual runs.

View File

@ -0,0 +1,152 @@
package vip.mate.channel.web;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Marks model-predicted tool results that are replaced by the actual post-tool
* answer segment.
*/
final class SegmentSupersedeDetector {
static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim";
private static final Pattern GENERATED_FILE_URL =
Pattern.compile("/api/v1/files/generated/[A-Za-z0-9-]+");
private static final Pattern BYTE_COUNT =
Pattern.compile("\\d+\\s*字节");
private static final Pattern REPLACEMENT_COUNT =
Pattern.compile("\\d+\\s*处");
private SegmentSupersedeDetector() {
}
static void markSuperseded(List<Map<String, Object>> segments) {
if (segments == null || segments.size() < 3) {
return;
}
for (int i = 0; i < segments.size(); i++) {
Map<String, Object> candidate = segments.get(i);
if (!isContent(candidate) || Boolean.TRUE.equals(candidate.get("superseded"))
|| followsToolResult(segments, i)) {
continue;
}
Claim predictedClaim = parseClaim(String.valueOf(candidate.getOrDefault("text", "")));
if (predictedClaim == null) {
continue;
}
int toolIndex = nextToolIndexBeforeContent(segments, i + 1);
if (toolIndex < 0) {
continue;
}
Map<String, Object> tool = segments.get(toolIndex);
if (Boolean.FALSE.equals(tool.get("toolSuccess"))
|| !toolMatchesClaim(String.valueOf(tool.getOrDefault("toolName", "")), predictedClaim)) {
continue;
}
int replacementIndex = nextMatchingContentIndex(segments, toolIndex + 1, predictedClaim);
if (replacementIndex < 0) {
continue;
}
Map<String, Object> replacement = segments.get(replacementIndex);
candidate.put("superseded", true);
candidate.put("supersededBySegmentId", String.valueOf(replacement.getOrDefault("id", "")));
candidate.put("supersededReason", REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM);
}
}
private static int nextToolIndexBeforeContent(List<Map<String, Object>> segments, int start) {
for (int i = start; i < segments.size(); i++) {
Map<String, Object> segment = segments.get(i);
if (isToolCall(segment)) {
return i;
}
if (isContent(segment)) {
return -1;
}
}
return -1;
}
private static boolean followsToolResult(List<Map<String, Object>> segments, int index) {
for (int i = index - 1; i >= 0; i--) {
Map<String, Object> segment = segments.get(i);
if (isContent(segment)) {
return false;
}
if (isToolCall(segment)) {
return true;
}
}
return false;
}
private static int nextMatchingContentIndex(List<Map<String, Object>> segments, int start, Claim predictedClaim) {
for (int i = start; i < segments.size(); i++) {
Map<String, Object> segment = segments.get(i);
if (isToolCall(segment)) {
return -1;
}
if (!isContent(segment)) {
continue;
}
Claim actualClaim = parseClaim(String.valueOf(segment.getOrDefault("text", "")));
if (predictedClaim.sameKind(actualClaim)) {
return i;
}
}
return -1;
}
private static boolean isContent(Map<String, Object> segment) {
return segment != null && "content".equals(segment.get("type"));
}
private static boolean isToolCall(Map<String, Object> segment) {
return segment != null && "tool_call".equals(segment.get("type"));
}
private static Claim parseClaim(String text) {
if (text == null || text.isBlank()) {
return null;
}
String upper = text.toUpperCase(Locale.ROOT);
if ((upper.contains("成功生成") || text.contains("已生成")) && GENERATED_FILE_URL.matcher(text).find()) {
for (String format : List.of("PDF", "DOCX", "PPTX", "XLSX")) {
if (upper.contains(format)) {
return new Claim("render", format);
}
}
}
if (text.contains("成功写入") && BYTE_COUNT.matcher(text).find()) {
return new Claim("write", "");
}
if (text.contains("成功替换") && REPLACEMENT_COUNT.matcher(text).find()) {
return new Claim("edit", "");
}
return null;
}
private static boolean toolMatchesClaim(String toolName, Claim claim) {
String normalized = toolName == null ? "" : toolName.toLowerCase(Locale.ROOT);
return switch (claim.type) {
case "render" -> normalized.contains("render" + claim.detail.toLowerCase(Locale.ROOT));
case "write" -> "write_file".equals(normalized);
case "edit" -> "edit_file".equals(normalized);
default -> false;
};
}
private record Claim(String type, String detail) {
boolean sameKind(Claim other) {
return other != null && type.equals(other.type) && detail.equals(other.detail);
}
}
}

Some files were not shown because too many files have changed in this diff Show More