release: v1.4.0

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

View File

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

View File

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

View File

@ -4,36 +4,21 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<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>

View File

@ -4,40 +4,24 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<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>

View File

@ -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"]

View File

@ -4,70 +4,33 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<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 CoreStateGraph 工作流引擎) ===== -->
<!-- ===== 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 AnthropicClaude 模型支持) ===== -->
<!-- ===== 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 OpenAPISwagger 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 SDKWebSocket 长连接,无需公网 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 SDKWebSocket 长连接 + 事件分发) ===== -->
<!-- ===== 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>
<!-- ===== JDADiscord 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 WebSocketTalk Mode ===== -->
<!-- ===== Spring WebSocket (Talk Mode) ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- ===== Slack SDKSocket 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`).

View File

@ -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,

View File

@ -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));

View File

@ -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,

View File

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

View File

@ -15,11 +15,14 @@ import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.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 ====================

View File

@ -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 ====================
/**

View File

@ -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 后调用所以最后一条就是当前消息

View File

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

View File

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

View File

@ -15,8 +15,11 @@ import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.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()

View File

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

View File

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

View File

@ -33,7 +33,32 @@ public record ChatOrigin(
@Nullable Long workspaceId,
@Nullable 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 ----------------

View File

@ -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 {

View File

@ -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) {

View File

@ -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)");
}
}
}

View File

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

View File

@ -270,7 +270,7 @@ public class AgentController {
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
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, "资源不属于当前工作区");
}
}

View File

@ -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());

View File

@ -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);
}

View File

@ -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);

View File

@ -9,8 +9,9 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.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;
}

View File

@ -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;
}

View File

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

View File

@ -10,6 +10,7 @@ import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.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;

View File

@ -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]";
}

View File

@ -2,6 +2,8 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.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;
}
}
}

View File

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

View File

@ -47,18 +47,33 @@ public class LimitExceededNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper;
/** 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

View File

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

View File

@ -18,7 +18,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
import 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 协议模型AnthropicChatModelMiniMax 也用此协议但不支持 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 thinkingMiniMax 等走 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);
}

View File

@ -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()

View File

@ -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;
}
/**

View File

@ -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;
}

View File

@ -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

View File

@ -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();

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 FinalAnswerNodeGoalEvaluation edge skips
* re-entering once it's true. The followup branch deliberately leaves it
* false so the self-continuation loop can re-evaluate the next answer; that
* loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus
* the goal's turn / LLM-call budgets.
*/
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
/**
* Number of auto-followups already injected in THIS graph run (one user
* turn). Bounds the self-continuation loop per single message independent
* of the goal's cross-turn turn_budget so one message can't drive an
* unbounded number of autonomous steps or exhaust the graph recursion
* limit. Implicitly 0 at the start of each graph invocation.
*/
public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count";
/**
* Cumulative agent LLM-call count already billed to the goal in THIS graph
* run. The run-to-completion loop evaluates multiple times per run while
* {@link #LLM_CALL_COUNT} keeps growing; recording only
* (current accounted) on each pass avoids re-billing earlier calls and
* exhausting the goal's LLM budget prematurely. Implicitly 0 at run start.
*/
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
/** Graph-node identifier for the GoalEvaluationNode. */
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
/**
@ -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";
}

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ import java.util.stream.Collectors;
/**
* Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry})
* 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(),

View File

@ -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();

View File

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

View File

@ -11,6 +11,7 @@ import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.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));
}

View File

@ -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;
/** 昵称 */

View File

@ -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需要 @机器人才响应
// 如果已设置 botPrefixshouldProcess() 已处理此处处理 configJson 中的 require_mention
if (!isDM && getConfigBoolean("require_mention", false)) {
String botPrefix = channelEntity.getBotPrefix();
if (botPrefix == null || botPrefix.isBlank()) {
// 设置了 require_mention 但没有 botPrefix无法判断 mention放行
log.debug("[{}] require_mention=true but no botPrefix configured, allowing", getChannelType());
}
// 如果有 botPrefixshouldProcess() 已经过滤过非 mention 消息此处放行
}
// 3. 检查 allow_from 白名单
// 2. 检查 allow_from 白名单
List<String> allowFrom = getConfigList("allow_from");
if (!allowFrom.isEmpty()) {
if (!allowFrom.contains(message.getSenderId())) {

View File

@ -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
}
}

View File

@ -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());
}
/**

View File

@ -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,

View File

@ -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 共用门禁否则群里任意成员可拒绝/取消他人的
// pendingsystem/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;
}

View File

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

View File

@ -43,7 +43,7 @@ public class ChannelController {
private final ChannelVerifierRegistry verifierRegistry;
private final 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, "资源不属于当前工作区");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -424,7 +424,7 @@ public class QQChannelAdapter extends AbstractChannelAdapter {
switch (op) {
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
*/

View File

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

View File

@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -520,6 +520,11 @@ public class ChatController {
AtomicBoolean finalized = new AtomicBoolean(false);
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 / maxnull 表示跟随 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()) {

View File

@ -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.

View File

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

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