mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
release: v1.4.0
This commit is contained in:
parent
493910bf5a
commit
fa6a5366b4
@ -8,6 +8,8 @@
|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://claw.mate.vip/docs)
|
||||
[](https://claw-demo.mate.vip)
|
||||
@ -31,6 +33,8 @@
|
||||
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
|
||||
>
|
||||
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
|
||||
>
|
||||
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
|
||||
|
||||
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
|
||||
<p align="center"><b>你的超级大脑</b></p>
|
||||
|
||||
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
|
||||
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://claw.mate.vip/docs)
|
||||
[](https://claw-demo.mate.vip)
|
||||
@ -31,6 +33,8 @@
|
||||
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
||||
>
|
||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
|
||||
>
|
||||
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
|
||||
|
||||
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||
|
||||
|
||||
@ -4,36 +4,21 @@
|
||||
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>
|
||||
|
||||
<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>
|
||||
<version>1.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MateClaw Plugin API</name>
|
||||
<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>
|
||||
<description>Plugin SDK contract for MateClaw - external plugins depend only on this module</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring AI core — for ToolCallback, ChatModel -->
|
||||
<!-- Spring AI core for ToolCallback and ChatModel. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-model</artifactId>
|
||||
@ -44,7 +29,6 @@
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.16</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
@ -52,16 +36,7 @@
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.18.3</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>spring-milestones</id>
|
||||
<url>https://repo.spring.io/milestone</url>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
|
||||
@ -4,40 +4,24 @@
|
||||
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>
|
||||
|
||||
<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>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MateClaw Sample Plugin</name>
|
||||
<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>
|
||||
<!-- MateClaw Plugin API -->
|
||||
<dependency>
|
||||
<groupId>vip.mate</groupId>
|
||||
<artifactId>mateclaw-plugin-api</artifactId>
|
||||
<version>1.1.0-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
@ -52,16 +36,7 @@
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>2.0.16</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>spring-milestones</id>
|
||||
<url>https://repo.spring.io/milestone</url>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
|
||||
@ -35,28 +35,29 @@ FROM maven:3.9-eclipse-temurin-21 AS builder
|
||||
|
||||
# Optional Maven extra flags passed at build time.
|
||||
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun
|
||||
# repos first — speeds up builds dramatically inside mainland China.
|
||||
# repos first. This speeds up builds inside mainland China.
|
||||
ARG MAVEN_FLAGS=""
|
||||
|
||||
# Inject mirror settings to avoid Maven Central timeouts in restricted networks
|
||||
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
|
||||
|
||||
# Build and install plugin-api into the local Maven cache first
|
||||
WORKDIR /plugin-api
|
||||
COPY mateclaw-plugin-api/pom.xml ./pom.xml
|
||||
COPY mateclaw-plugin-api/src ./src
|
||||
RUN mvn install -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
|
||||
# Pre-fetch mateclaw-server dependencies (uses mirror, so this won't hang)
|
||||
# Copy the root parent plus module POMs first for Docker layer caching.
|
||||
WORKDIR /build
|
||||
COPY mateclaw-server/pom.xml .
|
||||
RUN mvn dependency:go-offline -q ${MAVEN_FLAGS}
|
||||
COPY pom.xml ./pom.xml
|
||||
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
|
||||
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
|
||||
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
|
||||
|
||||
# Pre-fetch backend dependencies through the reactor so the parent POM,
|
||||
# dependencyManagement, and internal module versions all resolve consistently.
|
||||
RUN mvn -pl mateclaw-server -am dependency:go-offline -q ${MAVEN_FLAGS}
|
||||
|
||||
# Copy backend source and inject pre-built frontend into the right classpath location
|
||||
COPY mateclaw-server/src ./src
|
||||
COPY --from=frontend-builder /static ./src/main/resources/static
|
||||
COPY mateclaw-plugin-api/src mateclaw-plugin-api/src
|
||||
COPY mateclaw-server/src mateclaw-server/src
|
||||
COPY --from=frontend-builder /static mateclaw-server/src/main/resources/static
|
||||
|
||||
RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
|
||||
# Stage 3 — Runtime
|
||||
#
|
||||
@ -65,11 +66,11 @@ RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
# pre-installed. This avoids the `playwright install` step and the Alpine/musl
|
||||
# incompatibility that blocks browser_use on minimal images.
|
||||
#
|
||||
# We pin to the exact Playwright version declared in pom.xml (1.52.0). If you
|
||||
# We pin to the exact Playwright version declared in the root pom.xml. If you
|
||||
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
||||
# tag with the matching driver, so mismatched versions cause the java driver to
|
||||
# re-download browsers at runtime (defeating the whole point of this image).
|
||||
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.59.0-noble
|
||||
WORKDIR /app
|
||||
|
||||
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
||||
@ -106,7 +107,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||
TZ=Asia/Shanghai \
|
||||
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
|
||||
|
||||
COPY --from=builder /build/target/*.jar app.jar
|
||||
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
|
||||
EXPOSE 18088
|
||||
EXPOSE 1455
|
||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
||||
|
||||
@ -4,70 +4,33 @@
|
||||
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>
|
||||
|
||||
<groupId>vip.mate</groupId>
|
||||
<parent>
|
||||
<groupId>vip.mate</groupId>
|
||||
<artifactId>mateclaw</artifactId>
|
||||
<version>${revision}</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>mateclaw-server</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MateClaw Server</name>
|
||||
<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>
|
||||
<!-- ===== MateClaw Plugin API ===== -->
|
||||
<dependency>
|
||||
<groupId>vip.mate</groupId>
|
||||
<artifactId>mateclaw-plugin-api</artifactId>
|
||||
<version>1.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Web MVC(不引入 WebFlux,避免自动切换为响应式模式) ===== -->
|
||||
<!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Actuator — exposes Spring AI observation metrics (gen_ai.*) ===== -->
|
||||
<!-- ===== Actuator - exposes Spring AI observation metrics (gen_ai.*) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
@ -75,14 +38,13 @@
|
||||
|
||||
<!-- ===== Spring AI Alibaba DashScope ===== -->
|
||||
<!--
|
||||
1.1.2.2 需单独指定版本,不在 BOM 中
|
||||
内置 DashScope ChatModel / EmbeddingModel / ImageModel
|
||||
Version is managed centrally because this artifact is outside the Spring AI BOM.
|
||||
Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud.ai</groupId>
|
||||
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
|
||||
<version>${spring-ai-alibaba.version}</version>
|
||||
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
|
||||
<!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@ -91,11 +53,10 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring AI Alibaba Graph Core(StateGraph 工作流引擎) ===== -->
|
||||
<!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud.ai</groupId>
|
||||
<artifactId>spring-ai-alibaba-graph-core</artifactId>
|
||||
<version>${spring-ai-alibaba.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring AI OpenAI Compatible ===== -->
|
||||
@ -104,16 +65,15 @@
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring AI Anthropic(Claude 模型支持) ===== -->
|
||||
<!-- ===== Spring AI Anthropic (Claude model support) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-anthropic</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring AI MCP Client(动态 MCP server 连接管理) ===== -->
|
||||
<!-- ===== Spring AI MCP Client (dynamic MCP server connection management) ===== -->
|
||||
<!--
|
||||
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库,
|
||||
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
|
||||
Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
@ -126,31 +86,29 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== H2 内嵌数据库(开发环境) ===== -->
|
||||
<!-- ===== H2 embedded database (development) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== MySQL 驱动(生产环境) ===== -->
|
||||
<!-- ===== MySQL driver (production) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== MyBatis Plus(不引入 JPA,避免双 ORM 冲突) ===== -->
|
||||
<!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<!-- MyBatis Plus 分页插件(3.5.16 拆分为独立模块) -->
|
||||
<!-- MyBatis Plus pagination support is split into a separate module. -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring Security ===== -->
|
||||
@ -163,55 +121,49 @@
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== SpringDoc OpenAPI(Swagger UI for Spring MVC) ===== -->
|
||||
<!-- ===== SpringDoc OpenAPI (Swagger UI for Spring MVC) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Hutool 工具库 ===== -->
|
||||
<!-- ===== Hutool utilities ===== -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>${hutool.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== 钉钉 Stream SDK(WebSocket 长连接,无需公网 IP) ===== -->
|
||||
<!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.dingtalk.open</groupId>
|
||||
<artifactId>dingtalk-stream</artifactId>
|
||||
<version>1.3.12</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||
<!-- ===== Lark Open API SDK (WebSocket long connection and event dispatch) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.larksuite.oapi</groupId>
|
||||
<artifactId>oapi-sdk</artifactId>
|
||||
<version>2.6.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
||||
<!-- ===== Caffeine cache for skill runtime caching ===== -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== -->
|
||||
<!-- ===== SnakeYAML for SKILL.md frontmatter parsing ===== -->
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
@ -228,28 +180,24 @@
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Playwright (Browser Automation) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.microsoft.playwright</groupId>
|
||||
<artifactId>playwright</artifactId>
|
||||
<version>1.52.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== JDA(Discord Bot Gateway WebSocket 长连接) ===== -->
|
||||
<!-- ===== JDA (Discord Bot Gateway WebSocket long connection) ===== -->
|
||||
<dependency>
|
||||
<groupId>net.dv8tion</groupId>
|
||||
<artifactId>JDA</artifactId>
|
||||
<version>5.2.3</version>
|
||||
<exclusions>
|
||||
<!-- 排除 audio 相关依赖(MateClaw 不需要语音功能) -->
|
||||
<!-- Exclude audio dependencies because voice features are not used. -->
|
||||
<exclusion>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>opus-java</artifactId>
|
||||
@ -257,27 +205,24 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring WebSocket(Talk Mode) ===== -->
|
||||
<!-- ===== Spring WebSocket (Talk Mode) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Slack SDK(Socket Mode + Web API) ===== -->
|
||||
<!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.slack.api</groupId>
|
||||
<artifactId>slack-api-client</artifactId>
|
||||
<version>1.44.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.slack.api</groupId>
|
||||
<artifactId>bolt-socket-mode</artifactId>
|
||||
<version>1.44.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.glassfish.tyrus.bundles</groupId>
|
||||
<artifactId>tyrus-standalone-client</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Apache POI (in-process .docx generation) ===== -->
|
||||
@ -288,7 +233,6 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
|
||||
@ -302,60 +246,54 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.xmlgraphics</groupId>
|
||||
<artifactId>batik-transcoder</artifactId>
|
||||
<version>1.18</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.xmlgraphics</groupId>
|
||||
<artifactId>batik-codec</artifactId>
|
||||
<version>1.18</version>
|
||||
</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
|
||||
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>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.18.3</version>
|
||||
</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
|
||||
chains, after every system command + Python + POI-based path has failed.
|
||||
Used in production primarily by Windows users without Python or poppler
|
||||
installed; otherwise idle.
|
||||
|
||||
Pinned to the precise format modules called out in RFC-051 §5.2 — we
|
||||
deliberately avoid `tika-parsers-standard-package`, which pulls in mail,
|
||||
Pinned to the precise format modules the extractor calls directly. This
|
||||
deliberately avoids `tika-parsers-standard-package`, which pulls in mail,
|
||||
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
|
||||
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>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-parser-pdf-module</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-parser-microsoft-module</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Markdown -> PDF rendering =====
|
||||
Flying Saucer 9.13 ships a single `flying-saucer-pdf` artifact that
|
||||
writes PDF via OpenPDF (LGPL fork of iText 5). It does NOT depend on
|
||||
Flying Saucer ships a single `flying-saucer-pdf` artifact that
|
||||
writes PDF via OpenPDF (LGPL fork of iText). It does NOT depend on
|
||||
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
|
||||
supported, which the cover / header / footer rendering relies on.
|
||||
|
||||
@ -368,32 +306,26 @@
|
||||
<dependency>
|
||||
<groupId>org.xhtmlrenderer</groupId>
|
||||
<artifactId>flying-saucer-pdf</artifactId>
|
||||
<version>9.13.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-autolink</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Database Migration (Flyway) ===== -->
|
||||
@ -413,33 +345,30 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 architecture invariants) =====
|
||||
test-scope only — guards:
|
||||
<!-- ===== ArchUnit architecture invariants =====
|
||||
test-scope only, guards:
|
||||
- every ToolCallback implementation overrides call(String, ToolContext)
|
||||
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
|
||||
- CronJobRunner (introduced in PR-3) must not carry @Transactional
|
||||
(would silently fail under self-invocation; see RFC §5.2)
|
||||
- CronJobRunner must not carry @Transactional
|
||||
because it would silently fail under self-invocation
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ShedLock: distributed lock for the cron scheduler so a
|
||||
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. -->
|
||||
<dependency>
|
||||
<groupId>net.javacrumbs.shedlock</groupId>
|
||||
<artifactId>shedlock-spring</artifactId>
|
||||
<version>5.16.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.javacrumbs.shedlock</groupId>
|
||||
<artifactId>shedlock-provider-jdbc-template</artifactId>
|
||||
<version>5.16.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Graph algorithms (community detection, shortest path, centrality)
|
||||
@ -447,7 +376,6 @@
|
||||
<dependency>
|
||||
<groupId>org.jgrapht</groupId>
|
||||
<artifactId>jgrapht-core</artifactId>
|
||||
<version>1.5.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
|
||||
@ -456,7 +384,6 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
<version>3.0.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Expression language used by the workflow compiler to evaluate
|
||||
@ -467,7 +394,6 @@
|
||||
<dependency>
|
||||
<groupId>io.pebbletemplates</groupId>
|
||||
<artifactId>pebble</artifactId>
|
||||
<version>3.2.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
@ -476,6 +402,13 @@
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
@ -515,115 +448,7 @@
|
||||
</plugins>
|
||||
</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>
|
||||
<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.
|
||||
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
|
||||
|
||||
@ -24,7 +24,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
||||
// 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
|
||||
// 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
|
||||
// ApplicationContext fail when users deploy via Docker without setting the key.
|
||||
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
|
||||
|
||||
@ -11,6 +11,7 @@ import vip.mate.common.result.R;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — REST surface for managing ACP endpoints.
|
||||
@ -29,24 +30,28 @@ public class AcpEndpointController {
|
||||
|
||||
@Operation(summary = "List ACP endpoints")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<List<AcpEndpointEntity>> list() {
|
||||
return R.ok(service.list());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get ACP endpoint by id")
|
||||
@GetMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<AcpEndpointEntity> get(@PathVariable Long id) {
|
||||
return R.ok(service.get(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Create a custom ACP endpoint")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.create(body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Update an ACP endpoint")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<AcpEndpointEntity> update(@PathVariable Long id,
|
||||
@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.update(id, body));
|
||||
@ -54,6 +59,7 @@ public class AcpEndpointController {
|
||||
|
||||
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
return R.ok();
|
||||
@ -61,6 +67,7 @@ public class AcpEndpointController {
|
||||
|
||||
@Operation(summary = "Enable / disable an ACP endpoint")
|
||||
@PutMapping("/{id}/toggle")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
|
||||
@RequestParam boolean enabled) {
|
||||
return R.ok(service.toggle(id, enabled));
|
||||
@ -72,6 +79,7 @@ public class AcpEndpointController {
|
||||
*/
|
||||
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
|
||||
@PostMapping("/{id}/test")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> test(@PathVariable Long id) {
|
||||
AcpEndpointEntity endpoint = service.get(id);
|
||||
return R.ok(tester.testEndpoint(endpoint));
|
||||
|
||||
@ -20,6 +20,7 @@ import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
/**
|
||||
* RFC-090 §4.5 / §7 — unified Activity feed.
|
||||
@ -74,6 +75,7 @@ public class ActivityFeedController {
|
||||
*/
|
||||
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
|
||||
@GetMapping("/feed")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> feed(
|
||||
@RequestParam(required = false) Long workspaceId,
|
||||
@RequestParam(required = false) String source,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,11 +15,14 @@ import vip.mate.agent.event.AgentLifecycleEvent;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
|
||||
import vip.mate.memory.lifecycle.TurnContext;
|
||||
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.Map;
|
||||
@ -45,14 +48,22 @@ public class AgentService {
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
private final MemoryLifecycleMediator lifecycleMediator;
|
||||
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
|
||||
* trigger module's bridge listens and forwards into ingest. */
|
||||
@Autowired(required = false)
|
||||
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 ====================
|
||||
|
||||
@ -216,7 +227,7 @@ public class AgentService {
|
||||
*/
|
||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, message, conversationId,
|
||||
@ -232,7 +243,7 @@ public class AgentService {
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
@ -268,7 +279,7 @@ public class AgentService {
|
||||
String requesterId, String thinkingLevel,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
|
||||
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
||||
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
|
||||
@ -314,7 +325,7 @@ public class AgentService {
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, goal, conversationId,
|
||||
@ -341,7 +352,7 @@ public class AgentService {
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||
@ -369,7 +380,7 @@ public class AgentService {
|
||||
String toolCallPayload, String requesterId,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
@ -382,8 +393,20 @@ public class AgentService {
|
||||
}
|
||||
|
||||
public AgentState getAgentState(Long agentId) {
|
||||
BaseAgent agent = agentInstances.get(agentId);
|
||||
return agent != null ? agent.getState() : AgentState.IDLE;
|
||||
Map<String, BaseAgent> variants = agentInstances.get(agentId);
|
||||
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 -> {
|
||||
AgentEntity entity = getAgent(id);
|
||||
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
||||
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
|
||||
/**
|
||||
* Resolve (and cache) the Agent graph for a conversation, honouring the
|
||||
* conversation's pinned model. Conversations with no pin — IM channels
|
||||
* before issue #183 fix, cron, sub-tasks, or rows not yet created —
|
||||
* 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 ====================
|
||||
|
||||
@ -219,6 +219,22 @@ public class AgentToolSet {
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -11,6 +11,8 @@ import org.springframework.ai.content.Media;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.util.MimeType;
|
||||
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.llm.model.ModelConfigEntity;
|
||||
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).
|
||||
* 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).
|
||||
*
|
||||
* <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;
|
||||
protected int maxIterations = 100;
|
||||
public static final int MAX_ITERATIONS_HARD_CEILING = 150;
|
||||
protected int maxIterations = 150;
|
||||
|
||||
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */
|
||||
protected String workspaceBasePath;
|
||||
@ -116,9 +124,24 @@ public abstract class BaseAgent {
|
||||
protected MultimodalRouter multimodalRouter;
|
||||
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. */
|
||||
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) {
|
||||
this.chatClient = chatClient;
|
||||
@ -221,6 +244,24 @@ public abstract class BaseAgent {
|
||||
}
|
||||
|
||||
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);
|
||||
if (totalCount <= 0) {
|
||||
@ -490,7 +531,7 @@ public abstract class BaseAgent {
|
||||
// and bloat the prompt with scheduler metadata.
|
||||
if ("system".equals(entity.getRole())
|
||||
&& entity.getContent() != null
|
||||
&& entity.getContent().startsWith("📋 ")) {
|
||||
&& entity.getContent().startsWith(CRON_HEADER_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -616,7 +657,7 @@ public abstract class BaseAgent {
|
||||
String role = entity.getRole();
|
||||
if ("system".equals(role)
|
||||
&& entity.getContent() != null
|
||||
&& entity.getContent().startsWith("📋 ")) return true;
|
||||
&& entity.getContent().startsWith(CRON_HEADER_PREFIX)) return true;
|
||||
if ("assistant".equals(role)
|
||||
&& isApprovalPlaceholder(entity.getContent())) return true;
|
||||
if ("assistant".equals(role)
|
||||
@ -1136,6 +1177,15 @@ public abstract class BaseAgent {
|
||||
* the primary model can't already handle.
|
||||
*/
|
||||
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 {
|
||||
List<MessageEntity> history = conversationService.listMessages(conversationId);
|
||||
// 倒序取最后一条 user 消息(buildInitialState 在 saveMessage 后调用,所以最后一条就是当前消息)
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
/**
|
||||
* 请求级思考深度的 ThreadLocal 持有器。
|
||||
* <p>
|
||||
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode,
|
||||
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口。
|
||||
* <p>
|
||||
* 支持的值:off / low / medium / high / max,null 表示跟随模型默认。
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@ -139,7 +139,7 @@ public class AgentBindingController {
|
||||
}
|
||||
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
|
||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
||||
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,8 +15,11 @@ import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.routing.AgentBindingResolver;
|
||||
import vip.mate.skill.acp.AcpSkillBridge;
|
||||
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.repository.SkillMapper;
|
||||
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.service.AvailableToolService;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ -41,7 +50,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AgentBindingService {
|
||||
public class AgentBindingService implements AgentBindingResolver {
|
||||
|
||||
private final AgentSkillBindingMapper skillBindingMapper;
|
||||
private final AgentToolBindingMapper toolBindingMapper;
|
||||
@ -112,6 +121,7 @@ public class AgentBindingService {
|
||||
* 获取 Agent 绑定的 enabled skill ID 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
*/
|
||||
@Override
|
||||
public Set<Long> getBoundSkillIds(Long agentId) {
|
||||
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
||||
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.
|
||||
* Skills are per-workspace installable artifacts (each workspace has
|
||||
@ -200,11 +307,11 @@ public class AgentBindingService {
|
||||
* then apply the same workspace comparison.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Most {@code mate_skill} rows currently sit in the default workspace
|
||||
* (id=1) because skill creation doesn't yet honor the
|
||||
* {@code X-Workspace-Id} header; the real-skill branch is therefore
|
||||
* defense-in-depth right now and flips on automatically the moment
|
||||
* workspace-scoped skill creation lands. ACP enforcement is live today.
|
||||
* <p>Builtin skills are exempt: they are global capabilities seeded
|
||||
* once into the default workspace and shared with every workspace, so
|
||||
* any agent may bind them regardless of its own workspace. Only
|
||||
* workspace-owned skills (dynamic / installed / synthesized) are
|
||||
* tenancy-checked.
|
||||
*
|
||||
* @throws MateClawException 404 if the agent or skill doesn't exist;
|
||||
* 403 on a workspace mismatch.
|
||||
@ -241,6 +348,12 @@ public class AgentBindingService {
|
||||
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 skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
|
||||
if (agentWs != skillWs) {
|
||||
@ -276,8 +389,8 @@ public class AgentBindingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 §14.2 — single entry point that maps an agent's bindings to
|
||||
* the set of tool names allowed at runtime.
|
||||
* Single entry point that maps an agent's bindings to the set of tool
|
||||
* names allowed at runtime.
|
||||
*
|
||||
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
|
||||
* {@link #getBoundToolNames}):
|
||||
@ -306,13 +419,17 @@ public class AgentBindingService {
|
||||
* tools and skill-expanded tools:
|
||||
* <ul>
|
||||
* <li>{@link #SYSTEM_LEVEL_TOOLS} — agent-wide primitives.</li>
|
||||
* <li>Every currently-bindable MCP tool (any tool with
|
||||
* {@code source="mcp"} and {@code available=true} in the picker).
|
||||
* MCP servers are administrator-level capabilities; once enabled
|
||||
* globally they should not be silently hidden from an agent that
|
||||
* happens to have any other binding. To deny a specific MCP tool
|
||||
* to a specific agent, use the tool-guard deny path applied
|
||||
* upstream in {@code AgentGraphBuilder}.</li>
|
||||
* <li>Every currently-bindable MCP tool ({@code source="mcp"},
|
||||
* {@code available=true} in the picker) — but only when the agent
|
||||
* has not ticked any MCP tool itself. MCP servers are
|
||||
* administrator-level capabilities, so an agent that bound merely
|
||||
* a skill or a built-in tool keeps full MCP access. Once the
|
||||
* operator ticks specific MCP rows, that is read as a deliberate
|
||||
* 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>
|
||||
*/
|
||||
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).
|
||||
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
||||
|
||||
// Enabled MCP server tools auto-join the allowlist for the same
|
||||
// reason SYSTEM_LEVEL_TOOLS does: MCP servers are an
|
||||
// administrator-enabled capability, not a per-agent opt-in. Without
|
||||
// this union, an agent with any skill or built-in tool bound would
|
||||
// silently lose every MCP tool — users hit this when they bound one
|
||||
// built-in tool, didn't tick the MCP rows, and observed "only
|
||||
// built-in tools work". Operators who need to hide a specific MCP
|
||||
// tool from a specific agent still have the tool-guard deny path
|
||||
// (AgentGraphBuilder applies withDeniedToolsFiltered before this).
|
||||
merged.addAll(getEnabledMcpToolNames());
|
||||
// MCP tools. An agent that bound only a skill or a built-in tool
|
||||
// and ticked no MCP row keeps full access to every enabled MCP
|
||||
// tool: MCP servers are an administrator-enabled capability and
|
||||
// must not silently vanish just because some unrelated binding
|
||||
// exists. But once the operator ticks specific MCP rows, that is a
|
||||
// deliberate per-agent scope — only those MCP tools (already merged
|
||||
// via directTools above) stay, and the rest are not auto-joined, so
|
||||
// a role can be limited to a fixed MCP tool set. To instead hide a
|
||||
// single MCP tool from an agent that ticked no MCP row, use the
|
||||
// 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;
|
||||
}
|
||||
@ -391,8 +514,8 @@ public class AgentBindingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 §11 — tools that exist outside the skill scope and must
|
||||
* survive any agent-level skill binding restriction.
|
||||
* Tools that exist outside the skill scope and must survive any
|
||||
* agent-level skill binding restriction.
|
||||
*
|
||||
* <p>Add new entries here only after verifying the tool is genuinely
|
||||
* agent-wide, not skill-specific. Tools added here bypass the
|
||||
@ -417,8 +540,17 @@ public class AgentBindingService {
|
||||
"read_workspace_memory_file",
|
||||
"write_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;
|
||||
// these helpers let the LLM read SKILL.md / run scripts.
|
||||
"load_skill",
|
||||
"readSkillFile",
|
||||
"runSkillScript",
|
||||
"listSkillFiles",
|
||||
@ -433,7 +565,28 @@ public class AgentBindingService {
|
||||
// delegateParallel / listAvailableAgents. Same dead-name bug.
|
||||
"delegateToAgent",
|
||||
"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",
|
||||
// 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
|
||||
// declared inside any skill manifest. Pre-Phase-2b these were
|
||||
// universally visible; the new gate silently strips them whenever
|
||||
@ -464,6 +617,7 @@ public class AgentBindingService {
|
||||
"search",
|
||||
"browser_use",
|
||||
"read_file",
|
||||
"send_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"execute_shell_command",
|
||||
@ -471,7 +625,36 @@ public class AgentBindingService {
|
||||
"extract_document_text",
|
||||
"extract_pdf_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) {
|
||||
@ -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. */
|
||||
public List<AgentProviderPreference> listProviderPreferences(Long agentId) {
|
||||
@ -614,6 +797,7 @@ public class AgentBindingService {
|
||||
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
|
||||
* fallback chain order per agent.</p>
|
||||
*/
|
||||
@Override
|
||||
public List<String> getPreferredProviderIds(Long agentId) {
|
||||
if (agentId == null) return Collections.emptyList();
|
||||
return listProviderPreferences(agentId).stream()
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -33,7 +33,32 @@ public record ChatOrigin(
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@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}. */
|
||||
@ -41,7 +66,7 @@ public record ChatOrigin(
|
||||
|
||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||
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 ----------------
|
||||
|
||||
@ -51,7 +76,7 @@ public record ChatOrigin(
|
||||
@Nullable String workspaceBasePath) {
|
||||
return new ChatOrigin(null, conversationId,
|
||||
requesterId != null ? requesterId : "",
|
||||
workspaceId, workspaceBasePath, null, null);
|
||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null);
|
||||
}
|
||||
|
||||
public static ChatOrigin cron(@Nullable String conversationId,
|
||||
@ -60,25 +85,42 @@ public record ChatOrigin(
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget target) {
|
||||
return new ChatOrigin(null, conversationId, "system",
|
||||
workspaceId, workspaceBasePath, channelId, target);
|
||||
workspaceId, workspaceBasePath, channelId, target, true, null, null, null);
|
||||
}
|
||||
|
||||
// ---------------- Wither-style updates ----------------
|
||||
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget);
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
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 ----------------
|
||||
|
||||
@ -11,7 +11,7 @@ package vip.mate.agent.context;
|
||||
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||
* 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
|
||||
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
|
||||
* access. Mirrors {@link vip.mate.llm.chatmodel.ThinkingLevelHolder}.
|
||||
*/
|
||||
public final class ChatOriginHolder {
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 会话历史上下文窗口管理器(Hermes 风格升级版)
|
||||
* 会话历史上下文窗口管理器(四阶段压缩升级版)
|
||||
* <p>
|
||||
* 四阶段压缩策略:
|
||||
* <ol>
|
||||
@ -162,6 +162,21 @@ public class ConversationWindowManager {
|
||||
/** 每个会话的摘要冷却截止时间 */
|
||||
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;
|
||||
|
||||
// 尾部保护 token 预算:阈值的 20%(与 Hermes 一致)
|
||||
// 尾部保护 token 预算:阈值的 20%
|
||||
int tailTokenBudget = (int) (triggerThreshold * 0.20);
|
||||
|
||||
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,
|
||||
int tailTokenBudget, ChatModel chatModel,
|
||||
String conversationId, Long agentId,
|
||||
int preTokens, long spillsAtEntry) {
|
||||
int preTokens, long spillsAtEntry,
|
||||
String trigger) {
|
||||
broadcastCompactStatus(conversationId, "start", Map.of(
|
||||
"preTokens", preTokens,
|
||||
"messagesIn", messages.size(),
|
||||
"trigger", "token_threshold"
|
||||
"trigger", trigger
|
||||
));
|
||||
|
||||
// 动态计算尾部保护边界(替代固定 preserveRecentPairs)
|
||||
@ -457,7 +473,7 @@ public class ConversationWindowManager {
|
||||
? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry)
|
||||
: 0L;
|
||||
Map<String, Object> boundaryMetadata = new java.util.LinkedHashMap<>();
|
||||
boundaryMetadata.put("trigger", "token_threshold");
|
||||
boundaryMetadata.put("trigger", trigger);
|
||||
boundaryMetadata.put("preTokens", preTokens);
|
||||
boundaryMetadata.put("postTokens", resultTokens);
|
||||
boundaryMetadata.put("messagesSummarized", oldMessages.size());
|
||||
@ -888,6 +904,102 @@ public class ConversationWindowManager {
|
||||
&& 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 字符)。
|
||||
* <p>Spill-marker responses are left untouched so their on-disk pointer
|
||||
@ -907,10 +1019,10 @@ public class ConversationWindowManager {
|
||||
}
|
||||
String data = r.responseData();
|
||||
if (data != null && data.length() > 500) {
|
||||
String head = data.substring(0, 200);
|
||||
String tail = data.substring(data.length() - 200);
|
||||
String marker = "\n...[trimmed " + data.length() + " chars; "
|
||||
+ StructuredTruncator.FIDELITY_NOTE + "]...\n";
|
||||
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;
|
||||
} else {
|
||||
newResponses.add(r);
|
||||
@ -1081,9 +1193,9 @@ public class ConversationWindowManager {
|
||||
|
||||
String text = msg.getText();
|
||||
if (text != null && text.length() > CONTENT_MAX) {
|
||||
text = text.substring(0, CONTENT_HEAD)
|
||||
+ "\n...[截断 " + text.length() + " 字符]...\n"
|
||||
+ text.substring(text.length() - CONTENT_TAIL);
|
||||
String marker = "\n...[truncated " + text.length() + " chars; "
|
||||
+ StructuredTruncator.FIDELITY_NOTE + "]...\n";
|
||||
text = StructuredTruncator.truncate(text, CONTENT_HEAD, CONTENT_TAIL, marker);
|
||||
}
|
||||
|
||||
sb.append(role).append(": ").append(text != null ? text : "").append("\n\n");
|
||||
@ -1114,6 +1226,118 @@ public class ConversationWindowManager {
|
||||
|
||||
// ==================== 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) 恢复用的紧急压缩。
|
||||
* 不调用 LLM 摘要,直接丢弃较旧消息,只保留最近 4 条。
|
||||
@ -1155,6 +1379,8 @@ public class ConversationWindowManager {
|
||||
|
||||
private void evictExpiredEntries() {
|
||||
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) {
|
||||
|
||||
@ -46,6 +46,27 @@ public final class RuntimeContextInjector {
|
||||
* 构建运行时上下文消息(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);
|
||||
String dateStr = now.format(DATE_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.");
|
||||
}
|
||||
}
|
||||
|
||||
appendSenderBlockIfPresent(sb, origin);
|
||||
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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }} 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 }} 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;
|
||||
}
|
||||
}
|
||||
@ -270,7 +270,7 @@ public class AgentController {
|
||||
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
|
||||
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
|
||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
||||
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
/**
|
||||
* REST surface for managing live sub-agents:
|
||||
@ -86,6 +87,7 @@ public class SubagentController {
|
||||
*/
|
||||
@Operation(summary = "Interrupt a running sub-agent")
|
||||
@PostMapping("/{subagentId}/interrupt")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
|
||||
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
|
||||
boolean ok = registry.interrupt(subagentId);
|
||||
@ -106,6 +108,7 @@ public class SubagentController {
|
||||
*/
|
||||
@Operation(summary = "Set sub-agent spawn-pause for a conversation")
|
||||
@PostMapping("/spawn-pause")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
|
||||
Object parentObj = body == null ? null : body.get("parentConversationId");
|
||||
String parent = parentObj == null ? null : parentObj.toString();
|
||||
@ -128,12 +131,17 @@ public class SubagentController {
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sub-agents currently active under {@code parentConversationId}.
|
||||
* The query parameter is mandatory: returning all subagents process-wide
|
||||
* would let any logged-in user enumerate other tenants' delegation trees.
|
||||
* List the sub-agents currently active in the delegation tree rooted at
|
||||
* {@code parentConversationId} — the user-facing conversation. Returns the
|
||||
* 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")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
|
||||
Authentication auth) {
|
||||
if (parentConversationId == null || parentConversationId.isBlank()) {
|
||||
@ -143,7 +151,7 @@ public class SubagentController {
|
||||
if (!conversationService.isConversationOwner(parentConversationId, username)) {
|
||||
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)
|
||||
.toList();
|
||||
return R.ok(Map.of("subagents", snapshot));
|
||||
@ -163,6 +171,8 @@ public class SubagentController {
|
||||
dto.put("subagentId", rec.subagentId());
|
||||
dto.put("parentConversationId", rec.parentConversationId());
|
||||
dto.put("childConversationId", rec.childConversationId());
|
||||
dto.put("parentSubagentId", rec.parentSubagentId());
|
||||
dto.put("depth", rec.depth());
|
||||
dto.put("agentId", rec.agentId());
|
||||
dto.put("goal", rec.goal());
|
||||
dto.put("startedAt", rec.startedAt());
|
||||
|
||||
@ -89,10 +89,16 @@ public class SubagentHeartbeat {
|
||||
if (rec.status().compareAndSet("running", "stale")) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("subagentId", rec.subagentId());
|
||||
payload.put("parentSubagentId", rec.parentSubagentId());
|
||||
payload.put("depth", rec.depth());
|
||||
payload.put("cycles", sc);
|
||||
payload.put("lastTool", currentTool != null ? currentTool : "");
|
||||
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={})",
|
||||
rec.subagentId(), sc, limit);
|
||||
}
|
||||
|
||||
@ -53,7 +53,14 @@ public class SubagentRegistry {
|
||||
AtomicReference<String> lastSeenTool,
|
||||
AtomicInteger staleCount,
|
||||
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<>();
|
||||
@ -77,6 +84,16 @@ public class SubagentRegistry {
|
||||
* children spawn within the same millisecond.
|
||||
*/
|
||||
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();
|
||||
active.put(sid, new SubagentRecord(
|
||||
sid,
|
||||
@ -93,7 +110,10 @@ public class SubagentRegistry {
|
||||
new AtomicReference<>(null),
|
||||
new AtomicInteger(0),
|
||||
new AtomicLong(0),
|
||||
d));
|
||||
d,
|
||||
parentSubagentId,
|
||||
depth,
|
||||
rootConvId != null ? rootConvId : parentConvId));
|
||||
return sid;
|
||||
}
|
||||
|
||||
@ -120,9 +140,13 @@ public class SubagentRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all sub-agents whose parent matches {@code parentConvId}.
|
||||
* Filtering at the registry boundary prevents callers from accidentally
|
||||
* surfacing other tenants' subagents in API responses.
|
||||
* Snapshot of all sub-agents whose <em>immediate</em> parent matches
|
||||
* {@code parentConvId}. Filtering at the registry boundary prevents callers
|
||||
* 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) {
|
||||
if (parentConvId == null) return List.of();
|
||||
@ -131,6 +155,20 @@ public class SubagentRegistry {
|
||||
.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) {
|
||||
if (subagentId == null) return;
|
||||
active.remove(subagentId);
|
||||
|
||||
@ -9,8 +9,9 @@ import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import vip.mate.agent.AssistantThinkingRelay;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
|
||||
import vip.mate.llm.chatmodel.ReasoningContentCache;
|
||||
|
||||
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
|
||||
* HARD failures (AUTH / BILLING / MODEL_NOT_FOUND). Returns {@code null}
|
||||
* for SOFT errors and benign types — those keep the provider in-pool and
|
||||
* are handled by {@link vip.mate.llm.failover.ProviderHealthTracker}'s
|
||||
* cooldown instead.
|
||||
* provider-wide HARD failures (AUTH / BILLING). Returns {@code null} for
|
||||
* SOFT errors, benign types, and model-scoped errors — those keep the
|
||||
* provider in-pool.
|
||||
*
|
||||
* <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) {
|
||||
if (type == null) return null;
|
||||
return switch (type) {
|
||||
case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR;
|
||||
case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING;
|
||||
case MODEL_NOT_FOUND -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.MODEL_NOT_FOUND;
|
||||
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). */
|
||||
private boolean inPool(String 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 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
|
||||
// provider during a rate-limit window wastes time without recovery.
|
||||
// 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_CAP_MS = 60_000;
|
||||
|
||||
@ -429,7 +461,12 @@ public class NodeStreamingChatHelper {
|
||||
// Reactor Netty wraps the raw socket cause in WebClientRequestException;
|
||||
// surface that wrapper too so retries fire even when the cause chain
|
||||
// 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.UNKNOWN;
|
||||
@ -521,15 +558,23 @@ public class NodeStreamingChatHelper {
|
||||
removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage());
|
||||
break;
|
||||
}
|
||||
// RFC-009 P3.2: BILLING / MODEL_NOT_FOUND — provider-side hard failures
|
||||
// that won't change on retry. Skip to fallback chain (a different
|
||||
// provider may have credits, or the model name may be valid there).
|
||||
if (lastResult.errorType() == ErrorType.BILLING
|
||||
|| lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) {
|
||||
log.warn("[{}] Primary error={} — skipping same-model retries, handing off to fallback chain",
|
||||
phase, lastResult.errorType());
|
||||
// BILLING — provider-side hard failure (out of credit). Won't change
|
||||
// on retry and affects every model on the provider, so evict it and
|
||||
// hand off to the fallback chain (a different provider may have credits).
|
||||
if (lastResult.errorType() == ErrorType.BILLING) {
|
||||
log.warn("[{}] Primary billing failure — skipping same-model retries, handing off to fallback chain", phase);
|
||||
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;
|
||||
}
|
||||
// CLIENT_ERROR (400 Bad Request): 不重试(参数/格式错误重试也不会变)
|
||||
@ -560,18 +605,32 @@ public class NodeStreamingChatHelper {
|
||||
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||
return lastResult;
|
||||
}
|
||||
// Any other non-null errored result with a classified type that doStreamCall
|
||||
// chose NOT to retry (i.e. UNKNOWN, or RATE_LIMIT/SERVER_ERROR past MAX_RETRIES)
|
||||
// must exit — otherwise we silently spin through attempts and waste seconds
|
||||
// per turn on unrecoverable errors like DashScope's "url error" / unknown model.
|
||||
// RATE_LIMIT / SERVER_ERROR past their retry budget are provider-level
|
||||
// failures: the same model will not recover within this turn, but a
|
||||
// different provider can. Break to the fallback chain instead of
|
||||
// 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);
|
||||
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||
return lastResult;
|
||||
}
|
||||
// lastResult == null 表示需要重试
|
||||
}
|
||||
// If we exhausted the retry loop without a verdict, primary effectively failed.
|
||||
if (!primarySkipped && lastResult != null && lastResult.errorType() != ErrorType.NONE) {
|
||||
// If we exhausted the retry loop without a verdict, primary effectively
|
||||
// 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);
|
||||
}
|
||||
|
||||
@ -621,11 +680,17 @@ public class NodeStreamingChatHelper {
|
||||
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||
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) {
|
||||
// RFC-009 Phase 4: HARD errors evict from the pool so later
|
||||
// walks skip this provider outright. SOFT errors keep it
|
||||
// in-pool and let the tracker's cooldown absorb the blip.
|
||||
// HARD errors (auth / billing) evict the provider from the pool so
|
||||
// later walks skip it outright. SOFT and model-scoped errors keep it
|
||||
// in-pool — absorbed by the tracker's cooldown or simply retried.
|
||||
removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage());
|
||||
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
|
||||
}
|
||||
@ -724,7 +789,7 @@ public class NodeStreamingChatHelper {
|
||||
boolean broadcast, int attempt) {
|
||||
if (attempt > 0) {
|
||||
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 = Math.min(delay, BACKOFF_CAP_MS);
|
||||
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
||||
@ -765,7 +830,7 @@ public class NodeStreamingChatHelper {
|
||||
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
||||
|
||||
// thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。
|
||||
// 注意:内容流的字符级 / 句子级重复检测已整体移除(参考 Hermes 思路:
|
||||
// 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍:
|
||||
// agent 不替模型审核输出退化,靠 max_tokens + max_iterations 兜底);
|
||||
// 仅保留 thinking-only 这条体积兜底,处理 volcengine-plan 等 provider
|
||||
// 在 thinking 通道堆字符不出 content 的死循环(生产 trace c1eefa45)。
|
||||
@ -873,7 +938,7 @@ public class NodeStreamingChatHelper {
|
||||
thinkingAccum.append(thinkingDelta);
|
||||
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
||||
boolean suppressThinking = "off".equalsIgnoreCase(
|
||||
vip.mate.agent.ThinkingLevelHolder.get());
|
||||
vip.mate.llm.chatmodel.ThinkingLevelHolder.get());
|
||||
if (broadcast && !suppressThinking) {
|
||||
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
||||
}
|
||||
@ -1142,6 +1207,10 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
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);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
@ -1171,6 +1240,10 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
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);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
@ -1204,6 +1277,24 @@ public class NodeStreamingChatHelper {
|
||||
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.
|
||||
* 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("timeout") || msg.contains("Timeout")) return "Request timeout, please retry";
|
||||
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;
|
||||
}
|
||||
|
||||
@ -88,7 +88,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
log.info("[{}] StateGraph chat: conversationId={}", agentName, 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
|
||||
.flatMap(s -> s.<String>value(FINAL_ANSWER))
|
||||
@ -147,7 +154,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
|
||||
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
|
||||
.flatMap(s -> s.<String>value(FINAL_ANSWER))
|
||||
@ -491,7 +501,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用)
|
||||
// maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路,
|
||||
// 否则 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);
|
||||
int effectiveMaxIterations = (maxIterations <= 0)
|
||||
? 0
|
||||
@ -537,6 +547,29 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 -> {@code REASONING_NODE}, terminal ->
|
||||
* {@code END}) and the Plan-Execute graph (followup ->
|
||||
* {@code PLAN_GENERATION_NODE}, terminal -> {@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;
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
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.SourceEvidenceLedger;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
@ -163,21 +164,21 @@ public class ToolExecutionExecutor {
|
||||
static String truncateToolResult(String result, int maxChars) {
|
||||
if (result == null || result.length() <= maxChars) return result;
|
||||
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));
|
||||
boolean errorDetected = ERROR_TAIL_PATTERN.matcher(tailRegion).find();
|
||||
double headRatio = errorDetected ? 0.2 : 0.4;
|
||||
if (errorDetected) {
|
||||
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 tailLen = maxChars - headLen - 80;
|
||||
int tailLen = maxChars - headLen - marker.length();
|
||||
if (tailLen <= 0) tailLen = maxChars / 2;
|
||||
log.info("[ToolExecutor] Truncated tool result from {} to {} chars (headRatio={})",
|
||||
rawLen, maxChars, headRatio);
|
||||
return result.substring(0, headLen)
|
||||
+ "\n\n... [结果已截断,原始 " + rawLen + " 字符,保留首尾关键片段] ...\n\n"
|
||||
+ result.substring(rawLen - tailLen);
|
||||
return StructuredTruncator.truncate(result, headLen, tailLen, marker);
|
||||
}
|
||||
|
||||
private final Map<String, ToolCallback> toolCallbackMap;
|
||||
|
||||
@ -2,6 +2,7 @@ package vip.mate.agent.graph.executor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.agent.context.StructuredTruncator;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
@ -230,16 +231,16 @@ public class ToolResultStorage {
|
||||
if (body == null || body.length() <= maxChars) {
|
||||
return body;
|
||||
}
|
||||
int markerBudget = 120;
|
||||
int available = Math.max(200, maxChars - markerBudget);
|
||||
String marker = "\n\n... [tool result compacted for model context: tool="
|
||||
+ 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 tailLen = Math.max(100, available - headLen);
|
||||
if (headLen + tailLen >= body.length()) {
|
||||
return body;
|
||||
}
|
||||
String marker = "\n\n... [tool result compacted for model context: tool="
|
||||
+ toolName + ", original_chars=" + body.length() + "] ...\n\n";
|
||||
return body.substring(0, headLen) + marker + body.substring(body.length() - tailLen);
|
||||
return StructuredTruncator.truncate(body, headLen, tailLen, marker);
|
||||
}
|
||||
|
||||
private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) {
|
||||
@ -251,14 +252,16 @@ public class ToolResultStorage {
|
||||
}
|
||||
|
||||
private String buildPreview(String fullResult, String toolName, Path spillFile) {
|
||||
int previewLen = Math.min(props.getPreviewHeadChars(), fullResult.length());
|
||||
String head = fullResult.substring(0, previewLen);
|
||||
// Snap the preview to a complete JSON element so the model never sees a value
|
||||
// severed mid-token (which invites it to fabricate the omitted fields).
|
||||
String head = StructuredTruncator.headSlice(fullResult, props.getPreviewHeadChars());
|
||||
return SPILL_MARKER_PREFIX
|
||||
+ " tool=" + toolName
|
||||
+ " full_chars=" + fullResult.length()
|
||||
+ " path=" + spillFile.toAbsolutePath()
|
||||
+ "\n[Preview — first " + previewLen + " of " + fullResult.length()
|
||||
+ " chars. Use read_file with the path above to retrieve the rest.]\n"
|
||||
+ "\n[Preview — first " + head.length() + " of " + fullResult.length()
|
||||
+ " 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
|
||||
+ "\n…[truncated]";
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ package vip.mate.agent.graph.node;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
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 org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
@ -28,6 +30,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||
@Slf4j
|
||||
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 vip.mate.channel.web.ChatStreamTracker streamTracker;
|
||||
|
||||
@ -133,6 +143,98 @@ public class ActionNode implements NodeAction {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 FinalAnswer→GoalEvaluation 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));
|
||||
}
|
||||
}
|
||||
@ -47,18 +47,33 @@ public class LimitExceededNode implements NodeAction {
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
/** Optional i18n service; nullable so legacy/tests without Spring context still work. */
|
||||
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,
|
||||
NodeStreamingChatHelper streamingHelper) {
|
||||
this(chatModel, observationProcessor, streamingHelper, null);
|
||||
this(chatModel, observationProcessor, streamingHelper, null, null);
|
||||
}
|
||||
|
||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||
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.observationProcessor = observationProcessor;
|
||||
this.streamingHelper = streamingHelper;
|
||||
this.i18n = i18n;
|
||||
this.progressLedgerService = progressLedgerService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,7 +81,7 @@ public class LimitExceededNode implements NodeAction {
|
||||
*/
|
||||
@Deprecated
|
||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
|
||||
this(chatModel, observationProcessor, null, null);
|
||||
this(chatModel, observationProcessor, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -100,6 +115,25 @@ public class LimitExceededNode implements NodeAction {
|
||||
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
|
||||
String systemPrompt = SYSTEM_TEMPLATE.replace("{maxIterations}", String.valueOf(maxIterations));
|
||||
String userPrompt = USER_TEMPLATE
|
||||
|
||||
@ -72,7 +72,7 @@ public class ObservationNode implements NodeAction {
|
||||
// 合并为单条观察记录
|
||||
String combinedObservation = String.join("\n---\n", processedObservations);
|
||||
|
||||
// Budget Pressure Warning(Hermes 风格):接近上限时注入警告到工具结果中
|
||||
// Budget Pressure Warning:接近上限时注入警告到工具结果中
|
||||
// LLM 下一轮 reasoning 时能看到,从而主动收束,而非被硬性截断
|
||||
if (maxIterations > 0) {
|
||||
int progress = (int) ((double) nextIteration / maxIterations * 100);
|
||||
|
||||
@ -18,7 +18,7 @@ import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
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.context.ConversationWindowManager;
|
||||
import vip.mate.agent.context.RuntimeContextInjector;
|
||||
@ -72,7 +72,59 @@ public class ReasoningNode implements NodeAction {
|
||||
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
|
||||
* to prevent the recurring failure mode where a model says it will call a
|
||||
* tool but emits the description as final_answer text instead.
|
||||
@ -87,10 +139,42 @@ public class ReasoningNode implements NodeAction {
|
||||
+ "- 如果上一次工具调用因 args JSON 截断(max_tokens 超限)失败,\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 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;
|
||||
/**
|
||||
* 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;
|
||||
/** Wiki 相关性注入(可选,null 时跳过) */
|
||||
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,
|
||||
NodeStreamingChatHelper streamingHelper,
|
||||
@ -148,8 +248,66 @@ public class ReasoningNode implements NodeAction {
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||
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.toolSet = toolSet;
|
||||
this.toolCallbacks = toolSet.callbacks();
|
||||
this.toolDisclosureService = toolDisclosureService;
|
||||
this.reasoningEffort = reasoningEffort;
|
||||
this.supportsReasoningEffort = supportsReasoningEffort;
|
||||
this.streamingHelper = streamingHelper;
|
||||
@ -157,6 +315,8 @@ public class ReasoningNode implements NodeAction {
|
||||
this.streamTracker = streamTracker;
|
||||
this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
this.wikiContextService = wikiContextService;
|
||||
this.skillCatalogRenderer = skillCatalogRenderer;
|
||||
this.progressLedgerService = progressLedgerService;
|
||||
}
|
||||
|
||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||
@ -181,7 +341,9 @@ public class ReasoningNode implements NodeAction {
|
||||
@Deprecated
|
||||
public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) {
|
||||
this.chatModel = chatModel;
|
||||
this.toolSet = null;
|
||||
this.toolCallbacks = toolCallbacks;
|
||||
this.toolDisclosureService = null;
|
||||
this.reasoningEffort = null;
|
||||
this.supportsReasoningEffort = false;
|
||||
this.streamingHelper = null;
|
||||
@ -189,6 +351,8 @@ public class ReasoningNode implements NodeAction {
|
||||
this.streamTracker = null;
|
||||
this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
this.wikiContextService = null;
|
||||
this.skillCatalogRenderer = null;
|
||||
this.progressLedgerService = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -237,17 +401,16 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
// ======= 构建 Prompt =======
|
||||
String systemPrompt = accessor.systemPrompt();
|
||||
// RFC-049 follow-up: append a tool-use enforcement clause to every
|
||||
// ReasoningNode call. Without this, models (especially DeepSeek thinking
|
||||
// and Claude Opus) tend to "narrate" — emit a final_answer like "现在
|
||||
// 直接生成立项材料 docx" instead of actually calling renderDocx, which
|
||||
// makes the graph silently terminate at final_answer_node with the
|
||||
// narration as the user-facing reply.
|
||||
// Append a tool-use enforcement clause to every ReasoningNode call.
|
||||
// Without it, some models (notably DeepSeek thinking and Claude Opus)
|
||||
// tend to "narrate" — emit a final_answer like "现在直接生成立项材料
|
||||
// docx" instead of actually calling renderDocx, which makes the
|
||||
// graph silently terminate at final_answer_node with the narration
|
||||
// as the user-facing reply.
|
||||
//
|
||||
// Pattern adopted from hermes-agent's TOOL_USE_ENFORCEMENT_GUIDANCE
|
||||
// (`/agent/prompt_builder.py:179-191`). Appended to systemPrompt rather
|
||||
// than woven into the AgentEntity-stored prompt so it stays out of the
|
||||
// user-editable agent UI but is still always-on at runtime.
|
||||
// Appended at runtime rather than woven into the AgentEntity-stored
|
||||
// prompt so it stays out of the user-editable agent UI but is still
|
||||
// always-on for the runtime LLM.
|
||||
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
|
||||
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, "");
|
||||
List<Message> promptMessages = new ArrayList<>();
|
||||
promptMessages.add(new SystemMessage(systemPrompt));
|
||||
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
||||
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
|
||||
|
||||
// Wiki 相关性注入:根据用户消息提取相关页面摘要
|
||||
if (wikiContextService != null) {
|
||||
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
|
||||
// Build the non-history prefix ONCE. The PTL retry branch below
|
||||
// reuses this list verbatim so the retried prompt has exactly the
|
||||
// same system / runtime context / wiki injection as the original —
|
||||
// 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 {
|
||||
Long parsedAgentId = Long.parseLong(agentIdStr);
|
||||
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
|
||||
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
|
||||
promptMessages.add(new UserMessage(wikiRelevant));
|
||||
vip.mate.agent.progress.ProgressLedger ledger =
|
||||
progressLedgerService.load(conversationId);
|
||||
String snapshot = ledger.renderSnapshot();
|
||||
if (snapshot != null) {
|
||||
nonHistoryPrefix.add(new SystemMessage(snapshot));
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// agentId 无法解析时跳过 wiki 注入
|
||||
String staleReminder = ledger.renderStaleReminder(
|
||||
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) {
|
||||
// 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
|
||||
// tool results can be spilled to the workspace spill directory
|
||||
// (preserving the full body for read_file recovery) instead of
|
||||
@ -355,6 +568,7 @@ public class ReasoningNode implements NodeAction {
|
||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
|
||||
messages, conversationId, workspaceBasePath);
|
||||
}
|
||||
List<Message> promptMessages = new ArrayList<>(nonHistoryPrefix);
|
||||
promptMessages.addAll(messages);
|
||||
|
||||
// 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置)
|
||||
@ -362,7 +576,15 @@ public class ReasoningNode implements NodeAction {
|
||||
log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}",
|
||||
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);
|
||||
|
||||
@ -372,7 +594,7 @@ public class ReasoningNode implements NodeAction {
|
||||
// PTL compact retry 会再 +1。
|
||||
int nextLlmCallCount = accessor.llmCallCount() + 1;
|
||||
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}",
|
||||
promptMessages.size(), toolCallbacks.size(),
|
||||
promptMessages.size(), activeCallbacks.size(),
|
||||
accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount);
|
||||
|
||||
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
|
||||
@ -397,19 +619,34 @@ public class ReasoningNode implements NodeAction {
|
||||
try {
|
||||
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
|
||||
|
||||
// PTL 处理:压缩后重试
|
||||
// PTL 处理:结构化压缩后重试。复用 nonHistoryPrefix 保证重试
|
||||
// Prompt 仍带 wiki / runtime context;早期的 tail-only 路径会把
|
||||
// wiki 段一起丢掉,重试后的 prompt 比原始更短少一层信息。
|
||||
if (result.isPromptTooLong() && conversationWindowManager != null) {
|
||||
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry");
|
||||
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
|
||||
log.warn("[ReasoningNode] Prompt too long, attempting STRUCTURED compaction and retry");
|
||||
|
||||
// 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()) {
|
||||
List<Message> retryPromptMessages = new ArrayList<>();
|
||||
retryPromptMessages.add(new SystemMessage(systemPrompt));
|
||||
retryPromptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
||||
// Reuse the SAME non-history prefix — wiki/runtime context preserved.
|
||||
List<Message> retryPromptMessages = new ArrayList<>(nonHistoryPrefix);
|
||||
retryPromptMessages.addAll(compactedMessages);
|
||||
Prompt retryPrompt = new Prompt(retryPromptMessages, options);
|
||||
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
|
||||
messages.size(), compactedMessages.size());
|
||||
// compact retry 是第 2 次 LLM 调用,先递增再调用
|
||||
nextLlmCallCount++;
|
||||
pushPhase(conversationId, "reasoning", Map.of(
|
||||
"iteration", accessor.iterationCount(),
|
||||
@ -421,6 +658,28 @@ public class ReasoningNode implements NodeAction {
|
||||
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) {
|
||||
// "调用已发出但尚未产出内容时用户停止" — streamHelper 抛 CancellationException。
|
||||
// 返回空 finalAnswer + STOPPED,让 FinalAnswerNode 按 STOPPED 语义处理。
|
||||
@ -660,6 +919,56 @@ public class ReasoningNode implements NodeAction {
|
||||
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) {
|
||||
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
|
||||
return;
|
||||
@ -673,12 +982,12 @@ public class ReasoningNode implements NodeAction {
|
||||
* - AnthropicChatModel → AnthropicChatOptions(支持 extended thinking)
|
||||
* - 其他(OpenAI/DashScope)→ OpenAiChatOptions(支持 reasoningEffort)
|
||||
*/
|
||||
private ChatOptions buildChatOptions(String effectiveReasoning) {
|
||||
private ChatOptions buildChatOptions(String effectiveReasoning, List<ToolCallback> activeCallbacks) {
|
||||
// Anthropic 协议模型(AnthropicChatModel):MiniMax 也用此协议但不支持 thinking
|
||||
if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) {
|
||||
org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder =
|
||||
org.springframework.ai.anthropic.AnthropicChatOptions.builder()
|
||||
.toolCallbacks(toolCallbacks)
|
||||
.toolCallbacks(activeCallbacks)
|
||||
.internalToolExecutionEnabled(false);
|
||||
|
||||
// 仅对真正的 Claude 模型启用 extended thinking(MiniMax 等走 Anthropic 协议但不支持)
|
||||
@ -712,9 +1021,19 @@ public class ReasoningNode implements NodeAction {
|
||||
// 始终使用 OpenAiChatOptions(而非 ToolCallingChatOptions),
|
||||
// 因为 ToolCallingChatOptions 会丢失 OpenAI 特有参数(streamUsage 等),
|
||||
// 导致 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()
|
||||
.toolCallbacks(toolCallbacks)
|
||||
.maxTokens(maxOutputTokens);
|
||||
.toolCallbacks(activeCallbacks)
|
||||
.maxTokens(effectiveMaxTokens);
|
||||
if (StringUtils.hasText(effectiveReasoning)) {
|
||||
oaiBuilder.reasoningEffort(effectiveReasoning);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.StructuredTruncator;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
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",
|
||||
result.errorMessage());
|
||||
String fallback = observationText.length() > 500
|
||||
? observationText.substring(0, 500) + "...[摘要生成失败,已截断]"
|
||||
? StructuredTruncator.headSlice(observationText.toString(), 500)
|
||||
+ "\n...[摘要生成失败,仅保留原始观察的前部片段;数据不完整,请勿编造、补全或重新编号缺失内容]"
|
||||
: observationText.toString();
|
||||
AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback);
|
||||
return MateClawStateAccessor.output()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package vip.mate.agent.graph.observation;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.context.StructuredTruncator;
|
||||
import vip.mate.config.GraphObservationProperties;
|
||||
|
||||
import java.util.List;
|
||||
@ -94,12 +95,11 @@ public class ObservationProcessor {
|
||||
int headLen = (int) (available * effectiveHeadRatio);
|
||||
int tailLen = available - headLen;
|
||||
|
||||
String head = text.substring(0, headLen);
|
||||
String tail = text.substring(originalLen - tailLen);
|
||||
String result = StructuredTruncator.truncate(text, headLen, tailLen, marker);
|
||||
|
||||
log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})",
|
||||
originalLen, head.length() + tail.length(), maxLen, effectiveHeadRatio);
|
||||
return head + marker + tail;
|
||||
originalLen, result.length(), maxLen, effectiveHeadRatio);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -319,6 +319,24 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -115,6 +115,20 @@ public class PlanGenerationNode implements NodeAction {
|
||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||
PlanStateAccessor accessor = new PlanStateAccessor(state);
|
||||
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 agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
|
||||
String conversationId = accessor.conversationId();
|
||||
@ -148,7 +162,11 @@ public class PlanGenerationNode implements NodeAction {
|
||||
List<Message> promptMessages = new ArrayList<>();
|
||||
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
|
||||
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,
|
||||
// but do NOT force "any tool usage implies multi-step" — single-hop tool use
|
||||
|
||||
@ -27,6 +27,7 @@ import vip.mate.agent.context.RuntimeContextInjector;
|
||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.planning.service.PlanningService;
|
||||
import vip.mate.skill.runtime.SkillCatalogRenderer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -60,6 +61,12 @@ public class StepExecutionNode implements NodeAction {
|
||||
private final String reasoningEffort;
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
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}.
|
||||
@ -92,7 +99,20 @@ public class StepExecutionNode implements NodeAction {
|
||||
ConversationWindowManager conversationWindowManager) {
|
||||
this(chatModel, toolSet, executor, planningService, streamTracker,
|
||||
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. */
|
||||
@ -103,6 +123,19 @@ public class StepExecutionNode implements NodeAction {
|
||||
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
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.toolSet = toolSet;
|
||||
this.executor = executor;
|
||||
@ -111,6 +144,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.reasoningEffort = reasoningEffort;
|
||||
this.streamingHelper = streamingHelper;
|
||||
this.skillCatalogRenderer = skillCatalogRenderer;
|
||||
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
|
||||
}
|
||||
|
||||
@ -494,8 +528,18 @@ public class StepExecutionNode implements NodeAction {
|
||||
8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。
|
||||
""";
|
||||
messages.add(new SystemMessage(enhancedSystemPrompt));
|
||||
// 注入运行时上下文(当前时间 + 工作目录)
|
||||
messages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
||||
// Runtime skill catalog (rendered here instead of baked into the system
|
||||
// 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(对话历史 + 步骤结果的受控长度摘要)
|
||||
String workingContext = accessor.workingContext();
|
||||
|
||||
@ -107,6 +107,17 @@ public final class PlanStateAccessor {
|
||||
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)=====
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@ -235,8 +246,10 @@ public final class PlanStateAccessor {
|
||||
NodeStreamingChatHelper.StreamResult result) {
|
||||
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_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.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
||||
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@ -228,6 +228,26 @@ public final class MateClawStateAccessor {
|
||||
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 =====
|
||||
|
||||
public int promptTokens() {
|
||||
@ -246,6 +266,67 @@ public final class MateClawStateAccessor {
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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 ----
|
||||
|
||||
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
||||
@ -435,6 +526,97 @@ public final class MateClawStateAccessor {
|
||||
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() {
|
||||
return map;
|
||||
}
|
||||
|
||||
@ -168,6 +168,69 @@ public final class MateClawStateKeys {
|
||||
/** Source references observed from successful tool results during this run. */
|
||||
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 FinalAnswerNode→GoalEvaluation 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 =====
|
||||
|
||||
/**
|
||||
@ -179,4 +242,32 @@ public final class MateClawStateKeys {
|
||||
* workspace context.
|
||||
*/
|
||||
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";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 > 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');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* services.
|
||||
*
|
||||
@ -84,6 +84,9 @@ public class AgentRuntimeAggregator {
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
String rootConversationId,
|
||||
String parentSubagentId,
|
||||
int depth,
|
||||
Long agentId,
|
||||
String agentName,
|
||||
String agentIcon,
|
||||
@ -186,6 +189,9 @@ public class AgentRuntimeAggregator {
|
||||
rec.subagentId(),
|
||||
rec.parentConversationId(),
|
||||
rec.childConversationId(),
|
||||
rec.rootConversationId(),
|
||||
rec.parentSubagentId(),
|
||||
rec.depth(),
|
||||
rec.agentId(),
|
||||
ag == null ? null : ag.getName(),
|
||||
ag == null ? null : ag.getIcon(),
|
||||
|
||||
@ -18,16 +18,17 @@ import vip.mate.workspace.conversation.ConversationService;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
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
|
||||
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
|
||||
* owner-scoped — this controller is intentionally cross-tenant for the
|
||||
* operator role.
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Agent Runtime (Backstage)")
|
||||
@Tag(name = "Agent Runtime (Live)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/agent-runtime")
|
||||
@RequiredArgsConstructor
|
||||
@ -42,6 +43,7 @@ public class AgentRuntimeController {
|
||||
|
||||
@Operation(summary = "Snapshot of every in-flight agent turn")
|
||||
@GetMapping("/snapshot")
|
||||
@RequireGlobalAdmin
|
||||
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
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")
|
||||
@PostMapping("/runs/{conversationId}/stop")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
@ -59,6 +62,7 @@ public class AgentRuntimeController {
|
||||
|
||||
@Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored")
|
||||
@PostMapping("/runs/{conversationId}/recycle")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
@ -72,6 +76,7 @@ public class AgentRuntimeController {
|
||||
|
||||
@Operation(summary = "Interrupt one sub-agent (admin override of ownership check)")
|
||||
@PostMapping("/subagents/{subagentId}/interrupt")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
@ -87,6 +92,7 @@ public class AgentRuntimeController {
|
||||
*/
|
||||
@Operation(summary = "Recycle every run currently flagged as stuck")
|
||||
@PostMapping("/sweep")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> sweep(Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
|
||||
|
||||
@ -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 ----------
|
||||
|
||||
private ResolveOutcome performResolve(String pendingId, String userId,
|
||||
|
||||
@ -11,6 +11,7 @@ import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -35,12 +36,14 @@ public class AuthController {
|
||||
|
||||
@Operation(summary = "获取用户列表")
|
||||
@GetMapping("/users")
|
||||
@RequireGlobalAdmin
|
||||
public R<List<UserEntity>> listUsers() {
|
||||
return R.ok(authService.listUsers());
|
||||
}
|
||||
|
||||
@Operation(summary = "创建用户")
|
||||
@PostMapping("/users")
|
||||
@RequireGlobalAdmin
|
||||
public R<UserEntity> createUser(@RequestBody UserEntity user) {
|
||||
return R.ok(authService.createUser(user));
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package vip.mate.auth.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@ -20,7 +21,12 @@ public class UserEntity {
|
||||
/** 用户名 */
|
||||
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;
|
||||
|
||||
/** 昵称 */
|
||||
|
||||
@ -417,7 +417,10 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
||||
* - dm_policy / group_policy:控制私聊/群聊是否开放
|
||||
* - allow_from:用户白名单
|
||||
* - 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) {
|
||||
boolean isDM = isDirectMessage(message);
|
||||
@ -433,18 +436,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 群聊中检查 require_mention(需要 @机器人才响应)
|
||||
// 注:如果已设置 botPrefix,shouldProcess() 已处理;此处处理 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());
|
||||
}
|
||||
// 如果有 botPrefix,shouldProcess() 已经过滤过非 mention 消息,此处放行
|
||||
}
|
||||
|
||||
// 3. 检查 allow_from 白名单
|
||||
// 2. 检查 allow_from 白名单
|
||||
List<String> allowFrom = getConfigList("allow_from");
|
||||
if (!allowFrom.isEmpty()) {
|
||||
if (!allowFrom.contains(message.getSenderId())) {
|
||||
|
||||
@ -142,6 +142,24 @@ public interface ChannelAdapter {
|
||||
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.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
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,13 @@ public class ChannelChatOriginFactory {
|
||||
/* workspaceId */ channel.getWorkspaceId(),
|
||||
/* workspaceBasePath */ workspaceBasePath,
|
||||
/* channelId */ channel.getId(),
|
||||
/* channelTarget */ target);
|
||||
/* channelTarget */ target,
|
||||
/* cronOrigin */ false,
|
||||
/* senderName */ message.getSenderName(),
|
||||
/* channelType */ message.getChannelType() != null
|
||||
? message.getChannelType()
|
||||
: channel.getChannelType(),
|
||||
/* chatId */ message.getChatId());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -75,6 +75,57 @@ public class ChannelManager {
|
||||
*/
|
||||
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
|
||||
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
||||
@ -1140,7 +1191,9 @@ public class ChannelManager {
|
||||
return switch (type) {
|
||||
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
|
||||
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 "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
||||
|
||||
@ -7,6 +7,7 @@ import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
@ -487,6 +488,47 @@ public class ChannelMessageRouter {
|
||||
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 逻辑 + 审批拦截层) ====================
|
||||
|
||||
/**
|
||||
@ -509,20 +551,12 @@ public class ChannelMessageRouter {
|
||||
String replyTarget = resolveReplyTarget(message);
|
||||
|
||||
if (isApproveCommand(userText)) {
|
||||
// pendingId 校验:如果命令包含 shortId,验证是否匹配当前 pending
|
||||
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())));
|
||||
// pendingId 校验:approve / deny 共用——命令带 shortId 时必须匹配当前 pending。
|
||||
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// 身份校验:只有原始请求者可以审批(群聊安全)
|
||||
String originalRequester = pending.getUserId();
|
||||
if (originalRequester != null && !"system".equals(originalRequester)
|
||||
&& !originalRequester.equals(message.getSenderId())) {
|
||||
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
|
||||
log.warn("[{}] Approval rejected: sender={} != requester={}",
|
||||
adapter.getChannelType(), message.getSenderId(), originalRequester);
|
||||
// 身份校验:approve / deny 共用同一道门禁(群聊安全 + system/null fail-closed)。
|
||||
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
|
||||
@ -541,9 +575,24 @@ public class ChannelMessageRouter {
|
||||
return;
|
||||
|
||||
} else if (isDenyCommand(userText)) {
|
||||
// pendingId 校验:与 approve 一致——命令带 shortId 时必须匹配当前 pending,
|
||||
// 否则 /deny <其它ID> 会错误地拒绝当前 conversation 的 pending。
|
||||
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// 身份校验:deny 与 approve 共用门禁。否则群里任意成员可拒绝/取消他人的
|
||||
// pending,system/null 发起的审批也会被任意人 deny(取消审批、清 placeholder、
|
||||
// 写入 denied 状态);这类审批改到管理端处理。
|
||||
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
|
||||
return;
|
||||
}
|
||||
// Deny via IM: workflow.resolve owns the full state-machine transition.
|
||||
ResolveOutcome denyOutcome = approvalService.resolve(
|
||||
pending.getPendingId(), message.getSenderId(), "denied");
|
||||
if (denyOutcome.isAlreadyResolved()) {
|
||||
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
|
||||
return;
|
||||
}
|
||||
conversationService.removeApprovalPlaceholders(conversationId);
|
||||
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
|
||||
persistAndBroadcastApprovalHint(conversationId, denyHint,
|
||||
@ -554,8 +603,23 @@ public class ChannelMessageRouter {
|
||||
denyOutcome.messagesRewritten());
|
||||
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 {
|
||||
// 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");
|
||||
conversationService.removeApprovalPlaceholders(conversationId);
|
||||
String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。";
|
||||
@ -569,8 +633,40 @@ public class ChannelMessageRouter {
|
||||
}
|
||||
// ======= 审批拦截层结束 =======
|
||||
|
||||
// 确保会话存在(workspace 感知)
|
||||
conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId());
|
||||
// Ensure the conversation exists, seeded with the agent's
|
||||
// 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);
|
||||
@ -696,6 +792,15 @@ public class ChannelMessageRouter {
|
||||
|
||||
// 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞)
|
||||
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 {
|
||||
@ -813,6 +918,12 @@ public class ChannelMessageRouter {
|
||||
maybeGenerateVoiceReply(message, streamingAdapter, replyTarget,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,7 @@ public class ChannelController {
|
||||
private final ChannelVerifierRegistry verifierRegistry;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "获取渠道列表")
|
||||
@GetMapping
|
||||
public R<List<ChannelEntity>> list(
|
||||
@ -52,7 +52,7 @@ public class ChannelController {
|
||||
return R.ok(channelService.listChannelsByWorkspace(wsId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "按类型获取渠道列表")
|
||||
@GetMapping("/type/{channelType}")
|
||||
public R<List<ChannelEntity>> listByType(@PathVariable String channelType,
|
||||
@ -61,7 +61,7 @@ public class ChannelController {
|
||||
return R.ok(channelService.listChannelsByTypeAndWorkspace(channelType, wsId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "获取渠道详情")
|
||||
@GetMapping("/{id}")
|
||||
public R<ChannelEntity> get(@PathVariable Long id,
|
||||
@ -140,7 +140,7 @@ public class ChannelController {
|
||||
return R.ok(channelManager.getStatus());
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "获取指定渠道的实时健康状态(真连接状态,前端绿点应该绑这个)")
|
||||
@GetMapping("/{id}/health")
|
||||
public R<Map<String, Object>> health(@PathVariable Long id,
|
||||
@ -236,7 +236,7 @@ public class ChannelController {
|
||||
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
|
||||
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
|
||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
||||
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
* 1–3 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 <pendingId>" (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) + "…";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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": "<tool / risk / args summary>"},
|
||||
* {"tag": "action", "actions": [
|
||||
* {"tag": "button", "text": {...}, "type": "primary", "value": <approve payload>},
|
||||
* {"tag": "button", "text": {...}, "type": "danger", "value": <deny payload>}
|
||||
* ]}
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* </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;
|
||||
}
|
||||
}
|
||||
@ -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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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\"]}";
|
||||
}
|
||||
}
|
||||
@ -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/");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -424,7 +424,7 @@ public class QQChannelAdapter extends AbstractChannelAdapter {
|
||||
|
||||
switch (op) {
|
||||
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_RECONNECT -> {
|
||||
log.info("[qq] Server requested reconnect");
|
||||
@ -515,34 +515,51 @@ public class QQChannelAdapter extends AbstractChannelAdapter {
|
||||
|
||||
/**
|
||||
* 处理 DISPATCH 事件
|
||||
* <p>
|
||||
* `data` payload shape varies by event: object for messages/READY, empty string for RESUMED.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleDispatch(String eventType, Map<String, Object> data) {
|
||||
if (eventType == null || data == null) return;
|
||||
private void handleDispatch(String eventType, Object data) {
|
||||
if (eventType == null) return;
|
||||
|
||||
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" -> {
|
||||
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;
|
||||
quickDisconnectCount = 0;
|
||||
connectionState.set(ConnectionState.CONNECTED);
|
||||
lastError = null;
|
||||
log.info("[qq] READY received, session_id={}", sessionId);
|
||||
}
|
||||
case "RESUMED" -> {
|
||||
reconnectAttempts = 0;
|
||||
connectionState.set(ConnectionState.CONNECTED);
|
||||
lastError = null;
|
||||
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);
|
||||
case "C2C_MESSAGE_CREATE" -> dispatchMessage("c2c", eventType, data);
|
||||
case "GROUP_AT_MESSAGE_CREATE" -> dispatchMessage("group", eventType, data);
|
||||
case "AT_MESSAGE_CREATE" -> dispatchMessage("guild", eventType, data);
|
||||
case "DIRECT_MESSAGE_CREATE" -> dispatchMessage("dm", eventType, data);
|
||||
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)
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.channel.feishu.FeishuClientFactory;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.repository.ChannelMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
@ -30,6 +32,20 @@ public class ChannelService {
|
||||
|
||||
private final ChannelMapper channelMapper;
|
||||
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();
|
||||
|
||||
/**
|
||||
@ -121,6 +137,7 @@ public class ChannelService {
|
||||
channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson()));
|
||||
}
|
||||
channelMapper.updateById(channel);
|
||||
invalidateChannelCaches(channel.getId(), channel.getChannelType());
|
||||
log.info("Updated channel: {}", existing.getName());
|
||||
return channel;
|
||||
}
|
||||
@ -131,6 +148,7 @@ public class ChannelService {
|
||||
public void deleteChannel(Long id) {
|
||||
ChannelEntity channel = getChannel(id);
|
||||
channelMapper.deleteById(id);
|
||||
invalidateChannelCaches(id, channel.getChannelType());
|
||||
log.info("Deleted channel: {}", channel.getName());
|
||||
}
|
||||
|
||||
@ -141,10 +159,39 @@ public class ChannelService {
|
||||
ChannelEntity channel = getChannel(id);
|
||||
channel.setEnabled(enabled);
|
||||
channelMapper.updateById(channel);
|
||||
invalidateChannelCaches(id, channel.getChannelType());
|
||||
log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled");
|
||||
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) {
|
||||
Map<String, Object> incoming = parseConfig(incomingConfigJson);
|
||||
Map<String, Object> existing = parseConfig(existingConfigJson);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -520,6 +520,11 @@ public class ChatController {
|
||||
AtomicBoolean finalized = new AtomicBoolean(false);
|
||||
try {
|
||||
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);
|
||||
String promptText = buildPromptText(message, requestParts);
|
||||
conversationService.saveMessage(conversationId, "user", message, requestParts);
|
||||
@ -932,7 +937,7 @@ public class ChatController {
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
// 权限校验:已认证用户需验证会话归属,匿名用户(permitAll)直接放行
|
||||
if (auth != null && !conversationService.isConversationOwner(conversationId, username)) {
|
||||
return R.fail("无权操作该会话");
|
||||
return R.fail(403, "无权操作该会话");
|
||||
}
|
||||
boolean stopped = streamTracker.requestStop(conversationId);
|
||||
|
||||
@ -975,7 +980,7 @@ public class ChatController {
|
||||
Authentication auth) {
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
if (auth != null && !conversationService.isConversationOwner(conversationId, username)) {
|
||||
return R.fail("无权操作该会话");
|
||||
return R.fail(403, "无权操作该会话");
|
||||
}
|
||||
|
||||
if (!streamTracker.isRunning(conversationId)) {
|
||||
@ -1024,7 +1029,7 @@ public class ChatController {
|
||||
|
||||
String username = auth != null ? auth.getName() : null;
|
||||
if (username == null) {
|
||||
return R.fail("未登录,请先登录");
|
||||
return R.fail(401, "未登录,请先登录");
|
||||
}
|
||||
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId);
|
||||
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts());
|
||||
@ -1047,7 +1052,7 @@ public class ChatController {
|
||||
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)
|
||||
if (conversationService.conversationExists(conversationId)
|
||||
&& !conversationService.isConversationOwner(conversationId, username)) {
|
||||
return R.fail("无权操作该会话");
|
||||
return R.fail(403, "无权操作该会话");
|
||||
}
|
||||
if (file.isEmpty()) {
|
||||
return R.fail("上传文件不能为空");
|
||||
@ -1151,6 +1156,14 @@ public class ChatController {
|
||||
private Long lastEventId;
|
||||
/** 思考深度:off / low / medium / high / max,null 表示跟随 Agent 默认 */
|
||||
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()) {
|
||||
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 (completionTokens > 0) payload.put("completionTokens", completionTokens);
|
||||
@ -1982,6 +2018,7 @@ public class ChatController {
|
||||
synchronized String toMetadataJson() {
|
||||
finalizeToolCalls();
|
||||
finalizeRunningSegments("thinking", "content", "tool_call");
|
||||
SegmentSupersedeDetector.markSuperseded(segments);
|
||||
try {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
if (!toolCalls.isEmpty()) {
|
||||
|
||||
@ -394,6 +394,11 @@ public class ChatStreamTracker {
|
||||
|| "error".equals(eventName)
|
||||
|| "tool_approval_requested".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);
|
||||
}
|
||||
|
||||
@ -453,7 +458,18 @@ public class ChatStreamTracker {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state != null && state.done) {
|
||||
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) {
|
||||
// Reuse path: when complete() early-returns due to activeFluxCount > 0
|
||||
// (approval replay / interrupt / any leaked flux increment), the RunState
|
||||
@ -1448,17 +1464,64 @@ public class ChatStreamTracker {
|
||||
|
||||
/** 已完成的 RunState 保留时间(5 分钟) */
|
||||
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,防止内存泄漏。
|
||||
* - 已完成超过 5 分钟的 → 移除
|
||||
* - 存活超过 30 分钟的(无论是否完成)→ 强制移除
|
||||
* - 已完成超过 {@link #DONE_RETENTION_MS} 的 → 移除
|
||||
* - 自 {@link RunState#lastEventAt} 算起静默超过
|
||||
* {@link #idleTimeoutMinutes} 分钟的 → 强制移除(视为卡死)
|
||||
*/
|
||||
@org.springframework.scheduling.annotation.Scheduled(fixedRate = 600_000)
|
||||
public void cleanupStaleRuns() {
|
||||
long now = System.currentTimeMillis();
|
||||
long idleThresholdMs = (long) idleTimeoutMinutes * 60_000L;
|
||||
int evicted = 0;
|
||||
|
||||
var iterator = runs.entrySet().iterator();
|
||||
@ -1466,6 +1529,7 @@ public class ChatStreamTracker {
|
||||
var entry = iterator.next();
|
||||
RunState state = entry.getValue();
|
||||
long age = now - state.createdAt;
|
||||
long idleMs = now - state.lastEventAt;
|
||||
|
||||
boolean shouldEvict = false;
|
||||
String reason = null;
|
||||
@ -1473,12 +1537,35 @@ public class ChatStreamTracker {
|
||||
if (state.done && age > DONE_RETENTION_MS) {
|
||||
shouldEvict = true;
|
||||
reason = "completed and expired";
|
||||
} else if (age > MAX_LIFETIME_MS) {
|
||||
} else if (idleMs > idleThresholdMs) {
|
||||
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) {
|
||||
// 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());
|
||||
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
|
||||
@ -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
|
||||
* 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
|
||||
* effect (model wedged in a tool call beyond the timeout). Sequence
|
||||
* matches what {@link #onShutdown()} does for individual runs.
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user