mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
release: v1.6.0
This commit is contained in:
parent
84375da3c5
commit
db6caea824
35
.env.example
35
.env.example
@ -29,6 +29,11 @@ JWT_SECRET=
|
|||||||
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
|
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
|
||||||
MATECLAW_CORS_ALLOWED_ORIGINS=
|
MATECLAW_CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# 公开访问基址(如 https://mateclaw.example.com)。用于把智能体生成文件的下载
|
||||||
|
# 链接拼成绝对地址,便于在 Web 之外(IM 消息、复制链接、外部下载)直接打开。
|
||||||
|
# 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。
|
||||||
|
MATECLAW_PUBLIC_BASE_URL=
|
||||||
|
|
||||||
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
|
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
SEARXNG_SECRET=
|
SEARXNG_SECRET=
|
||||||
@ -69,6 +74,36 @@ MATECLAW_BROWSER_CHANNEL=
|
|||||||
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
|
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
|
||||||
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
|
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
|
||||||
|
|
||||||
|
# ==================== Wiki 知识库目录白名单(Docker 模式,可选)====================
|
||||||
|
#
|
||||||
|
# Docker 生产部署开启了路径安全校验(fail-closed)。
|
||||||
|
# 知识库使用「目录扫描」功能时,扫描路径必须在此白名单内,否则返回 400 错误。
|
||||||
|
# 多个路径用英文逗号分隔;留空则禁止所有目录扫描。
|
||||||
|
#
|
||||||
|
# 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
|
||||||
|
#
|
||||||
|
# 同时在 docker-compose.yml 的 volumes 里把宿主机目录挂进容器,例如:
|
||||||
|
# volumes:
|
||||||
|
# - /your/host/path:/data/wiki
|
||||||
|
MATE_WIKI_ALLOWED_SOURCE_ROOTS=
|
||||||
|
|
||||||
|
# ── Wiki 知识源自动同步(变更监测)总开关 ────────────────────────
|
||||||
|
# 定时扫描各知识库的源目录、自动消化新文件。默认关闭,运维主动开启。
|
||||||
|
# AND 语义:全局这个开关开 *且* 某知识库自己的「自动同步」开关也开,
|
||||||
|
# 该库才会被定时扫描;手动「立即扫描」不受此开关影响。
|
||||||
|
# 间隔单位毫秒,默认 5 分钟(目前为全局,暂不支持按库配置)。
|
||||||
|
MATE_WIKI_WATCHER_ENABLED=false
|
||||||
|
MATE_WIKI_WATCHER_INTERVAL_MS=300000
|
||||||
|
|
||||||
|
# ── Skill 工作区目录 ─────────────────────────────────────────────
|
||||||
|
# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。
|
||||||
|
# 默认(容器内)已指向 /app/data/skills,由 docker-compose 的 server_data 卷
|
||||||
|
# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。
|
||||||
|
# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。
|
||||||
|
# 仅当你想把 skill 目录放到别处(如独立的 bind mount)时才覆盖此项,
|
||||||
|
# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。
|
||||||
|
MATECLAW_SKILL_WORKSPACE_ROOT=
|
||||||
|
|
||||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -111,3 +111,8 @@ scripts/.*-sync-state.json
|
|||||||
# Sandbox / external client work that lives in this directory
|
# Sandbox / external client work that lives in this directory
|
||||||
# but should not ship in the repo.
|
# but should not ship in the repo.
|
||||||
outputs/
|
outputs/
|
||||||
|
|
||||||
|
# This is a pnpm monorepo — pnpm-lock.yaml is the only lockfile we track.
|
||||||
|
# Ignore stray npm/yarn lockfiles so they are not committed by mistake.
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|||||||
15
README.md
15
README.md
@ -217,9 +217,20 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0) for the full story.
|
**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0).
|
||||||
|
|
||||||
**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations.
|
**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0).
|
||||||
|
|
||||||
|
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0).
|
||||||
|
|
||||||
|
**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*:
|
||||||
|
|
||||||
|
- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters
|
||||||
|
- **Native code execution** — `execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side
|
||||||
|
- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading
|
||||||
|
- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user
|
||||||
|
- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]`
|
||||||
|
- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
15
README_zh.md
15
README_zh.md
@ -217,9 +217,20 @@ mateclaw/
|
|||||||
|
|
||||||
## 路线图
|
## 路线图
|
||||||
|
|
||||||
**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。完整故事见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
|
**v1.5.0(2026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
|
||||||
|
|
||||||
**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。
|
**v1.4.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
|
||||||
|
|
||||||
|
**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
|
||||||
|
|
||||||
|
**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*:
|
||||||
|
|
||||||
|
- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载
|
||||||
|
- **原生代码执行** — `execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内
|
||||||
|
- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传
|
||||||
|
- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆
|
||||||
|
- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]`
|
||||||
|
- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门
|
||||||
|
|
||||||
## 参与贡献
|
## 参与贡献
|
||||||
|
|
||||||
|
|||||||
@ -96,6 +96,19 @@ services:
|
|||||||
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
|
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
|
||||||
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
|
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
|
||||||
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
|
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
|
||||||
|
# Wiki 知识库目录扫描白名单(逗号分隔,留空则禁止所有目录扫描)。
|
||||||
|
# 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
|
||||||
|
# 记得同步在 volumes 里把宿主机路径挂进容器。
|
||||||
|
MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
|
||||||
|
# Wiki 知识源自动同步总开关(运维总闸,默认关)。AND 语义:全局开关与
|
||||||
|
# 每个知识库自己的「自动同步」开关都开,该库才会被定时扫描。
|
||||||
|
# 间隔单位毫秒,默认 5 分钟。
|
||||||
|
MATE_WIKI_WATCHER_ENABLED: ${MATE_WIKI_WATCHER_ENABLED:-false}
|
||||||
|
MATE_WIKI_WATCHER_INTERVAL_MS: ${MATE_WIKI_WATCHER_INTERVAL_MS:-300000}
|
||||||
|
# Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化
|
||||||
|
# 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。
|
||||||
|
# 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。
|
||||||
|
MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills}
|
||||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
||||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||||
# recommendation for Playwright / headless chrome.
|
# recommendation for Playwright / headless chrome.
|
||||||
@ -104,6 +117,9 @@ services:
|
|||||||
- "18080:18088" # host:container — app listens on 18088 inside the container
|
- "18080:18088" # host:container — app listens on 18088 inside the container
|
||||||
- "1455:1455"
|
- "1455:1455"
|
||||||
volumes:
|
volumes:
|
||||||
|
# server_data covers /app/data — H2 DB, wiki-uploads, AND the skill
|
||||||
|
# workspace (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a
|
||||||
|
# single volume persists everything. No separate skills volume needed.
|
||||||
- server_data:/app/data
|
- server_data:/app/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -105,9 +105,18 @@ RUN apt-get update \
|
|||||||
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
|
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
TZ=Asia/Shanghai \
|
TZ=Asia/Shanghai \
|
||||||
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
|
LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai -Dsun.jnu.encoding=UTF-8"
|
||||||
|
|
||||||
|
# Default DB profile, overridable by the SPRING_PROFILES_ACTIVE env var
|
||||||
|
# (compose sets it explicitly: mysql / postgres / kingbase). It must be an ENV,
|
||||||
|
# not a -D system property on the ENTRYPOINT: a hardcoded
|
||||||
|
# -Dspring.profiles.active outranks the SPRING_PROFILES_ACTIVE env var and would
|
||||||
|
# silently pin the profile regardless of what compose passes.
|
||||||
|
ENV SPRING_PROFILES_ACTIVE=mysql
|
||||||
|
|
||||||
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
|
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
|
||||||
EXPOSE 18088
|
EXPOSE 18088
|
||||||
EXPOSE 1455
|
EXPOSE 1455
|
||||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
|||||||
@ -337,6 +337,30 @@
|
|||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-mysql</artifactId>
|
<artifactId>flyway-mysql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Flyway PostgreSQL support (used by KingbaseES as well since KingbaseES is PostgreSQL-compatible) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>42.7.7</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
KingbaseES (人大金仓) JDBC driver is NOT on Maven Central, so it is
|
||||||
|
declared in the opt-in `kingbase` Maven profile instead of here.
|
||||||
|
The default build never resolves it. To build with KingbaseES:
|
||||||
|
1. install the driver: mvn install:install-file \
|
||||||
|
-Dfile=${KINGBASE_HOME}/Interface/jdbc/kingbase8-8.6.0.jar \
|
||||||
|
-DgroupId=com.kingbase8 -DartifactId=kingbase8 \
|
||||||
|
-Dversion=8.6.0 -Dpackaging=jar
|
||||||
|
2. build with the profile: mvn package -Pkingbase
|
||||||
|
No Java code imports com.kingbase8.* — the driver is loaded at
|
||||||
|
runtime via spring.datasource.driver-class-name only.
|
||||||
|
-->
|
||||||
|
|
||||||
<!-- ===== Spring Boot Test ===== -->
|
<!-- ===== Spring Boot Test ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -470,5 +494,24 @@
|
|||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</profile>
|
</profile>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Profile: KingbaseES (人大金仓) JDBC driver.
|
||||||
|
The driver is not published to Maven Central, so it is kept out of the
|
||||||
|
default build to keep `mvn package` resolvable for everyone. Install the
|
||||||
|
driver into the local repository, then build with `mvn package -Pkingbase`.
|
||||||
|
Runtime selection is via the `kingbase` Spring profile (application-kingbase.yml).
|
||||||
|
-->
|
||||||
|
<profile>
|
||||||
|
<id>kingbase</id>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.kingbase8</groupId>
|
||||||
|
<artifactId>kingbase8</artifactId>
|
||||||
|
<version>8.6.0</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</profile>
|
||||||
</profiles>
|
</profiles>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -1,19 +1,29 @@
|
|||||||
package vip.mate;
|
package vip.mate;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.DbType;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MateClaw - Personal AI Assistant
|
* MateClaw - Personal AI Assistant
|
||||||
* Powered by Spring AI Alibaba
|
* Powered by Spring AI Alibaba
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@SpringBootApplication(exclude = {
|
@SpringBootApplication(exclude = {
|
||||||
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
|
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
|
||||||
@ -33,22 +43,80 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@MapperScan("vip.mate.**.repository")
|
@MapperScan("vip.mate.**.repository")
|
||||||
public class MateClawApplication {
|
public class MateClawApplication {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
/** Cached DbType for the PaginationInnerInterceptor. */
|
||||||
|
private volatile DbType resolvedDbType;
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(MateClawApplication.class, args);
|
SpringApplication.run(MateClawApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the actual database type from the live DataSource so the
|
||||||
|
* {@link PaginationInnerInterceptor} always uses the correct dialect,
|
||||||
|
* even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.).
|
||||||
|
*
|
||||||
|
* <p>DbType is cached after the first successful detection; a failure
|
||||||
|
* falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type},
|
||||||
|
* or eventually to {@link DbType#MYSQL} — but by then the connection
|
||||||
|
* pool would already have failed.
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
void detectDbType() {
|
||||||
|
try (Connection conn = dataSource.getConnection()) {
|
||||||
|
String productName = conn.getMetaData().getDatabaseProductName().toLowerCase();
|
||||||
|
if (productName.contains("kingbase")) {
|
||||||
|
resolvedDbType = DbType.KINGBASE_ES;
|
||||||
|
} else if (productName.contains("postgresql")) {
|
||||||
|
resolvedDbType = DbType.POSTGRE_SQL;
|
||||||
|
} else if (productName.contains("mysql") || productName.contains("mariadb")) {
|
||||||
|
resolvedDbType = DbType.MYSQL;
|
||||||
|
} else if (productName.contains("h2")) {
|
||||||
|
resolvedDbType = DbType.H2;
|
||||||
|
} else {
|
||||||
|
// Let the PaginationInnerInterceptor auto-detect at query time
|
||||||
|
resolvedDbType = null;
|
||||||
|
}
|
||||||
|
if (resolvedDbType != null) {
|
||||||
|
log.info("Detected database type: {} (product={})", resolvedDbType, productName);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}",
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MyBatis Plus pagination plugin.
|
* MyBatis Plus pagination plugin.
|
||||||
*
|
*
|
||||||
* <p>DbType is auto-detected from the JDBC connection at runtime rather
|
* <p>When {@code resolvedDbType} is available the interceptor uses it directly;
|
||||||
* than hardcoded. Hardcoding H2 here meant the MySQL deployment used
|
* otherwise it falls back to JDBC-URL auto-detection, which works for
|
||||||
* the H2 dialect for the count query, which silently returned 0 —
|
* {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
|
||||||
* frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
|
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
PaginationInnerInterceptor pagination = resolvedDbType != null
|
||||||
|
? new PaginationInnerInterceptor(resolvedDbType)
|
||||||
|
: new PaginationInnerInterceptor();
|
||||||
|
interceptor.addInnerInterceptor(pagination);
|
||||||
return interceptor;
|
return interceptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a clear "READY" banner after all post-startup initialization,
|
||||||
|
* so operators can tell at a glance when the application is ready to serve.
|
||||||
|
*/
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void onReady() {
|
||||||
|
log.info("");
|
||||||
|
log.info("╔══════════════════════════════════════════════════════════════════════╗");
|
||||||
|
log.info("║ MateClaw is READY ✓ ║");
|
||||||
|
log.info("║ Web UI → http://localhost:18088 ║");
|
||||||
|
log.info("║ Swagger → http://localhost:18088/swagger-ui.html ║");
|
||||||
|
log.info("╚══════════════════════════════════════════════════════════════════════╝");
|
||||||
|
log.info("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,11 @@ public class AgentGraphBuilder {
|
|||||||
@org.springframework.beans.factory.annotation.Value(
|
@org.springframework.beans.factory.annotation.Value(
|
||||||
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
||||||
private boolean loadSkillToolEnabled;
|
private boolean loadSkillToolEnabled;
|
||||||
|
|
||||||
|
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
|
||||||
|
@org.springframework.beans.factory.annotation.Value(
|
||||||
|
"${mate.agent.markdown-normalize-enabled:true}")
|
||||||
|
private boolean markdownNormalizeEnabled;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
@ -152,6 +157,30 @@ public class AgentGraphBuilder {
|
|||||||
this.auditEventService = s;
|
this.auditEventService = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-step delegation dependencies for the Plan-Execute graph.
|
||||||
|
* Setter injection (like {@link #auditEventService}) breaks the
|
||||||
|
* {@code AgentService ⇆ AgentGraphBuilder} construction cycle. Null when not
|
||||||
|
* wired (legacy / test) — per-step delegation is then simply disabled.
|
||||||
|
*/
|
||||||
|
private AgentService agentService;
|
||||||
|
|
||||||
|
// @Lazy on the injection point: inject a lazy-resolution proxy so the
|
||||||
|
// AgentService ⇆ AgentGraphBuilder cycle is broken at bean-creation time
|
||||||
|
// (the real bean is resolved on first use, when the graph is built).
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
public void setAgentService(@org.springframework.context.annotation.Lazy AgentService agentService) {
|
||||||
|
this.agentService = agentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
public void setDelegateAgentTool(
|
||||||
|
@org.springframework.context.annotation.Lazy vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool) {
|
||||||
|
this.delegateAgentTool = delegateAgentTool;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。
|
* 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。
|
||||||
*/
|
*/
|
||||||
@ -549,8 +578,11 @@ public class AgentGraphBuilder {
|
|||||||
if (auditEventService != null) {
|
if (auditEventService != null) {
|
||||||
executor.setAuditEventService(auditEventService);
|
executor.setAuditEventService(auditEventService);
|
||||||
}
|
}
|
||||||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
|
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
|
||||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
|
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
|
||||||
|
// Per-step delegation: route a step assigned to a specialist agent
|
||||||
|
// through DelegateAgentTool (null when delegation deps aren't wired).
|
||||||
|
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
||||||
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
||||||
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
|
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
|
||||||
|
|
||||||
@ -574,6 +606,7 @@ public class AgentGraphBuilder {
|
|||||||
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
|
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
|
||||||
|
.addStrategy(PlanStateKeys.PLAN_REPLAN_COUNT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
|
||||||
// 工作上下文(REPLACE 策略,每次重新生成)
|
// 工作上下文(REPLACE 策略,每次重新生成)
|
||||||
@ -638,6 +671,7 @@ public class AgentGraphBuilder {
|
|||||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
|
||||||
// Skill progressive disclosure — pinned skills loaded this
|
// Skill progressive disclosure — pinned skills loaded this
|
||||||
// run. Registered in BOTH graphs so the read-merge-write in
|
// run. Registered in BOTH graphs so the read-merge-write in
|
||||||
// ActionNode is not dropped on multi-node merges.
|
// ActionNode is not dropped on multi-node merges.
|
||||||
@ -652,6 +686,7 @@ public class AgentGraphBuilder {
|
|||||||
// ├→ DIRECT_ANSWER_NODE → END
|
// ├→ DIRECT_ANSWER_NODE → END
|
||||||
// └→ STEP_EXECUTION → (StepProgressDispatcher)
|
// └→ STEP_EXECUTION → (StepProgressDispatcher)
|
||||||
// ├→ STEP_EXECUTION (loop)
|
// ├→ STEP_EXECUTION (loop)
|
||||||
|
// ├→ PLAN_GENERATION (re-plan on step failure, bounded by PLAN_REPLAN_COUNT)
|
||||||
// └→ PLAN_SUMMARY → (active goal?)
|
// └→ PLAN_SUMMARY → (active goal?)
|
||||||
// ├→ GOAL_EVALUATION → (followup?)
|
// ├→ GOAL_EVALUATION → (followup?)
|
||||||
// │ ├→ PLAN_GENERATION (re-plan)
|
// │ ├→ PLAN_GENERATION (re-plan)
|
||||||
@ -685,11 +720,18 @@ public class AgentGraphBuilder {
|
|||||||
Map.of(
|
Map.of(
|
||||||
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
||||||
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
|
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
|
||||||
|
// Step-failure recovery: re-plan the remaining work
|
||||||
|
// (StepProgressDispatcher returns this on phase=plan_replan).
|
||||||
|
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
|
||||||
StateGraph.END, StateGraph.END))
|
StateGraph.END, StateGraph.END))
|
||||||
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
|
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
|
||||||
AsyncEdgeAction.edge_async(state -> {
|
AsyncEdgeAction.edge_async(state -> {
|
||||||
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
||||||
boolean hasGoal = a.hasActiveGoal();
|
// Same-turn activation: fall back to a DB lookup (gated on the
|
||||||
|
// feature flag) so a goal the agent set THIS turn is evaluated now,
|
||||||
|
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
|
||||||
|
boolean hasGoal = goalProperties.isEnabled()
|
||||||
|
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
|
||||||
boolean already = a.goalEvaluatedThisRun();
|
boolean already = a.goalEvaluatedThisRun();
|
||||||
return (hasGoal && !already)
|
return (hasGoal && !already)
|
||||||
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
||||||
@ -714,7 +756,11 @@ public class AgentGraphBuilder {
|
|||||||
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
|
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
|
||||||
AsyncEdgeAction.edge_async(state -> {
|
AsyncEdgeAction.edge_async(state -> {
|
||||||
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
||||||
boolean hasGoal = a.hasActiveGoal();
|
// Same-turn activation: fall back to a DB lookup (gated on the
|
||||||
|
// feature flag) so a goal the agent set THIS turn is evaluated now,
|
||||||
|
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
|
||||||
|
boolean hasGoal = goalProperties.isEnabled()
|
||||||
|
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
|
||||||
boolean already = a.goalEvaluatedThisRun();
|
boolean already = a.goalEvaluatedThisRun();
|
||||||
return (hasGoal && !already)
|
return (hasGoal && !already)
|
||||||
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
||||||
@ -751,9 +797,17 @@ public class AgentGraphBuilder {
|
|||||||
* and tool-result chunking. Decoupled from the per-agent value so a small
|
* and tool-result chunking. Decoupled from the per-agent value so a small
|
||||||
* {@code max_iterations} can never accidentally re-introduce the silent
|
* {@code max_iterations} can never accidentally re-introduce the silent
|
||||||
* killer.
|
* killer.
|
||||||
|
* <p>
|
||||||
|
* The base segment budget is further multiplied to cover goal-driven "hard
|
||||||
|
* continuations" — each grants a fresh full iteration budget after a
|
||||||
|
* max-iterations turn (see {@code GoalEvaluationNode}). One run can perform
|
||||||
|
* up to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING} of them, so
|
||||||
|
* the ceiling is sized for {@code (1 + CEILING)} segments to keep the
|
||||||
|
* recursion guard from tripping before the soft caps do.
|
||||||
*/
|
*/
|
||||||
private static int frameworkRecursionLimit() {
|
private static int frameworkRecursionLimit() {
|
||||||
return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100;
|
int perSegment = (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4;
|
||||||
|
return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||||
@ -809,7 +863,7 @@ public class AgentGraphBuilder {
|
|||||||
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
|
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
|
||||||
LimitExceededNode limitExceededNode = new LimitExceededNode(
|
LimitExceededNode limitExceededNode = new LimitExceededNode(
|
||||||
chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService);
|
chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService);
|
||||||
FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache);
|
FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache, markdownNormalizeEnabled);
|
||||||
|
|
||||||
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
|
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
|
||||||
// 输入字段
|
// 输入字段
|
||||||
@ -821,6 +875,7 @@ public class AgentGraphBuilder {
|
|||||||
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
|
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
|
||||||
// 迭代控制
|
// 迭代控制
|
||||||
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.ITERATION_REFUND_COUNT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
|
||||||
// 工具调用
|
// 工具调用
|
||||||
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
|
||||||
@ -902,6 +957,7 @@ public class AgentGraphBuilder {
|
|||||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
|
||||||
// Skill progressive disclosure — pinned skills loaded this
|
// Skill progressive disclosure — pinned skills loaded this
|
||||||
// run. Registered in BOTH graphs so the read-merge-write in
|
// run. Registered in BOTH graphs so the read-merge-write in
|
||||||
// ActionNode is not dropped on multi-node merges.
|
// ActionNode is not dropped on multi-node merges.
|
||||||
@ -951,7 +1007,11 @@ public class AgentGraphBuilder {
|
|||||||
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
|
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
|
||||||
AsyncEdgeAction.edge_async(state -> {
|
AsyncEdgeAction.edge_async(state -> {
|
||||||
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
||||||
boolean hasGoal = a.hasActiveGoal();
|
// Same-turn activation: fall back to a DB lookup (gated on the
|
||||||
|
// feature flag) so a goal the agent set THIS turn is evaluated now,
|
||||||
|
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
|
||||||
|
boolean hasGoal = goalProperties.isEnabled()
|
||||||
|
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
|
||||||
boolean already = a.goalEvaluatedThisRun();
|
boolean already = a.goalEvaluatedThisRun();
|
||||||
return (hasGoal && !already)
|
return (hasGoal && !already)
|
||||||
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
||||||
@ -1315,6 +1375,23 @@ public class AgentGraphBuilder {
|
|||||||
|
|
||||||
// ==================== Prompt 构建 ====================
|
// ==================== Prompt 构建 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-stable platform identity, appended to every agent's system
|
||||||
|
* prompt. Answers "who are you / what are you based on". The volatile
|
||||||
|
* "which model right now" fact is injected per-turn by
|
||||||
|
* {@link vip.mate.agent.context.RuntimeContextInjector} instead, to
|
||||||
|
* keep this prefix's prompt-cache hash stable.
|
||||||
|
*/
|
||||||
|
static final String ABOUT_YOU_BLOCK = """
|
||||||
|
|
||||||
|
## About You
|
||||||
|
You are powered by MateClaw — a multi-user AI Agent platform built on
|
||||||
|
Spring Boot 3.5 and Spring AI Alibaba Graph. You are reachable through
|
||||||
|
WebChat and 8+ IM channels (DingTalk, Feishu, WeCom, WeChat, Telegram,
|
||||||
|
Discord, QQ, Slack). If asked who you are or what you are based on,
|
||||||
|
answer with MateClaw and the technology stack above.
|
||||||
|
""";
|
||||||
|
|
||||||
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
|
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
|
||||||
// The agent's own systemPrompt encodes its identity (role / goal /
|
// The agent's own systemPrompt encodes its identity (role / goal /
|
||||||
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
|
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
|
||||||
@ -1470,7 +1547,7 @@ public class AgentGraphBuilder {
|
|||||||
// Wiki 知识库上下文注入
|
// Wiki 知识库上下文注入
|
||||||
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
|
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
|
||||||
|
|
||||||
return basePrompt + toolGuidance + searchGuidance + wikiContext;
|
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -505,6 +505,19 @@ public class AgentService {
|
|||||||
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
|
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #289: an MCP server connecting / disconnecting / reconnecting
|
||||||
|
* changes the live tool set, but cached agents snapshot their tools at
|
||||||
|
* build time. Clear the cache so the next turn rebuilds against the
|
||||||
|
* current MCP tools instead of replying "from memory" with a stale,
|
||||||
|
* tool-less graph.
|
||||||
|
*/
|
||||||
|
@EventListener
|
||||||
|
public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) {
|
||||||
|
refreshAllAgents();
|
||||||
|
log.info("Agent caches refreshed after MCP server change: {}", event.reason());
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Lifecycle helpers ====================
|
// ==================== Lifecycle helpers ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -955,6 +955,11 @@ public abstract class BaseAgent {
|
|||||||
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
|
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
|
||||||
&& mediaCaptionService != null
|
&& mediaCaptionService != null
|
||||||
&& decision.sidecarModel() != null) {
|
&& decision.sidecarModel() != null) {
|
||||||
|
// The user's actual question (text parts only, excluding media markers)
|
||||||
|
// so the vision model tailors its description to what was asked rather
|
||||||
|
// than emitting a generic caption.
|
||||||
|
String userQuestion = extractUserQuestion(parts);
|
||||||
|
boolean captionPersisted = false;
|
||||||
for (MessageContentPart part : parts) {
|
for (MessageContentPart part : parts) {
|
||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
String contentType = part.getContentType();
|
String contentType = part.getContentType();
|
||||||
@ -963,13 +968,23 @@ public abstract class BaseAgent {
|
|||||||
&& !contentType.contains("svg");
|
&& !contentType.contains("svg");
|
||||||
if (!isImage) continue;
|
if (!isImage) continue;
|
||||||
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
|
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
|
||||||
decision.sidecarModel(), part, userLocale);
|
decision.sidecarModel(), part, userLocale, userQuestion);
|
||||||
if (result.isFailure()) {
|
if (result.isFailure()) {
|
||||||
log.warn("[{}] Sidecar caption failed for {}: {}",
|
log.warn("[{}] Sidecar caption failed for {}: {}",
|
||||||
agentName, part.getFileName(), result.failure().getMessage());
|
agentName, part.getFileName(), result.failure().getMessage());
|
||||||
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
|
if (isRemoteOnlyAttachment(part)) {
|
||||||
.append(part.getFileName())
|
// The image was never downloaded locally (only a remote
|
||||||
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
|
// channel URL survives) — for WeCom/aibot that URL points
|
||||||
|
// at short-lived AES-encrypted bytes, so captioning can
|
||||||
|
// never succeed until media download is enabled.
|
||||||
|
textBuilder.append("\n\n[系统提示] 图片 ")
|
||||||
|
.append(part.getFileName())
|
||||||
|
.append(" 未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载。");
|
||||||
|
} else {
|
||||||
|
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
|
||||||
|
.append(part.getFileName())
|
||||||
|
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
textBuilder.append("\n\n[图片附件描述: ")
|
textBuilder.append("\n\n[图片附件描述: ")
|
||||||
@ -977,9 +992,17 @@ public abstract class BaseAgent {
|
|||||||
.append("]\n")
|
.append("]\n")
|
||||||
.append(result.description())
|
.append(result.description())
|
||||||
.append("\n[/图片附件描述]");
|
.append("\n[/图片附件描述]");
|
||||||
|
// Persist the caption onto the part so later turns retain the image
|
||||||
|
// content: history user messages replay as text only, and without a
|
||||||
|
// stored caption every follow-up question loses the attachment.
|
||||||
|
part.setCaption(result.description());
|
||||||
|
captionPersisted = true;
|
||||||
String identifier = identifyPart(part);
|
String identifier = identifyPart(part);
|
||||||
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
|
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
|
||||||
}
|
}
|
||||||
|
if (captionPersisted && conversationService != null) {
|
||||||
|
conversationService.updateMessageParts(message, parts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Media> mediaList = new ArrayList<>();
|
List<Media> mediaList = new ArrayList<>();
|
||||||
@ -1048,7 +1071,7 @@ public abstract class BaseAgent {
|
|||||||
if (mediaPath == null) {
|
if (mediaPath == null) {
|
||||||
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
|
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
|
||||||
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
|
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
|
||||||
skippedAttachments.add(part.getFileName() + "(文件未找到)");
|
skippedAttachments.add(part.getFileName() + unresolvedAttachmentReason(part));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -1083,6 +1106,52 @@ public abstract class BaseAgent {
|
|||||||
return new CurrentTurnUserMessage(built, decision);
|
return new CurrentTurnUserMessage(built, decision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate the text parts of a message into the user's question, dropping
|
||||||
|
* image/file/media parts. Returns {@code null} when there is no usable text
|
||||||
|
* (e.g. an image-only IM message), which makes the caption fall back to the
|
||||||
|
* generic full-description prompt.
|
||||||
|
*/
|
||||||
|
private static String extractUserQuestion(List<MessageContentPart> parts) {
|
||||||
|
if (parts == null || parts.isEmpty()) return null;
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (MessageContentPart part : parts) {
|
||||||
|
if (part == null || !"text".equals(part.getType())) continue;
|
||||||
|
String text = part.getText();
|
||||||
|
if (text == null || text.isBlank()) continue;
|
||||||
|
if (sb.length() > 0) sb.append('\n');
|
||||||
|
sb.append(text.trim());
|
||||||
|
}
|
||||||
|
String question = sb.toString().trim();
|
||||||
|
return question.isEmpty() ? null : question;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an attachment has no resolvable local file and its only locator
|
||||||
|
* is a remote http(s) URL — i.e. the IM channel never downloaded it locally.
|
||||||
|
* For WeCom/aibot images that URL points at short-lived AES-encrypted bytes,
|
||||||
|
* so it is unusable as-is. Lets callers turn a generic "file not found" into
|
||||||
|
* an actionable hint instead of a dead end.
|
||||||
|
*/
|
||||||
|
private static boolean isRemoteOnlyAttachment(MessageContentPart part) {
|
||||||
|
if (part == null) return false;
|
||||||
|
if (part.getPath() != null && !part.getPath().isBlank()) return false;
|
||||||
|
String locator = part.getMediaId();
|
||||||
|
if (locator == null || locator.isBlank()) locator = part.getFileUrl();
|
||||||
|
return locator != null && (locator.startsWith("http://") || locator.startsWith("https://"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reason string appended to a skipped attachment whose local file could not
|
||||||
|
* be resolved — distinguishes "never downloaded" (channel media download
|
||||||
|
* off) from a genuine missing-file so the user gets an actionable message.
|
||||||
|
*/
|
||||||
|
private static String unresolvedAttachmentReason(MessageContentPart part) {
|
||||||
|
return isRemoteOnlyAttachment(part)
|
||||||
|
? "(图片未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载)"
|
||||||
|
: "(文件未找到)";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable identifier for de-duplicating parts already handled by the sidecar
|
* Stable identifier for de-duplicating parts already handled by the sidecar
|
||||||
* pass. Falls back across {@code path → mediaId → fileName} since not every
|
* pass. Falls back across {@code path → mediaId → fileName} since not every
|
||||||
@ -1194,7 +1263,14 @@ public abstract class BaseAgent {
|
|||||||
if ("user".equals(msg.getRole())) {
|
if ("user".equals(msg.getRole())) {
|
||||||
// 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text
|
// 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text
|
||||||
String content = conversationService.renderMessageContent(msg);
|
String content = conversationService.renderMessageContent(msg);
|
||||||
return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText);
|
CurrentTurnUserMessage built = buildUserMessageForCurrentTurn(
|
||||||
|
msg, content != null && !content.isBlank() ? content : userMessageText);
|
||||||
|
// Vision-capable models replay history as text only, so a
|
||||||
|
// follow-up question about an earlier image would otherwise be
|
||||||
|
// answered blind. Re-attach the most recent image to this turn
|
||||||
|
// so the model actually re-sees it. (Text-only models instead
|
||||||
|
// rely on the persisted sidecar caption — see buildUserMessageInternal.)
|
||||||
|
return maybeCarryRecentImage(history, i, msg, built);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -1204,6 +1280,85 @@ public abstract class BaseAgent {
|
|||||||
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
|
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far back (in messages) to look for an image to carry into a follow-up turn. */
|
||||||
|
private static final int CARRY_IMAGE_LOOKBACK = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a vision-capable model, re-attach the most recent image from a recent
|
||||||
|
* earlier turn to the current user message when the current turn carries no
|
||||||
|
* image of its own. History is replayed as text only (see {@link #toSpringMessage}),
|
||||||
|
* so without this a follow-up like "what's in the top-left of that photo?" is
|
||||||
|
* answered blind. Bounded to a single image within {@link #CARRY_IMAGE_LOOKBACK}
|
||||||
|
* messages so a long conversation doesn't re-send pixels on every turn.
|
||||||
|
*
|
||||||
|
* <p>No-op (returns {@code built} unchanged) when: the model can't see images,
|
||||||
|
* the current turn already has an image, no recent image exists, or the recent
|
||||||
|
* image has no resolvable local file (e.g. an undownloaded channel URL).
|
||||||
|
*/
|
||||||
|
private CurrentTurnUserMessage maybeCarryRecentImage(List<MessageEntity> history, int currentIdx,
|
||||||
|
MessageEntity currentMsg, CurrentTurnUserMessage built) {
|
||||||
|
try {
|
||||||
|
if (built == null || !modelSupportsVision()) return built;
|
||||||
|
if (messageHasImagePart(currentMsg)) return built; // current turn already carries an image
|
||||||
|
|
||||||
|
int from = Math.max(0, currentIdx - CARRY_IMAGE_LOOKBACK);
|
||||||
|
for (int j = currentIdx - 1; j >= from; j--) {
|
||||||
|
MessageEntity m = history.get(j);
|
||||||
|
if (m == null || !"user".equals(m.getRole())) continue;
|
||||||
|
List<MessageContentPart> parts = conversationService.parseMessageParts(m);
|
||||||
|
for (int k = parts.size() - 1; k >= 0; k--) {
|
||||||
|
MessageContentPart part = parts.get(k);
|
||||||
|
if (!isResolvableImagePart(part)) continue;
|
||||||
|
Path imgPath = resolveImagePath(part.getPath());
|
||||||
|
if (imgPath == null && part.getMediaId() != null) imgPath = resolveImagePath(part.getMediaId());
|
||||||
|
if (imgPath == null) continue;
|
||||||
|
String contentType = part.getContentType();
|
||||||
|
if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg";
|
||||||
|
try {
|
||||||
|
Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath));
|
||||||
|
UserMessage orig = built.userMessage();
|
||||||
|
String name = part.getFileName() == null ? "image" : part.getFileName();
|
||||||
|
String text = (orig.getText() == null ? "" : orig.getText())
|
||||||
|
+ "\n\n[系统提示] 以下图片是用户本次对话中较早发送的「" + name
|
||||||
|
+ "」,当前问题很可能与它相关。请直接查看该图片作答,不要凭记忆猜测。";
|
||||||
|
List<Media> media = new ArrayList<>();
|
||||||
|
if (orig.getMedia() != null) media.addAll(orig.getMedia());
|
||||||
|
media.add(carried);
|
||||||
|
log.debug("[{}] Carried recent image {} into follow-up turn for vision model",
|
||||||
|
agentName, name);
|
||||||
|
return new CurrentTurnUserMessage(
|
||||||
|
UserMessage.builder().text(text).media(media).build(),
|
||||||
|
built.routingDecision());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[{}] Failed to carry recent image {}: {}",
|
||||||
|
agentName, part.getFileName(), e.getMessage());
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[{}] maybeCarryRecentImage failed: {}", agentName, e.getMessage());
|
||||||
|
}
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean messageHasImagePart(MessageEntity message) {
|
||||||
|
for (MessageContentPart part : conversationService.parseMessageParts(message)) {
|
||||||
|
if (isResolvableImagePart(part)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An image part (not SVG) — the raster kind a multimodal API can ingest. */
|
||||||
|
private static boolean isResolvableImagePart(MessageContentPart part) {
|
||||||
|
if (part == null) return false;
|
||||||
|
String type = part.getType();
|
||||||
|
String contentType = part.getContentType();
|
||||||
|
boolean isImage = ("image".equals(type) || "file".equals(type))
|
||||||
|
&& contentType != null && contentType.startsWith("image/");
|
||||||
|
return isImage && !contentType.contains("svg");
|
||||||
|
}
|
||||||
|
|
||||||
protected Path resolveImagePath(String relativePath) {
|
protected Path resolveImagePath(String relativePath) {
|
||||||
if (relativePath == null || relativePath.isBlank()) {
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import vip.mate.agent.AgentService;
|
|||||||
import vip.mate.agent.binding.model.AgentProviderPreference;
|
import vip.mate.agent.binding.model.AgentProviderPreference;
|
||||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||||
|
import vip.mate.agent.binding.model.AgentWikiKbBinding;
|
||||||
import vip.mate.agent.binding.service.AgentBindingService;
|
import vip.mate.agent.binding.service.AgentBindingService;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.audit.service.AuditEventService;
|
import vip.mate.audit.service.AuditEventService;
|
||||||
@ -136,6 +137,32 @@ public class AgentBindingController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Knowledge Base Access Scope ====================
|
||||||
|
|
||||||
|
@Operation(summary = "获取 Agent 的知识库访问范围")
|
||||||
|
@GetMapping("/kbs")
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
public R<List<AgentWikiKbBinding>> listKbs(@PathVariable Long agentId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
|
return R.ok(bindingService.listKbBindings(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量设置 Agent 的知识库访问范围(替换模式,空表示不限制)")
|
||||||
|
@PutMapping("/kbs")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<Void> setKbs(@PathVariable Long agentId, @RequestBody List<Long> kbIds,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
|
bindingService.setKbBindings(agentId, kbIds);
|
||||||
|
agentService.invalidateAgentCache(agentId);
|
||||||
|
// A non-Vue caller can POST a bare `null`; the service tolerates it.
|
||||||
|
int count = kbIds == null ? 0 : kbIds.size();
|
||||||
|
auditEventService.record("UPDATE", "AGENT_WIKI_KB", String.valueOf(agentId),
|
||||||
|
"kbs=" + count, null);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Workspace Verification ====================
|
// ==================== Workspace Verification ====================
|
||||||
|
|
||||||
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
|
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
package vip.mate.agent.binding.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent ↔ knowledge base access scope row.
|
||||||
|
* <p>
|
||||||
|
* Each enabled row whitelists one KB for one agent. When an agent has at
|
||||||
|
* least one row the wiki tools restrict their visible KB set to the bound
|
||||||
|
* ones; an agent with no rows stays workspace-wide (legacy behavior).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_agent_wiki_kb")
|
||||||
|
public class AgentWikiKbBinding {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private Long agentId;
|
||||||
|
private Long kbId;
|
||||||
|
private Boolean enabled;
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package vip.mate.agent.binding.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import vip.mate.agent.binding.model.AgentWikiKbBinding;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AgentWikiKbBindingMapper extends BaseMapper<AgentWikiKbBinding> {
|
||||||
|
}
|
||||||
@ -9,9 +9,11 @@ import org.springframework.stereotype.Service;
|
|||||||
import vip.mate.agent.binding.model.AgentProviderPreference;
|
import vip.mate.agent.binding.model.AgentProviderPreference;
|
||||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||||
|
import vip.mate.agent.binding.model.AgentWikiKbBinding;
|
||||||
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
||||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||||
|
import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
@ -26,6 +28,8 @@ import vip.mate.skill.runtime.SkillRuntimeService;
|
|||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
import vip.mate.tool.model.AvailableToolDTO;
|
import vip.mate.tool.model.AvailableToolDTO;
|
||||||
import vip.mate.tool.service.AvailableToolService;
|
import vip.mate.tool.service.AvailableToolService;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
|
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@ -55,6 +59,17 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
private final AgentSkillBindingMapper skillBindingMapper;
|
private final AgentSkillBindingMapper skillBindingMapper;
|
||||||
private final AgentToolBindingMapper toolBindingMapper;
|
private final AgentToolBindingMapper toolBindingMapper;
|
||||||
private final AgentProviderPreferenceMapper providerPreferenceMapper;
|
private final AgentProviderPreferenceMapper providerPreferenceMapper;
|
||||||
|
/**
|
||||||
|
* Agent ↔ KB access-scope rows. Plain mapper (no transitive deps), so it
|
||||||
|
* is safe to wire directly here without risking a boot-time cycle.
|
||||||
|
*/
|
||||||
|
private final AgentWikiKbBindingMapper kbBindingMapper;
|
||||||
|
/**
|
||||||
|
* Used only to verify a KB lives in the agent's workspace before pinning
|
||||||
|
* it. Like {@link #agentMapper}, a bare mapper avoids pulling the wiki
|
||||||
|
* service layer (and its dependency on agent binding) into this bean.
|
||||||
|
*/
|
||||||
|
private final WikiKnowledgeBaseMapper kbMapper;
|
||||||
/**
|
/**
|
||||||
* {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit
|
* {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit
|
||||||
* near the agent boot path; the lazy proxy avoids a circular bean
|
* near the agent boot path; the lazy proxy avoids a circular bean
|
||||||
@ -93,6 +108,8 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
|
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
|
||||||
AgentToolBindingMapper toolBindingMapper,
|
AgentToolBindingMapper toolBindingMapper,
|
||||||
AgentProviderPreferenceMapper providerPreferenceMapper,
|
AgentProviderPreferenceMapper providerPreferenceMapper,
|
||||||
|
AgentWikiKbBindingMapper kbBindingMapper,
|
||||||
|
WikiKnowledgeBaseMapper kbMapper,
|
||||||
@Lazy SkillRuntimeService skillRuntimeService,
|
@Lazy SkillRuntimeService skillRuntimeService,
|
||||||
AvailableToolService availableToolService,
|
AvailableToolService availableToolService,
|
||||||
AgentMapper agentMapper,
|
AgentMapper agentMapper,
|
||||||
@ -101,6 +118,8 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
this.skillBindingMapper = skillBindingMapper;
|
this.skillBindingMapper = skillBindingMapper;
|
||||||
this.toolBindingMapper = toolBindingMapper;
|
this.toolBindingMapper = toolBindingMapper;
|
||||||
this.providerPreferenceMapper = providerPreferenceMapper;
|
this.providerPreferenceMapper = providerPreferenceMapper;
|
||||||
|
this.kbBindingMapper = kbBindingMapper;
|
||||||
|
this.kbMapper = kbMapper;
|
||||||
this.skillRuntimeService = skillRuntimeService;
|
this.skillRuntimeService = skillRuntimeService;
|
||||||
this.availableToolService = availableToolService;
|
this.availableToolService = availableToolService;
|
||||||
this.agentMapper = agentMapper;
|
this.agentMapper = agentMapper;
|
||||||
@ -725,6 +744,11 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
"write_file",
|
"write_file",
|
||||||
"edit_file",
|
"edit_file",
|
||||||
"execute_shell_command",
|
"execute_shell_command",
|
||||||
|
// Inline code execution — an agent-wide capability alongside shell.
|
||||||
|
// Lets any agent act on a documentation-only skill (a SKILL.md with
|
||||||
|
// no scripts) by writing and running the code its instructions
|
||||||
|
// describe. Dangerous code is screened by the same tool guard.
|
||||||
|
"execute_code",
|
||||||
"detect_file_type",
|
"detect_file_type",
|
||||||
"extract_document_text",
|
"extract_document_text",
|
||||||
"extract_pdf_text",
|
"extract_pdf_text",
|
||||||
@ -941,6 +965,123 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Knowledge base access scope ====================
|
||||||
|
|
||||||
|
/** Raw scope rows for the agent edit form, oldest first. */
|
||||||
|
public List<AgentWikiKbBinding> listKbBindings(Long agentId) {
|
||||||
|
return kbBindingMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<AgentWikiKbBinding>()
|
||||||
|
.eq(AgentWikiKbBinding::getAgentId, agentId)
|
||||||
|
.orderByAsc(AgentWikiKbBinding::getCreateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective KB ids the agent may see. Three states (mirror
|
||||||
|
* {@link #getBoundSkillIds}):
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code null} — {@code wiki_disabled=false} AND no binding rows.
|
||||||
|
* Caller treats this as "no agent-level restriction; inherit every
|
||||||
|
* KB in the agent's workspace" (the default wiki-tool behavior).</li>
|
||||||
|
* <li>{@code Set.of()} — either {@code wiki_disabled=true}, or binding
|
||||||
|
* rows exist but none are {@code enabled=true}. Caller treats this
|
||||||
|
* as "explicitly scoped to zero KBs" — wiki tools degrade with
|
||||||
|
* their standard "no knowledge base" message.</li>
|
||||||
|
* <li>non-empty set — the explicit allowlist.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The {@code wiki_disabled} flag takes precedence over row count, so
|
||||||
|
* a stale (flag + leftover rows) combination still surfaces as "no KBs".
|
||||||
|
* Mirrors how {@code skills_disabled} interacts with
|
||||||
|
* {@link #getBoundSkillIds}.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Set<Long> getBoundKbIds(Long agentId) {
|
||||||
|
if (isWikiDisabled(agentId)) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
List<AgentWikiKbBinding> bindings = listKbBindings(agentId);
|
||||||
|
if (bindings.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return bindings.stream()
|
||||||
|
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||||
|
.map(AgentWikiKbBinding::getKbId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the agent's KB access scope. An empty / null list clears the
|
||||||
|
* scope, returning the agent to workspace-wide (unrestricted) access.
|
||||||
|
* Every incoming KB must live in the agent's workspace — pinning a KB
|
||||||
|
* from another tenancy is refused (403).
|
||||||
|
*/
|
||||||
|
public void setKbBindings(Long agentId, List<Long> kbIds) {
|
||||||
|
// De-dup defensively: the unique index is (agent_id, kb_id, deleted),
|
||||||
|
// so two identical ids in the incoming list would collide on insert.
|
||||||
|
Set<Long> distinct = new LinkedHashSet<>();
|
||||||
|
if (kbIds != null) {
|
||||||
|
for (Long kbId : kbIds) {
|
||||||
|
if (kbId != null) {
|
||||||
|
distinct.add(kbId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate the whole set BEFORE deleting anything, so a rejected id
|
||||||
|
// can't leave the agent half-scoped.
|
||||||
|
for (Long kbId : distinct) {
|
||||||
|
requireKbInAgentWorkspace(agentId, kbId);
|
||||||
|
}
|
||||||
|
// Auto-clear wiki_disabled on a non-empty save — same contract as
|
||||||
|
// setSkillBindings: a concrete KB commitment contradicts an opt-out
|
||||||
|
// flag, so the data layer must never hold both states at once.
|
||||||
|
if (!distinct.isEmpty()) {
|
||||||
|
clearWikiDisabledFlag(agentId);
|
||||||
|
}
|
||||||
|
kbBindingMapper.delete(
|
||||||
|
new LambdaQueryWrapper<AgentWikiKbBinding>()
|
||||||
|
.eq(AgentWikiKbBinding::getAgentId, agentId));
|
||||||
|
for (Long kbId : distinct) {
|
||||||
|
AgentWikiKbBinding row = new AgentWikiKbBinding();
|
||||||
|
row.setAgentId(agentId);
|
||||||
|
row.setKbId(kbId);
|
||||||
|
row.setEnabled(true);
|
||||||
|
kbBindingMapper.insert(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse to scope an agent to a KB outside its workspace. KBs are
|
||||||
|
* workspace-shared artifacts ({@code mate_wiki_knowledge_base.workspace_id});
|
||||||
|
* letting workspace A's agent pin workspace B's KB would cross the
|
||||||
|
* tenancy boundary the same way a cross-workspace skill binding would.
|
||||||
|
* A {@code null} workspace on either side is normalized to the default
|
||||||
|
* workspace (1) to match the rest of the codebase.
|
||||||
|
*/
|
||||||
|
private void requireKbInAgentWorkspace(Long agentId, Long kbId) {
|
||||||
|
if (agentId == null) {
|
||||||
|
throw new MateClawException("err.agent.not_found", 404, "Agent ID is required");
|
||||||
|
}
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
if (agent == null) {
|
||||||
|
throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId);
|
||||||
|
}
|
||||||
|
WikiKnowledgeBaseEntity kb = kbMapper.selectById(kbId);
|
||||||
|
if (kb == null) {
|
||||||
|
throw new MateClawException("err.wiki.kb_not_found", 404,
|
||||||
|
"Knowledge base 不存在: " + kbId);
|
||||||
|
}
|
||||||
|
long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
|
||||||
|
long kbWs = kb.getWorkspaceId() == null ? 1L : kb.getWorkspaceId();
|
||||||
|
if (agentWs != kbWs) {
|
||||||
|
throw new MateClawException("err.wiki.cross_workspace_kb_binding", 403,
|
||||||
|
"Knowledge base " + kbId + " (workspace=" + kbWs
|
||||||
|
+ ") cannot be scoped to Agent " + agentId
|
||||||
|
+ " (workspace=" + agentWs + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Binding-mode flags (V126) ====================
|
// ==================== Binding-mode flags (V126) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -993,4 +1134,28 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
update.setToolsDisabled(false);
|
update.setToolsDisabled(false);
|
||||||
agentMapper.updateById(update);
|
agentMapper.updateById(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mirror of {@link #isSkillsDisabled} for the wiki/knowledge-base opt-out toggle. */
|
||||||
|
private boolean isWikiDisabled(Long agentId) {
|
||||||
|
if (agentId == null) return false;
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
return agent != null && Boolean.TRUE.equals(agent.getWikiDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of {@link #clearSkillsDisabledFlag} for the wiki toggle. Used as
|
||||||
|
* an auto-clear step in {@link #setKbBindings} so a concrete KB commitment
|
||||||
|
* always wins over a stale opt-out flag.
|
||||||
|
*/
|
||||||
|
private void clearWikiDisabledFlag(Long agentId) {
|
||||||
|
if (agentId == null) return;
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
if (agent == null || !Boolean.TRUE.equals(agent.getWikiDisabled())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentEntity update = new AgentEntity();
|
||||||
|
update.setId(agentId);
|
||||||
|
update.setWikiDisabled(false);
|
||||||
|
agentMapper.updateById(update);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,15 @@ public record ChatOrigin(
|
|||||||
* vs. group conversations. Null for 1:1 chats. Distinct from
|
* vs. group conversations. Null for 1:1 chats. Distinct from
|
||||||
* {@link #channelTarget()} (which targets cron / proactive sends).
|
* {@link #channelTarget()} (which targets cron / proactive sends).
|
||||||
*/
|
*/
|
||||||
@Nullable String chatId
|
@Nullable String chatId,
|
||||||
|
/**
|
||||||
|
* Public base URL ({@code scheme://host[:port][/contextPath]}) resolved
|
||||||
|
* from the inbound HTTP request on the request thread. Carried here so
|
||||||
|
* tools running on async/streaming threads — where no request is bound —
|
||||||
|
* can still mint absolute download links. Null for IM/cron origins, which
|
||||||
|
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
|
||||||
|
*/
|
||||||
|
@Nullable String baseUrl
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||||
@ -66,7 +74,7 @@ public record ChatOrigin(
|
|||||||
|
|
||||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
public static final ChatOrigin EMPTY =
|
public static final ChatOrigin EMPTY =
|
||||||
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null);
|
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
|
||||||
|
|
||||||
// ---------------- Factories per entry point ----------------
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
@ -74,9 +82,17 @@ public record ChatOrigin(
|
|||||||
@Nullable String requesterId,
|
@Nullable String requesterId,
|
||||||
@Nullable Long workspaceId,
|
@Nullable Long workspaceId,
|
||||||
@Nullable String workspaceBasePath) {
|
@Nullable String workspaceBasePath) {
|
||||||
|
return web(conversationId, requesterId, workspaceId, workspaceBasePath, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable String baseUrl) {
|
||||||
return new ChatOrigin(null, conversationId,
|
return new ChatOrigin(null, conversationId,
|
||||||
requesterId != null ? requesterId : "",
|
requesterId != null ? requesterId : "",
|
||||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null);
|
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ChatOrigin cron(@Nullable String conversationId,
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
@ -85,7 +101,7 @@ public record ChatOrigin(
|
|||||||
@Nullable Long channelId,
|
@Nullable Long channelId,
|
||||||
@Nullable ChannelTarget target) {
|
@Nullable ChannelTarget target) {
|
||||||
return new ChatOrigin(null, conversationId, "system",
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
workspaceId, workspaceBasePath, channelId, target, true, null, null, null);
|
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Wither-style updates ----------------
|
// ---------------- Wither-style updates ----------------
|
||||||
@ -93,20 +109,27 @@ public record ChatOrigin(
|
|||||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId);
|
senderName, channelType, chatId, baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
@Nullable String newWorkspaceBasePath) {
|
@Nullable String newWorkspaceBasePath) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId);
|
senderName, channelType, chatId, baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId);
|
senderName, channelType, chatId, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||||
|
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, newBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,7 +143,7 @@ public record ChatOrigin(
|
|||||||
@Nullable String newChatId) {
|
@Nullable String newChatId) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
newSenderName, newChannelType, newChatId);
|
newSenderName, newChannelType, newChatId, baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Spring AI ToolContext interop ----------------
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|||||||
@ -67,6 +67,24 @@ public final class RuntimeContextInjector {
|
|||||||
public static String buildContextMessage(String workspaceBasePath,
|
public static String buildContextMessage(String workspaceBasePath,
|
||||||
vip.mate.i18n.I18nService i18n,
|
vip.mate.i18n.I18nService i18n,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
|
return buildContextMessage(workspaceBasePath, i18n, origin, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full overload that also renders the agent's runtime model identity.
|
||||||
|
* The model line is emitted for EVERY origin (web / cron / IM / null)
|
||||||
|
* because it describes the agent, not the caller — only the sender
|
||||||
|
* block stays IM-only. {@code modelName}/{@code providerId} come from
|
||||||
|
* graph state ({@code RUNTIME_MODEL_NAME}/{@code RUNTIME_PROVIDER_ID}),
|
||||||
|
* i.e. the model selected at run start (mid-run failover is not
|
||||||
|
* reflected — accepted trade-off). Stays well under the 1024-char
|
||||||
|
* spring-ai user-cache threshold.
|
||||||
|
*/
|
||||||
|
public static String buildContextMessage(String workspaceBasePath,
|
||||||
|
vip.mate.i18n.I18nService i18n,
|
||||||
|
ChatOrigin origin,
|
||||||
|
String modelName,
|
||||||
|
String providerId) {
|
||||||
LocalDateTime now = LocalDateTime.now(ZONE);
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
String dateStr = now.format(DATE_FMT);
|
String dateStr = now.format(DATE_FMT);
|
||||||
String timeStr = now.format(TIME_FMT);
|
String timeStr = now.format(TIME_FMT);
|
||||||
@ -91,6 +109,7 @@ public final class RuntimeContextInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appendSenderBlockIfPresent(sb, origin);
|
appendSenderBlockIfPresent(sb, origin);
|
||||||
|
appendModelLineIfPresent(sb, modelName, providerId, i18n);
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,6 +140,33 @@ public final class RuntimeContextInjector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the agent's runtime model identity. Emitted for all origins
|
||||||
|
* (it's an agent fact, not a sender fact). Skipped when modelName is
|
||||||
|
* blank. Provider parenthetical is omitted when providerId is blank.
|
||||||
|
*/
|
||||||
|
private static void appendModelLineIfPresent(StringBuilder sb, String modelName,
|
||||||
|
String providerId,
|
||||||
|
vip.mate.i18n.I18nService i18n) {
|
||||||
|
if (modelName == null || modelName.isBlank()) return;
|
||||||
|
String model = modelName.trim();
|
||||||
|
sb.append("\n");
|
||||||
|
if (i18n != null) {
|
||||||
|
sb.append(i18n.msg("context.model_identity", model));
|
||||||
|
} else {
|
||||||
|
sb.append("[system-context] Model: ").append(model);
|
||||||
|
}
|
||||||
|
if (providerId != null && !providerId.isBlank()) {
|
||||||
|
sb.append(" (provider: ").append(providerId.trim()).append(')');
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
if (i18n != null) {
|
||||||
|
sb.append(i18n.msg("context.model_identity_hint"));
|
||||||
|
} else {
|
||||||
|
sb.append("If asked which model you are using, answer with this value for the current run.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a sender / channel / chat block when the origin carries
|
* Append a sender / channel / chat block when the origin carries
|
||||||
* meaningful IM context. Format is intentionally one line per
|
* meaningful IM context. Format is intentionally one line per
|
||||||
|
|||||||
@ -12,7 +12,9 @@ import vip.mate.channel.web.Utf8SseEmitter;
|
|||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.AgentState;
|
import vip.mate.agent.AgentState;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.agent.service.AgentGenerationService;
|
||||||
import vip.mate.agent.vo.AgentCapabilitiesVO;
|
import vip.mate.agent.vo.AgentCapabilitiesVO;
|
||||||
|
import vip.mate.agent.vo.AgentDraftVO;
|
||||||
import vip.mate.audit.service.AuditEventService;
|
import vip.mate.audit.service.AuditEventService;
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.service.ModelCapabilityService;
|
import vip.mate.llm.service.ModelCapabilityService;
|
||||||
@ -51,6 +53,7 @@ public class AgentController {
|
|||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final ModelCapabilityService modelCapabilityService;
|
private final ModelCapabilityService modelCapabilityService;
|
||||||
private final SystemSettingService systemSettingService;
|
private final SystemSettingService systemSettingService;
|
||||||
|
private final AgentGenerationService agentGenerationService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
@ -128,6 +131,16 @@ public class AgentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "根据一句话需求生成员工草稿(不落库)")
|
||||||
|
@PostMapping("/generate")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<AgentDraftVO> generate(
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
@RequestBody GenerateRequest request) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
return R.ok(agentGenerationService.generateDraft(request.getRequirement(), wsId));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "创建Agent")
|
@Operation(summary = "创建Agent")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
@ -270,6 +283,11 @@ public class AgentController {
|
|||||||
private String conversationId = "default";
|
private String conversationId = "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class GenerateRequest {
|
||||||
|
private String requirement;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验目标资源实际归属的 workspace 与请求 header 一致。
|
* 校验目标资源实际归属的 workspace 与请求 header 一致。
|
||||||
* 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击。
|
* 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击。
|
||||||
|
|||||||
@ -350,25 +350,57 @@ public class NodeStreamingChatHelper {
|
|||||||
// provider during a rate-limit window wastes time without recovery.
|
// provider during a rate-limit window wastes time without recovery.
|
||||||
// SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal).
|
// SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal).
|
||||||
static final int MAX_RETRIES_RATE_LIMIT = 2;
|
static final int MAX_RETRIES_RATE_LIMIT = 2;
|
||||||
private static final long BACKOFF_BASE_MS = 3000;
|
// EMPTY_RESPONSE: transient gateway blip often resolves on same-model
|
||||||
private static final long BACKOFF_CAP_MS = 60_000;
|
// retry (e.g., proxy timeout returns HTTP 200 with empty body). Keep
|
||||||
|
// the cap low — if it truly takes 4+ attempts, the provider is sick.
|
||||||
|
static final int MAX_RETRIES_EMPTY_RESPONSE = 3;
|
||||||
|
// UNKNOWN: conservative retry cap. Defensive: retry what we can't
|
||||||
|
// classify, but with a smaller budget than SERVER_ERROR (5 vs 10) to
|
||||||
|
// avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the
|
||||||
|
// ultimate safety net.
|
||||||
|
static final int MAX_RETRIES_UNKNOWN = 5;
|
||||||
|
// Hard time budget for the primary retry loop (3 min). Prevents
|
||||||
|
// retries from stalling a single conversation turn indefinitely.
|
||||||
|
// Aligned with WikiProcessingService.llmMaxTotalDurationMs.
|
||||||
|
//
|
||||||
|
// Because the backoff grows exponentially (3s, 6s, 12s, 24s, 48s, then
|
||||||
|
// capped at 60s), this wall-clock budget — not MAX_RETRIES — is what
|
||||||
|
// actually bounds a sustained SERVER_ERROR loop: only ~8 of the 10
|
||||||
|
// retries fit inside 3 minutes before the elapsed-time check in
|
||||||
|
// streamCallInternal breaks to the fallback chain.
|
||||||
|
//
|
||||||
|
// These three values are instance fields seeded from the DEFAULT_*
|
||||||
|
// constants (rather than compile-time constants) so tests can shrink
|
||||||
|
// them to exercise the full retry path in milliseconds instead of
|
||||||
|
// minutes. Production wiring never overrides them — see
|
||||||
|
// setRetryTimingForTest.
|
||||||
|
private static final long DEFAULT_MAX_TOTAL_DURATION_MS = 3 * 60 * 1000L;
|
||||||
|
private static final long DEFAULT_BACKOFF_BASE_MS = 3000;
|
||||||
|
private static final long DEFAULT_BACKOFF_CAP_MS = 60_000;
|
||||||
|
|
||||||
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
|
private long maxTotalDurationMs = DEFAULT_MAX_TOTAL_DURATION_MS;
|
||||||
|
private long backoffBaseMs = DEFAULT_BACKOFF_BASE_MS;
|
||||||
|
private long backoffCapMs = DEFAULT_BACKOFF_CAP_MS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断错误是否可重试(基于状态码/异常类型)
|
* Test-only seam to shrink the retry backoff and total-time budget so the
|
||||||
|
* full {@link #MAX_RETRIES} path (or the time-budget cut-off) can be
|
||||||
|
* exercised in milliseconds instead of minutes. Package-private and never
|
||||||
|
* invoked from production wiring, which always keeps the {@code DEFAULT_*}
|
||||||
|
* timings.
|
||||||
|
*
|
||||||
|
* @param backoffBaseMs base backoff for the first retry (doubles each attempt)
|
||||||
|
* @param backoffCapMs per-attempt backoff ceiling
|
||||||
|
* @param maxTotalDurationMs hard wall-clock budget for the whole primary retry loop
|
||||||
*/
|
*/
|
||||||
private static boolean isRetryable(Throwable error) {
|
void setRetryTimingForTest(long backoffBaseMs, long backoffCapMs, long maxTotalDurationMs) {
|
||||||
String msg = extractFullErrorChain(error);
|
this.backoffBaseMs = backoffBaseMs;
|
||||||
// Kimi engine_overloaded / 标准 HTTP 错误 / 速率限制
|
this.backoffCapMs = backoffCapMs;
|
||||||
return msg.contains("engine_overloaded")
|
this.maxTotalDurationMs = maxTotalDurationMs;
|
||||||
|| msg.contains("rate_limit") || msg.contains("RateLimitError")
|
|
||||||
|| msg.contains("429") || msg.contains("Too Many Requests")
|
|
||||||
|| msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|
|
||||||
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
|
|
||||||
|| msg.contains("Connection reset") || msg.contains("Connection refused");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分类错误类型(用于分级重试和上层 Node 决策)
|
* 分类错误类型(用于分级重试和上层 Node 决策)
|
||||||
*/
|
*/
|
||||||
@ -384,7 +416,27 @@ public class NodeStreamingChatHelper {
|
|||||||
|| msg.contains("请求体中的 input tokens 总数超出了模型允许")) {
|
|| msg.contains("请求体中的 input tokens 总数超出了模型允许")) {
|
||||||
return ErrorType.PROMPT_TOO_LONG;
|
return ErrorType.PROMPT_TOO_LONG;
|
||||||
}
|
}
|
||||||
// Auth errors
|
// Auth errors — keys, certs, DNS, TLS infrastructure. These will not
|
||||||
|
// self-heal on retry (a bad API key / expired cert / wrong host won't
|
||||||
|
// suddenly become valid), so classify as AUTH_ERROR to terminate the
|
||||||
|
// retry loop and hand off to the fallback chain.
|
||||||
|
// Infrastructure-level permanent failures checked first:
|
||||||
|
// DNS resolution (UnknownHostException) — misconfigured endpoint
|
||||||
|
// TLS certificate (CertificateException, SSLPeerUnverifiedException,
|
||||||
|
// pkix path building failed, certificate verify failed) — expired
|
||||||
|
// or untrusted certs that cannot recover without human intervention
|
||||||
|
if (msg.contains("UnknownHostException")
|
||||||
|
|| msg.contains("CertificateException")
|
||||||
|
|| msg.contains("SSLPeerUnverifiedException")
|
||||||
|
// Java's ValidatorException emits "PKIX path building failed" with an
|
||||||
|
// uppercase PKIX, and the error chain is not lower-cased — the pattern
|
||||||
|
// must match the real casing, otherwise the fatal cert failure falls
|
||||||
|
// through to the retryable SERVER_ERROR bucket and is retried in vain.
|
||||||
|
|| msg.contains("PKIX path building failed")
|
||||||
|
|| msg.contains("certificate verify failed")
|
||||||
|
|| msg.contains("certificate_unknown")) {
|
||||||
|
return ErrorType.AUTH_ERROR;
|
||||||
|
}
|
||||||
if (msg.contains("401") || msg.contains("Unauthorized") || msg.contains("Invalid API Key")
|
if (msg.contains("401") || msg.contains("Unauthorized") || msg.contains("Invalid API Key")
|
||||||
|| msg.contains("authentication") || msg.contains("AuthenticationError")) {
|
|| msg.contains("authentication") || msg.contains("AuthenticationError")) {
|
||||||
return ErrorType.AUTH_ERROR;
|
return ErrorType.AUTH_ERROR;
|
||||||
@ -404,11 +456,17 @@ public class NodeStreamingChatHelper {
|
|||||||
// a different provider may have credits, so we should fall back instead of
|
// a different provider may have credits, so we should fall back instead of
|
||||||
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
|
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
|
||||||
// ("credit balance is too low") use these phrases in 402-class responses.
|
// ("credit balance is too low") use these phrases in 402-class responses.
|
||||||
|
// Chinese provider patterns (Zhipu 1113, DashScope, general) — same hard
|
||||||
|
// failure semantics: retrying the same provider won't refill the balance.
|
||||||
if (msg.contains("402") || msg.contains("insufficient_quota")
|
if (msg.contains("402") || msg.contains("insufficient_quota")
|
||||||
|| msg.contains("credit balance is too low")
|
|| msg.contains("credit balance is too low")
|
||||||
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|
||||||
|| msg.contains("You exceeded your current quota")
|
|| msg.contains("You exceeded your current quota")
|
||||||
|| msg.contains("quota exceeded") || msg.contains("Quota exceeded")) {
|
|| msg.contains("quota exceeded") || msg.contains("Quota exceeded")
|
||||||
|
|| msg.contains("余额不足") || msg.contains("请充值")
|
||||||
|
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|
||||||
|
|| msg.contains("AccountBalanceNotEnough")
|
||||||
|
|| msg.contains("balance not enough")) {
|
||||||
return ErrorType.BILLING;
|
return ErrorType.BILLING;
|
||||||
}
|
}
|
||||||
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
||||||
@ -436,19 +494,14 @@ public class NodeStreamingChatHelper {
|
|||||||
|| msg.contains("InvalidEndpointOrModel")) {
|
|| msg.contains("InvalidEndpointOrModel")) {
|
||||||
return ErrorType.MODEL_NOT_FOUND;
|
return ErrorType.MODEL_NOT_FOUND;
|
||||||
}
|
}
|
||||||
// Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable.
|
|
||||||
// DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved
|
|
||||||
// or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported
|
|
||||||
// parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR
|
|
||||||
// (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real
|
|
||||||
// cause instead of a misleading "model not available" message.
|
|
||||||
if (msg.contains("400") || msg.contains("Bad Request")
|
|
||||||
|| msg.contains("invalid_request_error") || msg.contains("unsupported")
|
|
||||||
|| msg.contains("Tool names are not allowed")
|
|
||||||
|| msg.contains("InvalidParameter")) {
|
|
||||||
return ErrorType.CLIENT_ERROR;
|
|
||||||
}
|
|
||||||
// Server errors and transient TLS / socket-level network hiccups.
|
// Server errors and transient TLS / socket-level network hiccups.
|
||||||
|
// MUST be checked BEFORE CLIENT_ERROR. 5xx patterns (502/503/504) are
|
||||||
|
// transient gateway failures that self-heal on retry. If the error chain
|
||||||
|
// carries BOTH 5xx and 4xx-like keywords (a proxy 502 whose response body
|
||||||
|
// happens to say "bad request"), the 5xx is the root cause and should win
|
||||||
|
// — retrying a true 400 wastes seconds, but NOT retrying a transient 502
|
||||||
|
// loses the user's entire conversation turn. MAX_TOTAL_DURATION_MS
|
||||||
|
// provides the ultimate safety net against unbounded retry.
|
||||||
// Without the TLS-specific patterns, a single SSL fatal alert
|
// Without the TLS-specific patterns, a single SSL fatal alert
|
||||||
// (e.g. bad_record_mac during long-running streams) falls through to
|
// (e.g. bad_record_mac during long-running streams) falls through to
|
||||||
// UNKNOWN — non-retryable — so one transient handshake glitch surfaces
|
// UNKNOWN — non-retryable — so one transient handshake glitch surfaces
|
||||||
@ -477,9 +530,27 @@ public class NodeStreamingChatHelper {
|
|||||||
// in the response body when their backend is under high load or the
|
// in the response body when their backend is under high load or the
|
||||||
// upstream connection to the model server is disrupted. This is a
|
// upstream connection to the model server is disrupted. This is a
|
||||||
// transient server-side failure — classify as retryable.
|
// transient server-side failure — classify as retryable.
|
||||||
|| msg.contains("network connection error")) {
|
|| msg.contains("network connection error")
|
||||||
|
// AI gateway / reverse-proxy rewrites: upstream 5xx (502/503/504)
|
||||||
|
// surfaced as HTTP 400 with a body that describes the upstream
|
||||||
|
// outage. These are transient server-side failures — retryable.
|
||||||
|
|| msg.contains("temporarily unavailable")
|
||||||
|
|| msg.contains("service unavailable")
|
||||||
|
|| msg.contains("model is overloaded")) {
|
||||||
return ErrorType.SERVER_ERROR;
|
return ErrorType.SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
// Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable.
|
||||||
|
// DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved
|
||||||
|
// or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported
|
||||||
|
// parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR
|
||||||
|
// (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real
|
||||||
|
// cause instead of a misleading "model not available" message.
|
||||||
|
if (msg.contains("400") || msg.contains("Bad Request")
|
||||||
|
|| msg.contains("invalid_request_error") || msg.contains("unsupported")
|
||||||
|
|| msg.contains("Tool names are not allowed")
|
||||||
|
|| msg.contains("InvalidParameter")) {
|
||||||
|
return ErrorType.CLIENT_ERROR;
|
||||||
|
}
|
||||||
return ErrorType.UNKNOWN;
|
return ErrorType.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -550,6 +621,15 @@ public class NodeStreamingChatHelper {
|
|||||||
// 主模型重试循环
|
// 主模型重试循环
|
||||||
StreamResult lastResult = null;
|
StreamResult lastResult = null;
|
||||||
if (!primarySkipped) for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
if (!primarySkipped) for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
// Time budget check: prevent retries from stalling a single
|
||||||
|
// conversation turn indefinitely (e.g., a provider that stays
|
||||||
|
// at 503 for minutes). Aligned with Wiki's maxTotalDurationMs.
|
||||||
|
long elapsedMs = System.currentTimeMillis() - callStartMs;
|
||||||
|
if (elapsedMs >= maxTotalDurationMs) {
|
||||||
|
log.warn("[{}] Primary retry time budget exhausted ({}ms), handing off to fallback chain",
|
||||||
|
phase, elapsedMs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
llmCallCount++;
|
llmCallCount++;
|
||||||
if (attempt > 0) retryCount++;
|
if (attempt > 0) retryCount++;
|
||||||
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
|
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
|
||||||
@ -601,11 +681,18 @@ public class NodeStreamingChatHelper {
|
|||||||
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
||||||
return lastResult; // 已经重试过了
|
return lastResult; // 已经重试过了
|
||||||
}
|
}
|
||||||
// RFC-009: EMPTY_RESPONSE — break the primary-retry loop and fall through to
|
// EMPTY_RESPONSE — transient gateway blip often resolves on same-model
|
||||||
// the fallback chain. Retrying the same model that returned nothing is rarely
|
// retry (e.g., proxy timeout returns HTTP 200 with empty body).
|
||||||
// productive; a different provider has a better chance of succeeding.
|
// Retry up to MAX_RETRIES_EMPTY_RESPONSE before handing off to the
|
||||||
|
// fallback chain. A different provider has a better chance of
|
||||||
|
// succeeding if the same model repeatedly returns nothing.
|
||||||
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
|
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
|
||||||
log.warn("[{}] Primary returned empty response — skipping same-model retries, handing off to fallback chain", phase);
|
if (attempt < MAX_RETRIES_EMPTY_RESPONSE) {
|
||||||
|
log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
|
||||||
|
phase, attempt + 1, MAX_RETRIES_EMPTY_RESPONSE + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log.warn("[{}] Primary exhausted empty-response retries — handing off to fallback chain", phase);
|
||||||
recordPrimary(false);
|
recordPrimary(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -616,25 +703,33 @@ public class NodeStreamingChatHelper {
|
|||||||
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||||
return lastResult;
|
return lastResult;
|
||||||
}
|
}
|
||||||
// RATE_LIMIT / SERVER_ERROR past their retry budget are provider-level
|
// RATE_LIMIT / SERVER_ERROR / UNKNOWN past their retry budget are
|
||||||
// failures: the same model will not recover within this turn, but a
|
// provider-level failures: the same model will not recover within
|
||||||
// different provider can. Break to the fallback chain instead of
|
// this turn, but a different provider can. Break to the fallback
|
||||||
// returning — recordPrimary(false) runs once at the post-loop provider
|
// chain instead of returning — recordPrimary(false) runs once at
|
||||||
// health check below, and if every fallback also fails the chain
|
// the post-loop provider health check below, and if every fallback
|
||||||
// walker re-surfaces this same error to the caller.
|
// also fails the chain walker re-surfaces this same error to the
|
||||||
|
// caller.
|
||||||
|
// UNKNOWN errors are included defensively: an error we can't
|
||||||
|
// classify may be a transient (mis-classified by our keyword
|
||||||
|
// patterns) or a fatal (truly new error shape). Retrying with a
|
||||||
|
// smaller budget (MAX_RETRIES_UNKNOWN=5 vs MAX_RETRIES=10) is
|
||||||
|
// safer than immediate termination — MAX_TOTAL_DURATION_MS provides
|
||||||
|
// the ultimate safety net.
|
||||||
if (lastResult.errorType() == ErrorType.RATE_LIMIT
|
if (lastResult.errorType() == ErrorType.RATE_LIMIT
|
||||||
|| lastResult.errorType() == ErrorType.SERVER_ERROR) {
|
|| lastResult.errorType() == ErrorType.SERVER_ERROR
|
||||||
|
|| lastResult.errorType() == ErrorType.UNKNOWN) {
|
||||||
log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain",
|
log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain",
|
||||||
phase, lastResult.errorType());
|
phase, lastResult.errorType());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Any other non-null errored result (e.g. UNKNOWN) that doStreamCall
|
// Any truly unhandled error type — safety net. Prefer falling back
|
||||||
// chose NOT to retry must exit — otherwise we silently spin through
|
// over terminating the entire call. If this branch is ever hit in
|
||||||
// attempts and waste seconds per turn on unrecoverable errors like
|
// production, the type should be added explicitly above.
|
||||||
// DashScope's "url error" / unknown model.
|
|
||||||
recordPrimary(false);
|
recordPrimary(false);
|
||||||
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
log.warn("[{}] Primary returned unhandled error type={} — handing off to fallback chain",
|
||||||
return lastResult;
|
phase, lastResult.errorType());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// lastResult == null 表示需要重试
|
// lastResult == null 表示需要重试
|
||||||
}
|
}
|
||||||
@ -813,10 +908,10 @@ public class NodeStreamingChatHelper {
|
|||||||
String conversationId, String phase,
|
String conversationId, String phase,
|
||||||
boolean broadcast, int attempt) {
|
boolean broadcast, int attempt) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
|
||||||
// 加入 jitter 防止雷群效应
|
// 加入 jitter 防止雷群效应
|
||||||
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
|
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
|
||||||
delay = Math.min(delay, BACKOFF_CAP_MS);
|
delay = Math.min(delay, backoffCapMs);
|
||||||
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
||||||
phase, attempt, MAX_RETRIES, delay, conversationId);
|
phase, attempt, MAX_RETRIES, delay, conversationId);
|
||||||
// 广播给前端:用户可见的重试倒计时
|
// 广播给前端:用户可见的重试倒计时
|
||||||
@ -1155,12 +1250,20 @@ public class NodeStreamingChatHelper {
|
|||||||
conversationId, phase, errorType);
|
conversationId, phase, errorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit / Server error: retryable, but with different budgets.
|
// Rate limit / Server error / Unknown: retryable, but with different budgets.
|
||||||
// RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2).
|
// RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2).
|
||||||
// SERVER_ERROR: keep full MAX_RETRIES — upstream flaps often self-heal.
|
// SERVER_ERROR: keep full MAX_RETRIES — upstream flaps often self-heal.
|
||||||
if (errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.SERVER_ERROR) {
|
// UNKNOWN: conservative cap (5 vs 10). Defensive: retry what we can't
|
||||||
int effectiveMaxRetries = (errorType == ErrorType.RATE_LIMIT)
|
// classify, but with a smaller budget to avoid masking truly fatal
|
||||||
? MAX_RETRIES_RATE_LIMIT : MAX_RETRIES;
|
// errors. MAX_TOTAL_DURATION_MS provides the ultimate safety net.
|
||||||
|
if (errorType == ErrorType.RATE_LIMIT
|
||||||
|
|| errorType == ErrorType.SERVER_ERROR
|
||||||
|
|| errorType == ErrorType.UNKNOWN) {
|
||||||
|
int effectiveMaxRetries = switch (errorType) {
|
||||||
|
case RATE_LIMIT -> MAX_RETRIES_RATE_LIMIT;
|
||||||
|
case UNKNOWN -> MAX_RETRIES_UNKNOWN;
|
||||||
|
default -> MAX_RETRIES;
|
||||||
|
};
|
||||||
if (attempt < effectiveMaxRetries) {
|
if (attempt < effectiveMaxRetries) {
|
||||||
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
|
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
|
||||||
phase, attempt, effectiveMaxRetries, errorType, error.getMessage());
|
phase, attempt, effectiveMaxRetries, errorType, error.getMessage());
|
||||||
|
|||||||
@ -184,6 +184,44 @@ public class ToolExecutionExecutor {
|
|||||||
return StructuredTruncator.truncate(result, headLen, tailLen, marker);
|
return StructuredTruncator.truncate(result, headLen, tailLen, marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appended to the cross-platform shopping recommendation tool's result so the
|
||||||
|
* model reliably renders products as chat cards instead of a markdown table.
|
||||||
|
* The tool's MCP schema is globally callable, so a model can invoke it without
|
||||||
|
* ever loading the skill's instructions — this puts the rendering contract
|
||||||
|
* where the model always sees it: in the tool result itself.
|
||||||
|
*/
|
||||||
|
private static final String PRODUCT_CARD_RENDER_DIRECTIVE =
|
||||||
|
"\n\n[Rendering directive] Render these recommendations for the user as a"
|
||||||
|
+ " fenced code block whose language tag is exactly `product-cards`, containing a"
|
||||||
|
+ " JSON array. Each element: {name, url, imageUrl, price, originalPrice, lowestPrice,"
|
||||||
|
+ " platformLabel, shopName, purchaseAdvice}. Copy `url` and `imageUrl` verbatim from"
|
||||||
|
+ " the result above (never invent or alter them). The chat UI turns this block into"
|
||||||
|
+ " clickable product cards with a buy button. Do NOT use a markdown table or inline"
|
||||||
|
+ " image markdown for these products. You may add a short intro sentence and purchase"
|
||||||
|
+ " tips around the block.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when the tool is the cross-platform shopping
|
||||||
|
* recommendation tool and its result actually carries product records
|
||||||
|
* (so timeouts / empty results fall through to the model's own fallback).
|
||||||
|
*/
|
||||||
|
static boolean shouldAppendProductCardDirective(String toolName, String result) {
|
||||||
|
if (toolName == null || result == null) return false;
|
||||||
|
if (!toolName.contains("ckjia_shopping_recom")) return false;
|
||||||
|
return result.contains("recommendations")
|
||||||
|
|| result.contains("imageUrl")
|
||||||
|
|| result.contains("priceTag")
|
||||||
|
|| result.contains("markdownLink");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends {@link #PRODUCT_CARD_RENDER_DIRECTIVE} when applicable, else returns the result unchanged. */
|
||||||
|
static String withProductCardDirective(String toolName, String result) {
|
||||||
|
return shouldAppendProductCardDirective(toolName, result)
|
||||||
|
? result + PRODUCT_CARD_RENDER_DIRECTIVE
|
||||||
|
: result;
|
||||||
|
}
|
||||||
|
|
||||||
private final Map<String, ToolCallback> toolCallbackMap;
|
private final Map<String, ToolCallback> toolCallbackMap;
|
||||||
/**
|
/**
|
||||||
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
|
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
|
||||||
@ -675,8 +713,10 @@ public class ToolExecutionExecutor {
|
|||||||
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
|
||||||
|
// Append the card-rendering directive to the LLM-facing response only,
|
||||||
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, result != null ? result : "");
|
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
||||||
String safeError = isReturnDirect(callback)
|
String safeError = isReturnDirect(callback)
|
||||||
@ -905,8 +945,10 @@ public class ToolExecutionExecutor {
|
|||||||
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
|
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
|
// Append the card-rendering directive to the LLM-facing response only,
|
||||||
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), toolName, result != null ? result : "");
|
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
||||||
// RFC-052: for returnDirect tools, even the error message is
|
// RFC-052: for returnDirect tools, even the error message is
|
||||||
@ -946,7 +988,10 @@ public class ToolExecutionExecutor {
|
|||||||
ToolInvocationContext guardCtx = ToolInvocationContext.of(
|
ToolInvocationContext guardCtx = ToolInvocationContext.of(
|
||||||
toolName, java.util.Map.of(), arguments,
|
toolName, java.util.Map.of(), arguments,
|
||||||
conversationId, agentId,
|
conversationId, agentId,
|
||||||
/*channelType*/ null, requesterId, workspaceId);
|
/*channelType*/ null, requesterId, workspaceId)
|
||||||
|
// Carry the active workspace base path so a guardian can enforce
|
||||||
|
// the filesystem boundary before approval (issue #313).
|
||||||
|
.withWorkspaceBasePath(origin != null ? origin.workspaceBasePath() : null);
|
||||||
|
|
||||||
if (toolGuardService != null) {
|
if (toolGuardService != null) {
|
||||||
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
|
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import vip.mate.agent.graph.state.DirectToolOutput;
|
|||||||
import vip.mate.agent.graph.state.FinishReason;
|
import vip.mate.agent.graph.state.FinishReason;
|
||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||||
|
import vip.mate.common.text.MarkdownNormalizer;
|
||||||
import vip.mate.tool.document.GeneratedFileCache;
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -39,12 +40,25 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
*/
|
*/
|
||||||
private final GeneratedFileCache generatedFileCache;
|
private final GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill-switch for the deterministic Markdown cleanup applied to the answer
|
||||||
|
* body. {@code true} (default) runs {@link MarkdownNormalizer}; set to
|
||||||
|
* {@code false} to surface model output verbatim if a normalization edge
|
||||||
|
* case ever mangles a legitimate answer in production.
|
||||||
|
*/
|
||||||
|
private final boolean markdownNormalizeEnabled;
|
||||||
|
|
||||||
public FinalAnswerNode() {
|
public FinalAnswerNode() {
|
||||||
this(null);
|
this(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
|
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
|
||||||
|
this(generatedFileCache, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinalAnswerNode(GeneratedFileCache generatedFileCache, boolean markdownNormalizeEnabled) {
|
||||||
this.generatedFileCache = generatedFileCache;
|
this.generatedFileCache = generatedFileCache;
|
||||||
|
this.markdownNormalizeEnabled = markdownNormalizeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -161,6 +175,7 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
// validation so the validator sees the user-visible warning rather
|
// validation so the validator sees the user-visible warning rather
|
||||||
// than treating the fake link as a "reference".
|
// than treating the fake link as a "reference".
|
||||||
finalAnswer = scrubFakeUrls(finalAnswer);
|
finalAnswer = scrubFakeUrls(finalAnswer);
|
||||||
|
finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer);
|
||||||
|
|
||||||
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
||||||
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
||||||
@ -170,6 +185,17 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
validation.unsupportedReferences());
|
validation.unsupportedReferences());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic Markdown cleanup on the model-generated answer body. LLMs
|
||||||
|
// routinely emit malformed Markdown (missing heading spaces, glued `---`,
|
||||||
|
// unaligned table pipes) that prompt rules fail to prevent; this fixes the
|
||||||
|
// mechanical defects before the answer is persisted / sent to channels.
|
||||||
|
// Verbatim tool output (RETURN_DIRECT) and approval-wait paths return early
|
||||||
|
// above and are intentionally left untouched. Gated so operators can turn
|
||||||
|
// the rewrite off (mate.agent.markdown-normalize-enabled=false).
|
||||||
|
if (markdownNormalizeEnabled) {
|
||||||
|
finalAnswer = MarkdownNormalizer.normalize(finalAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
// Build the event list. Always carries the finish_reason event so
|
// Build the event list. Always carries the finish_reason event so
|
||||||
// downstream consumers (memory gate, channel accumulator, message
|
// downstream consumers (memory gate, channel accumulator, message
|
||||||
// metadata persistence) see a machine-readable status. When the
|
// metadata persistence) see a machine-readable status. When the
|
||||||
@ -211,9 +237,9 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
||||||
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||||
+ String.join(", ", unsupportedReferences)
|
+ String.join(", ", unsupportedReferences)
|
||||||
+ "。请继续读取相关文件后再下结论。";
|
+ "。请继续检索/读取相关证据后再下结论。";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -78,7 +78,12 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
|
|
||||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||||
|
|
||||||
Optional<Object> goalOpt = accessor.activeGoal();
|
// Resolve the active goal from the turn-start snapshot, falling back to
|
||||||
|
// a conversation lookup. The fallback is what makes a goal created
|
||||||
|
// MID-TURN (the agent calls setGoal, which only writes the DB) get
|
||||||
|
// evaluated on the very turn it was set — otherwise ACTIVE_GOAL is empty
|
||||||
|
// in this run's state and the goal would sit inert until the next message.
|
||||||
|
Optional<GoalEntity> goalOpt = resolveActiveGoal(state, goalService);
|
||||||
if (goalOpt.isEmpty()) {
|
if (goalOpt.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
@ -94,26 +99,32 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
// chat composable's `message_complete` handler optimistically sets
|
// chat composable's `message_complete` handler optimistically sets
|
||||||
// evaluating=true; without a balancing event the ring would stay
|
// evaluating=true; without a balancing event the ring would stay
|
||||||
// in that state forever after e.g. a max-iterations turn.
|
// in that state forever after e.g. a max-iterations turn.
|
||||||
Long goalIdForEvents = (goalOpt.get() instanceof GoalEntity ge) ? ge.getId() : null;
|
Long goalIdForEvents = goalOpt.get().getId();
|
||||||
|
|
||||||
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
|
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
|
||||||
// determines whether this turn counts. Plan-Execute usually doesn't
|
// determines whether this turn counts. Plan-Execute usually doesn't
|
||||||
// set finishReason on the happy path, so we only enforce these
|
// set finishReason on the happy path, so we only enforce these
|
||||||
// exit conditions in REACT mode + the universal awaiting_approval
|
// exit conditions in REACT mode + the universal awaiting_approval
|
||||||
// gate that both flavors share.
|
// gate that both flavors share.
|
||||||
|
// A turn that hit the ReAct iteration cap. Continuing it needs a FRESH
|
||||||
|
// iteration budget (a "hard continuation"), handled in the follow-up
|
||||||
|
// branch below; capture it here while finishReason is still authoritative.
|
||||||
|
boolean reactIterationCapReached = flavor == GraphFlavor.REACT
|
||||||
|
&& isIterationCapReached(accessor.finishReason());
|
||||||
|
|
||||||
if (flavor == GraphFlavor.REACT) {
|
if (flavor == GraphFlavor.REACT) {
|
||||||
String fr = accessor.finishReason();
|
String fr = accessor.finishReason();
|
||||||
if (FinishReason.EVIDENCE_INSUFFICIENT.getValue().equals(fr)
|
if (isHardSkipFinishReason(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);
|
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
.goalEvaluatedThisRun(true)
|
.goalEvaluatedThisRun(true)
|
||||||
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
|
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
// MAX_ITERATIONS_REACHED and EVIDENCE_INSUFFICIENT intentionally
|
||||||
|
// fall through: both mean "answer produced but the goal is likely
|
||||||
|
// unmet", which is exactly when a corrective follow-up helps.
|
||||||
|
// Max-iterations additionally needs a fresh budget (see below).
|
||||||
}
|
}
|
||||||
if (accessor.awaitingApproval()) {
|
if (accessor.awaitingApproval()) {
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
@ -122,14 +133,7 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Object goalObj = goalOpt.get();
|
GoalEntity goal = 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();
|
String terminal = accessor.terminalAnswer();
|
||||||
if (terminal.isEmpty()) {
|
if (terminal.isEmpty()) {
|
||||||
@ -226,6 +230,9 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int followupCountThisRun = accessor.goalFollowupCount();
|
int followupCountThisRun = accessor.goalFollowupCount();
|
||||||
|
int hardContinuationCount = accessor.goalHardContinuationCount();
|
||||||
|
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
|
||||||
|
GoalProperties.MAX_HARD_CONTINUATIONS_CEILING);
|
||||||
Optional<String> followup;
|
Optional<String> followup;
|
||||||
try {
|
try {
|
||||||
followup = followupService.maybeBuildFollowup(refreshed, result);
|
followup = followupService.maybeBuildFollowup(refreshed, result);
|
||||||
@ -241,11 +248,19 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
// active and the cross-message turn / LLM budget (or the user) carries
|
// active and the cross-message turn / LLM budget (or the user) carries
|
||||||
// it on.
|
// it on.
|
||||||
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
|
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
|
||||||
if (followup.isPresent() && perRunCapReached) {
|
// A max-iterations continuation re-runs a FULL fresh ReAct segment
|
||||||
log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run",
|
// (iteration budget reset), so it carries a tighter, dedicated cap on
|
||||||
followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId());
|
// top of the per-run follow-up cap — and is sized into the graph
|
||||||
|
// recursion ceiling. hardCap==0 keeps the legacy behaviour (a
|
||||||
|
// max-iterations turn simply ends the run).
|
||||||
|
boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap;
|
||||||
|
if (followup.isPresent() && (perRunCapReached || hardCapReached)) {
|
||||||
|
log.info("[GoalEvaluationNode] follow-up suppressed for goal={} " +
|
||||||
|
"(followups {}/{}, hardContinuations {}/{}, iterationCapReached={}); ending this run",
|
||||||
|
refreshed.getId(), followupCountThisRun, properties.getMaxFollowupsPerRun(),
|
||||||
|
hardContinuationCount, hardCap, reactIterationCapReached);
|
||||||
}
|
}
|
||||||
if (followup.isPresent() && !perRunCapReached) {
|
if (followup.isPresent() && !perRunCapReached && !hardCapReached) {
|
||||||
try {
|
try {
|
||||||
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
@ -283,6 +298,20 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
out.clearFinalAnswer()
|
out.clearFinalAnswer()
|
||||||
.clearFinishReason()
|
.clearFinishReason()
|
||||||
.messages(List.of((Message) new UserMessage(followup.get())));
|
.messages(List.of((Message) new UserMessage(followup.get())));
|
||||||
|
if (reactIterationCapReached) {
|
||||||
|
// Hard continuation: the run's iteration budget is spent, so
|
||||||
|
// grant a brand-new ReAct segment. Reset the counter, clear
|
||||||
|
// the stale limit-exceeded draft/flag (FinalAnswerNode prefers
|
||||||
|
// the draft over a freshly reasoned answer) and any latched
|
||||||
|
// error, and advance the dedicated hard-continuation counter.
|
||||||
|
out.iterationCount(0)
|
||||||
|
.clearLimitExceededDraft()
|
||||||
|
.error("")
|
||||||
|
.goalHardContinuationCount(hardContinuationCount + 1);
|
||||||
|
log.info("[GoalEvaluationNode] hard continuation {}/{} for goal={} " +
|
||||||
|
"(fresh ReAct iteration budget after max-iterations turn)",
|
||||||
|
hardContinuationCount + 1, hardCap, refreshed.getId());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Plan-Execute: wipe the wider mid-pass + terminal state.
|
// Plan-Execute: wipe the wider mid-pass + terminal state.
|
||||||
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
|
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
|
||||||
@ -318,6 +347,70 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active goal for this run: prefer the turn-start
|
||||||
|
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation
|
||||||
|
* lookup so a goal created mid-turn (via {@code setGoal}, which only writes
|
||||||
|
* the DB) is still evaluated on the turn it was set.
|
||||||
|
*
|
||||||
|
* <p>Shared by {@link #apply} and the {@code FinalAnswer/PlanSummary →
|
||||||
|
* GoalEvaluation} routing edges so both agree on whether a goal is active.
|
||||||
|
* The DB fallback costs one indexed lookup per terminal turn whose snapshot
|
||||||
|
* is empty; callers should additionally gate on {@code properties.isEnabled()}
|
||||||
|
* to skip it when the feature is off.
|
||||||
|
*/
|
||||||
|
public static Optional<GoalEntity> resolveActiveGoal(OverAllState state, GoalService goalService) {
|
||||||
|
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
||||||
|
Optional<Object> snapshot = a.activeGoal();
|
||||||
|
if (snapshot.isPresent() && snapshot.get() instanceof GoalEntity ge) {
|
||||||
|
return Optional.of(ge);
|
||||||
|
}
|
||||||
|
if (goalService == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String conversationId = a.conversationId();
|
||||||
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.ofNullable(goalService.findActiveByConversation(conversationId));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
log.warn("[GoalEvaluationNode] active-goal fallback lookup failed for conversation={}: {}",
|
||||||
|
conversationId, t.toString());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REACT-mode finish reasons that should neither count toward the goal nor
|
||||||
|
* trigger a continuation:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code STOPPED} — the user halted the run; don't fight them.</li>
|
||||||
|
* <li>{@code RETURN_DIRECT} — a tool produced the answer verbatim; this
|
||||||
|
* is not goal-progress reasoning work to evaluate or continue.</li>
|
||||||
|
* <li>{@code ERROR_FALLBACK} — a fatal error already failed the turn;
|
||||||
|
* re-running immediately would just re-fail.</li>
|
||||||
|
* </ul>
|
||||||
|
* Other terminal reasons — notably {@code MAX_ITERATIONS_REACHED} and
|
||||||
|
* {@code EVIDENCE_INSUFFICIENT} — mean "answer produced but the goal is
|
||||||
|
* likely unmet", which is exactly when a corrective follow-up helps, so
|
||||||
|
* they are deliberately NOT skipped.
|
||||||
|
*/
|
||||||
|
static boolean isHardSkipFinishReason(String finishReason) {
|
||||||
|
return FinishReason.STOPPED.getValue().equals(finishReason)
|
||||||
|
|| FinishReason.RETURN_DIRECT.getValue().equals(finishReason)
|
||||||
|
|| FinishReason.ERROR_FALLBACK.getValue().equals(finishReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the terminal turn hit the ReAct iteration cap. Continuing such
|
||||||
|
* a turn requires a fresh iteration budget (a "hard continuation"), because
|
||||||
|
* the run's shared budget is already exhausted.
|
||||||
|
*/
|
||||||
|
static boolean isIterationCapReached(String finishReason) {
|
||||||
|
return FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(finishReason);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */
|
/** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */
|
||||||
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
|
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
|
||||||
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());
|
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());
|
||||||
|
|||||||
@ -32,6 +32,18 @@ public class ObservationNode implements NodeAction {
|
|||||||
private final ObservationProcessor observationProcessor;
|
private final ObservationProcessor observationProcessor;
|
||||||
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
|
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progressive-disclosure meta-tools that perform setup, not real work. A
|
||||||
|
* round whose entire batch is one of these is refunded its iteration (see
|
||||||
|
* {@link MateClawStateKeys#ITERATION_REFUND_COUNT}). Mirrors the authoritative
|
||||||
|
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
||||||
|
*/
|
||||||
|
private static final java.util.Set<String> DISCLOSURE_TOOLS =
|
||||||
|
java.util.Set.of("load_skill", "enable_tool");
|
||||||
|
|
||||||
|
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
|
||||||
|
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
|
||||||
|
|
||||||
public ObservationNode(ObservationProcessor observationProcessor) {
|
public ObservationNode(ObservationProcessor observationProcessor) {
|
||||||
this(observationProcessor, null);
|
this(observationProcessor, null);
|
||||||
}
|
}
|
||||||
@ -56,14 +68,29 @@ public class ObservationNode implements NodeAction {
|
|||||||
|
|
||||||
int currentIteration = accessor.iterationCount();
|
int currentIteration = accessor.iterationCount();
|
||||||
int maxIterations = accessor.maxIterations();
|
int maxIterations = accessor.maxIterations();
|
||||||
int nextIteration = currentIteration + 1;
|
|
||||||
|
|
||||||
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
|
|
||||||
|
|
||||||
// 提取最新的工具结果并处理
|
// 提取最新的工具结果并处理
|
||||||
List<ToolResponseMessage.ToolResponse> toolResults =
|
List<ToolResponseMessage.ToolResponse> toolResults =
|
||||||
state.<List<ToolResponseMessage.ToolResponse>>value(TOOL_RESULTS).orElse(List.of());
|
state.<List<ToolResponseMessage.ToolResponse>>value(TOOL_RESULTS).orElse(List.of());
|
||||||
|
|
||||||
|
// Iteration refund: a round whose entire batch was progressive-disclosure
|
||||||
|
// setup (load_skill / enable_tool) did no real work, so don't charge it an
|
||||||
|
// iteration — otherwise a tight budget loses a step to the load-then-use
|
||||||
|
// two-step. Bounded by MAX_ITERATION_REFUNDS_PER_RUN so a model that only
|
||||||
|
// ever loads skills can't dodge the budget forever.
|
||||||
|
int refundCount = accessor.iterationRefundCount();
|
||||||
|
boolean setupOnlyRound = !toolResults.isEmpty()
|
||||||
|
&& toolResults.stream().allMatch(tr -> DISCLOSURE_TOOLS.contains(tr.name()));
|
||||||
|
boolean refundIteration = setupOnlyRound && refundCount < MAX_ITERATION_REFUNDS_PER_RUN;
|
||||||
|
int nextIteration = refundIteration ? currentIteration : currentIteration + 1;
|
||||||
|
|
||||||
|
if (refundIteration) {
|
||||||
|
log.info("[ObservationNode] Iteration refunded (setup-only round, refunds {}/{}); staying at {}/{}",
|
||||||
|
refundCount + 1, MAX_ITERATION_REFUNDS_PER_RUN, nextIteration, maxIterations);
|
||||||
|
} else {
|
||||||
|
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
|
||||||
|
}
|
||||||
|
|
||||||
// 将每个工具结果通过 ObservationProcessor 标准化和截断
|
// 将每个工具结果通过 ObservationProcessor 标准化和截断
|
||||||
List<String> processedObservations = toolResults.stream()
|
List<String> processedObservations = toolResults.stream()
|
||||||
.map(tr -> observationProcessor.process(tr.name(), tr.responseData()))
|
.map(tr -> observationProcessor.process(tr.name(), tr.responseData()))
|
||||||
@ -121,6 +148,10 @@ public class ObservationNode implements NodeAction {
|
|||||||
.shouldSummarize(shouldSummarize)
|
.shouldSummarize(shouldSummarize)
|
||||||
.toolCallCount(newToolCallCount);
|
.toolCallCount(newToolCallCount);
|
||||||
|
|
||||||
|
if (refundIteration) {
|
||||||
|
builder.iterationRefundCount(refundCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Close out the iteration we just observed. We use currentIteration
|
// Close out the iteration we just observed. We use currentIteration
|
||||||
// (not nextIteration) so the index pairs with whatever
|
// (not nextIteration) so the index pairs with whatever
|
||||||
// iteration_start the ReasoningNode emitted at the top of this turn.
|
// iteration_start the ReasoningNode emitted at the top of this turn.
|
||||||
|
|||||||
@ -141,19 +141,96 @@ public class ReasoningNode implements NodeAction {
|
|||||||
+ "required step is already done, output the final answer to the user now.";
|
+ "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
|
* Continuation nudge for the most common premature-stop pattern: an empty
|
||||||
* answer — it would route to the final-answer branch as an empty string and
|
* turn immediately after a successful tool call. The tool result is already
|
||||||
* terminate the run. Fatal / prompt-too-long / partial results are handled by
|
* in context but the model stopped before writing the user-facing answer
|
||||||
* their own branches and must not be misread as "empty".
|
* (e.g. a download URL produced by a send-file tool). Anchoring the nudge to
|
||||||
|
* the tool result recovers the answer in the same run instead of leaving the
|
||||||
|
* user to send another message to resume.
|
||||||
*/
|
*/
|
||||||
static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) {
|
private static final String POST_TOOL_EMPTY_NUDGE =
|
||||||
|
"上一步工具已成功返回(结果在上文)。请基于工具结果直接给出面向用户的最终答复"
|
||||||
|
+ "(例如下载地址 / 执行结论),不要停在思考阶段,也不要只描述\"接下来要做什么\"。";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continuation nudge for a turn that carries reasoning/thinking but no
|
||||||
|
* visible content and no tool call. Interleaved-thinking models sometimes
|
||||||
|
* "decide" the task is done in their reasoning yet never emit the answer
|
||||||
|
* text; this re-prompts them to write it (or call the next tool).
|
||||||
|
*/
|
||||||
|
private static final String THINKING_ONLY_NUDGE =
|
||||||
|
"你已完成思考但还没有输出正文。请现在把面向用户的最终答案写出来;"
|
||||||
|
+ "如果还有未完成的步骤,则立即调用对应工具。";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a no-tool-call reasoning turn cannot yet be accepted as a final answer.
|
||||||
|
* Both non-FINAL states route a turn into the bounded continuation-nudge loop
|
||||||
|
* instead of letting the final-answer branch emit an empty string and end the
|
||||||
|
* run prematurely.
|
||||||
|
*/
|
||||||
|
enum ContinuationIntent {
|
||||||
|
/**
|
||||||
|
* Real user-facing content present, or a tool call, or a failure owned by
|
||||||
|
* another branch (fatal / prompt-too-long / partial) — finalize normally.
|
||||||
|
*/
|
||||||
|
FINAL,
|
||||||
|
/**
|
||||||
|
* Reasoning/thinking present but no visible content and no tool call. The
|
||||||
|
* model "thought it was done" without writing the answer — common on
|
||||||
|
* interleaved-thinking models after a tool result. Nudge it to emit it.
|
||||||
|
*/
|
||||||
|
THINKING_ONLY,
|
||||||
|
/** No content, no thinking, no tool call — a fully blank turn. Nudge to continue. */
|
||||||
|
BLANK,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a no-tool-call turn's continuation intent. Fatal / prompt-too-long
|
||||||
|
* / partial results are handled by their own branches and must not be misread
|
||||||
|
* as needing a nudge.
|
||||||
|
*/
|
||||||
|
static ContinuationIntent classifyContinuation(NodeStreamingChatHelper.StreamResult result) {
|
||||||
if (result == null || result.hasToolCalls() || result.hasFatalError()
|
if (result == null || result.hasToolCalls() || result.hasFatalError()
|
||||||
|| result.isPromptTooLong() || result.partial()) {
|
|| result.isPromptTooLong() || result.partial()) {
|
||||||
return false;
|
return ContinuationIntent.FINAL;
|
||||||
}
|
}
|
||||||
boolean noContent = result.text() == null || result.text().isBlank();
|
boolean noContent = result.text() == null || result.text().isBlank();
|
||||||
|
if (!noContent) {
|
||||||
|
return ContinuationIntent.FINAL;
|
||||||
|
}
|
||||||
boolean noThinking = result.thinking() == null || result.thinking().isBlank();
|
boolean noThinking = result.thinking() == null || result.thinking().isBlank();
|
||||||
return noContent && noThinking;
|
return noThinking ? ContinuationIntent.BLANK : ContinuationIntent.THINKING_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully blank turn (no tool call, no content, no thinking) is not a usable
|
||||||
|
* answer — it would route to the final-answer branch as an empty string and
|
||||||
|
* terminate the run. Retained as a thin predicate over {@link #classifyContinuation}.
|
||||||
|
*/
|
||||||
|
static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) {
|
||||||
|
return classifyContinuation(result) == ContinuationIntent.BLANK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the newest conversational turn in the model input is a tool
|
||||||
|
* response — i.e. the model is about to reason over a fresh tool result. Used
|
||||||
|
* to pick the result-anchored continuation nudge for the common "empty turn
|
||||||
|
* right after a tool call" stop pattern.
|
||||||
|
*/
|
||||||
|
static boolean lastTurnIsToolResponse(List<Message> messages) {
|
||||||
|
if (messages == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||||
|
Message m = messages.get(i);
|
||||||
|
if (m instanceof ToolResponseMessage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (m instanceof UserMessage || m instanceof AssistantMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,6 +271,40 @@ public class ReasoningNode implements NodeAction {
|
|||||||
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
|
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
|
||||||
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
|
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
|
||||||
|
|
||||||
|
private static final String GROUNDED_CONTRACT = "\n\n"
|
||||||
|
+ "## 回答来源约束(强制规则)\n\n"
|
||||||
|
+ "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n"
|
||||||
|
+ "**必须遵守**:\n"
|
||||||
|
+ "1. **仅据证据作答**:如果工具返回的信息不足以回答问题,必须明确说明\"根据现有信息无法回答此问题\"。\n"
|
||||||
|
+ "2. **标记引用来源**:回答中引用的每个事实性陈述都必须用方括号数字标记来源,例如 [1]、[2]。\n"
|
||||||
|
+ "3. **文末列出来源**:在回答末尾列出所有引用的来源列表,格式为:\n"
|
||||||
|
+ " [1] 页面标题 - 章节(如有)\n"
|
||||||
|
+ " [2] 页面标题 - 章节(如有)\n"
|
||||||
|
+ "4. **禁止捏造来源**:不得引用未在本次对话中通过工具获取的页面或文件。\n"
|
||||||
|
+ "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n"
|
||||||
|
+ "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n";
|
||||||
|
|
||||||
|
private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
|
||||||
|
String prompt = basePrompt + TOOL_USE_ENFORCEMENT;
|
||||||
|
return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grounded-answer contract (cite-or-refuse) only fits agents that retrieve
|
||||||
|
* from a knowledge base. Detecting a bound {@code wiki_*} tool scopes the strict
|
||||||
|
* regime to those scenarios instead of degrading every agent — a casual agent
|
||||||
|
* with no KB should not be forced to refuse or emit [n] citations.
|
||||||
|
*/
|
||||||
|
private boolean hasWikiTool() {
|
||||||
|
if (toolCallbacks == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return toolCallbacks.stream().anyMatch(cb -> {
|
||||||
|
String name = cb.getToolDefinition().name();
|
||||||
|
return name != null && name.toLowerCase(Locale.ROOT).replace("-", "_").startsWith("wiki_");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private final ChatModel chatModel;
|
private final ChatModel chatModel;
|
||||||
private final List<ToolCallback> toolCallbacks;
|
private final List<ToolCallback> toolCallbacks;
|
||||||
/**
|
/**
|
||||||
@ -448,17 +559,16 @@ public class ReasoningNode implements NodeAction {
|
|||||||
|
|
||||||
// ======= 构建 Prompt =======
|
// ======= 构建 Prompt =======
|
||||||
String systemPrompt = accessor.systemPrompt();
|
String systemPrompt = accessor.systemPrompt();
|
||||||
// Append a tool-use enforcement clause to every ReasoningNode call.
|
// Tool-use enforcement is always appended: without it some models tend to
|
||||||
// Without it, some models (notably DeepSeek thinking and Claude Opus)
|
// "narrate" instead of calling tools. The grounded contract (cite-or-refuse)
|
||||||
// tend to "narrate" — emit a final_answer like "现在直接生成立项材料
|
// is appended only when the agent has a knowledge-base (wiki_*) tool bound,
|
||||||
// docx" instead of actually calling renderDocx, which makes the
|
// so KB-grounded scenarios get strict source attribution while general
|
||||||
// graph silently terminate at final_answer_node with the narration
|
// agents keep their normal answering behaviour.
|
||||||
// as the user-facing reply.
|
|
||||||
//
|
//
|
||||||
// Appended at runtime rather than woven into the AgentEntity-stored
|
// Appended at runtime rather than woven into the AgentEntity-stored
|
||||||
// prompt so it stays out of the user-editable agent UI but is still
|
// prompt so it stays out of the user-editable agent UI but is still
|
||||||
// always-on for the runtime LLM.
|
// always-on for the runtime LLM.
|
||||||
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
|
systemPrompt = buildGroundedSystemPrompt(systemPrompt, hasWikiTool());
|
||||||
List<Message> messages = accessor.messages();
|
List<Message> messages = accessor.messages();
|
||||||
|
|
||||||
// Per-loop budget: bound the working message list a single Reasoning
|
// Per-loop budget: bound the working message list a single Reasoning
|
||||||
@ -509,6 +619,8 @@ public class ReasoningNode implements NodeAction {
|
|||||||
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||||
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
|
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||||
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
|
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
|
||||||
|
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
||||||
|
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
||||||
|
|
||||||
// Build the non-history prefix ONCE. The PTL retry branch below
|
// Build the non-history prefix ONCE. The PTL retry branch below
|
||||||
// reuses this list verbatim so the retried prompt has exactly the
|
// reuses this list verbatim so the retried prompt has exactly the
|
||||||
@ -517,7 +629,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
// segment which led to "answer regressed after compaction"
|
// segment which led to "answer regressed after compaction"
|
||||||
// complaints on long sessions.
|
// complaints on long sessions.
|
||||||
List<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg,
|
List<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg,
|
||||||
accessor.chatOrigin());
|
accessor.chatOrigin(), runtimeModelName, runtimeProviderId);
|
||||||
|
|
||||||
// Append the runtime-rendered skill catalog as a SEPARATE SystemMessage
|
// Append the runtime-rendered skill catalog as a SEPARATE SystemMessage
|
||||||
// right after the skeleton system prompt. Keeping it out of the baked
|
// right after the skeleton system prompt. Keeping it out of the baked
|
||||||
@ -675,22 +787,40 @@ public class ReasoningNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty-completion guard: a turn with no tool call, no content, and
|
// Continuation guard: a no-tool-call turn with no visible content is
|
||||||
// no thinking is not a real answer. Under heavy message-window
|
// not a real answer, whether it is fully blank or carries only
|
||||||
// trimming on long multi-step tasks the model occasionally emits a
|
// reasoning. The final-answer branch would otherwise treat it as
|
||||||
// blank turn; the final-answer branch would then treat it as "done"
|
// "done" (finalAnswer="") and end the run prematurely. Two shapes:
|
||||||
// (finalAnswer="") and end the run prematurely (observed: a 10-item
|
// BLANK — no content, no thinking, no tool call. Seen under
|
||||||
// research task stopping at item 2). Re-prompt it to continue —
|
// heavy message-window trimming on long multi-step
|
||||||
// bounded, so a model that genuinely has nothing left still
|
// tasks (a 10-item research task stopping at item 2).
|
||||||
// terminates cleanly through the normal empty-answer path below.
|
// THINKING_ONLY — reasoning present but no content and no tool call.
|
||||||
|
// Interleaved-thinking models "decide" they are done
|
||||||
|
// in their reasoning yet never emit the answer text;
|
||||||
|
// most often right after a tool result (e.g. a
|
||||||
|
// send-file tool succeeds but the download URL is
|
||||||
|
// never written, so the user has to send another
|
||||||
|
// message to resume).
|
||||||
|
// Re-prompt to continue — bounded, so a model that genuinely has
|
||||||
|
// nothing left still terminates cleanly through the normal
|
||||||
|
// empty-answer path below. When the newest turn is a tool result, an
|
||||||
|
// answer-anchored nudge recovers the user-facing reply in the same run.
|
||||||
int emptyRetries = 0;
|
int emptyRetries = 0;
|
||||||
while (emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && isEmptyCompletion(result)) {
|
for (ContinuationIntent intent = classifyContinuation(result);
|
||||||
|
emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && intent != ContinuationIntent.FINAL;
|
||||||
|
intent = classifyContinuation(result)) {
|
||||||
emptyRetries++;
|
emptyRetries++;
|
||||||
log.warn("[ReasoningNode] Empty LLM completion (no tool call / content / thinking); "
|
boolean afterTool = lastTurnIsToolResponse(promptMessages);
|
||||||
+ "nudging to continue (retry {}/{}), conv={}",
|
String nudge = afterTool
|
||||||
emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId);
|
? POST_TOOL_EMPTY_NUDGE
|
||||||
|
: (intent == ContinuationIntent.THINKING_ONLY
|
||||||
|
? THINKING_ONLY_NUDGE
|
||||||
|
: EMPTY_COMPLETION_NUDGE);
|
||||||
|
log.warn("[ReasoningNode] {} completion (afterTool={}); nudging to continue "
|
||||||
|
+ "(retry {}/{}), conv={}",
|
||||||
|
intent, afterTool, emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId);
|
||||||
List<Message> nudgedMessages = new ArrayList<>(promptMessages);
|
List<Message> nudgedMessages = new ArrayList<>(promptMessages);
|
||||||
nudgedMessages.add(new UserMessage(EMPTY_COMPLETION_NUDGE));
|
nudgedMessages.add(new UserMessage(nudge));
|
||||||
Prompt nudgePrompt = new Prompt(nudgedMessages, options);
|
Prompt nudgePrompt = new Prompt(nudgedMessages, options);
|
||||||
nextLlmCallCount++;
|
nextLlmCallCount++;
|
||||||
result = streamingHelper.streamCall(
|
result = streamingHelper.streamCall(
|
||||||
@ -863,12 +993,14 @@ public class ReasoningNode implements NodeAction {
|
|||||||
"iteration", accessor.iterationCount(),
|
"iteration", accessor.iterationCount(),
|
||||||
"answerChars", content != null ? content.length() : 0
|
"answerChars", content != null ? content.length() : 0
|
||||||
));
|
));
|
||||||
|
String answerWithSources = accessor.sourceEvidenceLedger()
|
||||||
|
.appendWikiSourceTable(content != null ? content : "");
|
||||||
SourceEvidenceLedger.Validation validation =
|
SourceEvidenceLedger.Validation validation =
|
||||||
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
|
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
||||||
boolean evidenceInsufficient = !validation.valid();
|
boolean evidenceInsufficient = !validation.valid();
|
||||||
String finalAnswer = evidenceInsufficient
|
String finalAnswer = evidenceInsufficient
|
||||||
? evidenceWarning(validation.unsupportedReferences())
|
? evidenceWarning(validation.unsupportedReferences())
|
||||||
: (content != null ? content : "");
|
: answerWithSources;
|
||||||
if (evidenceInsufficient) {
|
if (evidenceInsufficient) {
|
||||||
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
||||||
validation.unsupportedReferences());
|
validation.unsupportedReferences());
|
||||||
@ -891,7 +1023,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.currentPhase("reasoning")
|
.currentPhase("reasoning")
|
||||||
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
||||||
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
||||||
.contentStreamed(!evidenceInsufficient)
|
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : ""))
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.llmCallCount(nextLlmCallCount)
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
@ -901,9 +1033,9 @@ public class ReasoningNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String evidenceWarning(List<String> unsupportedReferences) {
|
private static String evidenceWarning(List<String> unsupportedReferences) {
|
||||||
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||||
+ String.join(", ", unsupportedReferences)
|
+ String.join(", ", unsupportedReferences)
|
||||||
+ "。请继续读取相关文件后再下结论。";
|
+ "。请继续检索/读取相关证据后再下结论。";
|
||||||
}
|
}
|
||||||
|
|
||||||
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
||||||
@ -967,10 +1099,13 @@ public class ReasoningNode implements NodeAction {
|
|||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
String agentIdStr,
|
String agentIdStr,
|
||||||
String userMsg,
|
String userMsg,
|
||||||
vip.mate.agent.context.ChatOrigin chatOrigin) {
|
vip.mate.agent.context.ChatOrigin chatOrigin,
|
||||||
|
String runtimeModelName,
|
||||||
|
String runtimeProviderId) {
|
||||||
List<Message> prefix = new ArrayList<>();
|
List<Message> prefix = new ArrayList<>();
|
||||||
prefix.add(new SystemMessage(systemPrompt));
|
prefix.add(new SystemMessage(systemPrompt));
|
||||||
prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
|
prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(
|
||||||
|
workspaceBasePath, null, chatOrigin, runtimeModelName, runtimeProviderId)));
|
||||||
// When this turn already recalled the user's own current project from
|
// When this turn already recalled the user's own current project from
|
||||||
// structured memory, skip auto-injecting knowledge-base reference context.
|
// structured memory, skip auto-injecting knowledge-base reference context.
|
||||||
// Otherwise the KB pages (reference material, possibly about unrelated
|
// Otherwise the KB pages (reference material, possibly about unrelated
|
||||||
|
|||||||
@ -30,6 +30,12 @@ public class StepProgressDispatcher implements EdgeAction {
|
|||||||
if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
|
if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
|
||||||
return StateGraph.END;
|
return StateGraph.END;
|
||||||
}
|
}
|
||||||
|
// Step-failure recovery: a failed step requested a re-plan of the
|
||||||
|
// remaining work. Route back to PlanGeneration instead of aborting;
|
||||||
|
// PLAN_REPLAN_COUNT (set by StepExecutionNode) bounds the loop.
|
||||||
|
if ("plan_replan".equals(currentPhase)) {
|
||||||
|
return PlanStateKeys.PLAN_GENERATION_NODE;
|
||||||
|
}
|
||||||
|
|
||||||
int currentIndex = state.value(PlanStateKeys.CURRENT_STEP_INDEX, 0);
|
int currentIndex = state.value(PlanStateKeys.CURRENT_STEP_INDEX, 0);
|
||||||
List<String> steps = state.<List<String>>value(PlanStateKeys.PLAN_STEPS).orElse(List.of());
|
List<String> steps = state.<List<String>>value(PlanStateKeys.PLAN_STEPS).orElse(List.of());
|
||||||
|
|||||||
@ -10,17 +10,27 @@ import org.springframework.ai.chat.messages.UserMessage;
|
|||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
import org.springframework.ai.converter.BeanOutputConverter;
|
import org.springframework.ai.converter.BeanOutputConverter;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.context.ConversationWindowManager;
|
import vip.mate.agent.context.ConversationWindowManager;
|
||||||
import vip.mate.agent.context.RuntimeContextInjector;
|
import vip.mate.agent.context.RuntimeContextInjector;
|
||||||
|
import vip.mate.goal.config.GoalProperties;
|
||||||
|
import vip.mate.goal.model.GoalCreateRequest;
|
||||||
|
import vip.mate.goal.model.GoalCriterion;
|
||||||
|
import vip.mate.goal.model.GoalEntity;
|
||||||
|
import vip.mate.goal.service.GoalService;
|
||||||
import vip.mate.planning.service.PlanningService;
|
import vip.mate.planning.service.PlanningService;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@ -52,6 +62,17 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
private final NodeStreamingChatHelper streamingHelper;
|
private final NodeStreamingChatHelper streamingHelper;
|
||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
private final AgentToolSet toolSet;
|
private final AgentToolSet toolSet;
|
||||||
|
/** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */
|
||||||
|
private final GoalService goalService;
|
||||||
|
private final GoalProperties goalProperties;
|
||||||
|
/** Optional — advertise delegatable specialist agents to the planner and
|
||||||
|
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
|
||||||
|
private final AgentService agentService;
|
||||||
|
|
||||||
|
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
|
||||||
|
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
|
||||||
|
/** Cap the auto-derived goal title; the full request rides in the description. */
|
||||||
|
private static final int AUTO_GOAL_TITLE_MAX = 80;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured triage result — field names use @JsonProperty to match the
|
* Structured triage result — field names use @JsonProperty to match the
|
||||||
@ -61,7 +82,12 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
@JsonProperty("needs_planning") boolean needsPlanning,
|
@JsonProperty("needs_planning") boolean needsPlanning,
|
||||||
@JsonProperty("direct_answer") String directAnswer,
|
@JsonProperty("direct_answer") String directAnswer,
|
||||||
@JsonProperty("plan_type") String planType,
|
@JsonProperty("plan_type") String planType,
|
||||||
@JsonProperty("steps") List<String> steps
|
@JsonProperty("steps") List<String> steps,
|
||||||
|
// Optional per-step delegation: agent names parallel to steps (same
|
||||||
|
// order). An empty string / missing entry means "run with the parent
|
||||||
|
// agent". Only populated when delegatable specialist agents are
|
||||||
|
// advertised to the planner; absent for backward compatibility.
|
||||||
|
@JsonProperty("step_agents") List<String> stepAgents
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private static final String PLANNING_PROMPT = """
|
private static final String PLANNING_PROMPT = """
|
||||||
@ -96,15 +122,104 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
- 多部分、多阶段、需要逐步推进的目标走(C);真正单一原子动作走(B);只有简单一问一答才用(A)。
|
- 多部分、多阶段、需要逐步推进的目标走(C);真正单一原子动作走(B);只有简单一问一答才用(A)。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evidence gate — action signals in the USER GOAL. When triage returns
|
||||||
|
* direct_answer (A) but the goal contains any of these, the model almost
|
||||||
|
* certainly mis-routed a tool-requiring task; accepting the direct answer
|
||||||
|
* would end the turn without ever executing a tool ("复杂任务不执行就停止").
|
||||||
|
* <p>
|
||||||
|
* The gate is deliberately biased toward executing: a false positive only
|
||||||
|
* costs one extra executor pass (which still produces the answer, with or
|
||||||
|
* without tools), whereas a false negative silently drops the whole task.
|
||||||
|
* Intentionally excludes very common bare temporal words (现在/当前/最新)
|
||||||
|
* to avoid downgrading genuine knowledge Q&A on every occurrence.
|
||||||
|
*/
|
||||||
|
private static final java.util.regex.Pattern GOAL_REQUIRES_EXECUTION = java.util.regex.Pattern.compile(
|
||||||
|
"读取|读一下|读一份|打开文件|查一下|检索|搜索|联网|下载|上传|抓取"
|
||||||
|
+ "|记住|记一下|录入|保存|写入|存储|更新|删除|新建|创建|生成|画一[张幅]|画个"
|
||||||
|
+ "|运行|执行|调用|跑一下|发送|发给|安排|提醒|预约"
|
||||||
|
+ "|我的(记忆|文件|知识库|偏好|笔记|日程|目标)"
|
||||||
|
+ "|你(现在|目前)?(挂载|加载|有哪些|支持哪些)|挂载了哪些|你的(技能|工具|MCP|插件)"
|
||||||
|
+ "|帮我(做|改|查|建|写|发|跑|算|订|定|生成|整理|安排)"
|
||||||
|
+ "|\\.(java|py|ts|js|vue|md|json|ya?ml|sql|csv|xml|txt|sh)\\b",
|
||||||
|
java.util.regex.Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evidence gate — execution-promise phrasing in the direct answer itself.
|
||||||
|
* The model says it WILL act ("我先去读取…", "接下来调用…") rather than
|
||||||
|
* actually answering, which means the "direct answer" is really a plan
|
||||||
|
* preamble that would terminate before the action runs. Scoped to a verb
|
||||||
|
* whitelist so a normal narrative opener like "我来介绍一下杭州" is NOT caught.
|
||||||
|
*/
|
||||||
|
private static final java.util.regex.Pattern ANSWER_PROMISES_ACTION = java.util.regex.Pattern.compile(
|
||||||
|
"(我(先|这就|马上|稍后|接下来|现在)?(去|来)?|让我(先|来)?|接下来(我)?(会|要|将|需要)?|正在)"
|
||||||
|
+ "(读取|读一下|查一下|查询|检索|搜索|联网|调用|执行|运行|获取|访问|查看一下"
|
||||||
|
+ "|保存|记住|记录|写入|录入|创建|新建|生成|下载|上传|发送)");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a triage {@code direct_answer} (A) should be overridden
|
||||||
|
* and routed through the executor as a single-step plan instead. Package-
|
||||||
|
* private and side-effect free so the gate's regex behavior is unit-testable
|
||||||
|
* without mocking the whole node.
|
||||||
|
*
|
||||||
|
* @param goal the user goal
|
||||||
|
* @param directAnswer the answer the triage model produced (may be null)
|
||||||
|
*/
|
||||||
|
static boolean shouldOverrideDirectAnswer(String goal, String directAnswer) {
|
||||||
|
String userAsk = stripInjectedContext(goal);
|
||||||
|
boolean goalNeedsExecution = userAsk != null && GOAL_REQUIRES_EXECUTION.matcher(userAsk).find();
|
||||||
|
boolean answerPromisesAction = directAnswer != null && ANSWER_PROMISES_ACTION.matcher(directAnswer).find();
|
||||||
|
return goalNeedsExecution || answerPromisesAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips the injected {@code <memory-context>…</memory-context>} wrapper that
|
||||||
|
* RuntimeContextInjector prepends to every goal, returning just the user's
|
||||||
|
* actual ask. Without this the gate matches on the injected memory/profile
|
||||||
|
* text (which contains filenames like {@code user.md} and memory keywords),
|
||||||
|
* firing on essentially every task and defeating the direct-answer fast path.
|
||||||
|
*/
|
||||||
|
static String stripInjectedContext(String goal) {
|
||||||
|
if (goal == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int end = goal.lastIndexOf("</memory-context>");
|
||||||
|
if (end >= 0) {
|
||||||
|
return goal.substring(end + "</memory-context>".length()).trim();
|
||||||
|
}
|
||||||
|
return goal;
|
||||||
|
}
|
||||||
|
|
||||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
NodeStreamingChatHelper streamingHelper,
|
NodeStreamingChatHelper streamingHelper,
|
||||||
ConversationWindowManager conversationWindowManager,
|
ConversationWindowManager conversationWindowManager,
|
||||||
AgentToolSet toolSet) {
|
AgentToolSet toolSet) {
|
||||||
|
this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
|
NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
AgentToolSet toolSet,
|
||||||
|
GoalService goalService, GoalProperties goalProperties) {
|
||||||
|
this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet,
|
||||||
|
goalService, goalProperties, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
|
NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
AgentToolSet toolSet,
|
||||||
|
GoalService goalService, GoalProperties goalProperties,
|
||||||
|
AgentService agentService) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.planningService = planningService;
|
this.planningService = planningService;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
this.conversationWindowManager = conversationWindowManager;
|
this.conversationWindowManager = conversationWindowManager;
|
||||||
this.toolSet = toolSet;
|
this.toolSet = toolSet;
|
||||||
|
this.goalService = goalService;
|
||||||
|
this.goalProperties = goalProperties;
|
||||||
|
this.agentService = agentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,7 +227,119 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
|
||||||
this(chatModel, planningService, null, null, null);
|
this(chatModel, planningService, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-derive a goal from a freshly-generated multi-step plan so the
|
||||||
|
* Plan-Execute path engages the goal subsystem (the planner / step executor
|
||||||
|
* never call {@code setGoal} themselves). The plan steps become the goal's
|
||||||
|
* acceptance criteria — the plan IS the decomposition — so the first
|
||||||
|
* evaluation skips the bootstrap round and judges those criteria directly.
|
||||||
|
*
|
||||||
|
* <p>Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS
|
||||||
|
* run's GoalEvaluationNode picks it up) or {@code null} when not applicable:
|
||||||
|
* feature off, fewer than {@link #MIN_STEPS_FOR_AUTO_GOAL} steps, no channel
|
||||||
|
* context, or the conversation already has an active goal. Best-effort —
|
||||||
|
* any failure is swallowed so planning is never blocked by goal bookkeeping.
|
||||||
|
*/
|
||||||
|
GoalEntity maybeAutoCreateGoal(PlanStateAccessor accessor, List<String> steps) {
|
||||||
|
if (goalService == null || goalProperties == null
|
||||||
|
|| !goalProperties.isEnabled() || !goalProperties.isAutoGoalFromPlan()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (steps == null || steps.size() < MIN_STEPS_FOR_AUTO_GOAL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ChatOrigin origin = accessor.chatOrigin();
|
||||||
|
String convId = origin.conversationId();
|
||||||
|
if (convId == null || convId.isBlank() || origin.agentId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (goalService.findActiveByConversation(convId) != null) {
|
||||||
|
return null; // respect an existing goal (incl. re-plan passes)
|
||||||
|
}
|
||||||
|
String request = stripInjectedContext(accessor.goal()).strip();
|
||||||
|
GoalCreateRequest req = new GoalCreateRequest();
|
||||||
|
req.setConversationId(convId);
|
||||||
|
req.setAgentId(origin.agentId());
|
||||||
|
req.setWorkspaceId(origin.workspaceId() != null ? origin.workspaceId() : 1L);
|
||||||
|
req.setTitle(request.isEmpty() ? "多步任务"
|
||||||
|
: request.length() > AUTO_GOAL_TITLE_MAX
|
||||||
|
? request.substring(0, AUTO_GOAL_TITLE_MAX) : request);
|
||||||
|
req.setDescription(request);
|
||||||
|
List<GoalCriterion> criteria = steps.stream()
|
||||||
|
.filter(s -> s != null && !s.isBlank())
|
||||||
|
.map(s -> new GoalCriterion("", s.strip(), false, ""))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (!criteria.isEmpty()) {
|
||||||
|
req.setCriteria(criteria);
|
||||||
|
}
|
||||||
|
String username = origin.requesterId() != null && !origin.requesterId().isBlank()
|
||||||
|
? origin.requesterId() : "system";
|
||||||
|
GoalEntity created = goalService.create(req, username);
|
||||||
|
log.info("[PlanGeneration] Auto-derived goal {} from plan ({} criteria) for conversation {}",
|
||||||
|
created.getId(), criteria.size(), convId);
|
||||||
|
return created;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[PlanGeneration] Auto-goal-from-plan skipped (non-fatal): {}", e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled agents in the given workspace, excluding the parent (plan) agent
|
||||||
|
* itself — these are the agents a step can be delegated to. Empty when
|
||||||
|
* delegation is unavailable (no {@link AgentService}) or no peers exist.
|
||||||
|
*/
|
||||||
|
private List<AgentEntity> listDelegatableAgents(Long workspaceId, String parentAgentId) {
|
||||||
|
if (agentService == null || workspaceId == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return agentService.listAgentsByWorkspace(workspaceId, true).stream()
|
||||||
|
.filter(a -> a.getId() != null && !String.valueOf(a.getId()).equals(parentAgentId))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[PlanGeneration] Failed to list delegatable agents (non-fatal): {}", e.toString());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the planner's {@code step_agents} (agent names, parallel to steps) to
|
||||||
|
* agent ids. Returns {@code null} when nothing is delegated so {@code createPlan}
|
||||||
|
* stays on the legacy path. Names are matched case-insensitively against the
|
||||||
|
* delegatable agents; blank / unknown / parent-agent names resolve to {@code null}
|
||||||
|
* (that step runs with the parent agent).
|
||||||
|
*/
|
||||||
|
private List<Long> resolveStepAgents(List<String> steps, List<String> stepAgents,
|
||||||
|
Long workspaceId, String parentAgentId) {
|
||||||
|
if (stepAgents == null || stepAgents.isEmpty() || steps == null || steps.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<AgentEntity> delegatable = listDelegatableAgents(workspaceId, parentAgentId);
|
||||||
|
if (delegatable.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Long> byName = new HashMap<>();
|
||||||
|
for (AgentEntity a : delegatable) {
|
||||||
|
if (a.getName() != null) {
|
||||||
|
byName.put(a.getName().trim().toLowerCase(), a.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
boolean any = false;
|
||||||
|
for (int i = 0; i < steps.size(); i++) {
|
||||||
|
String name = i < stepAgents.size() ? stepAgents.get(i) : null;
|
||||||
|
Long id = (name == null || name.isBlank()) ? null : byName.get(name.trim().toLowerCase());
|
||||||
|
if (id != null) {
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
return any ? ids : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -134,7 +361,11 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String systemPrompt = accessor.systemPrompt();
|
String systemPrompt = accessor.systemPrompt();
|
||||||
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
|
// Persist plans under the real agent id (the same key StepExecutionNode
|
||||||
|
// reads), NOT the per-run trace id — otherwise mate_plan.agent_id holds a
|
||||||
|
// random trace string and listByAgent never matches, leaving the Plan
|
||||||
|
// board permanently empty even after plans are generated.
|
||||||
|
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||||
String conversationId = accessor.conversationId();
|
String conversationId = accessor.conversationId();
|
||||||
|
|
||||||
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
|
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
|
||||||
@ -169,8 +400,11 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
vip.mate.agent.context.ChatOrigin chatOrigin =
|
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||||
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
||||||
|
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
||||||
promptMessages.add(new UserMessage(
|
promptMessages.add(new UserMessage(
|
||||||
RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
|
RuntimeContextInjector.buildContextMessage(
|
||||||
|
workspaceBasePath, null, chatOrigin, runtimeModelName, runtimeProviderId)));
|
||||||
|
|
||||||
// Advertise available tools so the LLM can recognize when an action is possible,
|
// Advertise available tools so the LLM can recognize when an action is possible,
|
||||||
// but do NOT force "any tool usage implies multi-step" — single-hop tool use
|
// but do NOT force "any tool usage implies multi-step" — single-hop tool use
|
||||||
@ -184,6 +418,24 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advertise delegatable specialist agents so the planner can assign a
|
||||||
|
// multi-step plan's step to a dedicated agent (e.g. a test step to a
|
||||||
|
// QA agent, a UI step to a frontend agent). Only fills the step's
|
||||||
|
// step_agents slot; unassigned steps stay with the parent agent.
|
||||||
|
// Skipped entirely when no peer agents exist in the workspace.
|
||||||
|
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||||
|
if (!delegatable.isEmpty()) {
|
||||||
|
String agentLines = delegatable.stream()
|
||||||
|
.map(a -> "- " + a.getName()
|
||||||
|
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
promptMessages.add(new UserMessage(
|
||||||
|
"可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n"
|
||||||
|
+ agentLines
|
||||||
|
+ "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
|
||||||
|
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
|
||||||
|
}
|
||||||
|
|
||||||
// Inject working context (rolling conversation summary) so triage respects
|
// Inject working context (rolling conversation summary) so triage respects
|
||||||
// prior constraints without re-reading full history.
|
// prior constraints without re-reading full history.
|
||||||
String workingContext = accessor.workingContext();
|
String workingContext = accessor.workingContext();
|
||||||
@ -247,6 +499,31 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
||||||
String directAnswer = triage != null && triage.directAnswer() != null
|
String directAnswer = triage != null && triage.directAnswer() != null
|
||||||
? triage.directAnswer() : llmResponse;
|
? triage.directAnswer() : llmResponse;
|
||||||
|
|
||||||
|
// Evidence gate: catch a mis-routed A that actually needs tools.
|
||||||
|
// Downgrading to a single-step plan keeps tool access; the cost of
|
||||||
|
// a false positive is one extra executor pass, while a missed
|
||||||
|
// misroute drops the whole task silently.
|
||||||
|
if (shouldOverrideDirectAnswer(goal, directAnswer)) {
|
||||||
|
log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
|
||||||
|
+ "downgrading to single-step plan so tools can execute (goal: {})",
|
||||||
|
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
|
||||||
|
List<String> gatedSteps = List.of(goal);
|
||||||
|
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
|
||||||
|
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.needsPlanning(true)
|
||||||
|
.planId(gatedPlan.getId())
|
||||||
|
.planSteps(gatedSteps)
|
||||||
|
.planValid(true)
|
||||||
|
.currentStepIndex(0)
|
||||||
|
.currentPhase("plan_generated")
|
||||||
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
|
.mergeUsage(state, result)
|
||||||
|
.events(events)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
||||||
|
|
||||||
streamingHelper.broadcastContent(conversationId, directAnswer);
|
streamingHelper.broadcastContent(conversationId, directAnswer);
|
||||||
@ -273,13 +550,34 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
steps = List.of(goal);
|
steps = List.of(goal);
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan = planningService.createPlan(agentId, goal, steps);
|
// Resolve any per-step agent delegation the planner asked for. Null
|
||||||
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
|
// when nothing is delegated, keeping createPlan on the legacy path.
|
||||||
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
|
List<Long> stepAgentIds = resolveStepAgents(steps,
|
||||||
|
triage != null ? triage.stepAgents() : null,
|
||||||
|
chatOrigin.workspaceId(), agentId);
|
||||||
|
var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
|
||||||
|
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
|
||||||
|
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
|
||||||
|
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
|
||||||
|
|
||||||
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
||||||
|
|
||||||
return PlanStateAccessor.output()
|
// Auto-derive a goal from a genuine multi-step plan so the
|
||||||
|
// Plan-Execute path engages the goal subsystem. Injected into
|
||||||
|
// ACTIVE_GOAL so this same run's GoalEvaluationNode evaluates it.
|
||||||
|
GoalEntity autoGoal = maybeAutoCreateGoal(accessor, steps);
|
||||||
|
if (autoGoal != null && goalService != null) {
|
||||||
|
// Surface it to the UI exactly like the setGoal tool does
|
||||||
|
// ({goalId, conversationId, goal}) so the goal panel hydrates
|
||||||
|
// even though the user never called setGoal. Same SSE event the
|
||||||
|
// frontend goal store already listens for.
|
||||||
|
events.add(new GraphEventPublisher.GraphEvent("goal_created", Map.of(
|
||||||
|
"goalId", String.valueOf(autoGoal.getId()),
|
||||||
|
"conversationId", conversationId,
|
||||||
|
"goal", goalService.toResponse(autoGoal)), System.currentTimeMillis()));
|
||||||
|
}
|
||||||
|
|
||||||
|
PlanStateAccessor.OutputBuilder planOut = PlanStateAccessor.output()
|
||||||
.needsPlanning(true)
|
.needsPlanning(true)
|
||||||
.planId(plan.getId())
|
.planId(plan.getId())
|
||||||
.planSteps(steps)
|
.planSteps(steps)
|
||||||
@ -289,8 +587,11 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events);
|
||||||
.build();
|
if (autoGoal != null) {
|
||||||
|
planOut.put(MateClawStateKeys.ACTIVE_GOAL, autoGoal);
|
||||||
|
}
|
||||||
|
return planOut.build();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);
|
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);
|
||||||
@ -299,7 +600,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
// answer. This preserves tool access on the failure path; the previous
|
// answer. This preserves tool access on the failure path; the previous
|
||||||
// "direct answer" fallback silently degraded tool-requiring tasks.
|
// "direct answer" fallback silently degraded tool-requiring tasks.
|
||||||
try {
|
try {
|
||||||
var plan = planningService.createPlan(agentId, goal, List.of(goal));
|
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
|
||||||
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.needsPlanning(true)
|
.needsPlanning(true)
|
||||||
|
|||||||
@ -27,11 +27,16 @@ import vip.mate.agent.context.RuntimeContextInjector;
|
|||||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.planning.service.PlanningService;
|
import vip.mate.planning.service.PlanningService;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.skill.runtime.SkillCatalogRenderer;
|
import vip.mate.skill.runtime.SkillCatalogRenderer;
|
||||||
|
import vip.mate.tool.builtin.DelegateAgentTool;
|
||||||
|
import vip.mate.tool.builtin.DelegationContext;
|
||||||
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 步骤执行节点
|
* 步骤执行节点
|
||||||
@ -68,6 +73,17 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
*/
|
*/
|
||||||
private final SkillCatalogRenderer skillCatalogRenderer;
|
private final SkillCatalogRenderer skillCatalogRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-step delegation executor. Set after construction (this node is
|
||||||
|
* built by AgentGraphBuilder, not Spring) so a plan step assigned to a
|
||||||
|
* specialist agent runs on that agent. Null disables per-step delegation.
|
||||||
|
*/
|
||||||
|
private DelegateAgentTool delegateAgentTool;
|
||||||
|
|
||||||
|
public void setDelegateAgentTool(DelegateAgentTool delegateAgentTool) {
|
||||||
|
this.delegateAgentTool = delegateAgentTool;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
|
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
|
||||||
* Matching the agent-level cap means this constant is never the bottleneck —
|
* Matching the agent-level cap means this constant is never the bottleneck —
|
||||||
@ -89,6 +105,16 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
* pathological cases where the agent appears frozen to the user.
|
* pathological cases where the agent appears frozen to the user.
|
||||||
*/
|
*/
|
||||||
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
|
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max re-plans per graph run. When a step throws, the executor re-plans the
|
||||||
|
* remaining work around the failure instead of aborting the whole plan — a
|
||||||
|
* single transient tool error or one badly-scoped step no longer kills the
|
||||||
|
* task. Bounded so a step that fails every attempt can't re-plan forever;
|
||||||
|
* once exhausted the plan aborts as before. Kept small (the recursion
|
||||||
|
* ceiling already accommodates it) — raise with care.
|
||||||
|
*/
|
||||||
|
private static final int MAX_REPLANS_PER_RUN = 1;
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
||||||
@ -166,6 +192,8 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
vip.mate.agent.context.ChatOrigin chatOrigin =
|
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||||
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
||||||
|
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
||||||
|
|
||||||
if (stepIndex >= steps.size()) {
|
if (stepIndex >= steps.size()) {
|
||||||
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
||||||
@ -186,6 +214,19 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
// "react_step" / "first_turn" markers when both stream into the
|
// "react_step" / "first_turn" markers when both stream into the
|
||||||
// same SSE feed.
|
// same SSE feed.
|
||||||
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
||||||
|
|
||||||
|
// Per-step delegation: when this step is assigned to a different
|
||||||
|
// specialist agent, run it on that agent (as an isolated child) and use
|
||||||
|
// its reply as the step result, instead of executing locally with the
|
||||||
|
// parent agent's tools. assignedAgentId comes from the DB so it survives
|
||||||
|
// replay / approval-resume.
|
||||||
|
Long assignedAgentId = planningService.getStepAssignedAgent(planId, stepIndex);
|
||||||
|
if (delegateAgentTool != null && assignedAgentId != null
|
||||||
|
&& !assignedAgentId.equals(parseLongOrNull(agentId))) {
|
||||||
|
return executeDelegatedStep(accessor, stepIndex, step, planId, assignedAgentId,
|
||||||
|
conversationId, chatOrigin, events, iterationEventsOn);
|
||||||
|
}
|
||||||
|
|
||||||
if (iterationEventsOn) {
|
if (iterationEventsOn) {
|
||||||
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||||
}
|
}
|
||||||
@ -195,7 +236,8 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
planningService.updateSubPlanStatus(planId, stepIndex, "running");
|
planningService.updateSubPlanStatus(planId, stepIndex, "running");
|
||||||
|
|
||||||
// 构建消息列表
|
// 构建消息列表
|
||||||
List<Message> messages = buildStepMessages(accessor, step, systemPrompt, workspaceBasePath);
|
List<Message> messages = buildStepMessages(accessor, step, systemPrompt, workspaceBasePath,
|
||||||
|
runtimeModelName, runtimeProviderId);
|
||||||
|
|
||||||
// 显式工具执行循环
|
// 显式工具执行循环
|
||||||
String finalResult = null;
|
String finalResult = null;
|
||||||
@ -219,6 +261,12 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
long stepStartedAtMs = System.currentTimeMillis();
|
long stepStartedAtMs = System.currentTimeMillis();
|
||||||
boolean wallClockExceeded = false;
|
boolean wallClockExceeded = false;
|
||||||
|
|
||||||
|
// Signature-based progress detector: nudges the model to change strategy
|
||||||
|
// when a round stalls (repeated failures / identical results), and flags
|
||||||
|
// the step as stuck past a hard threshold so we re-plan instead of
|
||||||
|
// burning the whole tool-call budget and advancing with junk.
|
||||||
|
StepProgressTracker progressTracker = new StepProgressTracker();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
||||||
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
|
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
|
||||||
@ -350,6 +398,30 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Progress tracking: feed this round's tool results to the
|
||||||
|
// detector. A WARN-level stall injects a one-shot "change
|
||||||
|
// strategy" SystemMessage the model sees on its next call; a
|
||||||
|
// HALT-level stall stops the inner loop so the post-loop logic
|
||||||
|
// re-plans instead of spinning to the tool-call ceiling.
|
||||||
|
java.util.Map<String, String> idToArgs = new java.util.HashMap<>();
|
||||||
|
for (AssistantMessage.ToolCall tc : allToolCalls) {
|
||||||
|
if (tc != null && tc.id() != null) {
|
||||||
|
idToArgs.put(tc.id(), tc.arguments());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ToolResponseMessage.ToolResponse tr : toolResponses) {
|
||||||
|
var nudge = progressTracker.record(
|
||||||
|
tr.name(), idToArgs.getOrDefault(tr.id(), ""), tr.responseData());
|
||||||
|
if (nudge.isPresent()) {
|
||||||
|
messages.add(new SystemMessage(nudge.get()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (progressTracker.isStuck()) {
|
||||||
|
log.warn("[StepExecution] Step {} stalled ({}); stopping inner loop to re-plan",
|
||||||
|
stepIndex, progressTracker.haltReason());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// RFC-052: returnDirect short-circuit. Any direct tool in this
|
// RFC-052: returnDirect short-circuit. Any direct tool in this
|
||||||
// step ends the plan immediately; the dispatcher routes via
|
// step ends the plan immediately; the dispatcher routes via
|
||||||
// currentPhase=plan_aborted so no further LLM call happens.
|
// currentPhase=plan_aborted so no further LLM call happens.
|
||||||
@ -415,6 +487,56 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step-failure recovery WITHOUT an exception: the inner loop ended
|
||||||
|
// with no usable result — stalled (repeated failures / identical
|
||||||
|
// results, flagged by the progress tracker), hit the wall-clock or
|
||||||
|
// tool-call ceiling, or returned an empty answer. Re-plan the
|
||||||
|
// remaining work around it instead of advancing dependent steps with
|
||||||
|
// junk. Shares PLAN_REPLAN_COUNT with the exception path; once the
|
||||||
|
// budget is spent we fall through to the legacy "complete with a
|
||||||
|
// failure note" path below so the plan still terminates.
|
||||||
|
boolean noUsableResult = progressTracker.isStuck()
|
||||||
|
|| finalResult == null || finalResult.isBlank();
|
||||||
|
int noProgressReplanCount = accessor.replanCount();
|
||||||
|
if (noUsableResult && noProgressReplanCount < MAX_REPLANS_PER_RUN) {
|
||||||
|
String reason = progressTracker.isStuck()
|
||||||
|
? "本步骤陷入停滞(" + progressTracker.haltReason() + "),未取得有效结果"
|
||||||
|
: wallClockExceeded
|
||||||
|
? "本步骤超过最大耗时限制,未取得有效结果"
|
||||||
|
: finalResult == null
|
||||||
|
? "本步骤超过最大工具调用次数,未取得有效结果"
|
||||||
|
: "本步骤未产出有效结果";
|
||||||
|
planningService.updateSubPlanFailure(planId, stepIndex, reason);
|
||||||
|
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + ":" + reason);
|
||||||
|
events.add(GraphEventPublisher.stepCompleted(stepIndex, reason));
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, reason.length(), 0));
|
||||||
|
}
|
||||||
|
events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of(
|
||||||
|
"failedStepIndex", stepIndex,
|
||||||
|
"attempt", noProgressReplanCount + 1,
|
||||||
|
"maxReplans", MAX_REPLANS_PER_RUN,
|
||||||
|
"reason", reason), System.currentTimeMillis()));
|
||||||
|
log.warn("[StepExecution] Step {} produced no usable result ({}); re-planning (attempt {}/{})",
|
||||||
|
stepIndex + 1, reason, noProgressReplanCount + 1, MAX_REPLANS_PER_RUN);
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.workingContext(buildReplanContext(accessor, stepIndex, reason))
|
||||||
|
.currentPhase("plan_replan")
|
||||||
|
.replanCount(noProgressReplanCount + 1)
|
||||||
|
.planId(null)
|
||||||
|
.planSteps(List.of())
|
||||||
|
.planValid(false)
|
||||||
|
.needsPlanning(true)
|
||||||
|
.currentStepIndex(0)
|
||||||
|
.currentStepTitle("")
|
||||||
|
.currentStepResult("")
|
||||||
|
.contentStreamed(false)
|
||||||
|
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||||
|
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||||
|
.events(events)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
if (finalResult == null) {
|
if (finalResult == null) {
|
||||||
if (wallClockExceeded) {
|
if (wallClockExceeded) {
|
||||||
finalResult = "步骤执行超过最大耗时限制("
|
finalResult = "步骤执行超过最大耗时限制("
|
||||||
@ -435,6 +557,46 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||||
shortError != null ? shortError.length() : 0, 0));
|
shortError != null ? shortError.length() : 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step-failure recovery: rather than aborting the whole plan on a
|
||||||
|
// single failed step, re-plan the remaining work around the failure
|
||||||
|
// (up to MAX_REPLANS_PER_RUN). Completed steps are preserved in
|
||||||
|
// WORKING_CONTEXT, so the next PlanGeneration pass can skip them and
|
||||||
|
// route around (or retry) what broke. The mid-pass plan state is
|
||||||
|
// cleared so a fresh plan is derived; PLAN_REPLAN_COUNT bounds the loop.
|
||||||
|
int replanCount = accessor.replanCount();
|
||||||
|
if (replanCount < MAX_REPLANS_PER_RUN) {
|
||||||
|
String replanContext = buildReplanContext(accessor, stepIndex, shortError);
|
||||||
|
events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of(
|
||||||
|
"failedStepIndex", stepIndex,
|
||||||
|
"attempt", replanCount + 1,
|
||||||
|
"maxReplans", MAX_REPLANS_PER_RUN,
|
||||||
|
"error", shortError == null ? "" : shortError),
|
||||||
|
System.currentTimeMillis()));
|
||||||
|
log.warn("[StepExecution] Step {} failed; re-planning remaining work (attempt {}/{})",
|
||||||
|
stepIndex + 1, replanCount + 1, MAX_REPLANS_PER_RUN);
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.workingContext(replanContext)
|
||||||
|
.currentPhase("plan_replan")
|
||||||
|
.replanCount(replanCount + 1)
|
||||||
|
// Wipe mid-pass plan state so PlanGenerationNode re-derives
|
||||||
|
// a fresh plan from goal + (failure-augmented) context.
|
||||||
|
.planId(null)
|
||||||
|
.planSteps(List.of())
|
||||||
|
.planValid(false)
|
||||||
|
.needsPlanning(true)
|
||||||
|
.currentStepIndex(0)
|
||||||
|
.currentStepTitle("")
|
||||||
|
.currentStepResult("")
|
||||||
|
.contentStreamed(false)
|
||||||
|
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||||
|
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||||
|
.events(events)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("[StepExecution] Step {} failed and re-plan budget exhausted ({}); aborting plan",
|
||||||
|
stepIndex + 1, MAX_REPLANS_PER_RUN);
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.currentStepResult(shortError)
|
.currentStepResult(shortError)
|
||||||
.currentPhase("plan_aborted")
|
.currentPhase("plan_aborted")
|
||||||
@ -489,6 +651,98 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a step by delegating it to its assigned specialist agent. The
|
||||||
|
* delegated agent runs the step description as a self-contained goal and its
|
||||||
|
* reply becomes the step result. Mirrors the success/failure bookkeeping of
|
||||||
|
* the local execution path (sub-plan status, completed-results accumulation,
|
||||||
|
* incremental working-context update) so the rest of the plan graph is
|
||||||
|
* unaffected by where the step ran.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> executeDelegatedStep(
|
||||||
|
PlanStateAccessor accessor, int stepIndex, String step, Long planId,
|
||||||
|
Long assignedAgentId, String conversationId, ChatOrigin chatOrigin,
|
||||||
|
List<GraphEventPublisher.GraphEvent> events, boolean iterationEventsOn) {
|
||||||
|
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||||
|
}
|
||||||
|
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
|
||||||
|
events.add(GraphEventPublisher.phase("executing", Map.of(
|
||||||
|
"stepIndex", stepIndex, "stepTitle", step,
|
||||||
|
"delegatedAgentId", String.valueOf(assignedAgentId))));
|
||||||
|
planningService.updateSubPlanStatus(planId, stepIndex, "running");
|
||||||
|
|
||||||
|
log.info("[StepExecution] Delegating step {} to agent {}", stepIndex + 1, assignedAgentId);
|
||||||
|
|
||||||
|
// Seed the delegation context with the plan's REAL conversation id (from
|
||||||
|
// graph state) so the delegated child conversation is parented to it and
|
||||||
|
// stays hidden from the user's conversation list. The ChatOrigin in the
|
||||||
|
// plan-execute path carries no conversationId, so delegateByAgentId can't
|
||||||
|
// derive the parent on its own — we provide it here.
|
||||||
|
boolean seeded = false;
|
||||||
|
if (conversationId != null && !conversationId.isBlank()
|
||||||
|
&& DelegationContext.parentConversationId() == null
|
||||||
|
&& ToolExecutionContext.conversationId() == null) {
|
||||||
|
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
|
||||||
|
seeded = true;
|
||||||
|
}
|
||||||
|
String result;
|
||||||
|
try {
|
||||||
|
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
|
||||||
|
result = "[错误] 委派执行异常:" + e.getMessage();
|
||||||
|
} finally {
|
||||||
|
if (seeded) {
|
||||||
|
DelegationContext.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String finalResult = result != null ? result : "";
|
||||||
|
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
|
||||||
|
if (failed) {
|
||||||
|
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
|
||||||
|
} else {
|
||||||
|
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, finalResult.length(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the rolling working-context in sync exactly like the local path
|
||||||
|
// so later steps see this delegated step's result.
|
||||||
|
String prevWorkingContext = accessor.workingContext();
|
||||||
|
String formattedNewStep = formatStepResult(stepIndex, finalResult);
|
||||||
|
String updatedWorkingContext = prevWorkingContext.isEmpty()
|
||||||
|
? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep))
|
||||||
|
: appendStepIncremental(prevWorkingContext, formattedNewStep);
|
||||||
|
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.currentStepResult(finalResult)
|
||||||
|
.completedResults(formattedNewStep)
|
||||||
|
.currentStepIndex(stepIndex + 1)
|
||||||
|
.workingContext(updatedWorkingContext)
|
||||||
|
.currentPhase("step_completed")
|
||||||
|
.contentStreamed(true)
|
||||||
|
.events(events)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a string id to Long, or null when blank / non-numeric. */
|
||||||
|
private static Long parseLongOrNull(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(s.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-052: assemble the final answer text from direct tool outputs in this
|
* RFC-052: assemble the final answer text from direct tool outputs in this
|
||||||
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
|
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
|
||||||
@ -509,7 +763,8 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
|
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt,
|
||||||
|
String workspaceBasePath, String runtimeModelName, String runtimeProviderId) {
|
||||||
List<Message> messages = new ArrayList<>();
|
List<Message> messages = new ArrayList<>();
|
||||||
|
|
||||||
// Layer 1: System prompt(增强指令)
|
// Layer 1: System prompt(增强指令)
|
||||||
@ -537,9 +792,10 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
messages.add(new SystemMessage(skillCatalog));
|
messages.add(new SystemMessage(skillCatalog));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文)
|
// 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文 + 模型身份)
|
||||||
messages.add(new UserMessage(
|
messages.add(new UserMessage(
|
||||||
RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, accessor.chatOrigin())));
|
RuntimeContextInjector.buildContextMessage(
|
||||||
|
workspaceBasePath, null, accessor.chatOrigin(), runtimeModelName, runtimeProviderId)));
|
||||||
|
|
||||||
// Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要)
|
// Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要)
|
||||||
String workingContext = accessor.workingContext();
|
String workingContext = accessor.workingContext();
|
||||||
@ -607,6 +863,30 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment the working context with a note about the failed step so the next
|
||||||
|
* PlanGeneration pass re-plans around it. The completed-step results are
|
||||||
|
* already encoded in {@code WORKING_CONTEXT}; this appends only the failure
|
||||||
|
* so the planner can skip what's done, retry differently, or route around
|
||||||
|
* the broken step. The note is an internal LLM prompt (Chinese, matching the
|
||||||
|
* surrounding planning/execution prompts).
|
||||||
|
*/
|
||||||
|
static String buildReplanContext(PlanStateAccessor accessor, int failedStepIndex, String error) {
|
||||||
|
List<String> steps = accessor.planSteps();
|
||||||
|
String failedTitle = (failedStepIndex >= 0 && failedStepIndex < steps.size())
|
||||||
|
? steps.get(failedStepIndex) : ("步骤 " + (failedStepIndex + 1));
|
||||||
|
StringBuilder sb = new StringBuilder(accessor.workingContext());
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append("\n\n");
|
||||||
|
}
|
||||||
|
sb.append("【上一轮计划执行失败】步骤 ").append(failedStepIndex + 1)
|
||||||
|
.append("(").append(failedTitle).append(")执行失败:")
|
||||||
|
.append(error == null ? "未知错误" : error)
|
||||||
|
.append("\n请基于上面已完成的工作,重新规划达成总目标所需的剩余步骤:")
|
||||||
|
.append("绕开或换一种方式完成失败的部分,不要重复已经完成的步骤。");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将异常转换为简短的错误摘要,避免将完整异常体(尤其是 429 JSON)写入后续 prompt。
|
* 将异常转换为简短的错误摘要,避免将完整异常体(尤其是 429 JSON)写入后续 prompt。
|
||||||
* <ul>
|
* <ul>
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
package vip.mate.agent.graph.plan.node;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-step progress detector for the Plan-Execute executor.
|
||||||
|
*
|
||||||
|
* <p>A plan step runs its own inner tool-calling loop. Without progress
|
||||||
|
* tracking, a step can spin — repeatedly calling the same tool, or hammering
|
||||||
|
* different variants that all fail / return nothing — until it hits the
|
||||||
|
* tool-call ceiling, then "complete" with an empty result and let the plan
|
||||||
|
* plow into dependent steps that have no real input.
|
||||||
|
*
|
||||||
|
* <p>This tracker watches the tool results of each round and recognises two
|
||||||
|
* signature-based stall patterns:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>repeated failure</b> — the same call (tool name + canonical args)
|
||||||
|
* keeps failing, or the same tool keeps failing with different args;</li>
|
||||||
|
* <li><b>no progress</b> — a call keeps returning the <em>same</em> result,
|
||||||
|
* so re-issuing it yields nothing new.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Detection is graduated: at the WARN threshold it emits a one-shot nudge
|
||||||
|
* (injected back into the step's messages so the model changes strategy);
|
||||||
|
* past the HALT threshold it flags the step as stuck so the executor can stop
|
||||||
|
* the inner loop and re-plan instead of advancing with junk. Thresholds are
|
||||||
|
* deliberately low — the goal is to break a stall early, before the whole
|
||||||
|
* tool-call budget is burned.
|
||||||
|
*
|
||||||
|
* <p>Not thread-safe; create one per step.
|
||||||
|
*/
|
||||||
|
public final class StepProgressTracker {
|
||||||
|
|
||||||
|
/** Same exact call (tool + args) failing: nudge / halt thresholds. */
|
||||||
|
static final int SAME_CALL_FAIL_WARN = 2;
|
||||||
|
static final int SAME_CALL_FAIL_HALT = 4;
|
||||||
|
/** Same tool failing across different args: nudge / halt thresholds. */
|
||||||
|
static final int SAME_TOOL_FAIL_WARN = 3;
|
||||||
|
static final int SAME_TOOL_FAIL_HALT = 6;
|
||||||
|
/** Same call returning identical output (no new information): nudge / halt. */
|
||||||
|
static final int NO_PROGRESS_WARN = 2;
|
||||||
|
static final int NO_PROGRESS_HALT = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower-cased markers that identify a tool result as a failure / empty
|
||||||
|
* outcome. Kept intentionally small and language-mixed: tool errors in this
|
||||||
|
* codebase surface as English exception text, while a few common "not
|
||||||
|
* found" phrasings also appear in Chinese tool output.
|
||||||
|
*/
|
||||||
|
private static final String[] FAILURE_MARKERS = {
|
||||||
|
"execution failed", "error:", "exception", "timeout", "timed out",
|
||||||
|
"enoent", "no such file", "not found", "authentication failed",
|
||||||
|
"permission denied", "failed to", "未找到", "不存在", "没有找到", "执行失败", "无法"
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Map<String, Integer> sameCallFail = new HashMap<>();
|
||||||
|
private final Map<String, Integer> sameToolFail = new HashMap<>();
|
||||||
|
private final Map<String, Integer> resultRepeat = new HashMap<>();
|
||||||
|
private final Set<String> warnedKeys = new HashSet<>();
|
||||||
|
|
||||||
|
private boolean stuck = false;
|
||||||
|
private String haltReason = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one tool result from the current round.
|
||||||
|
*
|
||||||
|
* @param toolName the invoked tool's name (never null)
|
||||||
|
* @param argsJson the raw arguments JSON (may be empty when unresolved)
|
||||||
|
* @param resultText the tool's result text (may be null/empty)
|
||||||
|
* @return a nudge to inject into the step's messages when a WARN threshold
|
||||||
|
* was freshly crossed, otherwise empty. Each distinct warning fires
|
||||||
|
* at most once.
|
||||||
|
*/
|
||||||
|
public Optional<String> record(String toolName, String argsJson, String resultText) {
|
||||||
|
String name = toolName == null ? "tool" : toolName;
|
||||||
|
String args = argsJson == null ? "" : argsJson;
|
||||||
|
String result = resultText == null ? "" : resultText;
|
||||||
|
boolean failure = looksLikeFailure(result);
|
||||||
|
|
||||||
|
String callSig = name + "::" + args.hashCode();
|
||||||
|
String resultKey = callSig + "##" + result.trim().hashCode();
|
||||||
|
|
||||||
|
// No-progress: identical result for the same call, regardless of success.
|
||||||
|
int repeats = resultRepeat.merge(resultKey, 1, Integer::sum);
|
||||||
|
if (repeats >= NO_PROGRESS_HALT) {
|
||||||
|
markStuck("no_progress:" + name);
|
||||||
|
}
|
||||||
|
Optional<String> nudge = maybeWarn(repeats >= NO_PROGRESS_WARN, "np:" + resultKey,
|
||||||
|
"工具 " + name + " 已连续 " + repeats + " 次返回相同结果。不要重复同样的调用——"
|
||||||
|
+ "改用已有结果、换查询/换工具,或直接基于现有信息给出本步骤结论。");
|
||||||
|
|
||||||
|
if (failure) {
|
||||||
|
int callFails = sameCallFail.merge(callSig, 1, Integer::sum);
|
||||||
|
int toolFails = sameToolFail.merge(name, 1, Integer::sum);
|
||||||
|
if (callFails >= SAME_CALL_FAIL_HALT || toolFails >= SAME_TOOL_FAIL_HALT) {
|
||||||
|
markStuck("repeated_failure:" + name);
|
||||||
|
}
|
||||||
|
if (nudge.isEmpty()) {
|
||||||
|
nudge = maybeWarn(callFails >= SAME_CALL_FAIL_WARN, "cf:" + callSig,
|
||||||
|
"工具 " + name + " 用相同参数已失败 " + callFails + " 次,像是死循环。"
|
||||||
|
+ "先看错误原因再换一种方式,不要原样重试。");
|
||||||
|
}
|
||||||
|
if (nudge.isEmpty()) {
|
||||||
|
nudge = maybeWarn(toolFails >= SAME_TOOL_FAIL_WARN, "tf:" + name,
|
||||||
|
"工具 " + name + " 本步骤已失败 " + toolFails + " 次。停止在同一条失败路径上重试,"
|
||||||
|
+ "换工具或换思路完成本步骤。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nudge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a HALT threshold was crossed — the step should stop and re-plan. */
|
||||||
|
public boolean isStuck() {
|
||||||
|
return stuck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Machine-readable reason for the halt, or null when not stuck. */
|
||||||
|
public String haltReason() {
|
||||||
|
return haltReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> maybeWarn(boolean crossed, String key, String message) {
|
||||||
|
if (crossed && warnedKeys.add(key)) {
|
||||||
|
return Optional.of(message);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markStuck(String reason) {
|
||||||
|
if (!stuck) {
|
||||||
|
stuck = true;
|
||||||
|
haltReason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean looksLikeFailure(String result) {
|
||||||
|
if (result == null || result.isBlank()) {
|
||||||
|
return true; // an empty result is no progress either
|
||||||
|
}
|
||||||
|
String lower = result.toLowerCase();
|
||||||
|
for (String marker : FAILURE_MARKERS) {
|
||||||
|
if (lower.contains(marker)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -73,6 +73,11 @@ public final class PlanStateAccessor {
|
|||||||
return state.<List<String>>value(COMPLETED_RESULTS).orElse(List.of());
|
return state.<List<String>>value(COMPLETED_RESULTS).orElse(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-plans already performed this run (0 at run start). */
|
||||||
|
public int replanCount() {
|
||||||
|
return state.value(PLAN_REPLAN_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 终止 =====
|
// ===== 终止 =====
|
||||||
|
|
||||||
public String finalSummary() {
|
public String finalSummary() {
|
||||||
@ -187,6 +192,10 @@ public final class PlanStateAccessor {
|
|||||||
return put(CURRENT_STEP_INDEX, index);
|
return put(CURRENT_STEP_INDEX, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputBuilder replanCount(int count) {
|
||||||
|
return put(PLAN_REPLAN_COUNT, count);
|
||||||
|
}
|
||||||
|
|
||||||
public OutputBuilder currentStepTitle(String title) {
|
public OutputBuilder currentStepTitle(String title) {
|
||||||
return put(CURRENT_STEP_TITLE, title);
|
return put(CURRENT_STEP_TITLE, title);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,15 @@ public final class PlanStateKeys {
|
|||||||
public static final String CURRENT_STEP_RESULT = "current_step_result";
|
public static final String CURRENT_STEP_RESULT = "current_step_result";
|
||||||
public static final String COMPLETED_RESULTS = "completed_results"; // APPEND 策略
|
public static final String COMPLETED_RESULTS = "completed_results"; // APPEND 策略
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of re-plans performed in THIS graph run (REPLACE strategy). When a
|
||||||
|
* step throws, the executor re-plans the remaining work around the failure
|
||||||
|
* (carried in {@link #WORKING_CONTEXT}) instead of aborting outright, up to
|
||||||
|
* a small bound — this counter enforces that bound so a pathological failure
|
||||||
|
* loop can't re-plan forever. Implicitly 0 at run start.
|
||||||
|
*/
|
||||||
|
public static final String PLAN_REPLAN_COUNT = "plan_replan_count";
|
||||||
|
|
||||||
// ===== 终止 =====
|
// ===== 终止 =====
|
||||||
public static final String FINAL_SUMMARY = "final_summary";
|
public static final String FINAL_SUMMARY = "final_summary";
|
||||||
public static final String DIRECT_ANSWER = "direct_answer"; // 简单问答的直接回答
|
public static final String DIRECT_ANSWER = "direct_answer"; // 简单问答的直接回答
|
||||||
|
|||||||
@ -80,6 +80,11 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(LLM_CALL_COUNT, 0);
|
return state.value(LLM_CALL_COUNT, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Iterations refunded this run for setup-only (progressive-disclosure) rounds (0 at run start). */
|
||||||
|
public int iterationRefundCount() {
|
||||||
|
return state.value(ITERATION_REFUND_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 观察历史 =====
|
// ===== 观察历史 =====
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@ -303,6 +308,11 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
|
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hard continuations (fresh-budget ReAct segments) performed this run (0 at run start). */
|
||||||
|
public int goalHardContinuationCount() {
|
||||||
|
return state.value(GOAL_HARD_CONTINUATION_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
|
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
|
||||||
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
|
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
|
||||||
@ -368,6 +378,10 @@ public final class MateClawStateAccessor {
|
|||||||
return put(NEEDS_TOOL_CALL, needs);
|
return put(NEEDS_TOOL_CALL, needs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputBuilder iterationRefundCount(int count) {
|
||||||
|
return put(ITERATION_REFUND_COUNT, count);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 消息 ----
|
// ---- 消息 ----
|
||||||
public OutputBuilder messages(List<Message> msgs) {
|
public OutputBuilder messages(List<Message> msgs) {
|
||||||
return put(MESSAGES, msgs);
|
return put(MESSAGES, msgs);
|
||||||
@ -552,6 +566,10 @@ public final class MateClawStateAccessor {
|
|||||||
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
|
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputBuilder goalHardContinuationCount(int n) {
|
||||||
|
return put(GOAL_HARD_CONTINUATION_COUNT, n);
|
||||||
|
}
|
||||||
|
|
||||||
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
|
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
|
||||||
* immediately re-terminate via the existing final text. */
|
* immediately re-terminate via the existing final text. */
|
||||||
public OutputBuilder clearFinalAnswer() {
|
public OutputBuilder clearFinalAnswer() {
|
||||||
@ -563,6 +581,18 @@ public final class MateClawStateAccessor {
|
|||||||
return put(FINISH_REASON, "");
|
return put(FINISH_REASON, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe the limit-exceeded draft + flag. Required before a hard
|
||||||
|
* continuation re-enters the ReAct loop: FinalAnswerNode prefers
|
||||||
|
* FINAL_ANSWER_DRAFT over a freshly reasoned answer, so a stale draft
|
||||||
|
* left by LimitExceededNode would otherwise resurface as the next
|
||||||
|
* segment's answer.
|
||||||
|
*/
|
||||||
|
public OutputBuilder clearLimitExceededDraft() {
|
||||||
|
put(FINAL_ANSWER_DRAFT, "");
|
||||||
|
return put(LIMIT_EXCEEDED, false);
|
||||||
|
}
|
||||||
|
|
||||||
/** Plan-Execute follow-up: clear the terminal-side plan summary so
|
/** Plan-Execute follow-up: clear the terminal-side plan summary so
|
||||||
* the next PlanGeneration pass starts clean. Identifier is the
|
* the next PlanGeneration pass starts clean. Identifier is the
|
||||||
* string literal "final_summary" to avoid a compile-time link to
|
* string literal "final_summary" to avoid a compile-time link to
|
||||||
|
|||||||
@ -30,6 +30,16 @@ public final class MateClawStateKeys {
|
|||||||
public static final String CURRENT_ITERATION = "current_iteration";
|
public static final String CURRENT_ITERATION = "current_iteration";
|
||||||
public static final String MAX_ITERATIONS = "max_iterations";
|
public static final String MAX_ITERATIONS = "max_iterations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterations refunded this run because a reasoning round did no real work —
|
||||||
|
* its whole tool batch was progressive-disclosure setup ({@code load_skill}
|
||||||
|
* / {@code enable_tool}). ObservationNode skips the iteration increment for
|
||||||
|
* such rounds so a tight budget isn't eaten by the load-then-use two-step;
|
||||||
|
* this counter bounds the refunds so a model that only ever loads skills
|
||||||
|
* still terminates. Implicitly 0 at run start. REPLACE strategy.
|
||||||
|
*/
|
||||||
|
public static final String ITERATION_REFUND_COUNT = "iteration_refund_count";
|
||||||
|
|
||||||
// ===== 工具调用(REPLACE 策略)=====
|
// ===== 工具调用(REPLACE 策略)=====
|
||||||
public static final String TOOL_CALLS = "tool_calls";
|
public static final String TOOL_CALLS = "tool_calls";
|
||||||
public static final String TOOL_RESULTS = "tool_results";
|
public static final String TOOL_RESULTS = "tool_results";
|
||||||
@ -228,6 +238,22 @@ public final class MateClawStateKeys {
|
|||||||
*/
|
*/
|
||||||
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
|
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of "hard continuations" already performed in THIS graph run — a
|
||||||
|
* hard continuation is a goal follow-up that re-enters the ReAct loop with
|
||||||
|
* a FRESH iteration budget (CURRENT_ITERATION reset to 0) after a turn that
|
||||||
|
* ended in {@link FinishReason#MAX_ITERATIONS_REACHED}. Unlike a normal
|
||||||
|
* follow-up (which shares the run's single iteration budget), a hard
|
||||||
|
* continuation grants the goal a brand-new ReAct segment so a task too big
|
||||||
|
* for one budget can keep going autonomously instead of stalling until the
|
||||||
|
* user sends another message. Because each such segment costs up to a full
|
||||||
|
* {@code maxIterations} worth of node visits, it is bounded by a dedicated,
|
||||||
|
* tighter cap ({@code mateclaw.goal.max-hard-continuations-per-run}, clamped
|
||||||
|
* to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING})
|
||||||
|
* and sized into the graph recursion ceiling. Implicitly 0 at run start.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_HARD_CONTINUATION_COUNT = "goal_hard_continuation_count";
|
||||||
|
|
||||||
/** Graph-node identifier for the GoalEvaluationNode. */
|
/** Graph-node identifier for the GoalEvaluationNode. */
|
||||||
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
|
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,10 @@ import java.util.regex.Pattern;
|
|||||||
public record SourceEvidenceLedger(
|
public record SourceEvidenceLedger(
|
||||||
Set<String> sourcePaths,
|
Set<String> sourcePaths,
|
||||||
Set<String> sourceSymbols,
|
Set<String> sourceSymbols,
|
||||||
Set<String> failedPaths
|
Set<String> failedPaths,
|
||||||
|
Set<String> wikiPageTitles,
|
||||||
|
Set<String> wikiChunkIds,
|
||||||
|
Set<SourceEvidenceLedger.WikiCitation> wikiCitations
|
||||||
) implements Serializable {
|
) implements Serializable {
|
||||||
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
@ -31,15 +34,19 @@ public record SourceEvidenceLedger(
|
|||||||
"\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b");
|
"\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b");
|
||||||
private static final Pattern DECLARED_TYPE = Pattern.compile(
|
private static final Pattern DECLARED_TYPE = Pattern.compile(
|
||||||
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
|
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
|
||||||
|
private static final Pattern CITATION_MARKER = Pattern.compile("\\[(\\d+)\\]");
|
||||||
|
|
||||||
public SourceEvidenceLedger {
|
public SourceEvidenceLedger {
|
||||||
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
|
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
|
||||||
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
|
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
|
||||||
failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths);
|
failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths);
|
||||||
|
wikiPageTitles = Set.copyOf(wikiPageTitles == null ? Set.of() : wikiPageTitles);
|
||||||
|
wikiChunkIds = Set.copyOf(wikiChunkIds == null ? Set.of() : wikiChunkIds);
|
||||||
|
wikiCitations = Set.copyOf(wikiCitations == null ? Set.of() : wikiCitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SourceEvidenceLedger empty() {
|
public static SourceEvidenceLedger empty() {
|
||||||
return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of());
|
return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SourceEvidenceLedger fromToolResponses(List<ToolResponseMessage.ToolResponse> responses) {
|
public static SourceEvidenceLedger fromToolResponses(List<ToolResponseMessage.ToolResponse> responses) {
|
||||||
@ -56,6 +63,14 @@ public record SourceEvidenceLedger(
|
|||||||
recordReadFile(data, builder);
|
recordReadFile(data, builder);
|
||||||
} else {
|
} else {
|
||||||
recordPlainTextEvidence(data, builder);
|
recordPlainTextEvidence(data, builder);
|
||||||
|
// Only mine wiki citations from wiki retrieval tools. Sniffing every
|
||||||
|
// tool's JSON for a top-level title/pages/chunks field would let
|
||||||
|
// unrelated tools (e.g. getGoalStatus, which returns a top-level
|
||||||
|
// "title") populate the citation set and falsely force [n] citation
|
||||||
|
// enforcement on the final answer.
|
||||||
|
if (isWikiTool(response.name())) {
|
||||||
|
recordWikiEvidence(data, builder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return builder.build();
|
return builder.build();
|
||||||
@ -69,9 +84,15 @@ public record SourceEvidenceLedger(
|
|||||||
sourcePaths.forEach(builder::sourcePath);
|
sourcePaths.forEach(builder::sourcePath);
|
||||||
sourceSymbols.forEach(builder::symbol);
|
sourceSymbols.forEach(builder::symbol);
|
||||||
failedPaths.forEach(builder::failedPath);
|
failedPaths.forEach(builder::failedPath);
|
||||||
|
wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
wikiCitations.forEach(builder::wikiCitation);
|
||||||
other.sourcePaths.forEach(builder::sourcePath);
|
other.sourcePaths.forEach(builder::sourcePath);
|
||||||
other.sourceSymbols.forEach(builder::symbol);
|
other.sourceSymbols.forEach(builder::symbol);
|
||||||
other.failedPaths.forEach(builder::failedPath);
|
other.failedPaths.forEach(builder::failedPath);
|
||||||
|
other.wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
other.wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
other.wikiCitations.forEach(builder::wikiCitation);
|
||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,12 +101,61 @@ public record SourceEvidenceLedger(
|
|||||||
sourcePaths.forEach(builder::sourcePath);
|
sourcePaths.forEach(builder::sourcePath);
|
||||||
sourceSymbols.forEach(builder::symbol);
|
sourceSymbols.forEach(builder::symbol);
|
||||||
failedPaths.forEach(builder::failedPath);
|
failedPaths.forEach(builder::failedPath);
|
||||||
|
wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
wikiCitations.forEach(builder::wikiCitation);
|
||||||
builder.sourcePath(path);
|
builder.sourcePath(path);
|
||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SourceEvidenceLedger withWikiPageTitle(String title) {
|
||||||
|
Builder builder = new Builder();
|
||||||
|
sourcePaths.forEach(builder::sourcePath);
|
||||||
|
sourceSymbols.forEach(builder::symbol);
|
||||||
|
failedPaths.forEach(builder::failedPath);
|
||||||
|
wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
wikiCitations.forEach(builder::wikiCitation);
|
||||||
|
builder.wikiPageTitle(title);
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SourceEvidenceLedger withWikiChunkId(String chunkId) {
|
||||||
|
Builder builder = new Builder();
|
||||||
|
sourcePaths.forEach(builder::sourcePath);
|
||||||
|
sourceSymbols.forEach(builder::symbol);
|
||||||
|
failedPaths.forEach(builder::failedPath);
|
||||||
|
wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
wikiCitations.forEach(builder::wikiCitation);
|
||||||
|
builder.wikiChunkId(chunkId);
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean hasEvidence() {
|
public boolean hasEvidence() {
|
||||||
return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty();
|
return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty()
|
||||||
|
|| hasWikiEvidence();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasWikiEvidence() {
|
||||||
|
return !wikiPageTitles.isEmpty() || !wikiChunkIds.isEmpty() || !wikiCitations.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasWikiPageTitle(String title) {
|
||||||
|
if (title == null || title.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String normalized = title.trim();
|
||||||
|
return wikiPageTitles.contains(normalized)
|
||||||
|
|| wikiPageTitles.stream().anyMatch(t -> t.equalsIgnoreCase(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasWikiChunkId(String chunkId) {
|
||||||
|
return chunkId != null && wikiChunkIds.contains(chunkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasWikiCitationIndex(int index) {
|
||||||
|
return wikiCitations.stream().anyMatch(c -> c.index() == index);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasPath(String path) {
|
public boolean hasPath(String path) {
|
||||||
@ -103,6 +173,7 @@ public record SourceEvidenceLedger(
|
|||||||
}
|
}
|
||||||
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
|
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
|
||||||
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
|
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
|
||||||
|
|
||||||
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
|
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
|
||||||
while (fileMatcher.find()) {
|
while (fileMatcher.find()) {
|
||||||
String ref = fileMatcher.group();
|
String ref = fileMatcher.group();
|
||||||
@ -111,6 +182,7 @@ public record SourceEvidenceLedger(
|
|||||||
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
|
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
|
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
|
||||||
while (symbolMatcher.find()) {
|
while (symbolMatcher.find()) {
|
||||||
String ref = symbolMatcher.group();
|
String ref = symbolMatcher.group();
|
||||||
@ -118,9 +190,122 @@ public record SourceEvidenceLedger(
|
|||||||
unsupported.add(ref);
|
unsupported.add(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateWikiCitations(answer, unsupported);
|
||||||
|
|
||||||
return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported));
|
return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String appendWikiSourceTable(String answer) {
|
||||||
|
if (answer == null || answer.isBlank() || wikiCitations.isEmpty()) {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
LinkedHashSet<Integer> used = citationIndexesIn(answer);
|
||||||
|
if (used.isEmpty()) {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = answer;
|
||||||
|
StringBuilder additions = new StringBuilder();
|
||||||
|
|
||||||
|
for (Integer index : used) {
|
||||||
|
WikiCitation citation = wikiCitation(index);
|
||||||
|
if (citation == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String canonicalLine = citation.sourceLine();
|
||||||
|
if (sourceLineFor(result, index) != null) {
|
||||||
|
// Normalize in-place: replace the existing (possibly
|
||||||
|
// non-canonical) source line with the standard format so
|
||||||
|
// the frontend can reliably parse the source table.
|
||||||
|
result = replaceSourceLine(result, index, canonicalLine);
|
||||||
|
} else {
|
||||||
|
if (additions.isEmpty()) {
|
||||||
|
additions.append("\n\n来源:");
|
||||||
|
}
|
||||||
|
additions.append("\n").append(canonicalLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If source lines were normalized in-place but no 来源: header
|
||||||
|
// exists, insert one before the first source line so the frontend
|
||||||
|
// preprocessWikiCitations() can locate the source table.
|
||||||
|
if (additions.isEmpty() && !result.contains("来源:")) {
|
||||||
|
Matcher firstSource = Pattern
|
||||||
|
.compile("(?m)^\\[")
|
||||||
|
.matcher(result);
|
||||||
|
if (firstSource.find()) {
|
||||||
|
int pos = firstSource.start();
|
||||||
|
result = result.substring(0, pos) + "\n\n来源:\n" + result.substring(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return additions.isEmpty() ? result : result + additions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateWikiCitations(String answer, LinkedHashSet<String> unsupported) {
|
||||||
|
if (wikiCitations.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LinkedHashSet<Integer> indexes = citationIndexesIn(answer);
|
||||||
|
if (indexes.isEmpty()) {
|
||||||
|
unsupported.add("missing wiki citation [n]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Integer index : indexes) {
|
||||||
|
WikiCitation citation = wikiCitation(index);
|
||||||
|
if (citation == null) {
|
||||||
|
unsupported.add("wiki citation [" + index + "]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String sourceLine = sourceLineFor(answer, index);
|
||||||
|
if (sourceLine == null) {
|
||||||
|
unsupported.add("wiki source table [" + index + "]");
|
||||||
|
} else if (!citation.matchesSourceLine(sourceLine)) {
|
||||||
|
unsupported.add("wiki source title for [" + index + "]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LinkedHashSet<Integer> citationIndexesIn(String answer) {
|
||||||
|
LinkedHashSet<Integer> indexes = new LinkedHashSet<>();
|
||||||
|
Matcher citationMatcher = CITATION_MARKER.matcher(answer);
|
||||||
|
while (citationMatcher.find()) {
|
||||||
|
try {
|
||||||
|
indexes.add(Integer.parseInt(citationMatcher.group(1)));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WikiCitation wikiCitation(int index) {
|
||||||
|
return wikiCitations.stream()
|
||||||
|
.filter(c -> c.index() == index)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sourceLineFor(String answer, int index) {
|
||||||
|
Pattern pattern = Pattern.compile("(?m)^\\s*\\[" + index + "\\]\\s+(.+)$");
|
||||||
|
Matcher matcher = pattern.matcher(answer);
|
||||||
|
return matcher.find() ? matcher.group(1).trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the existing source line for {@code index} with the canonical
|
||||||
|
* form. Matches a full line starting with optional whitespace, {@code [N]},
|
||||||
|
* then any content, and replaces it in-place so the frontend can reliably
|
||||||
|
* parse the source table to build a citation index → title map.
|
||||||
|
*/
|
||||||
|
private static String replaceSourceLine(String answer, int index, String canonicalLine) {
|
||||||
|
Pattern pattern = Pattern.compile("(?m)^\\s*\\[" + index + "\\]\\s+.+$");
|
||||||
|
return pattern.matcher(answer).replaceFirst(
|
||||||
|
Matcher.quoteReplacement(canonicalLine));
|
||||||
|
}
|
||||||
|
|
||||||
private boolean hasFileName(String fileName) {
|
private boolean hasFileName(String fileName) {
|
||||||
String normalized = normalizePath(fileName);
|
String normalized = normalizePath(fileName);
|
||||||
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
||||||
@ -134,6 +319,13 @@ public record SourceEvidenceLedger(
|
|||||||
return normalized.equals("read_file");
|
return normalized.equals("read_file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isWikiTool(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return name.toLowerCase(Locale.ROOT).replace("-", "_").startsWith("wiki_");
|
||||||
|
}
|
||||||
|
|
||||||
private static void recordReadFile(String data, Builder builder) {
|
private static void recordReadFile(String data, Builder builder) {
|
||||||
try {
|
try {
|
||||||
JsonNode root = MAPPER.readTree(data);
|
JsonNode root = MAPPER.readTree(data);
|
||||||
@ -158,6 +350,56 @@ public record SourceEvidenceLedger(
|
|||||||
recordSymbols(text, builder);
|
recordSymbols(text, builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void recordWikiEvidence(String text, Builder builder) {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = MAPPER.readTree(text);
|
||||||
|
recordWikiArray(root.path("chunks"), builder);
|
||||||
|
recordWikiArray(root.path("pages"), builder);
|
||||||
|
String title = root.path("title").asText("");
|
||||||
|
String rawTitle = root.path("rawTitle").asText("");
|
||||||
|
if (!title.isBlank()) {
|
||||||
|
builder.wikiPageTitle(title);
|
||||||
|
builder.wikiCitation(new WikiCitation(1, "", title, "", null));
|
||||||
|
}
|
||||||
|
if (!rawTitle.isBlank()) {
|
||||||
|
builder.wikiPageTitle(rawTitle);
|
||||||
|
builder.wikiCitation(new WikiCitation(1, "", rawTitle, "", null));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void recordWikiArray(JsonNode nodes, Builder builder) {
|
||||||
|
if (!nodes.isArray()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int ordinal = 1;
|
||||||
|
for (JsonNode node : nodes) {
|
||||||
|
int index = node.path("index").isInt() ? node.path("index").asInt() : ordinal;
|
||||||
|
String title = firstNonBlank(node.path("rawTitle").asText(""), node.path("title").asText(""));
|
||||||
|
String chunkId = node.path("chunkId").asText("");
|
||||||
|
String section = node.path("section").asText("");
|
||||||
|
Integer pageNumber = node.hasNonNull("pageNumber") ? node.path("pageNumber").asInt() : null;
|
||||||
|
if (!title.isBlank()) {
|
||||||
|
builder.wikiPageTitle(title);
|
||||||
|
}
|
||||||
|
if (!chunkId.isBlank()) {
|
||||||
|
builder.wikiChunkId(chunkId);
|
||||||
|
}
|
||||||
|
if (!title.isBlank() || !chunkId.isBlank()) {
|
||||||
|
builder.wikiCitation(new WikiCitation(index, chunkId, title, section, pageNumber));
|
||||||
|
}
|
||||||
|
ordinal++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String first, String second) {
|
||||||
|
return first != null && !first.isBlank() ? first : (second == null ? "" : second);
|
||||||
|
}
|
||||||
|
|
||||||
private static void recordSymbols(String text, Builder builder) {
|
private static void recordSymbols(String text, Builder builder) {
|
||||||
Matcher matcher = DECLARED_TYPE.matcher(text);
|
Matcher matcher = DECLARED_TYPE.matcher(text);
|
||||||
while (matcher.find()) {
|
while (matcher.find()) {
|
||||||
@ -180,6 +422,9 @@ public record SourceEvidenceLedger(
|
|||||||
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
|
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
|
||||||
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
|
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
|
||||||
private final LinkedHashSet<String> failedPaths = new LinkedHashSet<>();
|
private final LinkedHashSet<String> failedPaths = new LinkedHashSet<>();
|
||||||
|
private final LinkedHashSet<String> wikiPageTitles = new LinkedHashSet<>();
|
||||||
|
private final LinkedHashSet<String> wikiChunkIds = new LinkedHashSet<>();
|
||||||
|
private final LinkedHashSet<WikiCitation> wikiCitations = new LinkedHashSet<>();
|
||||||
|
|
||||||
void sourcePath(String path) {
|
void sourcePath(String path) {
|
||||||
String normalized = normalizePath(path);
|
String normalized = normalizePath(path);
|
||||||
@ -207,8 +452,63 @@ public record SourceEvidenceLedger(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void wikiPageTitle(String title) {
|
||||||
|
if (title != null && !title.isBlank()) {
|
||||||
|
wikiPageTitles.add(title.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void wikiChunkId(String chunkId) {
|
||||||
|
if (chunkId != null && !chunkId.isBlank()) {
|
||||||
|
wikiChunkIds.add(chunkId.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void wikiCitation(WikiCitation citation) {
|
||||||
|
if (citation == null || citation.index() < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wikiCitations.removeIf(existing -> existing.index() == citation.index());
|
||||||
|
wikiCitations.add(citation.normalized());
|
||||||
|
}
|
||||||
|
|
||||||
SourceEvidenceLedger build() {
|
SourceEvidenceLedger build() {
|
||||||
return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths);
|
return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths,
|
||||||
|
wikiPageTitles, wikiChunkIds, wikiCitations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WikiCitation(int index, String chunkId, String title,
|
||||||
|
String section, Integer pageNumber) implements Serializable {
|
||||||
|
WikiCitation normalized() {
|
||||||
|
return new WikiCitation(index,
|
||||||
|
chunkId == null ? "" : chunkId.trim(),
|
||||||
|
title == null ? "" : title.trim(),
|
||||||
|
section == null ? "" : section.trim(),
|
||||||
|
pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
String sourceLine() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("[").append(index).append("] ");
|
||||||
|
sb.append(title == null || title.isBlank() ? "chunkId=" + chunkId : title);
|
||||||
|
if (section != null && !section.isBlank()) {
|
||||||
|
sb.append(" - ").append(section);
|
||||||
|
}
|
||||||
|
if (pageNumber != null) {
|
||||||
|
sb.append(" - page ").append(pageNumber);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean matchesSourceLine(String line) {
|
||||||
|
if (line == null || line.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (title != null && !title.isBlank() && line.contains(title)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return chunkId != null && !chunkId.isBlank() && line.contains(chunkId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -131,6 +131,22 @@ public class AgentEntity {
|
|||||||
@TableField(value = "tools_disabled")
|
@TableField(value = "tools_disabled")
|
||||||
private Boolean toolsDisabled;
|
private Boolean toolsDisabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit opt-out from every knowledge base. When {@code true},
|
||||||
|
* {@code AgentBindingService.getBoundKbIds} returns
|
||||||
|
* {@link java.util.Collections#emptySet()} and the wiki tools degrade with
|
||||||
|
* their standard "no knowledge base" message; the webchat
|
||||||
|
* {@code /wiki/pages} picker endpoint returns an empty list. Without this
|
||||||
|
* flag, leaving the KB picker empty means "inherit workspace-wide" — every
|
||||||
|
* KB visible — which is the right default but leaves no way to express
|
||||||
|
* "this agent intentionally uses no KB" (issue #304).
|
||||||
|
*
|
||||||
|
* <p>Same defaulting / auto-clear / update strategy contract as
|
||||||
|
* {@link #skillsDisabled}.
|
||||||
|
*/
|
||||||
|
@TableField(value = "wiki_disabled")
|
||||||
|
private Boolean wikiDisabled;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import java.time.Instant;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader / writer for the per-conversation progress ledger persisted as a
|
* Loader / writer for the per-conversation progress ledger persisted as a
|
||||||
@ -38,7 +39,7 @@ public class ProgressLedgerService {
|
|||||||
new TypeReference<>() {};
|
new TypeReference<>() {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-conversation mutex for the load-mutate-save sequence inside
|
* Per-conversation lock for the load-mutate-save sequence inside
|
||||||
* {@link #upsert}. Without this guard, a single agent turn that issues
|
* {@link #upsert}. Without this guard, a single agent turn that issues
|
||||||
* N parallel {@code progress_update} tool calls (observed: 12 calls in
|
* N parallel {@code progress_update} tool calls (observed: 12 calls in
|
||||||
* one batch when the model pre-registered every step at task start)
|
* one batch when the model pre-registered every step at task start)
|
||||||
@ -46,13 +47,26 @@ public class ProgressLedgerService {
|
|||||||
* the whole point of the ledger. Different conversations stay
|
* the whole point of the ledger. Different conversations stay
|
||||||
* uncontended; only intra-conversation writes serialise.
|
* uncontended; only intra-conversation writes serialise.
|
||||||
*
|
*
|
||||||
|
* <p>Must be a {@link ReentrantLock}, not an intrinsic {@code synchronized}
|
||||||
|
* monitor. Tool calls execute on virtual threads, and the critical section
|
||||||
|
* spans blocking JDBC I/O (load + persist). A virtual thread that blocks —
|
||||||
|
* whether on the DB call or while waiting to enter the lock — pins its
|
||||||
|
* carrier when the lock is an intrinsic monitor. A turn that fires dozens
|
||||||
|
* of parallel {@code progress_update} calls on the same conversation then
|
||||||
|
* pins every carrier in the pool at once: the holder cannot be rescheduled
|
||||||
|
* to release its connection and exit, JDBC connections are held past the
|
||||||
|
* leak-detection threshold, and the whole server stops servicing requests.
|
||||||
|
* {@code ReentrantLock} parks via {@code LockSupport}, which unmounts the
|
||||||
|
* virtual thread and frees the carrier, so contention costs a park instead
|
||||||
|
* of a pinned platform thread.
|
||||||
|
*
|
||||||
* <p>Entries are computed on demand and never explicitly removed; even
|
* <p>Entries are computed on demand and never explicitly removed; even
|
||||||
* with thousands of long-running conversations the map stays bounded by
|
* with thousands of long-running conversations the map stays bounded by
|
||||||
* the active conversation set, and any leak is a {@code Object} per
|
* the active conversation set, and any leak is one lock per conversation
|
||||||
* conversation id — small enough to ignore relative to the rest of the
|
* id — small enough to ignore relative to the rest of the per-conv state
|
||||||
* per-conv state already held in memory.
|
* already held in memory.
|
||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String, Object> upsertLocks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, ReentrantLock> upsertLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private final ConversationMapper conversationMapper;
|
private final ConversationMapper conversationMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@ -116,8 +130,9 @@ public class ProgressLedgerService {
|
|||||||
// last save() drops the other's entry. Observed in production: a
|
// last save() drops the other's entry. Observed in production: a
|
||||||
// 12-entry pre-registration collapsed to 8 because four sibling
|
// 12-entry pre-registration collapsed to 8 because four sibling
|
||||||
// tool calls landed in the same window.
|
// tool calls landed in the same window.
|
||||||
Object mutex = upsertLocks.computeIfAbsent(conversationId, k -> new Object());
|
ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
|
||||||
synchronized (mutex) {
|
lock.lock();
|
||||||
|
try {
|
||||||
ProgressLedger ledger = load(conversationId);
|
ProgressLedger ledger = load(conversationId);
|
||||||
Map<String, ProgressEntry> map = ledger.asMap();
|
Map<String, ProgressEntry> map = ledger.asMap();
|
||||||
ProgressEntry existing = map.get(key);
|
ProgressEntry existing = map.get(key);
|
||||||
@ -127,6 +142,8 @@ public class ProgressLedgerService {
|
|||||||
map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now()));
|
map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now()));
|
||||||
persist(conversationId, map);
|
persist(conversationId, map);
|
||||||
return new ProgressLedger(map);
|
return new ProgressLedger(map);
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,357 @@
|
|||||||
|
package vip.mate.agent.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.AgentGraphBuilder;
|
||||||
|
import vip.mate.agent.vo.AgentDraftVO;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.skill.model.SkillEntity;
|
||||||
|
import vip.mate.skill.service.SkillService;
|
||||||
|
import vip.mate.tool.model.AvailableToolDTO;
|
||||||
|
import vip.mate.tool.service.AvailableToolService;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a single natural-language requirement into a ready-to-review employee
|
||||||
|
* draft. The model is given the workspace's real capability catalog (tools,
|
||||||
|
* skills, knowledge bases) and asked to pick from it, so the resulting draft
|
||||||
|
* proposes a name, persona, type and a coherent set of capabilities in one
|
||||||
|
* shot. Every suggested capability is re-validated against the catalog before
|
||||||
|
* it leaves this service, so a hallucinated tool name or skill id never
|
||||||
|
* reaches the wizard.
|
||||||
|
*
|
||||||
|
* <p>The draft is intentionally not persisted here. The wizard renders it for
|
||||||
|
* review and edits, then commits through the existing agent-create and
|
||||||
|
* capability-binding endpoints — reusing their tested persistence and audit
|
||||||
|
* paths rather than duplicating them.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentGenerationService {
|
||||||
|
|
||||||
|
private final ModelConfigService modelConfigService;
|
||||||
|
private final AgentGraphBuilder agentGraphBuilder;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AvailableToolService availableToolService;
|
||||||
|
private final SkillService skillService;
|
||||||
|
private final WikiKnowledgeBaseService wikiKnowledgeBaseService;
|
||||||
|
|
||||||
|
/** Bound the catalog we feed the model so the prompt stays compact. */
|
||||||
|
private static final int MAX_TOOLS = 60;
|
||||||
|
private static final int MAX_SKILLS = 40;
|
||||||
|
private static final int MAX_KBS = 20;
|
||||||
|
|
||||||
|
public AgentDraftVO generateDraft(String requirement, Long workspaceId) {
|
||||||
|
if (requirement == null || requirement.isBlank()) {
|
||||||
|
throw new MateClawException("err.agent.generate_empty", 400,
|
||||||
|
"Please describe the employee you want to create");
|
||||||
|
}
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
|
||||||
|
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
|
||||||
|
if (defaultModel == null) {
|
||||||
|
throw new MateClawException("err.agent.generate_no_model", 400,
|
||||||
|
"No default model is configured yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the capability catalog the model is allowed to pick from.
|
||||||
|
List<AvailableToolDTO> tools = bindableTools();
|
||||||
|
List<SkillEntity> skills = workspaceSkills(wsId);
|
||||||
|
List<WikiKnowledgeBaseEntity> kbs = workspaceKbs(wsId);
|
||||||
|
|
||||||
|
String systemPrompt = buildSystemPrompt();
|
||||||
|
String userPrompt = buildUserPrompt(requirement.trim(), tools, skills, kbs);
|
||||||
|
|
||||||
|
String raw;
|
||||||
|
try {
|
||||||
|
ChatModel chatModel = agentGraphBuilder.buildRuntimeChatModel(defaultModel);
|
||||||
|
ChatResponse response = chatModel.call(new Prompt(List.of(
|
||||||
|
new SystemMessage(systemPrompt),
|
||||||
|
new UserMessage(userPrompt))));
|
||||||
|
raw = response != null && response.getResult() != null
|
||||||
|
&& response.getResult().getOutput() != null
|
||||||
|
? response.getResult().getOutput().getText() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentGen] LLM call failed: {}", e.getMessage());
|
||||||
|
throw new MateClawException("err.agent.generate_failed", 500,
|
||||||
|
"Failed to generate employee draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = parseJson(raw);
|
||||||
|
if (root == null || !root.isObject()) {
|
||||||
|
throw new MateClawException("err.agent.generate_failed", 500,
|
||||||
|
"Model returned an unexpected response");
|
||||||
|
}
|
||||||
|
return toDraft(root, tools, skills, kbs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Catalog ====================
|
||||||
|
|
||||||
|
private List<AvailableToolDTO> bindableTools() {
|
||||||
|
List<AvailableToolDTO> all;
|
||||||
|
try {
|
||||||
|
all = availableToolService.listAvailable();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentGen] failed to list tools: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<AvailableToolDTO> out = new ArrayList<>();
|
||||||
|
for (AvailableToolDTO t : all) {
|
||||||
|
// Only offer tools that are currently bindable and reachable; a
|
||||||
|
// stale MCP tool would resolve to nothing at chat time.
|
||||||
|
if (t != null && t.isAvailable() && !t.isStale()
|
||||||
|
&& t.getName() != null && !t.getName().isBlank()) {
|
||||||
|
out.add(t);
|
||||||
|
if (out.size() >= MAX_TOOLS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SkillEntity> workspaceSkills(long wsId) {
|
||||||
|
try {
|
||||||
|
List<SkillEntity> skills = skillService.listEnabledSkills(wsId);
|
||||||
|
return skills.size() > MAX_SKILLS ? skills.subList(0, MAX_SKILLS) : skills;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentGen] failed to list skills: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<WikiKnowledgeBaseEntity> workspaceKbs(long wsId) {
|
||||||
|
try {
|
||||||
|
List<WikiKnowledgeBaseEntity> kbs = wikiKnowledgeBaseService.listByWorkspace(wsId);
|
||||||
|
return kbs.size() > MAX_KBS ? kbs.subList(0, MAX_KBS) : kbs;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentGen] failed to list knowledge bases: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Prompt ====================
|
||||||
|
|
||||||
|
private String buildSystemPrompt() {
|
||||||
|
return """
|
||||||
|
You are an employee (AI agent) configuration generator for an agent platform.
|
||||||
|
Given a one-sentence requirement, output a single JSON object describing one
|
||||||
|
ready-to-use employee. Respond in the SAME language as the requirement.
|
||||||
|
|
||||||
|
Output ONLY the JSON object, no prose, no markdown fences. Schema:
|
||||||
|
{
|
||||||
|
"name": "short display name, no instruction words",
|
||||||
|
"icon": "a single emoji matching the role",
|
||||||
|
"description": "one concise sentence shown on the roster card",
|
||||||
|
"agentType": "react | plan_execute",
|
||||||
|
"role": "short role label",
|
||||||
|
"goal": "one short sentence on what this employee achieves",
|
||||||
|
"systemPrompt": "the full persona prompt: who it is, how it works, constraints",
|
||||||
|
"tags": ["1-3 short tags"],
|
||||||
|
"recommendedQuestions": ["2-4 starter questions a user might ask first"],
|
||||||
|
"tools": ["tool names chosen ONLY from the provided tool catalog"],
|
||||||
|
"skillIds": ["skill ids chosen ONLY from the provided skill catalog, as strings"],
|
||||||
|
"primaryKbId": "one knowledge base id from the catalog, or null"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use agentType "plan_execute" only for multi-step / long-horizon work; otherwise "react".
|
||||||
|
- Pick tools, skillIds and primaryKbId ONLY from the catalogs given below. Never invent
|
||||||
|
names or ids. If nothing fits, return an empty array (or null for primaryKbId).
|
||||||
|
- Prefer the smallest capability set that satisfies the requirement.
|
||||||
|
- Skills already bundle their own tools, so do not also list a tool a chosen skill provides.
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildUserPrompt(String requirement, List<AvailableToolDTO> tools,
|
||||||
|
List<SkillEntity> skills, List<WikiKnowledgeBaseEntity> kbs) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Requirement:\n").append(requirement).append("\n\n");
|
||||||
|
|
||||||
|
sb.append("Tool catalog (name — description):\n");
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
sb.append("(none)\n");
|
||||||
|
} else {
|
||||||
|
for (AvailableToolDTO t : tools) {
|
||||||
|
sb.append("- ").append(t.getName());
|
||||||
|
if (t.getDescription() != null && !t.getDescription().isBlank()) {
|
||||||
|
sb.append(" — ").append(trim(t.getDescription(), 120));
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\nSkill catalog (id — name — description):\n");
|
||||||
|
if (skills.isEmpty()) {
|
||||||
|
sb.append("(none)\n");
|
||||||
|
} else {
|
||||||
|
for (SkillEntity s : skills) {
|
||||||
|
sb.append("- ").append(s.getId()).append(" — ").append(s.getName());
|
||||||
|
if (s.getDescription() != null && !s.getDescription().isBlank()) {
|
||||||
|
sb.append(" — ").append(trim(s.getDescription(), 120));
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\nKnowledge base catalog (id — name — description):\n");
|
||||||
|
if (kbs.isEmpty()) {
|
||||||
|
sb.append("(none)\n");
|
||||||
|
} else {
|
||||||
|
for (WikiKnowledgeBaseEntity kb : kbs) {
|
||||||
|
sb.append("- ").append(kb.getId()).append(" — ").append(kb.getName());
|
||||||
|
if (kb.getDescription() != null && !kb.getDescription().isBlank()) {
|
||||||
|
sb.append(" — ").append(trim(kb.getDescription(), 120));
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Parse + validate ====================
|
||||||
|
|
||||||
|
private AgentDraftVO toDraft(JsonNode root, List<AvailableToolDTO> tools,
|
||||||
|
List<SkillEntity> skills, List<WikiKnowledgeBaseEntity> kbs) {
|
||||||
|
String name = text(root, "name");
|
||||||
|
if (name.isBlank()) {
|
||||||
|
name = "New employee";
|
||||||
|
}
|
||||||
|
String agentType = text(root, "agentType");
|
||||||
|
if (!"plan_execute".equals(agentType)) {
|
||||||
|
agentType = "react";
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentDraftVO.builder()
|
||||||
|
.name(trim(name, 60))
|
||||||
|
.icon(firstEmoji(text(root, "icon")))
|
||||||
|
.description(trim(text(root, "description"), 200))
|
||||||
|
.agentType(agentType)
|
||||||
|
.role(trim(text(root, "role"), 60))
|
||||||
|
.goal(trim(text(root, "goal"), 120))
|
||||||
|
.systemPrompt(text(root, "systemPrompt"))
|
||||||
|
.tags(stringList(root.get("tags"), 5))
|
||||||
|
.recommendedQuestions(stringList(root.get("recommendedQuestions"), 4))
|
||||||
|
.tools(validTools(root.get("tools"), tools))
|
||||||
|
.skillIds(validSkillIds(root.get("skillIds"), skills))
|
||||||
|
.primaryKbId(validKbId(root.get("primaryKbId"), kbs))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> validTools(JsonNode node, List<AvailableToolDTO> catalog) {
|
||||||
|
Set<String> allowed = new LinkedHashSet<>();
|
||||||
|
for (AvailableToolDTO t : catalog) allowed.add(t.getName());
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (node != null && node.isArray()) {
|
||||||
|
for (JsonNode n : node) {
|
||||||
|
String v = n.asText("");
|
||||||
|
if (allowed.contains(v) && !out.contains(v)) out.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> validSkillIds(JsonNode node, List<SkillEntity> catalog) {
|
||||||
|
Map<Long, Boolean> allowed = new LinkedHashMap<>();
|
||||||
|
for (SkillEntity s : catalog) allowed.put(s.getId(), Boolean.TRUE);
|
||||||
|
List<Long> out = new ArrayList<>();
|
||||||
|
if (node != null && node.isArray()) {
|
||||||
|
for (JsonNode n : node) {
|
||||||
|
Long id = asLong(n);
|
||||||
|
if (id != null && allowed.containsKey(id) && !out.contains(id)) out.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long validKbId(JsonNode node, List<WikiKnowledgeBaseEntity> catalog) {
|
||||||
|
Long id = asLong(node);
|
||||||
|
if (id == null) return null;
|
||||||
|
for (WikiKnowledgeBaseEntity kb : catalog) {
|
||||||
|
if (kb.getId().equals(id)) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
|
private JsonNode parseJson(String response) {
|
||||||
|
if (response == null || response.isBlank()) return null;
|
||||||
|
String cleaned = response.trim();
|
||||||
|
if (cleaned.startsWith("```json")) cleaned = cleaned.substring(7);
|
||||||
|
else if (cleaned.startsWith("```")) cleaned = cleaned.substring(3);
|
||||||
|
if (cleaned.endsWith("```")) cleaned = cleaned.substring(0, cleaned.length() - 3);
|
||||||
|
cleaned = cleaned.trim();
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(cleaned);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[AgentGen] JSON parse failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept both numeric and textual ids — textual is preferred to preserve precision. */
|
||||||
|
private Long asLong(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) return null;
|
||||||
|
try {
|
||||||
|
if (node.isTextual()) {
|
||||||
|
String v = node.asText().trim();
|
||||||
|
return v.isEmpty() ? null : Long.parseLong(v);
|
||||||
|
}
|
||||||
|
if (node.isNumber()) return node.asLong();
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode root, String field) {
|
||||||
|
JsonNode n = root.get(field);
|
||||||
|
return n == null || n.isNull() ? "" : n.asText("").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> stringList(JsonNode node, int max) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (node != null && node.isArray()) {
|
||||||
|
for (JsonNode n : node) {
|
||||||
|
String v = n.asText("").trim();
|
||||||
|
if (!v.isEmpty() && !out.contains(v)) {
|
||||||
|
out.add(v);
|
||||||
|
if (out.size() >= max) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String s, int max) {
|
||||||
|
if (s == null) return "";
|
||||||
|
String t = s.trim();
|
||||||
|
return t.length() > max ? t.substring(0, max) : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only the first emoji-ish glyph so the icon column never holds a sentence. */
|
||||||
|
private static String firstEmoji(String s) {
|
||||||
|
if (s == null || s.isBlank()) return "🤖";
|
||||||
|
String t = s.trim();
|
||||||
|
int end = t.offsetByCodePoints(0, Math.min(t.codePointCount(0, t.length()), 1));
|
||||||
|
return t.substring(0, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package vip.mate.agent.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An AI-generated employee draft produced from a single natural-language
|
||||||
|
* requirement. The draft is never persisted on its own — the create wizard
|
||||||
|
* shows it for review, lets the user tweak any field, then commits it through
|
||||||
|
* the normal agent-create and capability-binding endpoints.
|
||||||
|
*
|
||||||
|
* <p>Every suggested capability ({@link #tools}, {@link #skillIds},
|
||||||
|
* {@link #primaryKbId}) is validated against the workspace catalog before the
|
||||||
|
* draft is returned, so the wizard never offers a tool name or knowledge base
|
||||||
|
* that does not actually exist.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class AgentDraftVO {
|
||||||
|
|
||||||
|
/** Display name for the new employee. */
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Emoji icon chosen to match the role. */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
|
/** One-line description shown on the roster card. */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** Runtime kind: {@code react} or {@code plan_execute}. */
|
||||||
|
private String agentType;
|
||||||
|
|
||||||
|
/** Assembled persona / system prompt, editable before commit. */
|
||||||
|
private String systemPrompt;
|
||||||
|
|
||||||
|
/** Short role label, used for the card tagline preview. */
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/** Short goal statement, used for the card tagline preview. */
|
||||||
|
private String goal;
|
||||||
|
|
||||||
|
/** Suggested tags. */
|
||||||
|
private List<String> tags;
|
||||||
|
|
||||||
|
/** A few starter questions to seed the first conversation. */
|
||||||
|
private List<String> recommendedQuestions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool names to bind, drawn from the workspace's available tool catalog
|
||||||
|
* (built-in and MCP). Hallucinated names are dropped during validation.
|
||||||
|
*/
|
||||||
|
private List<String> tools;
|
||||||
|
|
||||||
|
/** Skill ids to bind, validated against the workspace's enabled skills. */
|
||||||
|
@JsonSerialize(contentUsing = ToStringSerializer.class)
|
||||||
|
private List<Long> skillIds;
|
||||||
|
|
||||||
|
/** Primary knowledge base id to attach, or null when none fits. */
|
||||||
|
@JsonSerialize(using = ToStringSerializer.class)
|
||||||
|
private Long primaryKbId;
|
||||||
|
}
|
||||||
@ -63,6 +63,37 @@ public class AuditEventService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步记录审计事件,显式指定 actor(而非从 SecurityContext 推导)。
|
||||||
|
* <p>用于非 MateClaw 用户的写操作 —— 当前主要是 webchat 访客。actor 形如
|
||||||
|
* {@code "webchat:<channelId>:<visitorId>"},{@code userId} 落 0(访客没有 MateClaw 账户)。
|
||||||
|
* IP / User-Agent 仍尽量从当前请求抓取(webEnvironment=NONE 下为 null,可接受)。
|
||||||
|
*/
|
||||||
|
public void recordAs(String actor, Long workspaceId, String action, String resourceType,
|
||||||
|
String resourceId, String resourceName, String detailJson) {
|
||||||
|
AuditEventEntity event = new AuditEventEntity();
|
||||||
|
event.setUsername(actor != null ? actor : "system");
|
||||||
|
event.setUserId(0L);
|
||||||
|
event.setAction(action);
|
||||||
|
event.setResourceType(resourceType);
|
||||||
|
event.setResourceId(resourceId);
|
||||||
|
event.setResourceName(resourceName);
|
||||||
|
event.setDetailJson(detailJson);
|
||||||
|
event.setWorkspaceId(workspaceId);
|
||||||
|
event.setCreateTime(LocalDateTime.now());
|
||||||
|
try {
|
||||||
|
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attrs != null) {
|
||||||
|
HttpServletRequest request = attrs.getRequest();
|
||||||
|
event.setIpAddress(getClientIp(request));
|
||||||
|
event.setUserAgent(truncate(request.getHeader("User-Agent"), 256));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 异步或非 web 上下文:跳过 IP/UA
|
||||||
|
}
|
||||||
|
insertAsync(event);
|
||||||
|
}
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
void insertAsync(AuditEventEntity event) {
|
void insertAsync(AuditEventEntity event) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -43,7 +43,8 @@ public class ChannelChatOriginFactory {
|
|||||||
/* channelType */ message.getChannelType() != null
|
/* channelType */ message.getChannelType() != null
|
||||||
? message.getChannelType()
|
? message.getChannelType()
|
||||||
: channel.getChannelType(),
|
: channel.getChannelType(),
|
||||||
/* chatId */ message.getChatId());
|
/* chatId */ message.getChatId(),
|
||||||
|
/* baseUrl */ null); // IM origins have no request host; rely on public-base-url config
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
||||||
import vip.mate.channel.discord.DiscordChannelAdapter;
|
import vip.mate.channel.discord.DiscordChannelAdapter;
|
||||||
@ -230,9 +231,12 @@ public class ChannelManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用启动完成后自动加载并启动所有已启用的渠道
|
* 应用启动完成后自动加载并启动所有已启用的渠道。
|
||||||
* 使用 ApplicationReadyEvent 确保数据库 schema/data 初始化完成
|
* 使用 ApplicationReadyEvent 确保数据库 schema/data 初始化完成。
|
||||||
|
* {@code @Async} — 渠道适配器的网络建连(如 Discord WebSocket / Telegram webhook)
|
||||||
|
* 可能因外部网络不可达而阻塞数分钟,异步启动避免卡住主线程。
|
||||||
*/
|
*/
|
||||||
|
@Async
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void init() {
|
public void init() {
|
||||||
log.info("Initializing ChannelManager...");
|
log.info("Initializing ChannelManager...");
|
||||||
|
|||||||
@ -1310,6 +1310,15 @@ public class ChannelMessageRouter {
|
|||||||
return message.getChannelType() + ":" + identifier;
|
return message.getChannelType() + ":" + identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only existence check for a conversation by its logical id. Used by
|
||||||
|
* adapters that need to alias a legacy conversationId scheme to a new one
|
||||||
|
* without rewriting stored rows (e.g. Feishu group session-id migration).
|
||||||
|
*/
|
||||||
|
public boolean conversationExists(String conversationId) {
|
||||||
|
return conversationService.findByConversationId(conversationId) != null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a sender-attribution tag for group messages. Returns
|
* Build a sender-attribution tag for group messages. Returns
|
||||||
* {@code [@senderName]} when the message is from a multi-user channel
|
* {@code [@senderName]} when the message is from a multi-user channel
|
||||||
|
|||||||
@ -707,7 +707,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final java.util.regex.Pattern GENERATED_URL_PATTERN =
|
private static final java.util.regex.Pattern GENERATED_URL_PATTERN =
|
||||||
java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)");
|
java.util.regex.Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)");
|
||||||
|
|
||||||
private static boolean isImageMime(String mimeType) {
|
private static boolean isImageMime(String mimeType) {
|
||||||
return mimeType != null && mimeType.toLowerCase().startsWith("image/");
|
return mimeType != null && mimeType.toLowerCase().startsWith("image/");
|
||||||
|
|||||||
@ -86,6 +86,38 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
/** 消息去重:最近处理过的 message_id */
|
/** 消息去重:最近处理过的 message_id */
|
||||||
private final Set<String> processedMessageIds = ConcurrentHashMap.newKeySet();
|
private final Set<String> processedMessageIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群内 bot 别名缓存:chatId → 学到的别名集合(openId / unionId / userId / name)。
|
||||||
|
* <p>飞书 SDK 投递的 mention 里,bot 的标识可能是群内自定义别名({@code ou_357e...} / 自定义名称),
|
||||||
|
* 而不是 {@code /bot/v3/info} 返回的全局 openId / app_name。我们在双投递场景下
|
||||||
|
* 机会性地学习这些别名,后续单事件投递的消息就能命中缓存。
|
||||||
|
*/
|
||||||
|
private final ConcurrentHashMap<String, Set<String>> chatBotAliases = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Max learned aliases retained per chat, to bound memory on busy groups. */
|
||||||
|
private static final int CHAT_ALIAS_MAX = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-messageId mention tracker(带 TTL)。
|
||||||
|
* <p>飞书 SDK 经常对同一条消息双投递:一份 mentions 含 bot 的<em>全局身份</em>(来自 /bot/v3/info),
|
||||||
|
* 另一份含 bot 的<em>群内别名</em>。我们累积同一 messageId 下<em>单 mention</em>投递看到的标识,
|
||||||
|
* 一旦其中任何一份被识别为 @bot,就把累积的标识写入 {@link #chatBotAliases}。
|
||||||
|
* <p>只累积单 mention 投递是有意为之:多 mention 投递(如 {@code @bot @某人})会把 bot 与
|
||||||
|
* 被同时 @ 的人混在一起,无法区分,若整体学习会把人误学成 bot 别名,导致之后 @ 该人的消息
|
||||||
|
* 被误判为 @bot。而飞书双投递里 bot 别名那一份本身就是单 mention,所以这样既安全又不丢功能。
|
||||||
|
*/
|
||||||
|
private final ConcurrentHashMap<String, MentionTrack> mentionTracker = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** mention tracker 条目 TTL(60s 远大于双投递的真实间隔,几个 ms 级别)。 */
|
||||||
|
private static final long MENTION_TRACK_TTL_MS = 60_000L;
|
||||||
|
|
||||||
|
/** Package-private for testing. */
|
||||||
|
static final class MentionTrack {
|
||||||
|
final Set<String> seenIds = ConcurrentHashMap.newKeySet();
|
||||||
|
final long createdAtMs = System.currentTimeMillis();
|
||||||
|
volatile boolean matched = false;
|
||||||
|
}
|
||||||
|
|
||||||
/** 昵称缓存:open_id → 显示名称 */
|
/** 昵称缓存:open_id → 显示名称 */
|
||||||
private final ConcurrentHashMap<String, String> nicknameCache = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, String> nicknameCache = new ConcurrentHashMap<>();
|
||||||
private static final int NICKNAME_CACHE_MAX = 500;
|
private static final int NICKNAME_CACHE_MAX = 500;
|
||||||
@ -114,6 +146,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
/** Bot's own open_id, fetched once from /open-apis/bot/v3/info and cached. */
|
/** Bot's own open_id, fetched once from /open-apis/bot/v3/info and cached. */
|
||||||
private volatile String botOpenId;
|
private volatile String botOpenId;
|
||||||
|
|
||||||
|
/** Bot's display name (app_name), fetched alongside open_id. Used for name-based mention matching. */
|
||||||
|
private volatile String botName;
|
||||||
|
|
||||||
/** Serializes lazy bot-open-id fetches so concurrent group messages share one API roundtrip. */
|
/** Serializes lazy bot-open-id fetches so concurrent group messages share one API roundtrip. */
|
||||||
private final Object botOpenIdLock = new Object();
|
private final Object botOpenIdLock = new Object();
|
||||||
|
|
||||||
@ -187,11 +222,15 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
|
|
||||||
record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {}
|
record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {}
|
||||||
|
|
||||||
private final Cache<String, List<RecentFileEntry>> recentFileCache = Caffeine.newBuilder()
|
// Package-private for testing: seed the cache directly to verify injection paths.
|
||||||
|
final Cache<String, List<RecentFileEntry>> recentFileCache = Caffeine.newBuilder()
|
||||||
.expireAfterWrite(RECENT_FILE_TTL_MINUTES, TimeUnit.MINUTES)
|
.expireAfterWrite(RECENT_FILE_TTL_MINUTES, TimeUnit.MINUTES)
|
||||||
.maximumSize(200)
|
.maximumSize(200)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Package-private for testing: redirect to a temp directory without touching real disk.
|
||||||
|
Path chatUploadsRoot = Path.of("data", "chat-uploads");
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@ -331,9 +370,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
// a torn write that could re-cache a stale id.
|
// a torn write that could re-cache a stale id.
|
||||||
synchronized (botOpenIdLock) {
|
synchronized (botOpenIdLock) {
|
||||||
this.botOpenId = null;
|
this.botOpenId = null;
|
||||||
|
this.botName = null;
|
||||||
this.botOpenIdLastFailureMs = 0L;
|
this.botOpenIdLastFailureMs = 0L;
|
||||||
}
|
}
|
||||||
this.processedMessageIds.clear();
|
this.processedMessageIds.clear();
|
||||||
|
this.chatBotAliases.clear();
|
||||||
|
this.mentionTracker.clear();
|
||||||
this.nicknameCache.clear();
|
this.nicknameCache.clear();
|
||||||
this.quotedMessageCache.clear();
|
this.quotedMessageCache.clear();
|
||||||
log.info("[feishu] Feishu channel stopped");
|
log.info("[feishu] Feishu channel stopped");
|
||||||
@ -643,14 +685,109 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
senderOpenId = sender.getSenderId().getOpenId();
|
senderOpenId = sender.getSenderId().getOpenId();
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isBotMentioned = isBotMentionedInEvent(message.getMentions());
|
com.lark.oapi.service.im.v1.model.MentionEvent[] mentions = message.getMentions();
|
||||||
|
boolean isBotMentioned = detectBotMentionWithLearning(mentions, chatId, messageId);
|
||||||
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, event);
|
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== @提及检测 ====================
|
// ==================== @提及检测 ====================
|
||||||
|
|
||||||
private boolean isBotMentionedInEvent(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions) {
|
/**
|
||||||
return eventMentionsContainBot(mentions, getBotOpenId());
|
* 判断本次事件是否 @ 了 bot,并机会性地学习"群内 bot 别名"。
|
||||||
|
*
|
||||||
|
* <p>飞书 SDK 在群内对同一条 @bot 的消息会双投递两个事件,两次的 mentions 数据形态不同:
|
||||||
|
* <ul>
|
||||||
|
* <li>一份带 bot 的<em>全局身份</em>(与 {@code /open-apis/bot/v3/info} 返回的 openId / app_name 一致);</li>
|
||||||
|
* <li>一份带 bot 的<em>群内别名</em>(用户给 bot 起的 chat-scope 名,openId 也是另一套)。</li>
|
||||||
|
* </ul>
|
||||||
|
* 重启后第一条消息能命中"全局身份"那一份直接匹配;后续消息往往只来一份"群内别名"。
|
||||||
|
* 本方法在双投递可见时把两份的所有标识聚合到 {@link #chatBotAliases},后续单事件投递就能命中缓存放行。
|
||||||
|
*
|
||||||
|
* <p>识别顺序:
|
||||||
|
* <ol>
|
||||||
|
* <li>直接匹配 {@code /bot/v3/info} 拿到的 botOpenId / botName;</li>
|
||||||
|
* <li>查 {@link #chatBotAliases} 缓存里学到的群内别名;</li>
|
||||||
|
* <li>双投递推断:同一 messageId 之前的事件已被识别 → 本事件的 mentions 也是 bot 的别名。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private boolean detectBotMentionWithLearning(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
|
||||||
|
String chatId, String messageId) {
|
||||||
|
return detectBotMentionWithLearning(mentions, chatId, messageId, getBotOpenId(), botName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private for testing: 纯有状态核心,bot 身份由调用方显式传入(避免触发 /bot/v3/info HTTP)。 */
|
||||||
|
boolean detectBotMentionWithLearning(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
|
||||||
|
String chatId, String messageId,
|
||||||
|
String botOpenId, String botName) {
|
||||||
|
if (mentions == null || mentions.length == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupMentionTracker();
|
||||||
|
|
||||||
|
// 把本次事件看到的所有标识累积到 per-messageId tracker —— 即使本次匹配不上,
|
||||||
|
// 后到的事件如果匹配成功,learnFromTrack 会把它们一起 cache。
|
||||||
|
MentionTrack track = null;
|
||||||
|
if (messageId != null) {
|
||||||
|
track = mentionTracker.computeIfAbsent(messageId, k -> new MentionTrack());
|
||||||
|
// Only single-mention deliveries are unambiguous bot identities. A
|
||||||
|
// multi-mention delivery (e.g. @bot @alice) mixes the bot with
|
||||||
|
// co-mentioned humans that must NOT be learned as aliases; Feishu's
|
||||||
|
// dual-delivery alias form is itself a single mention, so this is safe.
|
||||||
|
if (mentions.length == 1) {
|
||||||
|
collectMentionIdentifiers(mentions, track.seenIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 直接匹配 bot 的全局身份
|
||||||
|
if (eventMentionsContainBot(mentions, botOpenId, botName)) {
|
||||||
|
learnFromTrack(chatId, track);
|
||||||
|
if (track != null) track.matched = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 群内已学习别名命中
|
||||||
|
if (chatId != null) {
|
||||||
|
Set<String> learned = chatBotAliases.get(chatId);
|
||||||
|
if (learned != null && mentionMatchesAnyAlias(mentions, learned)) {
|
||||||
|
log.info("[feishu] @bot matched via learned chat alias: chatId={}, messageId={}", chatId, messageId);
|
||||||
|
learnFromTrack(chatId, track);
|
||||||
|
if (track != null) track.matched = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 双投递学习:同 messageId 的另一次投递已被识别 → 本事件 mentions 是 bot 别名
|
||||||
|
if (track != null && track.matched) {
|
||||||
|
log.info("[feishu] @bot inferred via dual-delivery learning: chatId={}, messageId={}", chatId, messageId);
|
||||||
|
learnFromTrack(chatId, track);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void learnFromTrack(String chatId, MentionTrack track) {
|
||||||
|
if (chatId == null || track == null || track.seenIds.isEmpty()) return;
|
||||||
|
Set<String> aliases = chatBotAliases.computeIfAbsent(chatId, k -> ConcurrentHashMap.newKeySet());
|
||||||
|
if (aliases.size() >= CHAT_ALIAS_MAX) return;
|
||||||
|
int before = aliases.size();
|
||||||
|
aliases.addAll(track.seenIds);
|
||||||
|
int added = aliases.size() - before;
|
||||||
|
if (added > 0) {
|
||||||
|
log.info("[feishu] Learned {} new bot alias(es) for chat={} (cache size={})",
|
||||||
|
added, chatId, aliases.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupMentionTracker() {
|
||||||
|
evictStaleTracks(mentionTracker, System.currentTimeMillis(), MENTION_TRACK_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private for testing: 按 TTL 淘汰 mention tracker 中的过期项({@code nowMs} 显式传入便于测试)。 */
|
||||||
|
static void evictStaleTracks(Map<String, MentionTrack> tracker, long nowMs, long ttlMs) {
|
||||||
|
long cutoff = nowMs - ttlMs;
|
||||||
|
tracker.entrySet().removeIf(e -> e.getValue().createdAtMs < cutoff);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isBotMentionedInWebhookMessage(Map<String, Object> message) {
|
private boolean isBotMentionedInWebhookMessage(Map<String, Object> message) {
|
||||||
@ -659,12 +796,51 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return webhookMentionsContainBot(list, getBotOpenId());
|
return webhookMentionsContainBot(list, getBotOpenId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 open_id */
|
/** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 bot(按 openId / unionId / userId / name 命中) */
|
||||||
static boolean eventMentionsContainBot(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
|
static boolean eventMentionsContainBot(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
|
||||||
String botOpenId) {
|
String botOpenId, String botName) {
|
||||||
if (mentions == null || mentions.length == 0 || botOpenId == null) return false;
|
if (mentions == null || mentions.length == 0) return false;
|
||||||
for (var mention : mentions) {
|
for (var mention : mentions) {
|
||||||
if (mention.getId() != null && botOpenId.equals(mention.getId().getOpenId())) return true;
|
var id = mention.getId();
|
||||||
|
// 匹配 openId、unionId、userId 中的任意一个
|
||||||
|
if (id != null && botOpenId != null) {
|
||||||
|
if (botOpenId.equals(id.getOpenId())) return true;
|
||||||
|
if (botOpenId.equals(id.getUnionId())) return true;
|
||||||
|
if (botOpenId.equals(id.getUserId())) return true;
|
||||||
|
}
|
||||||
|
// 飞书 SDK 对 bot mention 可能使用不同 ID 体系,fallback 到 name 匹配
|
||||||
|
if (botName != null && botName.equals(mention.getName())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private for testing: 把 mentions 中每个非空 openId / unionId / userId / name 灌入 sink。 */
|
||||||
|
static void collectMentionIdentifiers(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
|
||||||
|
Set<String> sink) {
|
||||||
|
if (mentions == null || sink == null) return;
|
||||||
|
for (var mention : mentions) {
|
||||||
|
var id = mention.getId();
|
||||||
|
if (id != null) {
|
||||||
|
if (id.getOpenId() != null) sink.add(id.getOpenId());
|
||||||
|
if (id.getUnionId() != null) sink.add(id.getUnionId());
|
||||||
|
if (id.getUserId() != null) sink.add(id.getUserId());
|
||||||
|
}
|
||||||
|
if (mention.getName() != null) sink.add(mention.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private for testing: mentions 中是否有任意 openId / unionId / userId / name 命中 aliases 集合。 */
|
||||||
|
static boolean mentionMatchesAnyAlias(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
|
||||||
|
Set<String> aliases) {
|
||||||
|
if (mentions == null || mentions.length == 0 || aliases == null || aliases.isEmpty()) return false;
|
||||||
|
for (var mention : mentions) {
|
||||||
|
var id = mention.getId();
|
||||||
|
if (id != null) {
|
||||||
|
if (id.getOpenId() != null && aliases.contains(id.getOpenId())) return true;
|
||||||
|
if (id.getUnionId() != null && aliases.contains(id.getUnionId())) return true;
|
||||||
|
if (id.getUserId() != null && aliases.contains(id.getUserId())) return true;
|
||||||
|
}
|
||||||
|
if (mention.getName() != null && aliases.contains(mention.getName())) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -734,7 +910,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
Map<?, ?> bot = (Map<?, ?>) body.get("bot");
|
Map<?, ?> bot = (Map<?, ?>) body.get("bot");
|
||||||
if (bot != null && bot.get("open_id") instanceof String openId && !openId.isBlank()) {
|
if (bot != null && bot.get("open_id") instanceof String openId && !openId.isBlank()) {
|
||||||
botOpenId = openId;
|
botOpenId = openId;
|
||||||
log.info("[feishu] Bot open_id fetched and cached: {}", openId);
|
if (bot.get("app_name") instanceof String name && !name.isBlank()) {
|
||||||
|
botName = name;
|
||||||
|
}
|
||||||
|
log.info("[feishu] Bot info fetched: open_id={}, name={}", openId, botName);
|
||||||
return openId;
|
return openId;
|
||||||
}
|
}
|
||||||
// 2xx with no bot.open_id field → treat as transient failure.
|
// 2xx with no bot.open_id field → treat as transient failure.
|
||||||
@ -794,6 +973,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(apiBase + "/open-apis/auth/v3/tenant_access_token/internal"))
|
.uri(URI.create(apiBase + "/open-apis/auth/v3/tenant_access_token/internal"))
|
||||||
.header("Content-Type", "application/json; charset=utf-8")
|
.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -940,10 +1120,16 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
// prompt only exposes the file name (not its path) to the model — so if this id does
|
// prompt only exposes the file name (not its path) to the model — so if this id does
|
||||||
// not match, ReadFileTool / DocumentExtractTool cannot find the cached file.
|
// not match, ReadFileTool / DocumentExtractTool cannot find the cached file.
|
||||||
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
||||||
|
// 群会话改用完整 chatId,但存量旧会话仍在 legacy 后缀下:读时别名回退,
|
||||||
|
// 让升级前已存在的群沿用旧 conversationId 延续,不重写存量行。
|
||||||
|
if (isGroup && chatId != null) {
|
||||||
|
shortSuffix = resolveGroupSessionSuffix(chatId);
|
||||||
|
}
|
||||||
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
|
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
|
||||||
|
|
||||||
|
String stagedUploadPath = null;
|
||||||
if (isFileMessage) {
|
if (isFileMessage) {
|
||||||
cacheRecentFile(messageId, messageType, contentStr, conversationId);
|
stagedUploadPath = cacheRecentFile(messageId, messageType, contentStr, conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// require_mention 群聊过滤:群聊中必须 @机器人才响应。
|
// require_mention 群聊过滤:群聊中必须 @机器人才响应。
|
||||||
@ -979,7 +1165,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
|
|
||||||
// 解析消息内容
|
// 解析消息内容
|
||||||
List<MessageContentPart> contentParts = new ArrayList<>();
|
List<MessageContentPart> contentParts = new ArrayList<>();
|
||||||
String textContent = extractContentParts(messageId, messageType, contentStr, contentParts);
|
String textContent = extractContentParts(messageId, messageType, contentStr, contentParts, stagedUploadPath);
|
||||||
|
|
||||||
if (contentParts.isEmpty() && (textContent == null || textContent.isBlank())) {
|
if (contentParts.isEmpty() && (textContent == null || textContent.isBlank())) {
|
||||||
log.debug("[feishu] Empty message content, ignoring");
|
log.debug("[feishu] Empty message content, ignoring");
|
||||||
@ -1379,19 +1565,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 会话 ID 优化 ====================
|
// ==================== 会话 ID ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成更短的会话标识后缀
|
* 生成会话标识后缀。
|
||||||
* - 群聊:app_id 后 4 位 + "_" + chat_id 后 8 位
|
* <ul>
|
||||||
* - 私聊:open_id 后 12 位
|
* <li>群聊:直接使用完整 {@code chatId}(全局唯一)。旧实现用 {@code {appId后4}_{chatId后8}}
|
||||||
|
* 截断后缀,不同群的 {@code chatId} 后 8 位可能相同 → 会话串台。改用完整 chatId 消除碰撞。
|
||||||
|
* 存量旧会话不重写,由 {@link #resolveGroupSessionSuffix} 做读时别名回退。</li>
|
||||||
|
* <li>私聊:保持原状(取 {@code openId} 后 12 位)。注意私聊路径下该后缀实际不参与
|
||||||
|
* conversationId——{@link #buildConversationId} 对 DM 直接用完整 {@code senderOpenId},
|
||||||
|
* 故私聊会话 ID 不受本次改动影响。</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
private String generateShortSessionSuffix(String chatId, String openId, boolean isGroup) {
|
private String generateShortSessionSuffix(String chatId, String openId, boolean isGroup) {
|
||||||
if (isGroup && chatId != null) {
|
if (isGroup && chatId != null) {
|
||||||
String appId = getConfigString("app_id", "");
|
return chatId;
|
||||||
String appSuffix = appId.length() >= 4 ? appId.substring(appId.length() - 4) : appId;
|
|
||||||
String chatSuffix = chatId.length() >= 8 ? chatId.substring(chatId.length() - 8) : chatId;
|
|
||||||
return appSuffix + "_" + chatSuffix;
|
|
||||||
}
|
}
|
||||||
if (openId != null) {
|
if (openId != null) {
|
||||||
return openId.length() >= 12 ? openId.substring(openId.length() - 12) : openId;
|
return openId.length() >= 12 ? openId.substring(openId.length() - 12) : openId;
|
||||||
@ -1402,6 +1591,49 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧群会话后缀算法:{@code {appId后4}_{chatId后8}}。仅用于读时别名回退——
|
||||||
|
* 在不重写存量行的前提下,让升级前已存在的群会话沿用旧 conversationId 无缝延续。
|
||||||
|
*/
|
||||||
|
// Package-private for testing.
|
||||||
|
String legacyGroupSuffix(String chatId) {
|
||||||
|
if (chatId == null) return null;
|
||||||
|
String appId = getConfigString("app_id", "");
|
||||||
|
String appSuffix = appId.length() >= 4 ? appId.substring(appId.length() - 4) : appId;
|
||||||
|
String chatSuffix = chatId.length() >= 8 ? chatId.substring(chatId.length() - 8) : chatId;
|
||||||
|
return appSuffix + "_" + chatSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群会话后缀的读时别名回退(不重写存量):
|
||||||
|
* <ul>
|
||||||
|
* <li>新群(两个 key 都无会话)→ 用完整 chatId 的 canonical key;</li>
|
||||||
|
* <li>已迁移群(canonical key 已有会话)→ 用 canonical;</li>
|
||||||
|
* <li>存量群(canonical 无、legacy 有)→ 沿用 legacy key,历史无缝延续。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
// Package-private for testing.
|
||||||
|
String resolveGroupSessionSuffix(String chatId) {
|
||||||
|
String canonical = chatId;
|
||||||
|
String legacy = legacyGroupSuffix(chatId);
|
||||||
|
if (legacy == null || legacy.equals(canonical)) return canonical;
|
||||||
|
boolean canonicalExists = messageRouter.conversationExists(CHANNEL_TYPE + ":" + canonical);
|
||||||
|
boolean legacyExists = !canonicalExists
|
||||||
|
&& messageRouter.conversationExists(CHANNEL_TYPE + ":" + legacy);
|
||||||
|
String picked = pickGroupSessionSuffix(canonical, legacy, canonicalExists, legacyExists);
|
||||||
|
if (picked.equals(legacy)) {
|
||||||
|
log.info("[feishu] Reusing legacy group session id for chat={} (read-time alias, no migration write)", chatId);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private for testing: 纯选择逻辑——存量 legacy 会话存在且尚未迁移时沿用 legacy,否则用 canonical。 */
|
||||||
|
static String pickGroupSessionSuffix(String canonical, String legacy,
|
||||||
|
boolean canonicalExists, boolean legacyExists) {
|
||||||
|
if (!canonicalExists && legacyExists) return legacy;
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the conversationId that {@link ChannelMessageRouter} would
|
* Compute the conversationId that {@link ChannelMessageRouter} would
|
||||||
* derive for this chat, so we can save inbound files to the matching
|
* derive for this chat, so we can save inbound files to the matching
|
||||||
@ -1431,10 +1663,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
* ({@code ReadFileTool}, {@code DocumentExtractTool}) can find it
|
* ({@code ReadFileTool}, {@code DocumentExtractTool}) can find it
|
||||||
* via {@code ChatUploadResolver}, and it gets cleaned up when the
|
* via {@code ChatUploadResolver}, and it gets cleaned up when the
|
||||||
* conversation is deleted.
|
* conversation is deleted.
|
||||||
|
*
|
||||||
|
* @return absolute path of the staged {@code data/chat-uploads/} copy, or
|
||||||
|
* {@code null} when nothing was cached (unsupported type, missing
|
||||||
|
* file key, download failure). Callers stamp this path onto the
|
||||||
|
* current-message content part so the path surfaced to the LLM is
|
||||||
|
* resolver-reachable instead of the sandbox-external media path.
|
||||||
*/
|
*/
|
||||||
private void cacheRecentFile(String messageId, String messageType, String contentStr,
|
private String cacheRecentFile(String messageId, String messageType, String contentStr,
|
||||||
String conversationId) {
|
String conversationId) {
|
||||||
try {
|
try {
|
||||||
|
log.info("[feishu] cacheRecentFile: type={}, conversationId={}, messageId={}", messageType, conversationId, messageId);
|
||||||
Map<String, Object> contentObj = objectMapper.readValue(contentStr, Map.class);
|
Map<String, Object> contentObj = objectMapper.readValue(contentStr, Map.class);
|
||||||
|
|
||||||
String fileKey = null;
|
String fileKey = null;
|
||||||
@ -1461,20 +1700,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
type = "file";
|
type = "file";
|
||||||
}
|
}
|
||||||
default -> {
|
default -> {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileKey == null) return;
|
if (fileKey == null) return null;
|
||||||
|
|
||||||
// Download file bytes
|
// Download file bytes
|
||||||
DownloadedResource dl = "image".equals(messageType)
|
DownloadedResource dl = "image".equals(messageType)
|
||||||
? maybeDownloadImage(messageId, fileKey)
|
? maybeDownloadImage(messageId, fileKey)
|
||||||
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
||||||
if (dl == null) return;
|
if (dl == null) return null;
|
||||||
|
|
||||||
// Save to data/chat-uploads/{conversationId}/
|
// Save to data/chat-uploads/{conversationId}/
|
||||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
Path uploadDir = chatUploadsRoot.resolve(conversationId);
|
||||||
Files.createDirectories(uploadDir);
|
Files.createDirectories(uploadDir);
|
||||||
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
||||||
? dl.fileName() : fileKey;
|
? dl.fileName() : fileKey;
|
||||||
@ -1502,8 +1741,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
log.info("[feishu] Cached recent file for conversation={}: {} ({} bytes, {})",
|
log.info("[feishu] Cached recent file for conversation={}: {} ({} bytes, {})",
|
||||||
conversationId, entry.fileName(), Files.size(dest), contentType);
|
conversationId, entry.fileName(), Files.size(dest), contentType);
|
||||||
|
|
||||||
|
return dest.toAbsolutePath().toString();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("[feishu] Failed to cache recent file: {}", e.getMessage());
|
log.warn("[feishu] Failed to cache recent file for conversation={}: {}", conversationId, e.getMessage(), e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1514,9 +1755,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
*
|
*
|
||||||
* @return updated textContent with file descriptions appended
|
* @return updated textContent with file descriptions appended
|
||||||
*/
|
*/
|
||||||
private String injectRecentFiles(String conversationId, List<MessageContentPart> parts, String textContent) {
|
// Package-private for testing.
|
||||||
|
String injectRecentFiles(String conversationId, List<MessageContentPart> parts, String textContent) {
|
||||||
List<RecentFileEntry> recent = recentFileCache.getIfPresent(conversationId);
|
List<RecentFileEntry> recent = recentFileCache.getIfPresent(conversationId);
|
||||||
if (recent == null || recent.isEmpty()) return textContent;
|
if (recent == null || recent.isEmpty()) {
|
||||||
|
// Fallback: scan data/chat-uploads/{conversationId}/ on disk.
|
||||||
|
// Survives process restart / Caffeine TTL expiry / GC eviction.
|
||||||
|
recent = loadRecentFilesFromDisk(conversationId);
|
||||||
|
if (recent.isEmpty()) return textContent;
|
||||||
|
log.info("[feishu] injectRecentFiles: cache miss, recovered {} file(s) from disk for conversation={}",
|
||||||
|
recent.size(), conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
// Collect paths already in parts to avoid duplicates
|
// Collect paths already in parts to avoid duplicates
|
||||||
Set<String> existingPaths = new java.util.HashSet<>();
|
Set<String> existingPaths = new java.util.HashSet<>();
|
||||||
@ -1546,20 +1795,111 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return text.toString();
|
return text.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan {@code data/chat-uploads/{conversationId}/} on disk and return
|
||||||
|
* the most recent files as {@link RecentFileEntry}s. Used as a
|
||||||
|
* fallback when the in-memory Caffeine cache has been evicted
|
||||||
|
* (process restart, TTL expiry, GC pressure) but the staged copies
|
||||||
|
* are still on disk.
|
||||||
|
*/
|
||||||
|
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
|
||||||
|
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
|
||||||
|
return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-private for testing: explicit {@code dir} and {@code cutoffMs} make the
|
||||||
|
* test deterministic without temp-directory path construction or time mocking.
|
||||||
|
* Production callers go through {@link #loadRecentFilesFromDisk(String)}.
|
||||||
|
*/
|
||||||
|
List<RecentFileEntry> loadRecentFilesFromDisk(Path dir, long cutoffMs) {
|
||||||
|
if (!Files.isDirectory(dir)) return List.of();
|
||||||
|
try (var stream = Files.list(dir)) {
|
||||||
|
return stream
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> {
|
||||||
|
try {
|
||||||
|
return Files.getLastModifiedTime(p).toMillis() >= cutoffMs;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sorted((a, b) -> {
|
||||||
|
try {
|
||||||
|
return Long.compare(
|
||||||
|
Files.getLastModifiedTime(b).toMillis(),
|
||||||
|
Files.getLastModifiedTime(a).toMillis());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.limit(RECENT_FILE_MAX_PER_CHAT)
|
||||||
|
.map(p -> {
|
||||||
|
String fileName = p.getFileName().toString();
|
||||||
|
// Strip timestamp prefix (e.g. "1777391026594_report.pdf" → "report.pdf")
|
||||||
|
int sep = fileName.indexOf('_');
|
||||||
|
String display = (sep > 0 && sep < 20) ? fileName.substring(sep + 1) : fileName;
|
||||||
|
String contentType = guessContentType(p);
|
||||||
|
return new RecentFileEntry(display, p.toAbsolutePath().toString(), null, contentType);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// warn (not debug): a failed disk scan silently drops recovered files, which
|
||||||
|
// reproduces the exact "bot can't see the file" symptom this fallback fixes.
|
||||||
|
log.warn("[feishu] Failed to scan disk for recent files in {}: {}", dir, e.getMessage(), e);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort content type from file extension. */
|
||||||
|
private static String guessContentType(Path p) {
|
||||||
|
String name = p.getFileName().toString().toLowerCase();
|
||||||
|
if (name.endsWith(".pdf")) return "application/pdf";
|
||||||
|
if (name.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
if (name.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
if (name.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
if (name.endsWith(".doc")) return "application/msword";
|
||||||
|
if (name.endsWith(".xls")) return "application/vnd.ms-excel";
|
||||||
|
if (name.endsWith(".ppt")) return "application/vnd.ms-powerpoint";
|
||||||
|
if (name.endsWith(".txt")) return "text/plain";
|
||||||
|
if (name.endsWith(".csv")) return "text/csv";
|
||||||
|
if (name.endsWith(".json")) return "application/json";
|
||||||
|
if (name.endsWith(".xml")) return "application/xml";
|
||||||
|
if (name.endsWith(".md")) return "text/markdown";
|
||||||
|
if (name.endsWith(".png")) return "image/png";
|
||||||
|
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (name.endsWith(".gif")) return "image/gif";
|
||||||
|
if (name.endsWith(".webp")) return "image/webp";
|
||||||
|
if (name.endsWith(".mp3")) return "audio/mpeg";
|
||||||
|
if (name.endsWith(".ogg")) return "audio/ogg";
|
||||||
|
if (name.endsWith(".opus")) return "audio/opus";
|
||||||
|
if (name.endsWith(".wav")) return "audio/wav";
|
||||||
|
if (name.endsWith(".mp4")) return "video/mp4";
|
||||||
|
if (name.endsWith(".mov")) return "video/quicktime";
|
||||||
|
if (name.endsWith(".zip")) return "application/zip";
|
||||||
|
if (name.endsWith(".rar")) return "application/x-rar-compressed";
|
||||||
|
if (name.endsWith(".7z")) return "application/x-7z-compressed";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 消息内容解析 ====================
|
// ==================== 消息内容解析 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析飞书消息内容为 contentParts
|
* 解析飞书消息内容为 contentParts
|
||||||
*
|
*
|
||||||
* @param messageId 消息 ID(用于媒体下载)
|
* @param messageId 消息 ID(用于媒体下载)
|
||||||
* @param messageType 消息类型
|
* @param messageType 消息类型
|
||||||
* @param contentStr 消息内容 JSON 字符串
|
* @param contentStr 消息内容 JSON 字符串
|
||||||
* @param parts 输出的 content parts
|
* @param parts 输出的 content parts
|
||||||
|
* @param stagedUploadPath {@code cacheRecentFile} 复制到 {@code data/chat-uploads/}
|
||||||
|
* 的绝对路径(可空)。非空时覆盖各附件 part 的 path,使其指向
|
||||||
|
* 沙箱可达(经 {@code ChatUploadResolver})的那一份,而不是
|
||||||
|
* 沙箱外的 {@code ~/.mateclaw/media/} 路径。
|
||||||
* @return 纯文本摘要
|
* @return 纯文本摘要
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private String extractContentParts(String messageId, String messageType, String contentStr,
|
private String extractContentParts(String messageId, String messageType, String contentStr,
|
||||||
List<MessageContentPart> parts) {
|
List<MessageContentPart> parts, String stagedUploadPath) {
|
||||||
if (contentStr == null) return null;
|
if (contentStr == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1588,6 +1928,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
DownloadedResource dl = maybeDownloadImage(messageId, imageKey);
|
DownloadedResource dl = maybeDownloadImage(messageId, imageKey);
|
||||||
MessageContentPart part = MessageContentPart.image(imageKey, null);
|
MessageContentPart part = MessageContentPart.image(imageKey, null);
|
||||||
applyDownload(part, dl);
|
applyDownload(part, dl);
|
||||||
|
if (stagedUploadPath != null) part.setPath(stagedUploadPath);
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[图片]";
|
yield "[图片]";
|
||||||
@ -1599,6 +1940,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
||||||
MessageContentPart part = MessageContentPart.file(fileKey, fileName, null);
|
MessageContentPart part = MessageContentPart.file(fileKey, fileName, null);
|
||||||
applyDownload(part, dl);
|
applyDownload(part, dl);
|
||||||
|
if (stagedUploadPath != null) part.setPath(stagedUploadPath);
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[文件: " + (fileName != null ? fileName : "") + "]";
|
yield "[文件: " + (fileName != null ? fileName : "") + "]";
|
||||||
@ -1612,6 +1954,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", "voice.opus");
|
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", "voice.opus");
|
||||||
MessageContentPart part = MessageContentPart.audio(fileKey, null);
|
MessageContentPart part = MessageContentPart.audio(fileKey, null);
|
||||||
applyDownload(part, dl);
|
applyDownload(part, dl);
|
||||||
|
if (stagedUploadPath != null) part.setPath(stagedUploadPath);
|
||||||
// STT hop: inject the transcript as a sibling text part BEFORE
|
// STT hop: inject the transcript as a sibling text part BEFORE
|
||||||
// the audio part so ChannelMessageRouter.buildPromptFromParts
|
// the audio part so ChannelMessageRouter.buildPromptFromParts
|
||||||
// sees real content instead of just "[音频]". WeCom / DingTalk
|
// sees real content instead of just "[音频]". WeCom / DingTalk
|
||||||
@ -1632,6 +1975,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
||||||
MessageContentPart part = MessageContentPart.video(fileKey, fileName);
|
MessageContentPart part = MessageContentPart.video(fileKey, fileName);
|
||||||
applyDownload(part, dl);
|
applyDownload(part, dl);
|
||||||
|
if (stagedUploadPath != null) part.setPath(stagedUploadPath);
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[视频]";
|
yield "[视频]";
|
||||||
|
|||||||
@ -85,6 +85,14 @@ public class ChatController {
|
|||||||
// RFC-058 PR-1: Utf8SseEmitter 显式声明 charset=UTF-8,防止中文在 Windows 中文 Chrome / 部分代理处乱码
|
// RFC-058 PR-1: Utf8SseEmitter 显式声明 charset=UTF-8,防止中文在 Windows 中文 Chrome / 部分代理处乱码
|
||||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
||||||
|
|
||||||
|
// Resolve the public base URL on THIS (request) thread. Every agent run
|
||||||
|
// below is dispatched to sseExecutor / reactive callbacks that run off
|
||||||
|
// the request thread, where the request is no longer bound and
|
||||||
|
// ServletUriComponentsBuilder would yield null. Capturing it here lets
|
||||||
|
// tool-generated download links carry an absolute host on the streaming,
|
||||||
|
// approval-replay, and queued-message paths alike.
|
||||||
|
final String requestBaseUrl = resolveRequestBaseUrl();
|
||||||
|
|
||||||
// ---- 分支 A:断线重连 ----
|
// ---- 分支 A:断线重连 ----
|
||||||
if (Boolean.TRUE.equals(request.getReconnect())) {
|
if (Boolean.TRUE.equals(request.getReconnect())) {
|
||||||
String reconnectUser = auth != null ? auth.getName() : "anonymous";
|
String reconnectUser = auth != null ? auth.getName() : "anonymous";
|
||||||
@ -258,7 +266,7 @@ public class ChatController {
|
|||||||
// deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息
|
// deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息
|
||||||
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
|
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||||
if (denyCr.allDone() && denyCr.queuedInput() != null) {
|
if (denyCr.allDone() && denyCr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||||
}
|
}
|
||||||
@ -272,7 +280,7 @@ public class ChatController {
|
|||||||
// 审批记录被另一个请求消费,但用户可能在等待期间排了消息
|
// 审批记录被另一个请求消费,但用户可能在等待期间排了消息
|
||||||
ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId);
|
ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||||
if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) {
|
if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||||
}
|
}
|
||||||
@ -298,6 +306,9 @@ public class ChatController {
|
|||||||
replayOrigin = vip.mate.agent.context.ChatOrigin.web(
|
replayOrigin = vip.mate.agent.context.ChatOrigin.web(
|
||||||
conversationId, username, workspaceId, null);
|
conversationId, username, workspaceId, null);
|
||||||
}
|
}
|
||||||
|
// Carry the request-thread base URL so any file a replayed
|
||||||
|
// tool generates gets an absolute download link.
|
||||||
|
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl);
|
||||||
Disposable disposable = agentService.chatWithReplayStream(
|
Disposable disposable = agentService.chatWithReplayStream(
|
||||||
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
@ -371,7 +382,7 @@ public class ChatController {
|
|||||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||||
if (cr.allDone()) {
|
if (cr.allDone()) {
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||||
@ -469,7 +480,7 @@ public class ChatController {
|
|||||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||||
if (cr.allDone()) {
|
if (cr.allDone()) {
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||||
@ -544,7 +555,8 @@ public class ChatController {
|
|||||||
// tools that need a workspace path read it from the agent (origin
|
// tools that need a workspace path read it from the agent (origin
|
||||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||||
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId());
|
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId())
|
||||||
|
.withBaseUrl(requestBaseUrl);
|
||||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
if (emitterDone.get()) return;
|
if (emitterDone.get()) return;
|
||||||
@ -680,7 +692,7 @@ public class ChatController {
|
|||||||
// genuinely doesn't want continuation, no message would
|
// genuinely doesn't want continuation, no message would
|
||||||
// have been in messageQueue to begin with.
|
// have been in messageQueue to begin with.
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
// 延迟关闭 emitter,确保最后的事件都已发送
|
// 延迟关闭 emitter,确保最后的事件都已发送
|
||||||
@ -771,7 +783,7 @@ public class ChatController {
|
|||||||
if (cr.allDone()) {
|
if (cr.allDone()) {
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
// 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug)
|
// 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug)
|
||||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
completeEmitterQuietly(emitter, emitterDone);
|
completeEmitterQuietly(emitter, emitterDone);
|
||||||
@ -891,7 +903,7 @@ public class ChatController {
|
|||||||
// — just run it. Aligns with doOnComplete and the 4 other
|
// — just run it. Aligns with doOnComplete and the 4 other
|
||||||
// queue-launch sites in this controller.
|
// queue-launch sites in this controller.
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
|
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
completeEmitterQuietly(emitter, emitterDone);
|
completeEmitterQuietly(emitter, emitterDone);
|
||||||
@ -1143,12 +1155,30 @@ public class ChatController {
|
|||||||
*/
|
*/
|
||||||
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
|
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
|
||||||
Long workspaceId, String endUserId) {
|
Long workspaceId, String endUserId) {
|
||||||
|
// Resolve the public base URL here, on the request thread, so it can ride
|
||||||
|
// the origin into async tool execution where no request is bound. Tools
|
||||||
|
// then mint absolute download links without operator config.
|
||||||
|
String baseUrl = resolveRequestBaseUrl();
|
||||||
if (endUserId != null && !endUserId.isBlank()) {
|
if (endUserId != null && !endUserId.isBlank()) {
|
||||||
return vip.mate.agent.context.ChatOrigin
|
return vip.mate.agent.context.ChatOrigin
|
||||||
.web(conversationId, endUserId.trim(), workspaceId, null)
|
.web(conversationId, endUserId.trim(), workspaceId, null, baseUrl)
|
||||||
.withSender(null, "api", null);
|
.withSender(null, "api", null);
|
||||||
}
|
}
|
||||||
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
|
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve {@code scheme://host[:port][/contextPath]} from the current request,
|
||||||
|
* honouring {@code X-Forwarded-*} when a {@code ForwardedHeaderFilter} is active.
|
||||||
|
* Returns null off the request thread (caller falls back to config / relative).
|
||||||
|
*/
|
||||||
|
private String resolveRequestBaseUrl() {
|
||||||
|
try {
|
||||||
|
return org.springframework.web.servlet.support.ServletUriComponentsBuilder
|
||||||
|
.fromCurrentContextPath().build().toUriString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@lombok.Data
|
@lombok.Data
|
||||||
@ -1218,7 +1248,8 @@ public class ChatController {
|
|||||||
* 支持链式续跑:queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用。
|
* 支持链式续跑:queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用。
|
||||||
*/
|
*/
|
||||||
private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone,
|
private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone,
|
||||||
ChatStreamTracker.QueuedInput preConsumedInput, String requesterId) {
|
ChatStreamTracker.QueuedInput preConsumedInput, String requesterId,
|
||||||
|
String baseUrl) {
|
||||||
if (preConsumedInput == null) {
|
if (preConsumedInput == null) {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
completeEmitterQuietly(emitter, emitterDone);
|
completeEmitterQuietly(emitter, emitterDone);
|
||||||
@ -1279,7 +1310,8 @@ public class ChatController {
|
|||||||
// a web-origin ChatOrigin so any cron job created during the queued
|
// a web-origin ChatOrigin so any cron job created during the queued
|
||||||
// turn keeps a consistent (null-channel) binding.
|
// turn keeps a consistent (null-channel) binding.
|
||||||
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
||||||
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null);
|
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null)
|
||||||
|
.withBaseUrl(baseUrl);
|
||||||
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
|
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
if (emitterDone.get()) return;
|
if (emitterDone.get()) return;
|
||||||
@ -1339,7 +1371,7 @@ public class ChatController {
|
|||||||
if (cr.allDone()) {
|
if (cr.allDone()) {
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
// 链式续跑:queued stream 期间又排了新消息
|
// 链式续跑:queued stream 期间又排了新消息
|
||||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId);
|
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
sseExecutor.execute(() -> {
|
sseExecutor.execute(() -> {
|
||||||
@ -1381,7 +1413,7 @@ public class ChatController {
|
|||||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||||
if (cr.allDone()) {
|
if (cr.allDone()) {
|
||||||
if (cr.queuedInput() != null) {
|
if (cr.queuedInput() != null) {
|
||||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId);
|
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl);
|
||||||
} else {
|
} else {
|
||||||
conversationService.updateStreamStatus(conversationId, "idle");
|
conversationService.updateStreamStatus(conversationId, "idle");
|
||||||
completeEmitterQuietly(emitter, emitterDone);
|
completeEmitterQuietly(emitter, emitterDone);
|
||||||
|
|||||||
@ -14,7 +14,7 @@ final class SegmentSupersedeDetector {
|
|||||||
static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim";
|
static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim";
|
||||||
|
|
||||||
private static final Pattern GENERATED_FILE_URL =
|
private static final Pattern GENERATED_FILE_URL =
|
||||||
Pattern.compile("/api/v1/files/generated/[A-Za-z0-9-]+");
|
Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+");
|
||||||
private static final Pattern BYTE_COUNT =
|
private static final Pattern BYTE_COUNT =
|
||||||
Pattern.compile("\\d+\\s*字节");
|
Pattern.compile("\\d+\\s*字节");
|
||||||
private static final Pattern REPLACEMENT_COUNT =
|
private static final Pattern REPLACEMENT_COUNT =
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
package vip.mate.channel.webchat;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
import vip.mate.channel.service.ChannelService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-facing webchat operations. Mounted under {@code /api/v1/admin/webchat/**}
|
||||||
|
* (outside the {@code /api/v1/channels/webchat/**} permitAll block) so it
|
||||||
|
* requires a regular MateClaw JWT — visitors cannot reach these endpoints.
|
||||||
|
*
|
||||||
|
* <p>Currently only manages visitor-token revocation. Audit-recorded via
|
||||||
|
* {@link AuditEventService}; actor is the JWT-authenticated admin username,
|
||||||
|
* not the visitor.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Tag(name = "WebChat 管理(管理员)")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/webchat")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebChatAdminController {
|
||||||
|
|
||||||
|
private final ChannelService channelService;
|
||||||
|
private final WebChatTokenRevocationService revocationService;
|
||||||
|
private final AuditEventService auditService;
|
||||||
|
|
||||||
|
@Operation(summary = "撤销访客的 visitorToken(ban 该 visitor 在管理端点的所有调用)")
|
||||||
|
@PostMapping("/revoked-visitor")
|
||||||
|
public R<Void> revokeVisitor(
|
||||||
|
@RequestBody RevokeVisitorRequest request,
|
||||||
|
Authentication auth) {
|
||||||
|
if (request == null || request.getChannelId() == null
|
||||||
|
|| request.getVisitorId() == null || request.getVisitorId().isBlank()) {
|
||||||
|
return R.fail(400, "channelId and visitorId are required");
|
||||||
|
}
|
||||||
|
ChannelEntity channel = channelService.getChannel(request.getChannelId());
|
||||||
|
if (channel == null || !"webchat".equals(channel.getChannelType())) {
|
||||||
|
return R.fail(404, "webchat channel not found");
|
||||||
|
}
|
||||||
|
revocationService.revoke(channel.getId(), request.getVisitorId().trim(),
|
||||||
|
request.getReason());
|
||||||
|
|
||||||
|
String adminUser = auth != null ? auth.getName() : "system";
|
||||||
|
auditService.record(
|
||||||
|
"webchat.revoke-visitor",
|
||||||
|
"CHANNEL",
|
||||||
|
String.valueOf(channel.getId()),
|
||||||
|
channel.getName(),
|
||||||
|
"{\"visitorId\":\"" + request.getVisitorId().trim()
|
||||||
|
+ "\",\"reason\":\"" + (request.getReason() != null ? request.getReason() : "")
|
||||||
|
+ "\",\"admin\":\"" + adminUser + "\"}",
|
||||||
|
channel.getWorkspaceId());
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "取消撤销访客(un-ban)")
|
||||||
|
@DeleteMapping("/revoked-visitor")
|
||||||
|
public R<Void> unrevokeVisitor(
|
||||||
|
@RequestBody Map<String, Object> body,
|
||||||
|
Authentication auth) {
|
||||||
|
Long channelId = body.get("channelId") instanceof Number n ? n.longValue() : null;
|
||||||
|
Object rawChannelId = body.get("channelId");
|
||||||
|
if (rawChannelId instanceof String s && !s.isBlank()) {
|
||||||
|
try { channelId = Long.parseLong(s); } catch (NumberFormatException ignored) { }
|
||||||
|
}
|
||||||
|
String visitorId = body.get("visitorId") instanceof String s ? s.trim() : null;
|
||||||
|
if (channelId == null || visitorId == null || visitorId.isEmpty()) {
|
||||||
|
return R.fail(400, "channelId and visitorId are required");
|
||||||
|
}
|
||||||
|
revocationService.unrevoke(channelId, visitorId);
|
||||||
|
|
||||||
|
String adminUser = auth != null ? auth.getName() : "system";
|
||||||
|
auditService.record(
|
||||||
|
"webchat.unrevoke-visitor",
|
||||||
|
"CHANNEL",
|
||||||
|
String.valueOf(channelId),
|
||||||
|
null,
|
||||||
|
"{\"visitorId\":\"" + visitorId + "\",\"admin\":\"" + adminUser + "\"}",
|
||||||
|
null);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class RevokeVisitorRequest {
|
||||||
|
private Long channelId;
|
||||||
|
private String visitorId;
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,52 @@
|
|||||||
|
package vip.mate.channel.webchat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralised error codes + messages for the visitor-facing webchat API.
|
||||||
|
* <p>
|
||||||
|
* Every {@code R.fail(...)} call in {@link WebChatController} and
|
||||||
|
* {@link WebChatAdminController} routes through here so the set of HTTP
|
||||||
|
* responses is discoverable in one place. Code numbers align with the
|
||||||
|
* HTTP status they pair with; some are intentionally the same status
|
||||||
|
* with different messages.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public enum WebChatErrors {
|
||||||
|
|
||||||
|
// ---- 400 BAD REQUEST ----
|
||||||
|
INVALID_SESSION_ID(400, "Invalid sessionId (allowed: letters, digits, '-', '_', length 1-64)"),
|
||||||
|
INVALID_VISITOR_ID(400, "Invalid visitorId (allowed: letters, digits, '-', '_', '.', ':', length 1-128)"),
|
||||||
|
TITLE_INVALID(400, "title 不合法(1-100 字)"),
|
||||||
|
NO_AGENT(400, "No agent configured for this WebChat channel"),
|
||||||
|
REQUESTED_AGENT_NOT_FOUND(400, "Requested agent not found"),
|
||||||
|
REQUESTED_AGENT_WRONG_WORKSPACE(400, "Requested agent does not belong to this channel's workspace"),
|
||||||
|
PINNED_BODY_REQUIRED(400, "body must contain {pinned: true|false}"),
|
||||||
|
ARCHIVE_BODY_REQUIRED(400, "body must contain {archived: true|false}"),
|
||||||
|
NO_USER_MESSAGE_TO_REGEN(400, "No user message to regenerate from"),
|
||||||
|
VISITOR_ID_REQUIRED(400, "visitorId is required"),
|
||||||
|
CHANNEL_AND_VISITOR_REQUIRED(400, "channelId and visitorId are required"),
|
||||||
|
|
||||||
|
// ---- 401 UNAUTHORIZED ----
|
||||||
|
INVALID_API_KEY(401, "Invalid API Key"),
|
||||||
|
INVALID_VISITOR_TOKEN(401, "Invalid or missing visitor token"),
|
||||||
|
|
||||||
|
// ---- 404 NOT FOUND ----
|
||||||
|
SESSION_NOT_FOUND(404, "Session not found"),
|
||||||
|
WEBCAT_CHANNEL_NOT_FOUND(404, "webchat channel not found"),
|
||||||
|
|
||||||
|
// ---- 409 CONFLICT ----
|
||||||
|
QUOTA_EMPTY_SESSIONS_EXCEEDED(409, "未活跃会话数已达上限(%d),请先发送消息或删除旧会话");
|
||||||
|
|
||||||
|
public final int code;
|
||||||
|
public final String message;
|
||||||
|
|
||||||
|
WebChatErrors(int code, String message) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply an int substitution to messages using {@code %d}. */
|
||||||
|
public String with(int arg) {
|
||||||
|
return String.format(message, arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
package vip.mate.channel.webchat;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage + validation for files exchanged over the WebChat channel.
|
||||||
|
*
|
||||||
|
* <p>WebChat is reached by untrusted external visitors (API key + visitor
|
||||||
|
* token, no JWT), so uploads are hardened here: size cap, extension allow-list,
|
||||||
|
* filename sanitization, and a server-issued stored name. Every path is derived
|
||||||
|
* from the server-computed {@code conversationId} — never from a client-supplied
|
||||||
|
* path — and download resolution is traversal-guarded.
|
||||||
|
*
|
||||||
|
* <p>Uploads are staged in an in-memory registry keyed by an opaque file id.
|
||||||
|
* Only when the visitor references that id on the next {@code /stream} call does
|
||||||
|
* the file become a real conversation attachment (the bytes already live under
|
||||||
|
* the conversation's upload dir, so cleanup rides the existing
|
||||||
|
* {@code cleanAttachmentFiles} cascade). Unreferenced staged files are swept
|
||||||
|
* after {@link #STAGING_TTL_MS}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class WebChatFileService {
|
||||||
|
|
||||||
|
/** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */
|
||||||
|
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
|
||||||
|
|
||||||
|
/** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */
|
||||||
|
private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
private final long maxSizeBytes;
|
||||||
|
private final Set<String> allowedExtensions;
|
||||||
|
private final int maxFilesPerConversation;
|
||||||
|
private final long maxTotalBytesPerConversation;
|
||||||
|
|
||||||
|
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
|
||||||
|
private final ConcurrentHashMap<String, StagedFile> staged = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public WebChatFileService(
|
||||||
|
@Value("${mateclaw.webchat.upload.enabled:true}") boolean enabled,
|
||||||
|
@Value("${mateclaw.webchat.upload.max-size-mb:20}") long maxSizeMb,
|
||||||
|
@Value("${mateclaw.webchat.upload.allowed-extensions:"
|
||||||
|
+ "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log,"
|
||||||
|
+ "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv,
|
||||||
|
@Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation,
|
||||||
|
@Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
|
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
|
||||||
|
.map(s -> s.trim().toLowerCase(Locale.ROOT))
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
this.maxFilesPerConversation = maxFilesPerConversation;
|
||||||
|
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata for a staged upload. */
|
||||||
|
public record StagedFile(String conversationId, String storedName, String originalName,
|
||||||
|
String contentType, long size, long expireAt) {
|
||||||
|
boolean expired() {
|
||||||
|
return System.currentTimeMillis() > expireAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown on any validation failure; the controller maps it to a 4xx. */
|
||||||
|
public static class UploadRejectedException extends RuntimeException {
|
||||||
|
public UploadRejectedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and store an uploaded file under the conversation's upload dir,
|
||||||
|
* returning a staged record whose {@code storedName} doubles as the opaque
|
||||||
|
* file id the visitor references on the next /stream call.
|
||||||
|
*
|
||||||
|
* @param conversationId server-derived conversation id (never client-supplied)
|
||||||
|
*/
|
||||||
|
public StagedFile store(String conversationId, MultipartFile file) throws IOException {
|
||||||
|
if (!enabled) {
|
||||||
|
throw new UploadRejectedException("WebChat file upload is disabled");
|
||||||
|
}
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new UploadRejectedException("Empty file");
|
||||||
|
}
|
||||||
|
if (file.getSize() > maxSizeBytes) {
|
||||||
|
throw new UploadRejectedException("File too large (max " + (maxSizeBytes / 1024 / 1024) + " MB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
||||||
|
// Strip any directory components, then collapse to a safe charset.
|
||||||
|
String baseName = Paths.get(originalName).getFileName().toString();
|
||||||
|
String ext = extensionOf(baseName);
|
||||||
|
if (ext.isEmpty() || !allowedExtensions.contains(ext)) {
|
||||||
|
throw new UploadRejectedException("File type not allowed: ." + ext);
|
||||||
|
}
|
||||||
|
String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||||
|
|
||||||
|
String storedName = UUID.randomUUID() + "_" + safeName;
|
||||||
|
Path dir = UPLOAD_ROOT.resolve(conversationId).normalize();
|
||||||
|
if (!dir.startsWith(UPLOAD_ROOT.normalize())) {
|
||||||
|
// conversationId is server-derived, so this should never happen; fail closed if it does.
|
||||||
|
throw new UploadRejectedException("Invalid conversation");
|
||||||
|
}
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
enforceConversationQuota(dir, file.getSize());
|
||||||
|
Path target = dir.resolve(storedName);
|
||||||
|
file.transferTo(target.toAbsolutePath());
|
||||||
|
|
||||||
|
String contentType = Optional.ofNullable(file.getContentType())
|
||||||
|
.filter(ct -> !ct.isBlank())
|
||||||
|
.orElseGet(() -> probe(target));
|
||||||
|
|
||||||
|
StagedFile entry = new StagedFile(conversationId, storedName, baseName, contentType,
|
||||||
|
file.getSize(), System.currentTimeMillis() + STAGING_TTL_MS);
|
||||||
|
staged.put(storedName, entry);
|
||||||
|
log.info("[webchat-file] Stored upload conv={} stored={} type={} size={}",
|
||||||
|
conversationId, storedName, contentType, file.getSize());
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a staged file id into its metadata, asserting it belongs to this
|
||||||
|
* conversation and has not expired. Consuming it removes the staging entry
|
||||||
|
* (the bytes remain as a committed conversation attachment). Returns empty
|
||||||
|
* if the id is unknown, expired, or belongs to another conversation.
|
||||||
|
*/
|
||||||
|
public Optional<StagedFile> consume(String conversationId, String fileId) {
|
||||||
|
if (fileId == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
StagedFile entry = staged.get(fileId);
|
||||||
|
if (entry == null || entry.expired() || !entry.conversationId().equals(conversationId)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
staged.remove(fileId);
|
||||||
|
return Optional.of(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traversal-safe resolution of a stored file under the conversation's dir.
|
||||||
|
* Both the dir and the final path are derived from the server-computed
|
||||||
|
* conversationId; the client-supplied {@code storedName} is confined by the
|
||||||
|
* {@code startsWith} guard. Returns empty if missing or escaping the dir.
|
||||||
|
*/
|
||||||
|
public Optional<Path> resolve(String conversationId, String storedName) {
|
||||||
|
if (storedName == null || storedName.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Path base = UPLOAD_ROOT.resolve(conversationId).normalize();
|
||||||
|
Path file = base.resolve(storedName).normalize();
|
||||||
|
if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a content type to the MessageContentPart type the agent/UI understands. */
|
||||||
|
public static String partTypeFor(String contentType) {
|
||||||
|
if (contentType == null) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
String ct = contentType.toLowerCase(Locale.ROOT);
|
||||||
|
if (ct.startsWith("image/")) return "image";
|
||||||
|
if (ct.startsWith("video/")) return "video";
|
||||||
|
if (ct.startsWith("audio/")) return "audio";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Periodically drop staged files the visitor never referenced. */
|
||||||
|
@Scheduled(fixedDelay = 15 * 60 * 1000L)
|
||||||
|
public void sweepExpired() {
|
||||||
|
staged.values().removeIf(entry -> {
|
||||||
|
if (!entry.expired()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resolve(entry.conversationId(), entry.storedName()).ifPresent(p -> {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(p);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("[webchat-file] Failed to delete expired staged file {}: {}",
|
||||||
|
p, e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bound a conversation's disk footprint: reject when the dir already holds
|
||||||
|
* the max file count, or when adding {@code incomingSize} would push the
|
||||||
|
* total over the cap. Cheap dir scan (these dirs hold at most a few dozen
|
||||||
|
* files); pairs with the staging TTL sweep that reclaims unreferenced files.
|
||||||
|
*/
|
||||||
|
private void enforceConversationQuota(Path dir, long incomingSize) throws IOException {
|
||||||
|
int count = 0;
|
||||||
|
long total = 0;
|
||||||
|
try (Stream<Path> files = Files.list(dir)) {
|
||||||
|
for (Path p : (Iterable<Path>) files::iterator) {
|
||||||
|
if (Files.isRegularFile(p)) {
|
||||||
|
count++;
|
||||||
|
total += Files.size(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count >= maxFilesPerConversation) {
|
||||||
|
throw new UploadRejectedException(
|
||||||
|
"Too many files in this conversation (max " + maxFilesPerConversation + ")");
|
||||||
|
}
|
||||||
|
if (total + incomingSize > maxTotalBytesPerConversation) {
|
||||||
|
throw new UploadRejectedException(
|
||||||
|
"Conversation upload quota exceeded (max "
|
||||||
|
+ (maxTotalBytesPerConversation / 1024 / 1024) + " MB)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extensionOf(String name) {
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == name.length() - 1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String probe(Path path) {
|
||||||
|
try {
|
||||||
|
String ct = Files.probeContentType(path);
|
||||||
|
return ct != null ? ct : "application/octet-stream";
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package vip.mate.channel.webchat;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent registry of visitors whose {@code visitorToken} HMAC is no longer
|
||||||
|
* accepted on management endpoints (list/messages/title/delete/stop/upload/
|
||||||
|
* regenerate). Created by V148. The unique constraint on
|
||||||
|
* {@code (channel_id, visitor_id, deleted)} makes re-revoke idempotent;
|
||||||
|
* setting {@code deleted = 1} un-revokes.
|
||||||
|
* <p>
|
||||||
|
* {@code POST /stream} is intentionally NOT bound by this — a revoked visitor
|
||||||
|
* can still start a fresh {@code /stream}, which mints a new token; the
|
||||||
|
* revocation applies to the old token presented on management endpoints.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("webchat_revoked_visitor")
|
||||||
|
public class WebChatRevokedVisitorEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long channelId;
|
||||||
|
|
||||||
|
/** Visitor identifier in the same charset as {@code WebChatController.normalizeVisitorId}. */
|
||||||
|
private String visitorId;
|
||||||
|
|
||||||
|
private LocalDateTime revokedAt;
|
||||||
|
|
||||||
|
/** Free-form reason (admin-supplied). Nullable. */
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
/** 0 = active revocation; 1 = un-revoked (tombstoned). */
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
package vip.mate.channel.webchat;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.channel.webchat.repository.WebChatRevokedVisitorMapper;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visitor-token revocation lookup with a process-local Caffeine cache in front
|
||||||
|
* of the {@code webchat_revoked_visitor} table.
|
||||||
|
*
|
||||||
|
* <p>The cache is best-effort: a revoked visitor may take up to
|
||||||
|
* {@link #CACHE_TTL} to become effectively revoked on a node that has the
|
||||||
|
* un-revoked entry cached. We accept that window — webchat is low-volume —
|
||||||
|
* rather than pay a DB round-trip on every management endpoint call. For
|
||||||
|
* multi-instance deployments the same eventual-consistency applies
|
||||||
|
* independently per node; the DB remains the source of truth and a fresh
|
||||||
|
* node sees revocations immediately on cold cache.
|
||||||
|
*
|
||||||
|
* <p>All operations are idempotent: revoking an already-revoked visitor is a
|
||||||
|
* no-op (the row's {@code revokedAt} is updated for record-keeping);
|
||||||
|
* un-revoking an un-revoked one is also a no-op.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebChatTokenRevocationService {
|
||||||
|
|
||||||
|
static final Duration CACHE_TTL = Duration.ofMinutes(5);
|
||||||
|
private static final int CACHE_MAX_SIZE = 10_000;
|
||||||
|
|
||||||
|
private final WebChatRevokedVisitorMapper revokedVisitorMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyed by "{channelId}:{visitorId}". Value is true when an active
|
||||||
|
* revocation row exists, false otherwise. absence means "not cached";
|
||||||
|
* callers must treat null as "fall through to DB".
|
||||||
|
*/
|
||||||
|
private final Cache<String, Boolean> revocationCache = Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(CACHE_TTL)
|
||||||
|
.maximumSize(CACHE_MAX_SIZE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if this visitor is currently revoked on this channel. Pads the
|
||||||
|
* cache miss with a single DB lookup; the result is then cached for
|
||||||
|
* {@link #CACHE_TTL}.
|
||||||
|
*/
|
||||||
|
public boolean isRevoked(Long channelId, String visitorId) {
|
||||||
|
if (channelId == null || visitorId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String key = channelId + ":" + visitorId;
|
||||||
|
Boolean cached = revocationCache.getIfPresent(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
boolean revoked = lookupRevoked(channelId, visitorId);
|
||||||
|
revocationCache.put(key, revoked);
|
||||||
|
return revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a revocation. Idempotent: re-revoking an already-revoked visitor
|
||||||
|
* refreshes {@code revokedAt} + {@code reason} on the existing row.
|
||||||
|
* Flushes the cache so the change is visible immediately on this node.
|
||||||
|
*/
|
||||||
|
public void revoke(Long channelId, String visitorId, String reason) {
|
||||||
|
WebChatRevokedVisitorEntity existing = findActive(channelId, visitorId);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (existing == null) {
|
||||||
|
WebChatRevokedVisitorEntity row = new WebChatRevokedVisitorEntity();
|
||||||
|
row.setChannelId(channelId);
|
||||||
|
row.setVisitorId(visitorId);
|
||||||
|
row.setReason(reason);
|
||||||
|
row.setRevokedAt(now);
|
||||||
|
row.setCreateTime(now);
|
||||||
|
row.setUpdateTime(now);
|
||||||
|
row.setDeleted(0);
|
||||||
|
revokedVisitorMapper.insert(row);
|
||||||
|
} else {
|
||||||
|
existing.setReason(reason);
|
||||||
|
existing.setRevokedAt(now);
|
||||||
|
existing.setUpdateTime(now);
|
||||||
|
revokedVisitorMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
revocationCache.put(channelId + ":" + visitorId, true);
|
||||||
|
log.info("[WebChat] visitor revoked: channelId={}, visitorId={}, reason={}",
|
||||||
|
channelId, visitorId, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lift a revocation. Idempotent. Removes the cache entry so subsequent
|
||||||
|
* {@link #isRevoked} calls re-query the DB (and find nothing).
|
||||||
|
*/
|
||||||
|
public void unrevoke(Long channelId, String visitorId) {
|
||||||
|
WebChatRevokedVisitorEntity existing = findActive(channelId, visitorId);
|
||||||
|
if (existing != null) {
|
||||||
|
existing.setDeleted(1);
|
||||||
|
existing.setUpdateTime(LocalDateTime.now());
|
||||||
|
revokedVisitorMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
// Invalidate rather than put(false): the un-revoke may race with a
|
||||||
|
// concurrent revoke on another node. Forcing a DB re-lookup is safer.
|
||||||
|
revocationCache.invalidate(channelId + ":" + visitorId);
|
||||||
|
log.info("[WebChat] visitor un-revoked: channelId={}, visitorId={}",
|
||||||
|
channelId, visitorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean lookupRevoked(Long channelId, String visitorId) {
|
||||||
|
return findActive(channelId, visitorId) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebChatRevokedVisitorEntity findActive(Long channelId, String visitorId) {
|
||||||
|
return revokedVisitorMapper.selectOne(new LambdaQueryWrapper<WebChatRevokedVisitorEntity>()
|
||||||
|
.eq(WebChatRevokedVisitorEntity::getChannelId, channelId)
|
||||||
|
.eq(WebChatRevokedVisitorEntity::getVisitorId, visitorId)
|
||||||
|
.eq(WebChatRevokedVisitorEntity::getDeleted, 0)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: drop every cached entry so the next isRevoked() falls through to DB. */
|
||||||
|
void invalidateCacheForTest() {
|
||||||
|
revocationCache.invalidateAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package vip.mate.channel.webchat.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import vip.mate.channel.webchat.WebChatRevokedVisitorEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapper for {@link WebChatRevokedVisitorEntity}. Lives under {@code repository}
|
||||||
|
* so the application-wide {@code @MapperScan("vip.mate.**.repository")} picks it up.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface WebChatRevokedVisitorMapper extends BaseMapper<WebChatRevokedVisitorEntity> {
|
||||||
|
}
|
||||||
@ -1502,7 +1502,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
* each adapter rewrites the URL to a channel-native attachment.
|
* each adapter rewrites the URL to a channel-native attachment.
|
||||||
*/
|
*/
|
||||||
private static final java.util.regex.Pattern GENERATED_URL_PATTERN =
|
private static final java.util.regex.Pattern GENERATED_URL_PATTERN =
|
||||||
java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)");
|
java.util.regex.Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the agent's text for {@code /api/v1/files/generated/{id}} URLs;
|
* Scan the agent's text for {@code /api/v1/files/generated/{id}} URLs;
|
||||||
@ -2870,6 +2870,16 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
// Fallback: download disabled or failed. Browser preview will be broken
|
// Fallback: download disabled or failed. Browser preview will be broken
|
||||||
// because the WeCom CDN URL carries a short-lived signature, but at
|
// because the WeCom CDN URL carries a short-lived signature, but at
|
||||||
// least the bubble shows "image.jpg" instead of "未命名 / unknown".
|
// least the bubble shows "image.jpg" instead of "未命名 / unknown".
|
||||||
|
// The image is also unusable by the model: the URL points at AES-encrypted
|
||||||
|
// bytes (when aeskey is present) and expires in ~5 minutes, so without a
|
||||||
|
// local download the agent can only report "file not found". Warn loudly so
|
||||||
|
// operators know to enable media download rather than chase a phantom bug.
|
||||||
|
boolean encrypted = aesKey != null && !aesKey.isBlank();
|
||||||
|
log.warn("[wecom] Inbound image '{}' stored URL-only ({} download). The model "
|
||||||
|
+ "cannot read it — enable 'media_download_enabled' on this channel. "
|
||||||
|
+ "url={}", fileNameHint,
|
||||||
|
getConfigBoolean("media_download_enabled", true) ? "failed" : "disabled",
|
||||||
|
encrypted ? "<encrypted COS URL>" : url);
|
||||||
MessageContentPart part = new MessageContentPart();
|
MessageContentPart part = new MessageContentPart();
|
||||||
part.setType("image");
|
part.setType("image");
|
||||||
part.setFileName(fileNameHint);
|
part.setFileName(fileNameHint);
|
||||||
|
|||||||
@ -0,0 +1,293 @@
|
|||||||
|
package vip.mate.common.text;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确定性 Markdown 规范化工具。
|
||||||
|
*
|
||||||
|
* <p>用于在 Agent 最终答案落库 / 发渠道前,修复 LLM 原始输出中常见的机械排版缺陷。纯本地正则处理,
|
||||||
|
* 不调用任何模型(零 token)。设计目标是「修畸形而不改语义」,因此遵循以下原则:</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>代码块感知</b>:先按 ``` / ~~~ 围栏切分,围栏内的内容原样保留,避免破坏代码里的
|
||||||
|
* {@code #} / {@code |} / {@code ---}。</li>
|
||||||
|
* <li><b>幂等</b>:{@code normalize(normalize(x)).equals(normalize(x))}。</li>
|
||||||
|
* <li><b>保守</b>:只在能高置信判断为畸形时才改写,散文中的散落管道符、行内 {@code #} 不动。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>覆盖的修复:ATX 标题补空格、{@code ---} 与后续内容粘连时拆行(含行首与 mid-line 后接标题两种)、
|
||||||
|
* 表格块单元格与分隔行对齐、标题与表格粘连时拆行、标题/表格块边界补空行。</p>
|
||||||
|
*
|
||||||
|
* <p>不在范围内(属语义判断,正则无法安全自动化,保留在提示词约束):Emoji 位置、代码块语言标注补全。</p>
|
||||||
|
*/
|
||||||
|
public final class MarkdownNormalizer {
|
||||||
|
|
||||||
|
private MarkdownNormalizer() {}
|
||||||
|
|
||||||
|
/** 行首 ATX 标题但紧跟非空格、非 # 字符(缺少标题空格)。 */
|
||||||
|
private static final Pattern HEADING_NO_SPACE = Pattern.compile("^(#{1,6})([^#\\s].*)$");
|
||||||
|
|
||||||
|
/** 已规范的标题行:#{1,6} + 空白。用于块边界判断。 */
|
||||||
|
private static final Pattern HEADING_LINE = Pattern.compile("^#{1,6}\\s.*$");
|
||||||
|
|
||||||
|
/** 主题分隔线与后续内容粘连:行首 3+ 短横,后面紧跟非短横的可见内容。 */
|
||||||
|
private static final Pattern HR_GLUED = Pattern.compile("^(-{3,})([^-\\s].*)$");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题分隔线粘连在「行内容之后」(mid-line),且后面紧跟一个 ATX 标题。
|
||||||
|
* 形如 {@code *来源…2026-06-01*---### 二、…} 或 {@code **90%**---### 综合判断}。
|
||||||
|
* 仅在 {@code ---} 后紧跟 {@code #} 标题时才拆,避免误伤散文里 em-dash 风格的 {@code ---}。
|
||||||
|
*/
|
||||||
|
private static final Pattern HR_MID_HEADING =
|
||||||
|
Pattern.compile("^(.*?\\S)\\s*(-{3,})\\s*(#{1,6}.*)$");
|
||||||
|
|
||||||
|
/** 标题行尾粘连了表格:#{1,6} 标题文字(不含管道符)+ 管道符起始的尾部。 */
|
||||||
|
private static final Pattern HEADING_TABLE = Pattern.compile("^(#{1,6}[^|\\n]*?)\\s*(\\|.+)$");
|
||||||
|
|
||||||
|
/** GFM 表格分隔行:由短横/冒号组成的单元格,用管道符分隔(必须同时含 - 与 |)。 */
|
||||||
|
private static final Pattern SEPARATOR_ROW =
|
||||||
|
Pattern.compile("^\\s*\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?\\s*$");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化 Markdown 文本。{@code null} 或空串原样返回。
|
||||||
|
*/
|
||||||
|
public static String normalize(String md) {
|
||||||
|
if (md == null || md.isEmpty()) {
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
String normalized = md.replace("\r\n", "\n").replace("\r", "\n");
|
||||||
|
String[] lines = normalized.split("\n", -1);
|
||||||
|
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
List<String> textBuf = new ArrayList<>();
|
||||||
|
boolean inFence = false;
|
||||||
|
String fenceMarker = null;
|
||||||
|
|
||||||
|
for (String line : lines) {
|
||||||
|
String lead = line.stripLeading();
|
||||||
|
if (!inFence && (lead.startsWith("```") || lead.startsWith("~~~"))) {
|
||||||
|
flushText(textBuf, out);
|
||||||
|
textBuf.clear();
|
||||||
|
inFence = true;
|
||||||
|
fenceMarker = lead.startsWith("```") ? "```" : "~~~";
|
||||||
|
out.add(line);
|
||||||
|
} else if (inFence && lead.startsWith(fenceMarker)) {
|
||||||
|
inFence = false;
|
||||||
|
fenceMarker = null;
|
||||||
|
out.add(line);
|
||||||
|
} else if (inFence) {
|
||||||
|
out.add(line);
|
||||||
|
} else {
|
||||||
|
textBuf.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushText(textBuf, out);
|
||||||
|
|
||||||
|
String result = String.join("\n", out);
|
||||||
|
// 仅清理文档首尾多余空行,不触碰代码块内部
|
||||||
|
return result.replaceAll("^\\n+", "").replaceAll("\\n+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 非代码段处理 ====================
|
||||||
|
|
||||||
|
private static void flushText(List<String> textLines, List<String> out) {
|
||||||
|
if (textLines.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1. 行级展开:HR 粘连拆行、标题粘表格拆行、标题补空格
|
||||||
|
List<String> expanded = new ArrayList<>();
|
||||||
|
for (String l : textLines) {
|
||||||
|
expanded.addAll(expandLine(l));
|
||||||
|
}
|
||||||
|
// 2. 表格块识别与规范化
|
||||||
|
List<String> normalizedLines = new ArrayList<>();
|
||||||
|
List<Boolean> isTable = new ArrayList<>();
|
||||||
|
normalizeTables(expanded, normalizedLines, isTable);
|
||||||
|
// 3. 标题/表格块边界补空行 + 折叠多余空行
|
||||||
|
out.addAll(insertBoundaryBlanks(normalizedLines, isTable));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> expandLine(String line) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
|
||||||
|
Matcher hrMid = HR_MID_HEADING.matcher(line);
|
||||||
|
if (hrMid.matches()) {
|
||||||
|
result.addAll(expandLine(hrMid.group(1)));
|
||||||
|
result.add("");
|
||||||
|
result.add("---");
|
||||||
|
result.add("");
|
||||||
|
result.addAll(expandLine(hrMid.group(3)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher hr = HR_GLUED.matcher(line);
|
||||||
|
if (hr.matches()) {
|
||||||
|
result.add("---");
|
||||||
|
result.add("");
|
||||||
|
result.addAll(expandLine(hr.group(2)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher ht = HEADING_TABLE.matcher(line);
|
||||||
|
if (ht.matches() && countPipes(ht.group(2)) >= 2) {
|
||||||
|
result.add(fixHeadingSpace(ht.group(1).strip()));
|
||||||
|
result.add("");
|
||||||
|
result.add(ht.group(2).strip());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(fixHeadingSpace(line));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行首 ATX 标题缺空格时补一个空格。为避免误伤 {@code #5}、{@code #1} 这类引用,
|
||||||
|
* 紧跟数字的不处理。
|
||||||
|
*/
|
||||||
|
private static String fixHeadingSpace(String line) {
|
||||||
|
Matcher m = HEADING_NO_SPACE.matcher(line);
|
||||||
|
if (m.matches()) {
|
||||||
|
String hashes = m.group(1);
|
||||||
|
String rest = m.group(2);
|
||||||
|
if (!Character.isDigit(rest.charAt(0))) {
|
||||||
|
return hashes + " " + rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 表格规范化 ====================
|
||||||
|
|
||||||
|
private static void normalizeTables(List<String> lines, List<String> out, List<Boolean> isTable) {
|
||||||
|
int n = lines.size();
|
||||||
|
boolean[] tbl = new boolean[n];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
if (isSeparatorRow(lines.get(i)) && i > 0 && containsPipe(lines.get(i - 1))) {
|
||||||
|
int start = i - 1;
|
||||||
|
int end = i;
|
||||||
|
int j = i + 1;
|
||||||
|
while (j < n && !lines.get(j).isBlank() && containsPipe(lines.get(j))
|
||||||
|
&& !HEADING_LINE.matcher(lines.get(j)).matches()) {
|
||||||
|
end = j;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
for (int k = start; k <= end; k++) {
|
||||||
|
tbl[k] = true;
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
if (tbl[i]) {
|
||||||
|
out.add(normalizeTableRow(lines.get(i), isSeparatorRow(lines.get(i))));
|
||||||
|
} else {
|
||||||
|
out.add(lines.get(i));
|
||||||
|
}
|
||||||
|
isTable.add(tbl[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeTableRow(String line, boolean separator) {
|
||||||
|
String s = line.strip();
|
||||||
|
if (s.startsWith("|")) {
|
||||||
|
s = s.substring(1);
|
||||||
|
}
|
||||||
|
if (s.endsWith("|")) {
|
||||||
|
s = s.substring(0, s.length() - 1);
|
||||||
|
}
|
||||||
|
String[] cells = s.split("(?<!\\\\)\\|", -1);
|
||||||
|
StringBuilder sb = new StringBuilder("|");
|
||||||
|
for (String cell : cells) {
|
||||||
|
String c = cell.strip();
|
||||||
|
if (separator) {
|
||||||
|
c = normalizeDelimiterCell(c);
|
||||||
|
}
|
||||||
|
sb.append(' ').append(c).append(" |");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeDelimiterCell(String cell) {
|
||||||
|
boolean left = cell.startsWith(":");
|
||||||
|
boolean right = cell.endsWith(":");
|
||||||
|
return (left ? ":" : "") + "---" + (right ? ":" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSeparatorRow(String line) {
|
||||||
|
return line.indexOf('-') >= 0 && line.indexOf('|') >= 0
|
||||||
|
&& SEPARATOR_ROW.matcher(line).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsPipe(String line) {
|
||||||
|
return line.indexOf('|') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countPipes(String s) {
|
||||||
|
int count = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
if (s.charAt(i) == '|') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 块边界空行 ====================
|
||||||
|
|
||||||
|
private static final int BLANK = 0;
|
||||||
|
private static final int HEADING = 1;
|
||||||
|
private static final int TABLE = 2;
|
||||||
|
private static final int OTHER = 3;
|
||||||
|
private static final int NONE = -1;
|
||||||
|
|
||||||
|
private static List<String> insertBoundaryBlanks(List<String> lines, List<Boolean> isTable) {
|
||||||
|
List<String> res = new ArrayList<>();
|
||||||
|
int lastType = NONE;
|
||||||
|
for (int i = 0; i < lines.size(); i++) {
|
||||||
|
String cur = lines.get(i);
|
||||||
|
int curType = classify(cur, isTable.get(i));
|
||||||
|
|
||||||
|
if (curType == BLANK) {
|
||||||
|
if (lastType == BLANK || lastType == NONE) {
|
||||||
|
continue; // 折叠连续空行 / 去掉前导空行
|
||||||
|
}
|
||||||
|
res.add(cur);
|
||||||
|
lastType = BLANK;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastType != NONE && lastType != BLANK && needsBlankBetween(lastType, curType)) {
|
||||||
|
res.add("");
|
||||||
|
}
|
||||||
|
res.add(cur);
|
||||||
|
lastType = curType;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean needsBlankBetween(int prev, int cur) {
|
||||||
|
if (cur == HEADING || prev == HEADING) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cur == TABLE && prev != TABLE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return prev == TABLE && cur != TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int classify(String line, boolean tableFlag) {
|
||||||
|
if (line.isBlank()) {
|
||||||
|
return BLANK;
|
||||||
|
}
|
||||||
|
if (tableFlag) {
|
||||||
|
return TABLE;
|
||||||
|
}
|
||||||
|
if (HEADING_LINE.matcher(line).matches()) {
|
||||||
|
return HEADING;
|
||||||
|
}
|
||||||
|
return OTHER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,12 @@ import java.util.concurrent.Executor;
|
|||||||
* The inner delegate uses virtual threads (JDK 21); the outer
|
* The inner delegate uses virtual threads (JDK 21); the outer
|
||||||
* {@link DelegatingSecurityContextTaskExecutor} wrapper propagates the caller's
|
* {@link DelegatingSecurityContextTaskExecutor} wrapper propagates the caller's
|
||||||
* SecurityContext (JWT identity, audit permissions) to every @Async invocation.
|
* SecurityContext (JWT identity, audit permissions) to every @Async invocation.
|
||||||
|
* <p>
|
||||||
|
* A concurrency limit is set to prevent runaway virtual-thread creation
|
||||||
|
* from exhausting the HikariCP pool (typical pattern: a stampede of
|
||||||
|
* {@code @Async} tasks all trying to acquire DB connections at the same
|
||||||
|
* minute boundary). Excess tasks are rejected immediately so the
|
||||||
|
* scheduler threads never block on submission.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -21,6 +27,13 @@ import java.util.concurrent.Executor;
|
|||||||
@EnableAsync
|
@EnableAsync
|
||||||
public class AsyncSecurityConfig implements AsyncConfigurer {
|
public class AsyncSecurityConfig implements AsyncConfigurer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap in-flight async tasks. Well below HikariCP maximum-pool-size (30)
|
||||||
|
* so async tasks never saturate the pool on their own — non-async paths
|
||||||
|
* (HTTP requests, SSE, channel adapters) always have headroom.
|
||||||
|
*/
|
||||||
|
private static final int ASYNC_CONCURRENCY_LIMIT = 24;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Executor getAsyncExecutor() {
|
public Executor getAsyncExecutor() {
|
||||||
// Keep DelegatingSecurityContextTaskExecutor so SecurityContext
|
// Keep DelegatingSecurityContextTaskExecutor so SecurityContext
|
||||||
@ -28,6 +41,7 @@ public class AsyncSecurityConfig implements AsyncConfigurer {
|
|||||||
// Replace the inner platform-thread pool with a virtual-thread executor.
|
// Replace the inner platform-thread pool with a virtual-thread executor.
|
||||||
var delegate = new SimpleAsyncTaskExecutorBuilder()
|
var delegate = new SimpleAsyncTaskExecutorBuilder()
|
||||||
.virtualThreads(true)
|
.virtualThreads(true)
|
||||||
|
.concurrencyLimit(ASYNC_CONCURRENCY_LIMIT)
|
||||||
.threadNamePrefix("async-vt-")
|
.threadNamePrefix("async-vt-")
|
||||||
.build();
|
.build();
|
||||||
return new DelegatingSecurityContextTaskExecutor(delegate);
|
return new DelegatingSecurityContextTaskExecutor(delegate);
|
||||||
|
|||||||
@ -38,9 +38,18 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
|
|||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
/** Cached flag: true when running on MySQL/MariaDB, false for H2. */
|
/** Cached flag: true when running on MySQL/MariaDB, false for H2/Kingbase. */
|
||||||
private volatile Boolean isMySQL;
|
private volatile Boolean isMySQL;
|
||||||
|
|
||||||
|
/** Cached flag: true when running on KingbaseES. */
|
||||||
|
private volatile Boolean isKingbase;
|
||||||
|
|
||||||
|
/** Cached flag: true when running on PostgreSQL. */
|
||||||
|
private volatile Boolean isPostgres;
|
||||||
|
|
||||||
|
/** Cached human-readable label of the connected database, e.g. "MySQL" / "H2" / "PostgreSQL". */
|
||||||
|
private volatile String databaseLabel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When true, wait for Desktop splash screen to call /setup/init with chosen language.
|
* When true, wait for Desktop splash screen to call /setup/init with chosen language.
|
||||||
* When false (default), auto-initialize immediately on startup.
|
* When false (default), auto-initialize immediately on startup.
|
||||||
@ -111,6 +120,10 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
|
|||||||
String scriptName;
|
String scriptName;
|
||||||
if (isMySQL()) {
|
if (isMySQL()) {
|
||||||
scriptName = "en-US".equals(locale) ? "db/data-mysql-en.sql" : "db/data-mysql-zh.sql";
|
scriptName = "en-US".equals(locale) ? "db/data-mysql-en.sql" : "db/data-mysql-zh.sql";
|
||||||
|
} else if (isKingbase() || isPostgres()) {
|
||||||
|
// PostgreSQL-family seed (covers both PostgreSQL and KingbaseES,
|
||||||
|
// which share the same ON CONFLICT / SERIAL-free DDL dialect).
|
||||||
|
scriptName = "en-US".equals(locale) ? "db/data-kingbase-en.sql" : "db/data-kingbase-zh.sql";
|
||||||
} else {
|
} else {
|
||||||
scriptName = "en-US".equals(locale) ? "db/data-en.sql" : "db/data-zh.sql";
|
scriptName = "en-US".equals(locale) ? "db/data-en.sql" : "db/data-zh.sql";
|
||||||
}
|
}
|
||||||
@ -140,6 +153,57 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Friendly product name of the currently connected database, e.g. {@code "MySQL"},
|
||||||
|
* {@code "PostgreSQL"}, {@code "H2"} or {@code "KingbaseES"}. Read once from JDBC
|
||||||
|
* metadata and cached — the connected database never changes at runtime.
|
||||||
|
*
|
||||||
|
* @return the product name, or {@code "Unknown"} if metadata is unavailable.
|
||||||
|
*/
|
||||||
|
public String getDatabaseLabel() {
|
||||||
|
if (databaseLabel == null) {
|
||||||
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
|
databaseLabel = normalizeDatabaseLabel(connection.getMetaData().getDatabaseProductName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Failed to read database product name: {}", e.getMessage());
|
||||||
|
databaseLabel = "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return databaseLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw JDBC product name to a clean, canonical label. Some drivers append
|
||||||
|
* version noise to the product name (e.g. KingbaseES reports "KingbaseES V008R006");
|
||||||
|
* collapsing on a keyword keeps the displayed label stable across driver versions
|
||||||
|
* and consistent with the dialect this runner detects for DDL.
|
||||||
|
*
|
||||||
|
* @return a canonical label, or {@code "Unknown"} when the product name is absent.
|
||||||
|
*/
|
||||||
|
static String normalizeDatabaseLabel(String product) {
|
||||||
|
if (product == null || product.isBlank()) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
String lower = product.toLowerCase();
|
||||||
|
if (lower.contains("kingbase")) {
|
||||||
|
// KingbaseES is the product name; show the vendor's Chinese brand name.
|
||||||
|
return "人大金仓";
|
||||||
|
}
|
||||||
|
if (lower.contains("mariadb")) {
|
||||||
|
return "MariaDB";
|
||||||
|
}
|
||||||
|
if (lower.contains("mysql")) {
|
||||||
|
return "MySQL";
|
||||||
|
}
|
||||||
|
if (lower.contains("postgresql")) {
|
||||||
|
return "PostgreSQL";
|
||||||
|
}
|
||||||
|
if (lower.contains("h2")) {
|
||||||
|
return "H2";
|
||||||
|
}
|
||||||
|
return product.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean tableExists(String tableName) throws Exception {
|
private boolean tableExists(String tableName) throws Exception {
|
||||||
try (Connection connection = dataSource.getConnection()) {
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
DatabaseMetaData metaData = connection.getMetaData();
|
DatabaseMetaData metaData = connection.getMetaData();
|
||||||
@ -159,15 +223,43 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
|
|||||||
try (Connection connection = dataSource.getConnection()) {
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
String dbProduct = connection.getMetaData().getDatabaseProductName().toLowerCase();
|
String dbProduct = connection.getMetaData().getDatabaseProductName().toLowerCase();
|
||||||
isMySQL = dbProduct.contains("mysql") || dbProduct.contains("mariadb");
|
isMySQL = dbProduct.contains("mysql") || dbProduct.contains("mariadb");
|
||||||
log.info("Detected database: {} (MySQL mode: {})", dbProduct, isMySQL);
|
isKingbase = dbProduct.contains("kingbase");
|
||||||
|
// KingbaseES reports its own product name ("KingbaseES"), so the
|
||||||
|
// postgres check stays mutually exclusive with the kingbase one.
|
||||||
|
isPostgres = dbProduct.contains("postgresql") && !isKingbase;
|
||||||
|
if (isKingbase) {
|
||||||
|
log.info("Detected database: {} (KingbaseES mode)", dbProduct);
|
||||||
|
} else if (isPostgres) {
|
||||||
|
log.info("Detected database: {} (PostgreSQL mode)", dbProduct);
|
||||||
|
} else {
|
||||||
|
log.info("Detected database: {} (MySQL mode: {})", dbProduct, isMySQL);
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to detect database type, falling back to H2 mode", e);
|
log.warn("Failed to detect database type, falling back to H2 mode", e);
|
||||||
isMySQL = false;
|
isMySQL = false;
|
||||||
|
isKingbase = false;
|
||||||
|
isPostgres = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isMySQL;
|
return isMySQL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isKingbase() {
|
||||||
|
if (isKingbase == null) {
|
||||||
|
// Trigger detection
|
||||||
|
isMySQL();
|
||||||
|
}
|
||||||
|
return isKingbase != null && isKingbase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPostgres() {
|
||||||
|
if (isPostgres == null) {
|
||||||
|
// Trigger detection
|
||||||
|
isMySQL();
|
||||||
|
}
|
||||||
|
return isPostgres != null && isPostgres;
|
||||||
|
}
|
||||||
|
|
||||||
private void runScript(String path) {
|
private void runScript(String path) {
|
||||||
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
|
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
|
||||||
populator.setContinueOnError(false);
|
populator.setContinueOnError(false);
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
package vip.mate.config;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import com.zaxxer.hikari.HikariPoolMXBean;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodically logs HikariCP pool metrics so operators can detect
|
||||||
|
* connection exhaustion / leak patterns before the application stalls.
|
||||||
|
* <p>
|
||||||
|
* Runs every 30s — frequent enough to catch a pool drain within 1-2
|
||||||
|
* ticks, cheap enough to never become a problem itself.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class HikariPoolMonitor {
|
||||||
|
|
||||||
|
private final HikariDataSource hikari;
|
||||||
|
|
||||||
|
public HikariPoolMonitor(DataSource dataSource) {
|
||||||
|
if (dataSource instanceof HikariDataSource hds) {
|
||||||
|
this.hikari = hds;
|
||||||
|
} else if (dataSource instanceof org.springframework.jdbc.datasource.DelegatingDataSource dds
|
||||||
|
&& dds.getTargetDataSource() instanceof HikariDataSource hds) {
|
||||||
|
// Some auto-configurations wrap Hikari in a delegating DS.
|
||||||
|
this.hikari = hds;
|
||||||
|
} else {
|
||||||
|
this.hikari = null;
|
||||||
|
log.info("[HikariMonitor] DataSource is not HikariCP — pool monitor disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 30_000, initialDelay = 60_000)
|
||||||
|
public void logPoolStats() {
|
||||||
|
if (hikari == null) return;
|
||||||
|
|
||||||
|
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
|
||||||
|
if (pool == null) return;
|
||||||
|
|
||||||
|
int active = pool.getActiveConnections();
|
||||||
|
int idle = pool.getIdleConnections();
|
||||||
|
int total = pool.getTotalConnections();
|
||||||
|
int waiting = pool.getThreadsAwaitingConnection();
|
||||||
|
|
||||||
|
// Normal — log at debug so it doesn't spam the console.
|
||||||
|
log.debug("[HikariMonitor] pool: active={}, idle={}, total={}, max={}, waiting={}",
|
||||||
|
active, idle, total, hikari.getMaximumPoolSize(), waiting);
|
||||||
|
|
||||||
|
// Warning threshold: more than 80 % of the pool is active AND
|
||||||
|
// threads are queued waiting for a connection.
|
||||||
|
int maxPool = hikari.getMaximumPoolSize();
|
||||||
|
if (active > maxPool * 0.8 && waiting > 0) {
|
||||||
|
log.warn("[HikariMonitor] Pool pressure detected — active={}, idle={}, "
|
||||||
|
+ "total={}, max={}, waiting={}", active, idle, total, maxPool, waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical: pool is fully saturated AND threads are waiting.
|
||||||
|
if (active >= maxPool && waiting > 0) {
|
||||||
|
log.error("[HikariMonitor] Pool EXHAUSTED — active={}, max={}, waiting={}. "
|
||||||
|
+ "Application will appear frozen until connections are released.",
|
||||||
|
active, maxPool, waiting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package vip.mate.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||||
|
import org.springframework.scheduling.annotation.SchedulingConfigurer;
|
||||||
|
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled-task thread-pool configuration.
|
||||||
|
* <p>
|
||||||
|
* Spring Boot's auto-configured {@code TaskScheduler} defaults to
|
||||||
|
* <b>pool-size = 1</b>, which serializes every {@code @Scheduled}
|
||||||
|
* method across the entire application. With 15+ scheduled beans
|
||||||
|
* (health checks, trigger sync, fact rebuild, feature-flag refresh,
|
||||||
|
* etc.) and several firing on the same cron tick, a single-thread
|
||||||
|
* pool causes back-pressure that makes the application appear "frozen"
|
||||||
|
* when any task blocks briefly on database I/O or lock acquisition.
|
||||||
|
* <p>
|
||||||
|
* This config sets a pool large enough to absorb simultaneous
|
||||||
|
* minute/half-hour boundaries without head-of-line blocking, while
|
||||||
|
* keeping thread count low so the scheduler does not contend with
|
||||||
|
* HikariCP or the async virtual-thread pool.
|
||||||
|
* <p>
|
||||||
|
* <b>IMPORTANT:</b> {@code @EnableScheduling} is declared once on
|
||||||
|
* {@link vip.mate.MateClawApplication}. Declaring it here as well
|
||||||
|
* creates a <em>second</em> {@code ScheduledAnnotationBeanPostProcessor}
|
||||||
|
* that competes with the first, resulting in some tasks unknowingly
|
||||||
|
* scheduled on the default single-thread executor.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SchedulingConfig implements SchedulingConfigurer {
|
||||||
|
|
||||||
|
/** Pool threads — enough for concurrent ticks but kept moderate. */
|
||||||
|
private static final int POOL_SIZE = 4;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TaskScheduler taskScheduler() {
|
||||||
|
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
|
scheduler.setPoolSize(POOL_SIZE);
|
||||||
|
scheduler.setThreadNamePrefix("sched-");
|
||||||
|
scheduler.setRemoveOnCancelPolicy(true);
|
||||||
|
scheduler.setAwaitTerminationSeconds(30);
|
||||||
|
scheduler.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
return scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureTasks(ScheduledTaskRegistrar registrar) {
|
||||||
|
// Re-use the singleton TaskScheduler bean so we never create two
|
||||||
|
// separate thread-pool instances (the @Bean above is the single
|
||||||
|
// source of truth).
|
||||||
|
registrar.setTaskScheduler(taskScheduler());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,7 +62,9 @@ public class SecurityConfig {
|
|||||||
"/api/v1/channels/webhook/**",
|
"/api/v1/channels/webhook/**",
|
||||||
"/api/v1/channels/webchat/**",
|
"/api/v1/channels/webchat/**",
|
||||||
"/api/v1/talk/ws",
|
"/api/v1/talk/ws",
|
||||||
// RFC-045: tool-generated files served via unguessable UUID + 10-min TTL
|
// RFC-045: tool-generated files served via unguessable UUID; entries
|
||||||
|
// expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an
|
||||||
|
// IM-delivered link opened later) is intentional, the UUID is the guard.
|
||||||
"/api/v1/files/generated/**"
|
"/api/v1/files/generated/**"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
// 所有其他 API 接口需要认证
|
// 所有其他 API 接口需要认证
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-03 Lane G2 — distributed lock provider for the cron scheduler.
|
* RFC-03 Lane G2 — distributed lock provider for the cron scheduler.
|
||||||
@ -37,13 +38,38 @@ public class ShedLockConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public LockProvider lockProvider(DataSource dataSource) {
|
public LockProvider lockProvider(DataSource dataSource) {
|
||||||
log.info("[ShedLock] Initializing JDBC LockProvider for cron scheduling");
|
boolean useDbTime = supportsDbTime(dataSource);
|
||||||
return new JdbcTemplateLockProvider(
|
log.info("[ShedLock] Initializing JDBC LockProvider for cron scheduling (usingDbTime={})", useDbTime);
|
||||||
|
|
||||||
|
JdbcTemplateLockProvider.Configuration.Builder builder =
|
||||||
JdbcTemplateLockProvider.Configuration.builder()
|
JdbcTemplateLockProvider.Configuration.builder()
|
||||||
.withJdbcTemplate(new JdbcTemplate(dataSource))
|
.withJdbcTemplate(new JdbcTemplate(dataSource))
|
||||||
.withTableName("shedlock")
|
.withTableName("shedlock");
|
||||||
.usingDbTime() // server-side NOW() — avoids node clock drift
|
|
||||||
.build()
|
// Server-side DB time (NOW()) avoids node clock drift across a
|
||||||
);
|
// multi-instance deployment. ShedLock's built-in db-time dialect map
|
||||||
|
// covers MySQL/MariaDB, PostgreSQL, H2, etc. KingbaseES is not in that
|
||||||
|
// map, so usingDbTime() would throw there — fall back to app-server
|
||||||
|
// time for it, which is safe given lockAtMostFor=PT30M.
|
||||||
|
if (useDbTime) {
|
||||||
|
builder.usingDbTime();
|
||||||
|
}
|
||||||
|
return new JdbcTemplateLockProvider(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the DataSource's database is covered by ShedLock's
|
||||||
|
* built-in db-time dialect map. Only KingbaseES is excluded; on any
|
||||||
|
* detection failure we conservatively return false so the lock provider
|
||||||
|
* never throws at acquisition time.
|
||||||
|
*/
|
||||||
|
private boolean supportsDbTime(DataSource dataSource) {
|
||||||
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
|
String product = connection.getMetaData().getDatabaseProductName().toLowerCase();
|
||||||
|
return !product.contains("kingbase");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ShedLock] Could not detect database product; using app-server time: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -196,10 +196,13 @@ public class CronJobLifecycleService {
|
|||||||
String convId = conversationId != null ? conversationId : run.getConversationId();
|
String convId = conversationId != null ? conversationId : run.getConversationId();
|
||||||
String text = result != null && result.getText() != null ? result.getText() : "";
|
String text = result != null && result.getText() != null ? result.getText() : "";
|
||||||
|
|
||||||
|
int totalTokens = chatResult != null
|
||||||
|
? chatResult.promptTokens() + chatResult.completionTokens() : 0;
|
||||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
.eq(CronJobRunEntity::getId, run.getId())
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
.set(CronJobRunEntity::getStatus, "succeeded")
|
.set(CronJobRunEntity::getStatus, "succeeded")
|
||||||
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now()));
|
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
|
||||||
|
.set(totalTokens > 0, CronJobRunEntity::getTokenUsage, totalTokens));
|
||||||
|
|
||||||
if (silent) {
|
if (silent) {
|
||||||
// No-op run: persist a short marker so the tasks_<wsId>
|
// No-op run: persist a short marker so the tasks_<wsId>
|
||||||
@ -208,7 +211,17 @@ public class CronJobLifecycleService {
|
|||||||
// real content to deliver or to learn from.
|
// real content to deliver or to learn from.
|
||||||
String marker = i18n != null ? i18n.msg("cron.run.silent")
|
String marker = i18n != null ? i18n.msg("cron.run.silent")
|
||||||
: "(本次定时任务无新内容,已跳过)";
|
: "(本次定时任务无新内容,已跳过)";
|
||||||
conversationService.saveMessage(convId, "assistant", marker);
|
// A silent run still made a full LLM call, so carry its token usage
|
||||||
|
// onto the marker message — otherwise the settings-page total (which
|
||||||
|
// aggregates MessageEntity token columns) under-counts cron spend.
|
||||||
|
if (chatResult != null
|
||||||
|
&& (chatResult.promptTokens() > 0 || chatResult.completionTokens() > 0)) {
|
||||||
|
conversationService.saveMessage(convId, "assistant", marker, null, "completed",
|
||||||
|
chatResult.promptTokens(), chatResult.completionTokens(),
|
||||||
|
chatResult.runtimeModel(), chatResult.runtimeProvider());
|
||||||
|
} else {
|
||||||
|
conversationService.saveMessage(convId, "assistant", marker);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,14 @@ public class DatasourceConnectionManager implements DisposableBean {
|
|||||||
extra = (extra == null || extra.isBlank()) ? schemaParam : extra + "&" + schemaParam;
|
extra = (extra == null || extra.isBlank()) ? schemaParam : extra + "&" + schemaParam;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "kingbase":
|
||||||
|
case "kingbasees":
|
||||||
|
baseUrl = String.format("jdbc:kingbase8://%s:%d/%s", host, port, dbName);
|
||||||
|
if (entity.getSchemaName() != null && !entity.getSchemaName().isBlank()) {
|
||||||
|
String schemaParam = "currentSchema=" + entity.getSchemaName();
|
||||||
|
extra = (extra == null || extra.isBlank()) ? schemaParam : extra + "&" + schemaParam;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "clickhouse":
|
case "clickhouse":
|
||||||
baseUrl = String.format("jdbc:clickhouse://%s:%d/%s", host, port, dbName);
|
baseUrl = String.format("jdbc:clickhouse://%s:%d/%s", host, port, dbName);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
package vip.mate.doc;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.tool.builtin.MateClawDocService;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置帮助文档的只读接口,供前端文档查看器消费。
|
||||||
|
*
|
||||||
|
* <p>文档本体打包在 classpath:docs/{zh,en}/ 下,与给智能体用的
|
||||||
|
* {@link MateClawDocService} 共享同一套扫描/校验逻辑。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Docs")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/docs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DocController {
|
||||||
|
|
||||||
|
private final MateClawDocService docService;
|
||||||
|
|
||||||
|
@Operation(summary = "列出某语言下的全部帮助文档(slug + 标题)")
|
||||||
|
@GetMapping
|
||||||
|
public R<List<MateClawDocService.DocMeta>> list(
|
||||||
|
@RequestParam(defaultValue = "zh") String lang) {
|
||||||
|
return R.ok(docService.list(normalizeLang(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "读取单篇帮助文档正文(已剥离 frontmatter)")
|
||||||
|
@GetMapping("/content")
|
||||||
|
public R<Map<String, Object>> content(
|
||||||
|
@RequestParam(defaultValue = "zh") String lang,
|
||||||
|
@RequestParam String slug) {
|
||||||
|
String normLang = normalizeLang(lang);
|
||||||
|
String body = docService.read(normLang, slug);
|
||||||
|
if (body == null) {
|
||||||
|
return R.fail(404, "Document not found");
|
||||||
|
}
|
||||||
|
String title = docService.list(normLang).stream()
|
||||||
|
.filter(d -> d.slug().equals(slug))
|
||||||
|
.map(MateClawDocService.DocMeta::title)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(slug);
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("slug", slug);
|
||||||
|
payload.put("title", title);
|
||||||
|
payload.put("content", body);
|
||||||
|
return R.ok(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLang(String lang) {
|
||||||
|
if (lang == null) {
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
String l = lang.toLowerCase();
|
||||||
|
// 前端 locale 形如 zh-CN / en-US,取主语言段。
|
||||||
|
if (l.startsWith("en")) {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.validation.BindException;
|
import org.springframework.validation.BindException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
@ -121,6 +122,21 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found"));
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring's default lets this escape to the catch-all below, surfacing as a
|
||||||
|
* 500 with a stack trace. Return a clean 405 so the client gets a
|
||||||
|
* structured body and the log stays at WARN. Doubles as a defence when a
|
||||||
|
* malformed path segment makes a reverse proxy strip the trailing path
|
||||||
|
* (e.g. a conversationId ending in ":" lands a GET on a @DeleteMapping).
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||||
|
public ResponseEntity<R<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
log.warn("Method not supported: {} {} (supported: {})",
|
||||||
|
request.getMethod(), request.getRequestURI(), e.getSupportedHttpMethods());
|
||||||
|
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(R.fail(405, "Method not allowed"));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<R<Void>> handleException(Exception e,
|
public ResponseEntity<R<Void>> handleException(Exception e,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
|
|||||||
@ -16,6 +16,16 @@ import org.springframework.stereotype.Component;
|
|||||||
@ConfigurationProperties(prefix = "mateclaw.goal")
|
@ConfigurationProperties(prefix = "mateclaw.goal")
|
||||||
public class GoalProperties {
|
public class GoalProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time ceiling for {@link #maxHardContinuationsPerRun}. The graph
|
||||||
|
* recursion limit is sized statically to accommodate this many extra
|
||||||
|
* fresh-budget ReAct segments per run, so the runtime value is clamped to
|
||||||
|
* it — an operator cannot push hard continuations past what the recursion
|
||||||
|
* backstop was sized for. Raising this requires re-sizing the recursion
|
||||||
|
* ceiling in {@code AgentGraphBuilder.frameworkRecursionLimit()}.
|
||||||
|
*/
|
||||||
|
public static final int MAX_HARD_CONTINUATIONS_CEILING = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master switch — when off, the graph never invokes GoalEvaluationNode
|
* Master switch — when off, the graph never invokes GoalEvaluationNode
|
||||||
* (the conditional edge sees no active goal, so the node is unreachable).
|
* (the conditional edge sees no active goal, so the node is unreachable).
|
||||||
@ -39,6 +49,19 @@ public class GoalProperties {
|
|||||||
*/
|
*/
|
||||||
private boolean allowAutoFollowup = true;
|
private boolean allowAutoFollowup = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-derive a goal from a multi-step Plan-Execute plan. The Plan-Execute
|
||||||
|
* planner decomposes the request into steps and the step executor is a
|
||||||
|
* narrow "task runner" — neither calls {@code setGoal}, so without this a
|
||||||
|
* Plan-Execute run never engages the goal subsystem. When enabled, a goal is
|
||||||
|
* created server-side at plan generation (title = request, acceptance
|
||||||
|
* criteria seeded from the plan steps), so the already-wired
|
||||||
|
* GoalEvaluationNode tracks completion. Gated by {@link #enabled}; only
|
||||||
|
* fires for genuine multi-step plans and when the conversation has no active
|
||||||
|
* goal yet. Set to {@code false} to keep Plan-Execute goal-free.
|
||||||
|
*/
|
||||||
|
private boolean autoGoalFromPlan = true;
|
||||||
|
|
||||||
/** Default turn budget when the user doesn't override. */
|
/** Default turn budget when the user doesn't override. */
|
||||||
private int defaultTurnBudget = 20;
|
private int defaultTurnBudget = 20;
|
||||||
|
|
||||||
@ -57,6 +80,20 @@ public class GoalProperties {
|
|||||||
*/
|
*/
|
||||||
private int maxFollowupsPerRun = 8;
|
private int maxFollowupsPerRun = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max "hard continuations" per single graph run. A hard continuation is a
|
||||||
|
* goal follow-up that re-enters the ReAct loop with a FRESH iteration
|
||||||
|
* budget after a turn that hit {@code MAX_ITERATIONS_REACHED} — letting a
|
||||||
|
* task too large for one budget keep going autonomously instead of stalling
|
||||||
|
* until the user sends another message. Each one costs up to a full
|
||||||
|
* {@code maxIterations} worth of node visits, so this is a dedicated cap on
|
||||||
|
* top of {@link #maxFollowupsPerRun}, clamped to
|
||||||
|
* {@link #MAX_HARD_CONTINUATIONS_CEILING} and sized into the graph recursion
|
||||||
|
* ceiling. The goal's cross-turn turn / LLM-call budgets still apply. Set to
|
||||||
|
* 0 to keep the previous behaviour (max-iterations turns end the run).
|
||||||
|
*/
|
||||||
|
private int maxHardContinuationsPerRun = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider/model id for the evaluator. Empty string means "use the
|
* Provider/model id for the evaluator. Empty string means "use the
|
||||||
* same model as the chat agent" — convenient for dev, expensive in
|
* same model as the chat agent" — convenient for dev, expensive in
|
||||||
|
|||||||
@ -137,11 +137,27 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True for any Claude 4.7+ model — the family that drops temperature /
|
* Detect the Claude Fable model line (e.g. {@code claude-fable-5}).
|
||||||
* top_p / top_k and exposes the "xhigh" thinking tier between high and max.
|
* Fable is a reasoning-first family that follows the same strict API
|
||||||
|
* contract as Claude 4.7+: temperature / top_p / top_k must be unset
|
||||||
|
* (any non-default value returns HTTP 400) and the "xhigh" adaptive
|
||||||
|
* thinking tier is available. Matching on the {@code claude-fable} token
|
||||||
|
* covers the direct-API id, the OpenRouter-prefixed form
|
||||||
|
* ({@code anthropic/claude-fable-5}), and future {@code claude-fable-N}
|
||||||
|
* revisions without a per-version code change.
|
||||||
|
*/
|
||||||
|
static boolean isClaudeFable(String modelName) {
|
||||||
|
if (modelName == null) return false;
|
||||||
|
return modelName.toLowerCase().contains("claude-fable");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for any modern Claude model that drops temperature / top_p / top_k
|
||||||
|
* and exposes the "xhigh" thinking tier between high and max — the Claude
|
||||||
|
* 4.7 / 4.8 generations and the Fable reasoning line.
|
||||||
*/
|
*/
|
||||||
static boolean isClaude47OrLater(String modelName) {
|
static boolean isClaude47OrLater(String modelName) {
|
||||||
return isClaude47(modelName) || isClaude48(modelName);
|
return isClaude47(modelName) || isClaude48(modelName) || isClaudeFable(modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||||||
|
|||||||
@ -79,6 +79,7 @@ public class ProviderInitProbe {
|
|||||||
this.strategies = map;
|
this.strategies = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void onApplicationReady() {
|
public void onApplicationReady() {
|
||||||
probeAllConfigured();
|
probeAllConfigured();
|
||||||
|
|||||||
@ -77,6 +77,16 @@ public class ModelConfigEntity {
|
|||||||
*/
|
*/
|
||||||
private String modalities;
|
private String modalities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient, request-scoped flag set by {@link vip.mate.llm.service.ModelConfigService#listByType}
|
||||||
|
* when a modality filter is supplied: {@code true} when this row's declared or
|
||||||
|
* heuristically-resolved capabilities cover the requested modality. Lets the
|
||||||
|
* sidecar selector list every enabled chat model while still highlighting the
|
||||||
|
* ones already known to support the modality. Never persisted.
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Boolean modalityCapable;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import java.util.List;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read access to an agent's skill / provider bindings, as needed by
|
* Read access to an agent's skill / provider / wiki-kb bindings, as needed by
|
||||||
* {@link ProviderRouter} for capability-aware routing.
|
* {@link ProviderRouter} for capability-aware routing and by webchat
|
||||||
|
* endpoints that need to enumerate an agent's visible catalog.
|
||||||
*
|
*
|
||||||
* <p>Declared in the {@code llm} layer so the routing code depends only on
|
* <p>Declared in the {@code llm} layer so the routing code depends only on
|
||||||
* this abstraction. The {@code agent} layer supplies the implementation,
|
* this abstraction. The {@code agent} layer supplies the implementation,
|
||||||
@ -23,4 +24,14 @@ public interface AgentBindingResolver {
|
|||||||
* Provider ids the agent prefers, in priority order; empty when none.
|
* Provider ids the agent prefers, in priority order; empty when none.
|
||||||
*/
|
*/
|
||||||
List<String> getPreferredProviderIds(Long agentId);
|
List<String> getPreferredProviderIds(Long agentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiki knowledge-base ids bound to the agent, or {@code null} when the
|
||||||
|
* agent has no explicit KB scope (meaning "workspace-wide — every KB
|
||||||
|
* in the agent's workspace is visible"). Mirrors the three-state
|
||||||
|
* contract of {@link #getBoundSkillIds}: {@code null} = inherit
|
||||||
|
* default, {@code Set.of()} = explicitly scoped to nothing, non-empty
|
||||||
|
* = the explicit allowlist.
|
||||||
|
*/
|
||||||
|
Set<Long> getBoundKbIds(Long agentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,18 @@ public class MediaCaptionService {
|
|||||||
private final RetryTemplate retryTemplate;
|
private final RetryTemplate retryTemplate;
|
||||||
|
|
||||||
public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale) {
|
public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale) {
|
||||||
|
return caption(visionModel, imagePart, locale, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caption an image, optionally tailored to a user question. When
|
||||||
|
* {@code userQuestion} is non-blank the vision model is asked to answer it
|
||||||
|
* directly (in addition to describing the image), so multi-turn follow-ups
|
||||||
|
* get an answer rather than a generic description. When blank, falls back to
|
||||||
|
* the factual full-description prompt.
|
||||||
|
*/
|
||||||
|
public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale,
|
||||||
|
String userQuestion) {
|
||||||
if (visionModel == null || imagePart == null) {
|
if (visionModel == null || imagePart == null) {
|
||||||
return CaptionResult.failure(0, new IllegalArgumentException("vision model or image part is null"));
|
return CaptionResult.failure(0, new IllegalArgumentException("vision model or image part is null"));
|
||||||
}
|
}
|
||||||
@ -59,7 +71,7 @@ public class MediaCaptionService {
|
|||||||
ChatModel chatModel = chatModelFactory.buildFor(visionModel, retryTemplate);
|
ChatModel chatModel = chatModelFactory.buildFor(visionModel, retryTemplate);
|
||||||
ChatClient client = ChatClient.create(chatModel);
|
ChatClient client = ChatClient.create(chatModel);
|
||||||
UserMessage userMessage = UserMessage.builder()
|
UserMessage userMessage = UserMessage.builder()
|
||||||
.text(buildPrompt(locale, imagePart.getFileName()))
|
.text(buildPrompt(locale, imagePart.getFileName(), userQuestion))
|
||||||
.media(List.of(new Media(MimeType.valueOf(contentType), new FileSystemResource(mediaPath))))
|
.media(List.of(new Media(MimeType.valueOf(contentType), new FileSystemResource(mediaPath))))
|
||||||
.build();
|
.build();
|
||||||
String description = client.prompt()
|
String description = client.prompt()
|
||||||
@ -87,9 +99,27 @@ public class MediaCaptionService {
|
|||||||
* (matches the primary user base) but switches to English so vision-model output
|
* (matches the primary user base) but switches to English so vision-model output
|
||||||
* matches the chat language and avoids polluting English-only contexts.
|
* matches the chat language and avoids polluting English-only contexts.
|
||||||
*/
|
*/
|
||||||
private String buildPrompt(Locale locale, String fileName) {
|
private String buildPrompt(Locale locale, String fileName, String userQuestion) {
|
||||||
boolean english = locale != null && Locale.ENGLISH.getLanguage().equalsIgnoreCase(locale.getLanguage());
|
boolean english = locale != null && Locale.ENGLISH.getLanguage().equalsIgnoreCase(locale.getLanguage());
|
||||||
String fileHint = (fileName == null || fileName.isBlank()) ? "" : " (" + fileName + ")";
|
String fileHint = (fileName == null || fileName.isBlank()) ? "" : " (" + fileName + ")";
|
||||||
|
boolean hasQuestion = userQuestion != null && !userQuestion.isBlank();
|
||||||
|
|
||||||
|
if (hasQuestion) {
|
||||||
|
String question = userQuestion.trim();
|
||||||
|
// Question-aware: extract everything relevant to the user's ask, then
|
||||||
|
// answer it. Answering in the question's own language keeps the caption
|
||||||
|
// consistent with the chat regardless of the configured locale.
|
||||||
|
if (english) {
|
||||||
|
return "Look at this image" + fileHint + " and answer the user's question. "
|
||||||
|
+ "First note any details relevant to the question (objects, scene, visible text/OCR, "
|
||||||
|
+ "numbers, layout), then answer directly. Reply in the same language as the question. "
|
||||||
|
+ "Question: " + question;
|
||||||
|
}
|
||||||
|
return "请仔细查看这张图片" + fileHint + ",并回答用户的问题。"
|
||||||
|
+ "先指出与问题相关的细节(物体、场景、画面文字/OCR、数字、排版等),再直接作答。"
|
||||||
|
+ "用与问题相同的语言回复。问题:" + question;
|
||||||
|
}
|
||||||
|
|
||||||
if (english) {
|
if (english) {
|
||||||
return "Describe this image" + fileHint
|
return "Describe this image" + fileHint
|
||||||
+ " concisely: list the main objects, scene, any visible text (OCR), "
|
+ " concisely: list the main objects, scene, any visible text (OCR), "
|
||||||
|
|||||||
@ -125,11 +125,29 @@ public class MultimodalRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the configured sidecar model for a modality. Returns null when:
|
* Resolve the configured default vision model, or {@code null} if none is set
|
||||||
|
* or the referenced model is missing/disabled. Exposed so tools (e.g. an
|
||||||
|
* on-demand image-analysis tool) can reuse the exact same model the automatic
|
||||||
|
* sidecar uses, keeping behaviour consistent across the auto and tool paths.
|
||||||
|
*/
|
||||||
|
public ModelConfigEntity resolveVisionSidecar() {
|
||||||
|
return resolveSidecar(Modality.VISION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the configured sidecar model for a modality. Returns null only when:
|
||||||
* - the setting is empty / blank;
|
* - the setting is empty / blank;
|
||||||
* - the referenced row no longer exists or has been disabled;
|
* - the referenced row no longer exists or has been disabled.
|
||||||
* - the row's resolved capability set does not actually contain the modality.
|
|
||||||
* The caller treats null as "ask the user to configure one."
|
* The caller treats null as "ask the user to configure one."
|
||||||
|
* <p>
|
||||||
|
* An explicit sidecar selection is treated as the user's own capability
|
||||||
|
* declaration: a provider-compatible model can be vision-capable in practice
|
||||||
|
* even when the built-in heuristics don't recognize its name and it carries no
|
||||||
|
* declared {@code modalities}. Rejecting such a model here made it impossible to
|
||||||
|
* use a perfectly good compatible-mode vision model as the sidecar. We therefore
|
||||||
|
* honour the explicit choice and only emit a diagnostic when the heuristics
|
||||||
|
* can't confirm it — a wrong pick degrades gracefully (the caption call fails and
|
||||||
|
* the attachment is reported as un-processed) rather than being silently ignored.
|
||||||
*/
|
*/
|
||||||
private ModelConfigEntity resolveSidecar(Modality modality) {
|
private ModelConfigEntity resolveSidecar(Modality modality) {
|
||||||
SystemSettingsDTO settings = systemSettingService.getSettings();
|
SystemSettingsDTO settings = systemSettingService.getSettings();
|
||||||
@ -148,9 +166,9 @@ public class MultimodalRouter {
|
|||||||
}
|
}
|
||||||
if (model == null || !Boolean.TRUE.equals(model.getEnabled())) return null;
|
if (model == null || !Boolean.TRUE.equals(model.getEnabled())) return null;
|
||||||
if (!capabilityService.supports(model.getModelName(), model.getModalities(), modality)) {
|
if (!capabilityService.supports(model.getModelName(), model.getModalities(), modality)) {
|
||||||
log.warn("Configured sidecar model {}/{} does not actually support {} — ignoring",
|
log.info("Configured sidecar model {}/{} is not recognized as {}-capable by the "
|
||||||
model.getProvider(), model.getModelName(), modality);
|
+ "built-in heuristics; honouring the explicit selection anyway",
|
||||||
return null;
|
model.getProvider(), model.getModelName(), modality.name().toLowerCase());
|
||||||
}
|
}
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,7 @@ public class ModelCapabilityService {
|
|||||||
|
|
||||||
// ===== Anthropic Claude =====
|
// ===== Anthropic Claude =====
|
||||||
// Vision yes (image), native video no — Anthropic's API only accepts images.
|
// Vision yes (image), native video no — Anthropic's API only accepts images.
|
||||||
|
m.put("claude-fable", EnumSet.of(Modality.VISION));
|
||||||
m.put("claude-4.7", EnumSet.of(Modality.VISION));
|
m.put("claude-4.7", EnumSet.of(Modality.VISION));
|
||||||
m.put("claude-4.5", EnumSet.of(Modality.VISION));
|
m.put("claude-4.5", EnumSet.of(Modality.VISION));
|
||||||
m.put("claude-4", EnumSet.of(Modality.VISION));
|
m.put("claude-4", EnumSet.of(Modality.VISION));
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import vip.mate.llm.event.ModelConfigChangedEvent;
|
|||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.repository.ModelConfigMapper;
|
import vip.mate.llm.repository.ModelConfigMapper;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
|
||||||
@ -73,9 +74,18 @@ public class ModelConfigService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional modality filter (case-insensitive: {@code "vision" / "video" / "audio"}).
|
* Optional modality filter (case-insensitive: {@code "vision" / "video" / "audio"}).
|
||||||
* When non-null, only enabled rows whose resolved capability set contains the
|
* Used by the multimodal sidecar settings UI to populate "default vision model" /
|
||||||
* requested modality survive — used by the multimodal sidecar settings UI to
|
* "default video model" dropdowns.
|
||||||
* populate "default vision model" / "default video model" dropdowns.
|
* <p>
|
||||||
|
* The filter does <b>not</b> hide models the built-in heuristics fail to recognize:
|
||||||
|
* a provider-compatible model (e.g. a DashScope OpenAI-compatible vision model with
|
||||||
|
* a custom name) is vision-capable in practice even though its name matches no
|
||||||
|
* built-in prefix and it carries no declared {@code modalities}. Hard-filtering
|
||||||
|
* those out left them un-selectable as a sidecar. Instead every <em>enabled</em>
|
||||||
|
* chat model is returned; each row's transient {@link ModelConfigEntity#getModalityCapable()}
|
||||||
|
* flag records whether its declared / heuristic capabilities already cover the
|
||||||
|
* requested modality, and known-capable rows are sorted to the top so the UI can
|
||||||
|
* highlight them while still letting the user pick any model.
|
||||||
*/
|
*/
|
||||||
public List<ModelConfigEntity> listByType(String modelType, String modality) {
|
public List<ModelConfigEntity> listByType(String modelType, String modality) {
|
||||||
List<ModelConfigEntity> rows;
|
List<ModelConfigEntity> rows;
|
||||||
@ -100,7 +110,12 @@ public class ModelConfigService {
|
|||||||
}
|
}
|
||||||
return rows.stream()
|
return rows.stream()
|
||||||
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
|
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
|
||||||
.filter(m -> modelCapabilityService.supports(m.getModelName(), m.getModalities(), required))
|
.peek(m -> m.setModalityCapable(
|
||||||
|
modelCapabilityService.supports(m.getModelName(), m.getModalities(), required)))
|
||||||
|
// Known-capable models first; preserve the existing default-then-name
|
||||||
|
// order within each group.
|
||||||
|
.sorted(Comparator.comparing(
|
||||||
|
(ModelConfigEntity m) -> Boolean.TRUE.equals(m.getModalityCapable())).reversed())
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,13 @@ public class ModelDiscoveryService {
|
|||||||
|
|
||||||
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
|
||||||
|
// Shared HttpClient for OpenAI-compatible providers — avoids creating a new
|
||||||
|
// native thread + connection pool per request (was causing thread-leak OOM).
|
||||||
|
private static final HttpClient SHARED_HTTP_CLIENT = HttpClient.newBuilder()
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.connectTimeout(Duration.ofSeconds(30))
|
||||||
|
.build();
|
||||||
|
|
||||||
// Virtual-thread executor for parallel model probing (lightweight, short-lived)
|
// Virtual-thread executor for parallel model probing (lightweight, short-lived)
|
||||||
private static final ExecutorService PROBE_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
private static final ExecutorService PROBE_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
@ -962,10 +969,7 @@ public class ModelDiscoveryService {
|
|||||||
* the upgrade negotiation.
|
* the upgrade negotiation.
|
||||||
*/
|
*/
|
||||||
private RestClient.Builder openAiCompatibleClientBuilder() {
|
private RestClient.Builder openAiCompatibleClientBuilder() {
|
||||||
HttpClient httpClient = HttpClient.newBuilder()
|
return RestClient.builder().requestFactory(new JdkClientHttpRequestFactory(SHARED_HTTP_CLIENT));
|
||||||
.version(HttpClient.Version.HTTP_1_1)
|
|
||||||
.build();
|
|
||||||
return RestClient.builder().requestFactory(new JdkClientHttpRequestFactory(httpClient));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@ -81,6 +81,66 @@ public class MemoryProperties {
|
|||||||
/** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search") */
|
/** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search") */
|
||||||
private Set<String> disabledProviders = new HashSet<>();
|
private Set<String> disabledProviders = new HashSet<>();
|
||||||
|
|
||||||
|
// ==================== Always-on injection budget ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character budget for the always-on structured memory block injected into
|
||||||
|
* every system prompt (user + feedback entries). When exceeded, the
|
||||||
|
* most-recently-updated entries are kept and older ones are omitted, so
|
||||||
|
* accumulated memory cannot grow the per-turn context without bound.
|
||||||
|
* 0 = unlimited (legacy behavior).
|
||||||
|
*/
|
||||||
|
private int systemBlockMaxChars = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard cap on the number of entries injected per structured type in the
|
||||||
|
* always-on block. Keeps the newest entries per type even when the global
|
||||||
|
* character budget would otherwise admit more from a single type.
|
||||||
|
* 0 = unlimited.
|
||||||
|
*/
|
||||||
|
private int systemBlockMaxEntriesPerType = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the nightly consolidation pass over always-on structured memory
|
||||||
|
* (user/feedback): an LLM merges near-duplicate and stale entries so the
|
||||||
|
* files shrink on disk rather than only being trimmed at injection time.
|
||||||
|
*/
|
||||||
|
private boolean structuredConsolidationEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum entry count before a structured type is consolidated. Below this
|
||||||
|
* the file is already small, so the LLM call is skipped.
|
||||||
|
*/
|
||||||
|
private int structuredConsolidationMinEntries = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron expression for the structured-memory consolidation maintenance task.
|
||||||
|
* Independent of {@code dreamingCron} so the two passes can be scheduled
|
||||||
|
* (and gated) separately. Default: 3:30 AM daily, after nightly emergence.
|
||||||
|
*/
|
||||||
|
private String structuredConsolidationCron = "0 30 3 * * ?";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of owner buckets (shared + personal) consolidated per agent
|
||||||
|
* per run. Bounds LLM cost on agents with many per-owner memory buckets;
|
||||||
|
* remaining owners are picked up on subsequent runs. 0 = unlimited.
|
||||||
|
*/
|
||||||
|
private int structuredConsolidationMaxOwnersPerRun = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic character ceiling for PROFILE.md, the always-on user-profile
|
||||||
|
* file. The summarization pass rewrites it and is asked to stay concise; this
|
||||||
|
* is the hard backstop that truncates at a section boundary if it overruns.
|
||||||
|
* 0 = unlimited.
|
||||||
|
*/
|
||||||
|
private int profileMaxChars = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic character ceiling for MEMORY.md, the always-on long-term
|
||||||
|
* memory file rewritten by summarization and emergence. 0 = unlimited.
|
||||||
|
*/
|
||||||
|
private int memoryMdMaxChars = 8000;
|
||||||
|
|
||||||
// ==================== Dream v2 Feature Flags ====================
|
// ==================== Dream v2 Feature Flags ====================
|
||||||
|
|
||||||
// --- Phase 1: Lifecycle mediator wiring ---
|
// --- Phase 1: Lifecycle mediator wiring ---
|
||||||
|
|||||||
@ -37,6 +37,30 @@ public class MemoryController {
|
|||||||
private final MemoryProperties memoryProperties;
|
private final MemoryProperties memoryProperties;
|
||||||
private final DreamingScheduler dreamingScheduler;
|
private final DreamingScheduler dreamingScheduler;
|
||||||
private final WorkspaceFileService workspaceFileService;
|
private final WorkspaceFileService workspaceFileService;
|
||||||
|
private final StructuredMemoryConsolidationService structuredConsolidationService;
|
||||||
|
|
||||||
|
@Operation(summary = "手动触发 always-on 结构化记忆整合(user/feedback,合并去重过时条目)")
|
||||||
|
@PostMapping("/{agentId}/structured-consolidation")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<Map<String, Object>> triggerStructuredConsolidation(@PathVariable Long agentId) {
|
||||||
|
try {
|
||||||
|
StructuredMemoryConsolidationService.ConsolidationStats s =
|
||||||
|
structuredConsolidationService.consolidateAgent(agentId);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("ownersConsolidated", s.ownersConsolidated);
|
||||||
|
out.put("updated", s.updated);
|
||||||
|
out.put("skippedSmall", s.skippedSmall);
|
||||||
|
out.put("skippedOverCap", s.skippedOverCap);
|
||||||
|
out.put("failed", s.failed);
|
||||||
|
out.put("entriesBefore", s.entriesBefore);
|
||||||
|
out.put("entriesAfter", s.entriesAfter);
|
||||||
|
return R.ok(out);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Memory] Manual structured consolidation failed for agent={}: {}",
|
||||||
|
agentId, e.getMessage(), e);
|
||||||
|
return R.fail("结构化记忆整合失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md,NIGHTLY 模式)")
|
@Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md,NIGHTLY 模式)")
|
||||||
@PostMapping("/{agentId}/emergence")
|
@PostMapping("/{agentId}/emergence")
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package vip.mate.memory.fact.projection;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
@ -14,6 +15,9 @@ import java.util.List;
|
|||||||
* Scheduled full rebuild of the fact projection for all active agents.
|
* Scheduled full rebuild of the fact projection for all active agents.
|
||||||
* Cron expression configured via mate.memory.fact.projection-rebuild-cron.
|
* Cron expression configured via mate.memory.fact.projection-rebuild-cron.
|
||||||
* Only runs when projection-enabled=true.
|
* Only runs when projection-enabled=true.
|
||||||
|
* <p>
|
||||||
|
* {@code @Async} keeps the scheduler-thread pool free — the actual DB
|
||||||
|
* work runs on the virtual-thread async executor.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -26,6 +30,7 @@ public class FactProjectionScheduler {
|
|||||||
private final FactProjectionBuilder projectionBuilder;
|
private final FactProjectionBuilder projectionBuilder;
|
||||||
private final MemoryProperties properties;
|
private final MemoryProperties properties;
|
||||||
|
|
||||||
|
@Async
|
||||||
@Scheduled(cron = "${mate.memory.fact.projection-rebuild-cron:0 */30 * * * ?}")
|
@Scheduled(cron = "${mate.memory.fact.projection-rebuild-cron:0 */30 * * * ?}")
|
||||||
public void rebuildAll() {
|
public void rebuildAll() {
|
||||||
if (!properties.getFact().isProjectionEnabled()) {
|
if (!properties.getFact().isProjectionEnabled()) {
|
||||||
|
|||||||
@ -0,0 +1,72 @@
|
|||||||
|
package vip.mate.memory.scheduler;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.memory.MemoryProperties;
|
||||||
|
import vip.mate.memory.service.StructuredMemoryConsolidationService;
|
||||||
|
import vip.mate.memory.service.StructuredMemoryConsolidationService.ConsolidationStats;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled maintenance for always-on structured memory.
|
||||||
|
* <p>
|
||||||
|
* Runs the consolidation pass that merges duplicate / stale user & feedback
|
||||||
|
* entries so the always-on block shrinks at the storage level. Kept separate from
|
||||||
|
* {@link DreamingScheduler} so it has its own cron and enable flag — disabling
|
||||||
|
* nightly dreaming must not silently disable structured consolidation, and vice
|
||||||
|
* versa.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StructuredMemoryMaintenanceScheduler {
|
||||||
|
|
||||||
|
private final AgentService agentService;
|
||||||
|
private final StructuredMemoryConsolidationService consolidationService;
|
||||||
|
private final MemoryProperties properties;
|
||||||
|
|
||||||
|
/** Last run time, for the status API. */
|
||||||
|
@Getter
|
||||||
|
private volatile LocalDateTime lastRunTime;
|
||||||
|
|
||||||
|
@Scheduled(cron = "${mate.memory.structured-consolidation-cron:0 30 3 * * ?}")
|
||||||
|
public void runConsolidation() {
|
||||||
|
if (!properties.isStructuredConsolidationEnabled()) {
|
||||||
|
log.debug("[StructuredConsolidation] Disabled, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[StructuredConsolidation] Starting maintenance cycle");
|
||||||
|
ConsolidationStats total = new ConsolidationStats();
|
||||||
|
int agents = 0;
|
||||||
|
|
||||||
|
for (AgentEntity agent : agentService.listAgents()) {
|
||||||
|
if (!Boolean.TRUE.equals(agent.getEnabled())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
agents++;
|
||||||
|
try {
|
||||||
|
ConsolidationStats agentStats = consolidationService.consolidateAgent(agent.getId());
|
||||||
|
total.add(agentStats);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[StructuredConsolidation] Failed for agent={} ({}): {}",
|
||||||
|
agent.getId(), agent.getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRunTime = LocalDateTime.now();
|
||||||
|
log.info("[StructuredConsolidation] Cycle done: agents={}, buckets={}, updated={}, "
|
||||||
|
+ "skipped(small)={}, skipped(cap)={}, failed={}, entries {}->{}",
|
||||||
|
agents, total.ownersConsolidated, total.updated,
|
||||||
|
total.skippedSmall, total.skippedOverCap, total.failed,
|
||||||
|
total.entriesBefore, total.entriesAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package vip.mate.memory.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic character-budget backstop for always-on memory files
|
||||||
|
* (PROFILE.md / MEMORY.md) that are injected into every system prompt.
|
||||||
|
* <p>
|
||||||
|
* These files are rewritten wholesale by the summarization and emergence passes,
|
||||||
|
* which are instructed to stay concise but have no hard ceiling — so on their own
|
||||||
|
* they can grow the per-turn context without bound. This enforces a deterministic
|
||||||
|
* cap: when content exceeds the budget it is truncated at a Markdown section
|
||||||
|
* boundary (keeping the head, where the core/principle sections live) and a marker
|
||||||
|
* is appended. The LLM rewrite remains the primary, content-aware compressor; this
|
||||||
|
* is the last-resort guarantee that the file stays bounded.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
final class AlwaysOnFileBudget {
|
||||||
|
|
||||||
|
/** Appended when content is truncated; user-facing note kept in the file. */
|
||||||
|
static final String MARKER = "\n\n> ⚠️ 后续内容已截断以控制注入体积。";
|
||||||
|
|
||||||
|
private AlwaysOnFileBudget() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate {@code content} to at most {@code maxChars} characters, cutting at
|
||||||
|
* the last {@code "## "} section boundary that fits so a section is never split
|
||||||
|
* mid-way. Returns the input unchanged when it already fits or when budgeting
|
||||||
|
* is disabled ({@code maxChars <= 0}).
|
||||||
|
*/
|
||||||
|
static String enforce(String content, int maxChars) {
|
||||||
|
if (content == null || maxChars <= 0 || content.length() <= maxChars) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
int limit = Math.max(0, maxChars - MARKER.length());
|
||||||
|
// Prefer cutting at a section boundary within the budget; "\n## " keeps the
|
||||||
|
// leading section header intact. Fall back to a hard cut when none fits.
|
||||||
|
int boundary = content.lastIndexOf("\n## ", limit);
|
||||||
|
String head = boundary > 0 ? content.substring(0, boundary) : content.substring(0, limit);
|
||||||
|
return head.stripTrailing() + MARKER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -159,6 +159,9 @@ public class MemoryEmergenceService {
|
|||||||
return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content");
|
return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic backstop so the always-on MEMORY.md stays bounded even
|
||||||
|
// if the LLM rewrite ignores the "keep concise" instruction.
|
||||||
|
newContent = AlwaysOnFileBudget.enforce(newContent, properties.getMemoryMdMaxChars());
|
||||||
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
|
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
|
||||||
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent));
|
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent));
|
||||||
String llmReason = root.path("reason").asText("");
|
String llmReason = root.path("reason").asText("");
|
||||||
|
|||||||
@ -328,6 +328,7 @@ public class MemorySummarizationService {
|
|||||||
* (TEAM) file when there is no real owner (cron / system).
|
* (TEAM) file when there is no real owner (cron / system).
|
||||||
*/
|
*/
|
||||||
private void saveMemory(Long agentId, String filename, String content, String ownerKey) {
|
private void saveMemory(Long agentId, String filename, String content, String ownerKey) {
|
||||||
|
content = capAlwaysOnFile(filename, content);
|
||||||
if (isPersonal(ownerKey)) {
|
if (isPersonal(ownerKey)) {
|
||||||
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
|
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
|
||||||
} else {
|
} else {
|
||||||
@ -335,6 +336,21 @@ public class MemorySummarizationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the deterministic size ceiling on the always-on profile/memory files
|
||||||
|
* so they cannot grow the per-turn system prompt without bound. Daily notes and
|
||||||
|
* other files (only recalled on demand) are left untouched.
|
||||||
|
*/
|
||||||
|
private String capAlwaysOnFile(String filename, String content) {
|
||||||
|
if ("PROFILE.md".equals(filename)) {
|
||||||
|
return AlwaysOnFileBudget.enforce(content, properties.getProfileMaxChars());
|
||||||
|
}
|
||||||
|
if ("MEMORY.md".equals(filename)) {
|
||||||
|
return AlwaysOnFileBudget.enforce(content, properties.getMemoryMdMaxChars());
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
/** A real, isolatable owner — i.e. not null/blank and not the system bucket. */
|
/** A real, isolatable owner — i.e. not null/blank and not the system bucket. */
|
||||||
private boolean isPersonal(String ownerKey) {
|
private boolean isPersonal(String ownerKey) {
|
||||||
return ownerKey != null && !ownerKey.isBlank()
|
return ownerKey != null && !ownerKey.isBlank()
|
||||||
|
|||||||
@ -0,0 +1,184 @@
|
|||||||
|
package vip.mate.memory.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.converter.BeanOutputConverter;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.AgentGraphBuilder;
|
||||||
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.memory.MemoryProperties;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nightly consolidation of always-on structured memory (user / feedback).
|
||||||
|
* <p>
|
||||||
|
* These typed files are injected into every system prompt and only ever grow:
|
||||||
|
* nudge and post-conversation summarization append entries, key-exact dedup lets
|
||||||
|
* paraphrased keys through, and no pass ever merges them. Over time the always-on
|
||||||
|
* block inflates per-turn context. This service periodically rewrites each
|
||||||
|
* always-on type file into a smaller, deduplicated, non-stale set via the LLM, so
|
||||||
|
* accumulated memory shrinks at the storage level rather than only being trimmed
|
||||||
|
* at injection time.
|
||||||
|
* <p>
|
||||||
|
* Consolidation runs per bucket: the shared (TEAM/GLOBAL) file plus every personal
|
||||||
|
* owner's file, because most growth accumulates in per-owner buckets that the
|
||||||
|
* always-on prefetch injects each turn. The number of buckets processed per agent
|
||||||
|
* per run is capped to bound LLM cost.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StructuredMemoryConsolidationService {
|
||||||
|
|
||||||
|
private final StructuredMemoryService structuredMemoryService;
|
||||||
|
private final ModelConfigService modelConfigService;
|
||||||
|
private final AgentGraphBuilder agentGraphBuilder;
|
||||||
|
private final MemoryProperties properties;
|
||||||
|
|
||||||
|
/** Typed LLM output for a single consolidation call. */
|
||||||
|
public record ConsolidationResult(boolean shouldUpdate, List<Entry> entries, String reason) {
|
||||||
|
public record Entry(String key, String content) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate counters for one consolidation run, for observability. */
|
||||||
|
public static class ConsolidationStats {
|
||||||
|
public int ownersConsolidated; // buckets that passed the gate and called the LLM
|
||||||
|
public int updated; // buckets actually rewritten
|
||||||
|
public int skippedSmall; // buckets below the min-entries gate
|
||||||
|
public int skippedOverCap; // buckets deferred to a later run by the per-run cap
|
||||||
|
public int failed; // buckets whose LLM/parse/write raised
|
||||||
|
public int entriesBefore;
|
||||||
|
public int entriesAfter;
|
||||||
|
|
||||||
|
public void add(ConsolidationStats o) {
|
||||||
|
ownersConsolidated += o.ownersConsolidated;
|
||||||
|
updated += o.updated;
|
||||||
|
skippedSmall += o.skippedSmall;
|
||||||
|
skippedOverCap += o.skippedOverCap;
|
||||||
|
failed += o.failed;
|
||||||
|
entriesBefore += o.entriesBefore;
|
||||||
|
entriesAfter += o.entriesAfter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consolidate every always-on structured bucket (shared + personal) for an agent. */
|
||||||
|
public ConsolidationStats consolidateAgent(Long agentId) {
|
||||||
|
ConsolidationStats stats = new ConsolidationStats();
|
||||||
|
if (!properties.isStructuredConsolidationEnabled()) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
int cap = properties.getStructuredConsolidationMaxOwnersPerRun();
|
||||||
|
int remaining = cap > 0 ? cap : Integer.MAX_VALUE;
|
||||||
|
|
||||||
|
for (String type : structuredMemoryService.alwaysOnTypes()) {
|
||||||
|
for (String ownerKey : structuredMemoryService.consolidatableOwnerKeys(agentId, type)) {
|
||||||
|
String content = structuredMemoryService.readTypeRaw(agentId, type, ownerKey);
|
||||||
|
int count = structuredMemoryService.countEntries(content);
|
||||||
|
if (count < properties.getStructuredConsolidationMinEntries()) {
|
||||||
|
stats.skippedSmall++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (remaining <= 0) {
|
||||||
|
stats.skippedOverCap++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
remaining--;
|
||||||
|
stats.ownersConsolidated++;
|
||||||
|
stats.entriesBefore += count;
|
||||||
|
try {
|
||||||
|
int after = consolidateBucket(agentId, type, ownerKey, content, count);
|
||||||
|
if (after >= 0) {
|
||||||
|
stats.updated++;
|
||||||
|
stats.entriesAfter += after;
|
||||||
|
} else {
|
||||||
|
stats.entriesAfter += count; // no write — bucket unchanged
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
stats.failed++;
|
||||||
|
stats.entriesAfter += count;
|
||||||
|
log.warn("[StructuredConsolidation] agent={} type={} owner={} failed: {}",
|
||||||
|
agentId, type, ownerKey, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidate one bucket. Returns the new entry count when the file was
|
||||||
|
* rewritten, or {@code -1} when nothing was written (LLM declined, produced
|
||||||
|
* unparseable output, or would not have reduced the entry count).
|
||||||
|
*/
|
||||||
|
private int consolidateBucket(Long agentId, String type, String ownerKey, String content, int count) {
|
||||||
|
BeanOutputConverter<ConsolidationResult> converter = new BeanOutputConverter<>(ConsolidationResult.class);
|
||||||
|
String systemPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-system");
|
||||||
|
String userPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-user")
|
||||||
|
.replace("{type}", type)
|
||||||
|
.replace("{today}", LocalDate.now().toString())
|
||||||
|
.replace("{count}", String.valueOf(count))
|
||||||
|
.replace("{content}", content);
|
||||||
|
|
||||||
|
ChatModel chatModel = buildChatModel();
|
||||||
|
ChatResponse resp = chatModel.call(new Prompt(List.of(
|
||||||
|
new SystemMessage(systemPrompt),
|
||||||
|
new UserMessage(userPrompt),
|
||||||
|
new UserMessage(converter.getFormat()))));
|
||||||
|
String text = resp.getResult().getOutput().getText();
|
||||||
|
|
||||||
|
ConsolidationResult result;
|
||||||
|
try {
|
||||||
|
result = converter.convert(text);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[StructuredConsolidation] Unparseable LLM output agent={} type={} owner={}: {}",
|
||||||
|
agentId, type, ownerKey, e.getMessage());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (result == null || !result.shouldUpdate()
|
||||||
|
|| result.entries() == null || result.entries().isEmpty()) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
LinkedHashMap<String, String> consolidated = new LinkedHashMap<>();
|
||||||
|
for (ConsolidationResult.Entry e : result.entries()) {
|
||||||
|
if (e == null) continue;
|
||||||
|
String key = e.key() == null ? "" : e.key().trim();
|
||||||
|
String value = e.content() == null ? "" : e.content().trim();
|
||||||
|
if (!key.isEmpty() && !value.isEmpty()) {
|
||||||
|
consolidated.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (consolidated.isEmpty()) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety invariant: consolidation must never grow the entry count. A model
|
||||||
|
// that hallucinates extra entries would otherwise make the bloat worse.
|
||||||
|
if (consolidated.size() > count) {
|
||||||
|
log.debug("[StructuredConsolidation] agent={} type={} owner={} produced {} > {} entries; skipping write",
|
||||||
|
agentId, type, ownerKey, consolidated.size(), count);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredMemoryService.replaceTypeEntries(agentId, type, ownerKey, consolidated, "consolidation");
|
||||||
|
log.info("[StructuredConsolidation] agent={} type={} owner={} consolidated {} -> {} entries",
|
||||||
|
agentId, type, ownerKey, count, consolidated.size());
|
||||||
|
return consolidated.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatModel buildChatModel() {
|
||||||
|
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
|
||||||
|
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -105,6 +105,7 @@ public class StructuredMemoryService {
|
|||||||
|
|
||||||
private final WorkspaceFileService workspaceFileService;
|
private final WorkspaceFileService workspaceFileService;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final vip.mate.memory.MemoryProperties properties;
|
||||||
|
|
||||||
/** Per-file lock to prevent concurrent read-modify-write on the same file */
|
/** Per-file lock to prevent concurrent read-modify-write on the same file */
|
||||||
private final ConcurrentHashMap<String, ReentrantLock> fileLocks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, ReentrantLock> fileLocks = new ConcurrentHashMap<>();
|
||||||
@ -240,35 +241,133 @@ public class StructuredMemoryService {
|
|||||||
return buildMemoryBlock(agentId, null);
|
return buildMemoryBlock(agentId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reserve left for structural overhead (headers, blank lines) when truncating a single oversized entry. */
|
||||||
|
private static final int BLOCK_STRUCTURE_RESERVE = 120;
|
||||||
|
|
||||||
/** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */
|
/** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */
|
||||||
public String buildMemoryBlock(Long agentId, String ownerKey) {
|
public String buildMemoryBlock(Long agentId, String ownerKey) {
|
||||||
StringBuilder sb = new StringBuilder();
|
int maxChars = Math.max(0, properties.getSystemBlockMaxChars());
|
||||||
boolean hasContent = false;
|
int maxPerType = Math.max(0, properties.getSystemBlockMaxEntriesPerType());
|
||||||
|
|
||||||
|
// 1. Collect every always-on entry with its update date and a global
|
||||||
|
// insertion index (file order, types in SYSTEM_PROMPT_TYPES order).
|
||||||
|
// Truncate any single entry larger than the whole budget so it can
|
||||||
|
// never blow the cap on its own.
|
||||||
|
int contentCap = maxChars > 0 ? Math.max(1, maxChars - BLOCK_STRUCTURE_RESERVE) : -1;
|
||||||
|
List<BlockEntry> all = new ArrayList<>();
|
||||||
|
int globalIndex = 0;
|
||||||
for (String type : SYSTEM_PROMPT_TYPES) {
|
for (String type : SYSTEM_PROMPT_TYPES) {
|
||||||
String fileContent = readFileSafe(agentId, toFilename(type), ownerKey);
|
String fileContent = readFileSafe(agentId, toFilename(type), ownerKey);
|
||||||
if (fileContent.isBlank()) continue;
|
if (fileContent.isBlank()) continue;
|
||||||
|
for (Map.Entry<String, String> entry : parseSections(fileContent).entrySet()) {
|
||||||
|
String content = extractContentOnly(entry.getValue());
|
||||||
|
if (content.isBlank()) continue;
|
||||||
|
if (contentCap >= 0 && content.length() > contentCap) {
|
||||||
|
content = content.substring(0, contentCap) + "…";
|
||||||
|
}
|
||||||
|
all.add(new BlockEntry(type, entry.getKey(), content,
|
||||||
|
extractUpdated(entry.getValue()), globalIndex++));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (all.isEmpty()) return "";
|
||||||
|
|
||||||
Map<String, String> sections = parseSections(fileContent);
|
// 2. Enforce the always-on budget against the TRUE rendered length
|
||||||
if (sections.isEmpty()) continue;
|
// (headers, blank lines, and the omission note all counted), keeping
|
||||||
|
// the most-recently-updated entries so accumulated memory cannot grow
|
||||||
|
// the per-turn context without bound.
|
||||||
|
Set<BlockEntry> kept = selectWithinBudget(all, maxChars, maxPerType);
|
||||||
|
int omitted = all.size() - kept.size();
|
||||||
|
return renderBlock(all, kept, omitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A candidate entry for the always-on block, with budget metadata. */
|
||||||
|
private record BlockEntry(String type, String key, String content,
|
||||||
|
String updated, int index) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the always-on block: survivors grouped by type, in original file
|
||||||
|
* order (stable ordering keeps the system prefix cacheable), followed by an
|
||||||
|
* omission note when entries were dropped.
|
||||||
|
*/
|
||||||
|
private String renderBlock(List<BlockEntry> all, Set<BlockEntry> kept, int omitted) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
boolean hasContent = false;
|
||||||
|
for (String type : SYSTEM_PROMPT_TYPES) {
|
||||||
|
List<BlockEntry> typeEntries = all.stream()
|
||||||
|
.filter(e -> e.type().equals(type) && kept.contains(e))
|
||||||
|
.sorted(Comparator.comparingInt(BlockEntry::index))
|
||||||
|
.toList();
|
||||||
|
if (typeEntries.isEmpty()) continue;
|
||||||
|
|
||||||
if (!hasContent) {
|
if (!hasContent) {
|
||||||
sb.append("## Structured Memory\n\n");
|
sb.append("## Structured Memory\n\n");
|
||||||
hasContent = true;
|
hasContent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.append("### ").append(typeDisplayName(type)).append("\n");
|
sb.append("### ").append(typeDisplayName(type)).append("\n");
|
||||||
for (Map.Entry<String, String> entry : sections.entrySet()) {
|
for (BlockEntry e : typeEntries) {
|
||||||
// Extract just the content line (skip metadata)
|
sb.append("- **").append(e.key()).append("**: ").append(e.content()).append("\n");
|
||||||
String content = extractContentOnly(entry.getValue());
|
|
||||||
sb.append("- **").append(entry.getKey()).append("**: ").append(content).append("\n");
|
|
||||||
}
|
}
|
||||||
sb.append("\n");
|
sb.append("\n");
|
||||||
}
|
}
|
||||||
|
if (omitted > 0) {
|
||||||
|
sb.append("> ").append(omitted)
|
||||||
|
.append(" older memory entries omitted to bound context size.\n");
|
||||||
|
}
|
||||||
return sb.toString().trim();
|
return sb.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the entries that fit the always-on injection budget, preferring the
|
||||||
|
* most-recently-updated ones. Applies a per-type entry cap first, then a
|
||||||
|
* global character budget measured against the actual rendered block (not
|
||||||
|
* just bullet lengths). Newer entries (later update date, then later
|
||||||
|
* insertion order) win; ties and missing dates fall back to insertion order.
|
||||||
|
*/
|
||||||
|
private Set<BlockEntry> selectWithinBudget(List<BlockEntry> all, int maxChars, int maxPerType) {
|
||||||
|
// Keep-priority: most recent update first, then most recently inserted.
|
||||||
|
Comparator<BlockEntry> newestFirst = Comparator
|
||||||
|
.comparing(BlockEntry::updated, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||||
|
.thenComparingInt(BlockEntry::index)
|
||||||
|
.reversed();
|
||||||
|
|
||||||
|
// Per-type cap: drop the oldest entries beyond the cap.
|
||||||
|
List<BlockEntry> survivors = new ArrayList<>(all);
|
||||||
|
if (maxPerType > 0) {
|
||||||
|
Set<BlockEntry> overflow = new HashSet<>();
|
||||||
|
for (String type : SYSTEM_PROMPT_TYPES) {
|
||||||
|
List<BlockEntry> ofType = survivors.stream()
|
||||||
|
.filter(e -> e.type().equals(type))
|
||||||
|
.sorted(newestFirst)
|
||||||
|
.toList();
|
||||||
|
if (ofType.size() > maxPerType) {
|
||||||
|
overflow.addAll(ofType.subList(maxPerType, ofType.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
survivors.removeAll(overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxChars <= 0) {
|
||||||
|
return new HashSet<>(survivors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global character budget: admit newest entries while the fully rendered
|
||||||
|
// block stays within budget. Measuring the real render (including the
|
||||||
|
// omission note) makes the cap exact; once an entry no longer fits, every
|
||||||
|
// remaining entry is older and is dropped too.
|
||||||
|
List<BlockEntry> ordered = survivors.stream().sorted(newestFirst).toList();
|
||||||
|
Set<BlockEntry> picked = new HashSet<>();
|
||||||
|
for (BlockEntry e : ordered) {
|
||||||
|
Set<BlockEntry> trial = new HashSet<>(picked);
|
||||||
|
trial.add(e);
|
||||||
|
int omittedIfStop = all.size() - trial.size();
|
||||||
|
if (renderBlock(all, trial, Math.max(0, omittedIfStop)).length() > maxChars) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
picked.add(e);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a query-conditioned memory block for per-turn prefetch injection.
|
* Build a query-conditioned memory block for per-turn prefetch injection.
|
||||||
* Scores {@link #PREFETCH_TYPES} entries against the user's question and returns
|
* Scores {@link #PREFETCH_TYPES} entries against the user's question and returns
|
||||||
@ -400,6 +499,97 @@ public class StructuredMemoryService {
|
|||||||
return "structured/" + type + ".md";
|
return "structured/" + type + ".md";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Consolidation support ====================
|
||||||
|
|
||||||
|
/** The always-on structured types injected into every system prompt. */
|
||||||
|
public List<String> alwaysOnTypes() {
|
||||||
|
return SYSTEM_PROMPT_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the raw Markdown of a structured type file (owner-scoped when personal). */
|
||||||
|
public String readTypeRaw(Long agentId, String type, String ownerKey) {
|
||||||
|
validateType(type);
|
||||||
|
return readFileSafe(agentId, toFilename(type), ownerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count the {@code ## key} entries in a structured file's raw Markdown. */
|
||||||
|
public int countEntries(String rawContent) {
|
||||||
|
return (rawContent == null || rawContent.isBlank()) ? 0 : parseSections(rawContent).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct buckets that hold entries for a structured type and are eligible
|
||||||
|
* for consolidation: the shared bucket (returned as {@code null}) plus each
|
||||||
|
* personal owner that has its own row. Lets the nightly maintenance pass
|
||||||
|
* consolidate per-owner memory, where most growth actually accumulates.
|
||||||
|
*/
|
||||||
|
public List<String> consolidatableOwnerKeys(Long agentId, String type) {
|
||||||
|
validateType(type);
|
||||||
|
String filename = toFilename(type);
|
||||||
|
List<String> owners = new ArrayList<>();
|
||||||
|
owners.add(null); // shared (TEAM/GLOBAL) bucket
|
||||||
|
for (WorkspaceFileEntity f : workspaceFileService.listFiles(agentId)) {
|
||||||
|
if (filename.equals(f.getFilename()) && isPersonal(f.getOwnerKey())
|
||||||
|
&& !owners.contains(f.getOwnerKey())) {
|
||||||
|
owners.add(f.getOwnerKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically replace all entries of a structured type with a consolidated set,
|
||||||
|
* re-serialized in the canonical {@code ## key / content / > Source | Updated}
|
||||||
|
* format. Used by the nightly consolidation pass to shrink always-on memory.
|
||||||
|
* Insertion order of {@code entries} is preserved.
|
||||||
|
* <p>
|
||||||
|
* Update dates are preserved per key: an entry whose key already existed keeps
|
||||||
|
* its original {@code Updated} date, and a newly-merged key inherits the newest
|
||||||
|
* date among the existing entries. This keeps recency/LRU semantics intact —
|
||||||
|
* consolidation must not make a batch of old facts look freshly written.
|
||||||
|
*/
|
||||||
|
public void replaceTypeEntries(Long agentId, String type, String ownerKey,
|
||||||
|
LinkedHashMap<String, String> entries, String source) {
|
||||||
|
validateType(type);
|
||||||
|
String filename = toFilename(type);
|
||||||
|
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename;
|
||||||
|
ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
// Derive prior update dates so consolidation preserves provenance.
|
||||||
|
Map<String, String> keyToDate = new HashMap<>();
|
||||||
|
String newestDate = "";
|
||||||
|
for (Map.Entry<String, String> s : parseSections(readFileSafe(agentId, filename, ownerKey)).entrySet()) {
|
||||||
|
String d = extractUpdated(s.getValue());
|
||||||
|
if (!d.isEmpty()) {
|
||||||
|
keyToDate.put(s.getKey(), d);
|
||||||
|
if (d.compareTo(newestDate) > 0) newestDate = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String fallbackDate = newestDate.isEmpty() ? LocalDate.now().toString() : newestDate;
|
||||||
|
String src = source != null ? source : "consolidation";
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (Map.Entry<String, String> e : entries.entrySet()) {
|
||||||
|
if (e.getKey() == null || e.getKey().isBlank()
|
||||||
|
|| e.getValue() == null || e.getValue().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = e.getKey().trim();
|
||||||
|
String date = keyToDate.getOrDefault(key, fallbackDate);
|
||||||
|
if (sb.length() > 0) sb.append("\n\n");
|
||||||
|
sb.append("## ").append(key).append("\n")
|
||||||
|
.append(e.getValue().trim())
|
||||||
|
.append("\n> Source: ").append(src).append(" | Updated: ").append(date);
|
||||||
|
}
|
||||||
|
saveStructured(agentId, filename, sb.toString(), ownerKey);
|
||||||
|
log.info("[StructuredMemory] Replaced {} entries in '{}' for agent={} owner={} (source={})",
|
||||||
|
entries.size(), filename, agentId, ownerKey, src);
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void validateType(String type) {
|
private void validateType(String type) {
|
||||||
if (!VALID_TYPES.contains(type)) {
|
if (!VALID_TYPES.contains(type)) {
|
||||||
throw new IllegalArgumentException("Invalid memory type: " + type
|
throw new IllegalArgumentException("Invalid memory type: " + type
|
||||||
|
|||||||
@ -23,10 +23,14 @@ public class PlanningController {
|
|||||||
|
|
||||||
private final PlanningService planningService;
|
private final PlanningService planningService;
|
||||||
|
|
||||||
@Operation(summary = "获取 Agent 的计划列表")
|
@Operation(summary = "获取计划列表(带 agentId 则按员工,否则跨员工取最近 N 条)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public R<List<PlanEntity>> listByAgent(@RequestParam String agentId) {
|
public R<List<PlanEntity>> list(@RequestParam(required = false) String agentId,
|
||||||
return R.ok(planningService.listPlansByAgent(agentId));
|
@RequestParam(required = false, defaultValue = "100") int limit) {
|
||||||
|
if (agentId != null && !agentId.isBlank()) {
|
||||||
|
return R.ok(planningService.listPlansByAgent(agentId));
|
||||||
|
}
|
||||||
|
return R.ok(planningService.listRecentPlans(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取计划详情(含步骤)")
|
@Operation(summary = "获取计划详情(含步骤)")
|
||||||
|
|||||||
@ -21,6 +21,9 @@ public class PlanEntity {
|
|||||||
/** 关联的 Agent ID(字符串) */
|
/** 关联的 Agent ID(字符串) */
|
||||||
private String agentId;
|
private String agentId;
|
||||||
|
|
||||||
|
/** 产生该计划的对话/运行 ID(可空,历史行为 null)。用于把计划绑定到具体运行、支持跨员工/协同分组。 */
|
||||||
|
private String conversationId;
|
||||||
|
|
||||||
/** 任务目标 */
|
/** 任务目标 */
|
||||||
private String goal;
|
private String goal;
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,14 @@ public class SubPlanEntity {
|
|||||||
/** 步骤状态:pending / running / completed / failed */
|
/** 步骤状态:pending / running / completed / failed */
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegated agent id for this step. When non-null, the executor routes this
|
||||||
|
* step to that specialist agent instead of the parent (plan) agent. Null =
|
||||||
|
* run with the parent agent (original behavior). The frontend resolves the
|
||||||
|
* display name from this id via the agent store.
|
||||||
|
*/
|
||||||
|
private Long assignedAgentId;
|
||||||
|
|
||||||
/** 步骤执行结果 */
|
/** 步骤执行结果 */
|
||||||
@TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String result;
|
private String result;
|
||||||
|
|||||||
@ -34,10 +34,36 @@ public class PlanningService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public PlanEntity createPlan(String agentId, String goal, List<String> steps) {
|
public PlanEntity createPlan(String agentId, String goal, List<String> steps) {
|
||||||
|
return createPlan(agentId, null, goal, steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建执行计划,并绑定到产生它的对话/运行。
|
||||||
|
* conversationId 可空(历史调用方),便于把计划归到某次运行,支撑跨员工/协同看板。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public PlanEntity createPlan(String agentId, String conversationId, String goal, List<String> steps) {
|
||||||
|
return createPlan(agentId, conversationId, goal, steps, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建执行计划,并为每个步骤可选地指派专职子 agent。
|
||||||
|
* stepAgentIds 与 steps 等长同序(按位置对应);某位为 null 表示该步骤由父 agent 执行。
|
||||||
|
* stepAgentIds 整体可空(无委派的历史调用方)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public PlanEntity createPlan(String agentId, String conversationId, String goal,
|
||||||
|
List<String> steps, List<Long> stepAgentIds) {
|
||||||
PlanEntity plan = new PlanEntity();
|
PlanEntity plan = new PlanEntity();
|
||||||
plan.setAgentId(agentId);
|
plan.setAgentId(agentId);
|
||||||
|
plan.setConversationId(conversationId);
|
||||||
plan.setGoal(goal);
|
plan.setGoal(goal);
|
||||||
plan.setStatus("running");
|
// A freshly generated plan is queued, not yet running: it sits in the
|
||||||
|
// board's "pending" column until its first step actually starts
|
||||||
|
// (updateSubPlanStatus promotes the plan to "running" then). This makes
|
||||||
|
// the board's pending column meaningful for queued / approval-gated
|
||||||
|
// plans instead of being perpetually empty.
|
||||||
|
plan.setStatus("pending");
|
||||||
plan.setTotalSteps(steps.size());
|
plan.setTotalSteps(steps.size());
|
||||||
plan.setCompletedSteps(0);
|
plan.setCompletedSteps(0);
|
||||||
planMapper.insert(plan);
|
planMapper.insert(plan);
|
||||||
@ -48,6 +74,11 @@ public class PlanningService {
|
|||||||
sub.setStepIndex(i);
|
sub.setStepIndex(i);
|
||||||
sub.setDescription(steps.get(i));
|
sub.setDescription(steps.get(i));
|
||||||
sub.setStatus("pending");
|
sub.setStatus("pending");
|
||||||
|
// Per-step delegation: only set when an assignment exists for this
|
||||||
|
// index; otherwise the step runs with the parent agent.
|
||||||
|
if (stepAgentIds != null && i < stepAgentIds.size()) {
|
||||||
|
sub.setAssignedAgentId(stepAgentIds.get(i));
|
||||||
|
}
|
||||||
subPlanMapper.insert(sub);
|
subPlanMapper.insert(sub);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,6 +95,13 @@ public class PlanningService {
|
|||||||
sub.setStatus(status);
|
sub.setStatus(status);
|
||||||
if ("running".equals(status)) {
|
if ("running".equals(status)) {
|
||||||
sub.setStartTime(LocalDateTime.now());
|
sub.setStartTime(LocalDateTime.now());
|
||||||
|
// Promote the parent plan out of the "pending" (queued) column
|
||||||
|
// the moment its first step actually starts executing.
|
||||||
|
PlanEntity plan = planMapper.selectById(planId);
|
||||||
|
if (plan != null && "pending".equals(plan.getStatus())) {
|
||||||
|
plan.setStatus("running");
|
||||||
|
planMapper.updateById(plan);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subPlanMapper.updateById(sub);
|
subPlanMapper.updateById(sub);
|
||||||
}
|
}
|
||||||
@ -111,6 +149,17 @@ public class PlanningService {
|
|||||||
.orderByDesc(PlanEntity::getCreateTime));
|
.orderByDesc(PlanEntity::getCreateTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨员工获取最近的计划列表(用于团队/泳道看板)。
|
||||||
|
* 按创建时间倒序,limit 兜底防止全表拉取。
|
||||||
|
*/
|
||||||
|
public List<PlanEntity> listRecentPlans(int limit) {
|
||||||
|
int capped = limit <= 0 ? 100 : Math.min(limit, 500);
|
||||||
|
return planMapper.selectList(new LambdaQueryWrapper<PlanEntity>()
|
||||||
|
.orderByDesc(PlanEntity::getCreateTime)
|
||||||
|
.last("LIMIT " + capped));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取计划详情(含子计划)
|
* 获取计划详情(含子计划)
|
||||||
*/
|
*/
|
||||||
@ -135,6 +184,17 @@ public class PlanningService {
|
|||||||
.orderByAsc(SubPlanEntity::getStepIndex));
|
.orderByAsc(SubPlanEntity::getStepIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent delegated to run a given step, or {@code null} when the step
|
||||||
|
* runs with the parent (plan) agent. Read by the executor to route a step to
|
||||||
|
* its specialist agent. Sourced from the DB (not graph state) so it survives
|
||||||
|
* replay / approval-resume.
|
||||||
|
*/
|
||||||
|
public Long getStepAssignedAgent(Long planId, int stepIndex) {
|
||||||
|
SubPlanEntity sub = getSubPlan(planId, stepIndex);
|
||||||
|
return sub != null ? sub.getAssignedAgentId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记计划失败
|
* 标记计划失败
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.channel.ChannelManager;
|
import vip.mate.channel.ChannelManager;
|
||||||
import vip.mate.llm.service.ModelProviderService;
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
@ -65,7 +66,9 @@ public class PluginManager {
|
|||||||
* Load all plugins on application startup.
|
* Load all plugins on application startup.
|
||||||
* Scans three paths in priority order: workspace > user-global.
|
* Scans three paths in priority order: workspace > user-global.
|
||||||
* Higher priority plugins shadow lower priority ones with the same name.
|
* Higher priority plugins shadow lower priority ones with the same name.
|
||||||
|
* {@code @Async} — 文件系统扫描和 JAR 类加载不阻塞主启动线程。
|
||||||
*/
|
*/
|
||||||
|
@Async
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@Order(250)
|
@Order(250)
|
||||||
public void loadAllPlugins() {
|
public void loadAllPlugins() {
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import java.util.zip.ZipInputStream;
|
|||||||
* <li>Zip Slip path traversal</li>
|
* <li>Zip Slip path traversal</li>
|
||||||
* <li>Per-file ≤1MB, total ≤50MB</li>
|
* <li>Per-file ≤1MB, total ≤50MB</li>
|
||||||
* <li>Only SKILL.md / references/ / scripts/ entries are kept</li>
|
* <li>Only SKILL.md / references/ / scripts/ entries are kept</li>
|
||||||
|
* <li>Binary entries are skipped with a WARN — bundle storage is text-only,
|
||||||
|
* so decoding them as text would persist corrupted content</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Extraction is two-pass: the entire archive is buffered in memory first
|
* <p>Extraction is two-pass: the entire archive is buffered in memory first
|
||||||
@ -229,6 +231,23 @@ public class ZipSkillFetcher {
|
|||||||
throw new IOException("Total extracted size exceeds 50MB limit");
|
throw new IOException("Total extracted size exceeds 50MB limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skill bundles persist file contents as text (mate_skill_file
|
||||||
|
// is a TEXT column; SkillBundle carries Map<String,String>).
|
||||||
|
// Decoding a binary entry (.png/.woff/.zip/compiled helper, …)
|
||||||
|
// as text replaces every invalid byte with U+FFFD, so the file
|
||||||
|
// would be stored permanently corrupted and "restored" broken
|
||||||
|
// on every sync. Binary resources are not supported in a bundle
|
||||||
|
// today, so skip them with a clear WARN instead of silently
|
||||||
|
// mangling them — matches how unknown root-level files are
|
||||||
|
// already handled below. (Root-level binaries were already
|
||||||
|
// dropped; this also covers binaries nested in scripts/ and
|
||||||
|
// references/, which previously slipped through corrupted.)
|
||||||
|
if (isLikelyBinary(bytes)) {
|
||||||
|
log.warn("[ZipSkillFetcher] Skipping binary entry (not supported in skill bundles): {}", entryName);
|
||||||
|
zis.closeEntry();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
String content = new String(bytes, charset);
|
String content = new String(bytes, charset);
|
||||||
String normalizedName = entryPath.toString().replace('\\', '/');
|
String normalizedName = entryPath.toString().replace('\\', '/');
|
||||||
String fileName = entryPath.getFileName().toString();
|
String fileName = entryPath.getFileName().toString();
|
||||||
@ -289,6 +308,27 @@ public class ZipSkillFetcher {
|
|||||||
return new ExtractedSkill(skillMdContent, references, scripts);
|
return new ExtractedSkill(skillMdContent, references, scripts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic binary detector: an entry is treated as binary if a NUL byte
|
||||||
|
* (0x00) appears within the inspected prefix. UTF-8 and GBK text never
|
||||||
|
* contain a NUL, while virtually every binary format (PNG/WOFF/ZIP/class/
|
||||||
|
* native executable) carries one near the start — this is the same cheap,
|
||||||
|
* reliable test git uses to decide "is this a text file". Inspecting only a
|
||||||
|
* prefix keeps it O(1) for large entries.
|
||||||
|
*/
|
||||||
|
private static boolean isLikelyBinary(byte[] bytes) {
|
||||||
|
if (bytes == null || bytes.length == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int limit = Math.min(bytes.length, 8000);
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
if (bytes[i] == 0x00) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a root-level file (sibling of SKILL.md, no directory prefix)
|
* Classify a root-level file (sibling of SKILL.md, no directory prefix)
|
||||||
* by extension. Returns {@code "scripts"} / {@code "references"} for
|
* by extension. Returns {@code "scripts"} / {@code "references"} for
|
||||||
|
|||||||
@ -495,7 +495,10 @@ public class SkillRuntimeService {
|
|||||||
sb.append("To read a skill's reference or script files, use ");
|
sb.append("To read a skill's reference or script files, use ");
|
||||||
sb.append("`readSkillFile(skillName=<name>, filePath=\"references/...\")`. ");
|
sb.append("`readSkillFile(skillName=<name>, filePath=\"references/...\")`. ");
|
||||||
sb.append("Skills with a `scripts/` directory expose `runSkillScript`; ");
|
sb.append("Skills with a `scripts/` directory expose `runSkillScript`; ");
|
||||||
sb.append("SKILL.md will name the script when needed.\n\n");
|
sb.append("SKILL.md will name the script when needed. ");
|
||||||
|
sb.append("If a skill describes steps but ships no runnable script, write the code ");
|
||||||
|
sb.append("its instructions describe and run it with ");
|
||||||
|
sb.append("`execute_code(language=<python|bash|node>, code=..., skillName=<name>)`.\n\n");
|
||||||
sb.append("| Skill | Status | Description |\n");
|
sb.append("| Skill | Status | Description |\n");
|
||||||
sb.append("|-------|--------|-------------|\n");
|
sb.append("|-------|--------|-------------|\n");
|
||||||
for (ResolvedSkill skill : selected) {
|
for (ResolvedSkill skill : selected) {
|
||||||
|
|||||||
@ -28,10 +28,22 @@ import java.util.concurrent.TimeUnit;
|
|||||||
public class SkillScriptExecutionService {
|
public class SkillScriptExecutionService {
|
||||||
|
|
||||||
private static final long DEFAULT_TIMEOUT_SECONDS = 30;
|
private static final long DEFAULT_TIMEOUT_SECONDS = 30;
|
||||||
|
private static final long MAX_TIMEOUT_SECONDS = 300;
|
||||||
private static final int MAX_OUTPUT_BYTES = 50_000;
|
private static final int MAX_OUTPUT_BYTES = 50_000;
|
||||||
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
|
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
|
||||||
.toLowerCase(Locale.ROOT).contains("win");
|
.toLowerCase(Locale.ROOT).contains("win");
|
||||||
|
|
||||||
|
/** Supported inline-code languages mapped to the temp-file extension. */
|
||||||
|
private static final Map<String, String> LANGUAGE_EXTENSIONS = Map.of(
|
||||||
|
"python", ".py",
|
||||||
|
"py", ".py",
|
||||||
|
"bash", ".sh",
|
||||||
|
"sh", ".sh",
|
||||||
|
"shell", ".sh",
|
||||||
|
"node", ".js",
|
||||||
|
"javascript", ".js",
|
||||||
|
"js", ".js");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行脚本(兼容签名 — 不注入额外 env vars)
|
* 执行脚本(兼容签名 — 不注入额外 env vars)
|
||||||
*
|
*
|
||||||
@ -56,6 +68,85 @@ public class SkillScriptExecutionService {
|
|||||||
* @return 执行结果
|
* @return 执行结果
|
||||||
*/
|
*/
|
||||||
public ScriptResult execute(Path scriptPath, List<String> args, Map<String, String> envVars) {
|
public ScriptResult execute(Path scriptPath, List<String> args, Map<String, String> envVars) {
|
||||||
|
return executeResolved(scriptPath, args, envVars, DEFAULT_TIMEOUT_SECONDS, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute LLM-generated source code inline, without a pre-existing script file.
|
||||||
|
* <p>
|
||||||
|
* Materializes {@code code} into a temporary file (extension chosen from
|
||||||
|
* {@code language}) inside {@code workingDir}, runs it through the same
|
||||||
|
* interpreter-selection + timeout + output-capping + env-injection path as
|
||||||
|
* {@link #execute(Path, List, Map)}, then deletes the temp file.
|
||||||
|
*
|
||||||
|
* <p>This is what makes a documentation-only skill (a SKILL.md with no
|
||||||
|
* {@code scripts:} entries) runnable: the agent reads the instructions,
|
||||||
|
* generates code, and runs it here.
|
||||||
|
*
|
||||||
|
* @param language one of python / bash / node (and aliases); selects the interpreter
|
||||||
|
* @param code the source code to run; must be non-blank
|
||||||
|
* @param workingDir directory the temp file is written to and the process cwd. When {@code null}
|
||||||
|
* a private temp scratch directory is created and removed afterward; when
|
||||||
|
* non-null it must be an existing directory (e.g. a skill or workspace dir)
|
||||||
|
* @param args optional positional arguments passed to the program
|
||||||
|
* @param envVars optional env vars injected into the subprocess (e.g. decrypted skill secrets)
|
||||||
|
* @param timeoutSeconds optional timeout override; clamped to (0, {@value #MAX_TIMEOUT_SECONDS}], defaults to {@value #DEFAULT_TIMEOUT_SECONDS}
|
||||||
|
* @return execution result
|
||||||
|
*/
|
||||||
|
public ScriptResult executeCode(String language, String code, Path workingDir,
|
||||||
|
List<String> args, Map<String, String> envVars, Long timeoutSeconds) {
|
||||||
|
if (code == null || code.isBlank()) {
|
||||||
|
return ScriptResult.error(-1, "No code supplied");
|
||||||
|
}
|
||||||
|
String ext = language == null ? null
|
||||||
|
: LANGUAGE_EXTENSIONS.get(language.trim().toLowerCase(Locale.ROOT));
|
||||||
|
if (ext == null) {
|
||||||
|
return ScriptResult.error(-1, "Unsupported language: " + language
|
||||||
|
+ ". Supported: python, bash, node");
|
||||||
|
}
|
||||||
|
if (workingDir != null && !Files.isDirectory(workingDir)) {
|
||||||
|
return ScriptResult.error(-1, "Working directory does not exist: " + workingDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
long timeout = DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
if (timeoutSeconds != null && timeoutSeconds > 0) {
|
||||||
|
timeout = Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No caller-supplied directory (e.g. an agent with no workspace base path):
|
||||||
|
// run in a private scratch directory and remove it afterward. Mirrors the
|
||||||
|
// shell tool tolerating a null working directory rather than failing.
|
||||||
|
Path scratchDir = null;
|
||||||
|
Path codeFile = null;
|
||||||
|
try {
|
||||||
|
Path dir = workingDir;
|
||||||
|
if (dir == null) {
|
||||||
|
scratchDir = Files.createTempDirectory("mc_code_ws_");
|
||||||
|
dir = scratchDir;
|
||||||
|
}
|
||||||
|
// Write the code into the working dir so the process cwd matches the
|
||||||
|
// file location — relative paths in the generated code resolve as the
|
||||||
|
// author expects, and skill-scoped runs stay inside the skill dir.
|
||||||
|
codeFile = Files.createTempFile(dir, "mc_code_", ext);
|
||||||
|
Files.writeString(codeFile, code, StandardCharsets.UTF_8);
|
||||||
|
if (!IS_WINDOWS && ext.equals(".sh")) {
|
||||||
|
codeFile.toFile().setExecutable(true);
|
||||||
|
}
|
||||||
|
// Scrub sensitive host env vars: the code is LLM-authored, so it must
|
||||||
|
// not inherit the server's API keys / tokens. Skill secrets, when
|
||||||
|
// supplied via envVars, are re-added on top.
|
||||||
|
return executeResolved(codeFile, args, envVars, timeout, true);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to materialize inline code: {}", e.getMessage());
|
||||||
|
return ScriptResult.error(-1, "Failed to write code file: " + e.getMessage());
|
||||||
|
} finally {
|
||||||
|
deleteQuietly(codeFile);
|
||||||
|
deleteDirQuietly(scratchDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScriptResult executeResolved(Path scriptPath, List<String> args, Map<String, String> envVars,
|
||||||
|
long timeoutSeconds, boolean scrubSensitiveEnv) {
|
||||||
if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) {
|
if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) {
|
||||||
return ScriptResult.error(-1, "Script not found: " + scriptPath);
|
return ScriptResult.error(-1, "Script not found: " + scriptPath);
|
||||||
}
|
}
|
||||||
@ -113,7 +204,15 @@ public class SkillScriptExecutionService {
|
|||||||
pb.directory(scriptPath.getParent().toFile());
|
pb.directory(scriptPath.getParent().toFile());
|
||||||
pb.redirectOutput(stdoutFile.toFile());
|
pb.redirectOutput(stdoutFile.toFile());
|
||||||
pb.redirectError(stderrFile.toFile());
|
pb.redirectError(stderrFile.toFile());
|
||||||
// RFC-091: inject per-skill secrets / settings as env vars.
|
// Strip secrets from the inherited environment before injecting the
|
||||||
|
// caller's own env. Used for LLM-authored inline code so it never
|
||||||
|
// sees the server's API keys / tokens via process inheritance.
|
||||||
|
if (scrubSensitiveEnv) {
|
||||||
|
pb.environment().keySet().removeIf(key ->
|
||||||
|
key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")
|
||||||
|
|| key.contains("PASSWORD") || key.contains("CREDENTIAL"));
|
||||||
|
}
|
||||||
|
// Inject per-skill secrets / settings as env vars.
|
||||||
// pb.environment() inherits the parent process env; putAll
|
// pb.environment() inherits the parent process env; putAll
|
||||||
// OVERRIDES same-named entries with the supplied values.
|
// OVERRIDES same-named entries with the supplied values.
|
||||||
// Null / blank values are skipped to avoid clearing
|
// Null / blank values are skipped to avoid clearing
|
||||||
@ -129,12 +228,12 @@ public class SkillScriptExecutionService {
|
|||||||
|
|
||||||
Process process = pb.start();
|
Process process = pb.start();
|
||||||
|
|
||||||
boolean finished = process.waitFor(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
killProcess(process);
|
killProcess(process);
|
||||||
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
|
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
|
||||||
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
|
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
|
||||||
String timeoutMsg = "[timeout after " + DEFAULT_TIMEOUT_SECONDS + "s]";
|
String timeoutMsg = "[timeout after " + timeoutSeconds + "s]";
|
||||||
stderr = stderr.isEmpty() ? timeoutMsg : stderr + "\n" + timeoutMsg;
|
stderr = stderr.isEmpty() ? timeoutMsg : stderr + "\n" + timeoutMsg;
|
||||||
return new ScriptResult(-1, stdout, stderr);
|
return new ScriptResult(-1, stdout, stderr);
|
||||||
}
|
}
|
||||||
@ -199,6 +298,16 @@ public class SkillScriptExecutionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recursively remove a scratch directory created for an inline-code run. */
|
||||||
|
private static void deleteDirQuietly(Path dir) {
|
||||||
|
if (dir == null) return;
|
||||||
|
try (var paths = Files.walk(dir)) {
|
||||||
|
paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
|
||||||
|
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||||
|
});
|
||||||
|
} catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
@lombok.Data
|
@lombok.Data
|
||||||
@lombok.AllArgsConstructor
|
@lombok.AllArgsConstructor
|
||||||
public static class ScriptResult {
|
public static class ScriptResult {
|
||||||
|
|||||||
@ -47,10 +47,36 @@ public class SkillWorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按约定解析 skill 工作区路径:{root}/{skillName}/
|
* Resolve the conventional skill workspace path: {@code {root}/{sanitizedName}}.
|
||||||
|
* <p>
|
||||||
|
* Deterministic in {@code skillName} alone (no filesystem-state dependency). The
|
||||||
|
* non-ASCII collision fixed in #254 comes from {@link #sanitizeNameForFs} preserving
|
||||||
|
* Unicode letters/digits, so distinct names already map to distinct directories. No
|
||||||
|
* {@code -hash} suffix is appended: skill names are charset-constrained, so two names
|
||||||
|
* sanitizing to the same string is not a real case, and keeping the bare name avoids
|
||||||
|
* changing the path scheme for every existing skill (which would orphan already-created
|
||||||
|
* workspaces with no migration).
|
||||||
*/
|
*/
|
||||||
public Path resolveConventionPath(String skillName) {
|
public Path resolveConventionPath(String skillName) {
|
||||||
return getWorkspaceRoot().resolve(sanitizeName(skillName));
|
return getWorkspaceRoot().resolve(sanitizeNameForFs(skillName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a skill name into a filesystem-safe directory segment.
|
||||||
|
* <p>
|
||||||
|
* Keeps the same charset as the legacy {@code [a-zA-Z0-9_.-]} rule but additionally
|
||||||
|
* preserves Unicode letters/digits ({@code \p{L}\p{N}}), so non-ASCII names no longer
|
||||||
|
* collapse to underscores and collide (#254). Everything else — path separators,
|
||||||
|
* control characters, whitespace — becomes {@code _}. Hyphens are kept (not folded to
|
||||||
|
* {@code _}) so existing kebab-case skill paths (e.g. {@code browser-cdp}) are unchanged
|
||||||
|
* across upgrades; only the Unicode handling differs from the legacy behavior.
|
||||||
|
*/
|
||||||
|
private String sanitizeNameForFs(String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return "unnamed";
|
||||||
|
}
|
||||||
|
String cleaned = name.strip().replaceAll("[^\\p{L}\\p{N}_.\\-]", "_");
|
||||||
|
return cleaned.isEmpty() ? "unnamed" : cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -116,11 +142,18 @@ public class SkillWorkspaceManager {
|
|||||||
Files.createDirectories(workspaceDir.resolve("scripts"));
|
Files.createDirectories(workspaceDir.resolve("scripts"));
|
||||||
|
|
||||||
Path skillMd = workspaceDir.resolve("SKILL.md");
|
Path skillMd = workspaceDir.resolve("SKILL.md");
|
||||||
if (overwrite || !Files.exists(skillMd)) {
|
String content = (initialContent != null && !initialContent.isBlank())
|
||||||
String content = (initialContent != null && !initialContent.isBlank())
|
? initialContent
|
||||||
? initialContent
|
: buildDefaultSkillMd(skillName);
|
||||||
: buildDefaultSkillMd(skillName);
|
if (overwrite) {
|
||||||
Files.writeString(skillMd, content);
|
Files.writeString(skillMd, content);
|
||||||
|
} else {
|
||||||
|
// 原子创建,避免并发上传时的 TOCTOU 竞态
|
||||||
|
try {
|
||||||
|
Files.writeString(skillMd, content, StandardOpenOption.CREATE_NEW);
|
||||||
|
} catch (FileAlreadyExistsException e) {
|
||||||
|
// 另一线程已创建,跳过写入
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Initialized skill workspace: {} (overwrite={})", workspaceDir, overwrite);
|
log.info("Initialized skill workspace: {} (overwrite={})", workspaceDir, overwrite);
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
package vip.mate.system.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.system.proxy.ProxyManager;
|
||||||
|
import vip.mate.system.proxy.ProxySettings;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global outbound-proxy configuration. System-wide, so reads require an admin
|
||||||
|
* and writes require the global admin.
|
||||||
|
*/
|
||||||
|
@Tag(name = "网络代理")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/settings/proxy")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProxyController {
|
||||||
|
|
||||||
|
private final ProxyManager proxyManager;
|
||||||
|
|
||||||
|
@Operation(summary = "获取全局代理配置")
|
||||||
|
@GetMapping
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<ProxyConfigResponse> get() {
|
||||||
|
return R.ok(toResponse(proxyManager.currentSettings()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "保存全局代理配置")
|
||||||
|
@PutMapping
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<ProxyConfigResponse> save(@RequestBody ProxyConfigRequest req) {
|
||||||
|
ProxySettings saved = proxyManager.save(
|
||||||
|
Boolean.TRUE.equals(req.getEnabled()),
|
||||||
|
req.getUrl(),
|
||||||
|
req.getNonProxyHosts());
|
||||||
|
if (saved.enabled() && !saved.valid()) {
|
||||||
|
return R.fail(saved.error());
|
||||||
|
}
|
||||||
|
return R.ok(toResponse(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "测试代理连通性")
|
||||||
|
@PostMapping("/test")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<ProxyManager.ProbeResult> test(@RequestBody ProxyConfigRequest req) {
|
||||||
|
ProxyManager.ProbeResult result = proxyManager.test(req.getUrl());
|
||||||
|
return R.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProxyConfigResponse toResponse(ProxySettings s) {
|
||||||
|
ProxyConfigResponse resp = new ProxyConfigResponse();
|
||||||
|
resp.setEnabled(s.enabled());
|
||||||
|
resp.setUrl(s.url());
|
||||||
|
resp.setNonProxyHosts(s.nonProxyHosts());
|
||||||
|
resp.setValid(s.valid());
|
||||||
|
resp.setError(s.error());
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class ProxyConfigRequest {
|
||||||
|
private Boolean enabled;
|
||||||
|
private String url;
|
||||||
|
private String nonProxyHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class ProxyConfigResponse {
|
||||||
|
private boolean enabled;
|
||||||
|
private String url;
|
||||||
|
private String nonProxyHosts;
|
||||||
|
private boolean valid;
|
||||||
|
private String error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,291 @@
|
|||||||
|
package vip.mate.system.proxy;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.Authenticator;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.PasswordAuthentication;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.net.ProxySelector;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs and refreshes the process-wide outbound proxy from the global
|
||||||
|
* {@code proxy.*} system settings.
|
||||||
|
*
|
||||||
|
* <p>When enabled, the configured proxy is applied through three mechanisms so
|
||||||
|
* it reaches every egress style in the backend with a single switch, instead of
|
||||||
|
* patching dozens of scattered HTTP-client construction sites:
|
||||||
|
* <ol>
|
||||||
|
* <li>A default {@link ProxySelector} — honored by every
|
||||||
|
* {@link java.net.http.HttpClient} built without an explicit proxy (LLM
|
||||||
|
* calls, model probes, most channel adapters, MCP, STT/TTS) and by
|
||||||
|
* {@code HttpURLConnection} (the Hutool-based search / media tools).</li>
|
||||||
|
* <li>{@code http(s).proxyHost}/{@code socksProxyHost} system properties — for
|
||||||
|
* libraries that read them directly.</li>
|
||||||
|
* <li>A static accessor ({@link #chromeProxyServer()}) the browser launcher
|
||||||
|
* reads to add {@code --proxy-server}, since Chromium does not honor the
|
||||||
|
* JVM proxy.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>SOCKS proxies are honored by {@code HttpURLConnection} but silently ignored
|
||||||
|
* by {@code java.net.http.HttpClient}; see {@link ProxySettings} for the
|
||||||
|
* resulting coverage boundary.
|
||||||
|
*
|
||||||
|
* <p>Adapters that carry their own per-channel proxy (Telegram / Discord set an
|
||||||
|
* explicit {@code .proxy()}) override the default selector, so this global proxy
|
||||||
|
* never fights a more specific one.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class ProxyManager {
|
||||||
|
|
||||||
|
static final String KEY_ENABLED = "proxy.enabled";
|
||||||
|
static final String KEY_URL = "proxy.url";
|
||||||
|
static final String KEY_NON_PROXY_HOSTS = "proxy.nonProxyHosts";
|
||||||
|
|
||||||
|
private static final String[] MANAGED_SYSTEM_PROPS = {
|
||||||
|
"http.proxyHost", "http.proxyPort",
|
||||||
|
"https.proxyHost", "https.proxyPort",
|
||||||
|
"http.nonProxyHosts",
|
||||||
|
"socksProxyHost", "socksProxyPort",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current {@code scheme://host:port} for Chromium's {@code --proxy-server},
|
||||||
|
* or {@code null} when no proxy is active. Static so the static browser
|
||||||
|
* launch-arg builder can read it without a bean reference.
|
||||||
|
*/
|
||||||
|
private static volatile String chromeProxyServer;
|
||||||
|
|
||||||
|
private final SystemSettingService settings;
|
||||||
|
|
||||||
|
/** The default selector present before we ever overrode it, for restore. */
|
||||||
|
private final ProxySelector originalDefaultSelector;
|
||||||
|
private final Authenticator originalDefaultAuthenticator;
|
||||||
|
private final AtomicReference<ProxySettings> current = new AtomicReference<>();
|
||||||
|
private volatile boolean authenticatorInstalled;
|
||||||
|
|
||||||
|
public ProxyManager(SystemSettingService settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.originalDefaultSelector = ProxySelector.getDefault();
|
||||||
|
this.originalDefaultAuthenticator = Authenticator.getDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chromium {@code --proxy-server} value, or {@code null} when inactive. */
|
||||||
|
public static String chromeProxyServer() {
|
||||||
|
return chromeProxyServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void onReady() {
|
||||||
|
try {
|
||||||
|
apply(readSettings());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to apply proxy settings at startup: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-read persisted settings and re-apply. Called after a config save. */
|
||||||
|
public synchronized void refresh() {
|
||||||
|
apply(readSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProxySettings currentSettings() {
|
||||||
|
ProxySettings s = current.get();
|
||||||
|
return s != null ? s : readSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProxySettings readSettings() {
|
||||||
|
boolean enabled = settings.getBool(KEY_ENABLED, false);
|
||||||
|
String url = settings.getString(KEY_URL, "");
|
||||||
|
String nph = settings.getString(KEY_NON_PROXY_HOSTS, "");
|
||||||
|
return ProxySettings.parse(enabled, url, nph);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist new config and apply it immediately. Returns the parsed result. */
|
||||||
|
public synchronized ProxySettings save(boolean enabled, String url, String nonProxyHosts) {
|
||||||
|
settings.saveBool(KEY_ENABLED, enabled, "Global outbound proxy enabled");
|
||||||
|
settings.saveString(KEY_URL, url == null ? "" : url.trim(), "Global outbound proxy url");
|
||||||
|
settings.saveString(KEY_NON_PROXY_HOSTS,
|
||||||
|
nonProxyHosts == null ? "" : nonProxyHosts.trim(),
|
||||||
|
"Global proxy bypass list (| separated)");
|
||||||
|
ProxySettings parsed = readSettings();
|
||||||
|
apply(parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void apply(ProxySettings s) {
|
||||||
|
current.set(s);
|
||||||
|
clearManagedSystemProps();
|
||||||
|
if (!s.active()) {
|
||||||
|
ProxySelector.setDefault(originalDefaultSelector);
|
||||||
|
uninstallAuthenticator();
|
||||||
|
chromeProxyServer = null;
|
||||||
|
if (s.enabled() && !s.valid()) {
|
||||||
|
log.warn("Global proxy is enabled but the url is invalid ({}); running without a proxy",
|
||||||
|
s.error());
|
||||||
|
} else {
|
||||||
|
log.info("Global proxy disabled; outbound traffic goes direct");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxySelector.setDefault(new GlobalProxySelector(s, originalDefaultSelector));
|
||||||
|
setSystemProps(s);
|
||||||
|
installAuthenticatorIfNeeded(s);
|
||||||
|
chromeProxyServer = s.chromeProxyServer();
|
||||||
|
log.info("Global proxy active: {} {}:{}{} (bypass: {})",
|
||||||
|
s.isSocks() ? "SOCKS" : "HTTP", s.host(), s.port(),
|
||||||
|
s.hasCredentials() ? " (auth)" : "", s.nonProxyHosts());
|
||||||
|
if (s.isSocks()) {
|
||||||
|
log.warn("SOCKS proxy applies to HttpURLConnection-based egress (search/media) only; "
|
||||||
|
+ "the java.net.http LLM/streaming path does not support SOCKS and will go direct");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setSystemProps(ProxySettings s) {
|
||||||
|
if (s.isSocks()) {
|
||||||
|
System.setProperty("socksProxyHost", s.host());
|
||||||
|
System.setProperty("socksProxyPort", String.valueOf(s.port()));
|
||||||
|
} else {
|
||||||
|
System.setProperty("http.proxyHost", s.host());
|
||||||
|
System.setProperty("http.proxyPort", String.valueOf(s.port()));
|
||||||
|
System.setProperty("https.proxyHost", s.host());
|
||||||
|
System.setProperty("https.proxyPort", String.valueOf(s.port()));
|
||||||
|
// JVM uses '|'-separated patterns here — same format we persist.
|
||||||
|
if (StringUtils.hasText(s.nonProxyHosts())) {
|
||||||
|
System.setProperty("http.nonProxyHosts", s.nonProxyHosts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearManagedSystemProps() {
|
||||||
|
for (String prop : MANAGED_SYSTEM_PROPS) {
|
||||||
|
System.clearProperty(prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void installAuthenticatorIfNeeded(ProxySettings s) {
|
||||||
|
if (!s.hasCredentials()) {
|
||||||
|
uninstallAuthenticator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String user = s.username();
|
||||||
|
final char[] pass = s.password() == null ? new char[0] : s.password().toCharArray();
|
||||||
|
// Allow Basic proxy auth over an HTTPS CONNECT tunnel (disabled by default since JDK 8u111).
|
||||||
|
System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "");
|
||||||
|
System.setProperty("jdk.http.auth.proxying.disabledSchemes", "");
|
||||||
|
Authenticator.setDefault(new Authenticator() {
|
||||||
|
@Override
|
||||||
|
protected PasswordAuthentication getPasswordAuthentication() {
|
||||||
|
if (getRequestorType() == RequestorType.PROXY) {
|
||||||
|
return new PasswordAuthentication(user, pass);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
authenticatorInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uninstallAuthenticator() {
|
||||||
|
if (authenticatorInstalled) {
|
||||||
|
Authenticator.setDefault(originalDefaultAuthenticator);
|
||||||
|
authenticatorInstalled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe reachability of the configured proxy by opening a TCP connection to
|
||||||
|
* its host:port. Confirms the proxy endpoint is listening (the common
|
||||||
|
* failure mode — proxy app not running / wrong port) without depending on
|
||||||
|
* upstream internet connectivity. Returns latency in milliseconds.
|
||||||
|
*/
|
||||||
|
public ProbeResult test(String url) {
|
||||||
|
ProxySettings s = ProxySettings.parse(true, url, null);
|
||||||
|
if (!s.valid()) {
|
||||||
|
return new ProbeResult(false, 0, s.error());
|
||||||
|
}
|
||||||
|
long start = System.nanoTime();
|
||||||
|
try (Socket socket = new Socket()) {
|
||||||
|
socket.connect(new InetSocketAddress(s.host(), s.port()), 4000);
|
||||||
|
long ms = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
return new ProbeResult(true, ms, null);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return new ProbeResult(false, 0, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of {@link #test(String)}. */
|
||||||
|
public record ProbeResult(boolean ok, long latencyMs, String error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes through the configured proxy unless the target host matches the
|
||||||
|
* bypass list, in which case it defers to the selector that was the default
|
||||||
|
* before we took over (preserving any pre-existing direct/proxy behavior).
|
||||||
|
*/
|
||||||
|
private static final class GlobalProxySelector extends ProxySelector {
|
||||||
|
private final List<Proxy> proxyList;
|
||||||
|
private final List<String> bypass;
|
||||||
|
private final ProxySelector fallback;
|
||||||
|
|
||||||
|
GlobalProxySelector(ProxySettings s, ProxySelector fallback) {
|
||||||
|
this.proxyList = List.of(s.toProxy());
|
||||||
|
this.bypass = s.bypassPatterns();
|
||||||
|
this.fallback = fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Proxy> select(URI uri) {
|
||||||
|
String host = uri == null ? null : uri.getHost();
|
||||||
|
if (host == null || matchesBypass(host)) {
|
||||||
|
return fallback != null ? fallback.select(uri) : List.of(Proxy.NO_PROXY);
|
||||||
|
}
|
||||||
|
return proxyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
|
||||||
|
if (fallback != null) {
|
||||||
|
fallback.connectFailed(uri, sa, ioe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesBypass(String host) {
|
||||||
|
for (String pattern : bypass) {
|
||||||
|
if (matches(pattern, host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Glob match with leading/trailing {@code *}, matching JVM nonProxyHosts semantics. */
|
||||||
|
private static boolean matches(String pattern, String host) {
|
||||||
|
if (pattern.equalsIgnoreCase(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pattern.startsWith("*") && pattern.endsWith("*") && pattern.length() > 2) {
|
||||||
|
return host.toLowerCase().contains(pattern.substring(1, pattern.length() - 1).toLowerCase());
|
||||||
|
}
|
||||||
|
if (pattern.startsWith("*")) {
|
||||||
|
return host.toLowerCase().endsWith(pattern.substring(1).toLowerCase());
|
||||||
|
}
|
||||||
|
if (pattern.endsWith("*")) {
|
||||||
|
return host.toLowerCase().startsWith(pattern.substring(0, pattern.length() - 1).toLowerCase());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
package vip.mate.system.proxy;
|
||||||
|
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed, validated form of the global outbound-proxy configuration.
|
||||||
|
*
|
||||||
|
* <p>The raw config is a single proxy URL — {@code http://127.0.0.1:7890},
|
||||||
|
* {@code https://host:443}, or {@code socks5://127.0.0.1:1080} — with optional
|
||||||
|
* {@code user:pass@} credentials. The scheme selects the proxy type:
|
||||||
|
* {@code socks}/{@code socks5}/{@code socks4} map to a SOCKS proxy, everything
|
||||||
|
* else to an HTTP proxy.
|
||||||
|
*
|
||||||
|
* <p>HTTP/HTTPS proxies are honored across the whole backend (the JDK
|
||||||
|
* {@link java.net.http.HttpClient} and {@code HttpURLConnection} both respect a
|
||||||
|
* default {@link java.net.ProxySelector}). SOCKS is only honored by the
|
||||||
|
* {@code HttpURLConnection}-based egress (search / media tools); the
|
||||||
|
* {@code java.net.http} client silently ignores a SOCKS proxy, so SOCKS does not
|
||||||
|
* cover the LLM / streaming path — callers must use an HTTP proxy for that.
|
||||||
|
*/
|
||||||
|
public final class ProxySettings {
|
||||||
|
|
||||||
|
/** Default bypass list applied when the user leaves the field blank. */
|
||||||
|
public static final String DEFAULT_NON_PROXY_HOSTS =
|
||||||
|
"localhost|127.*|[::1]|10.*|172.16.*|172.17.*|172.18.*|172.19.*|"
|
||||||
|
+ "172.2*|172.30.*|172.31.*|192.168.*|*.local";
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
private final String url;
|
||||||
|
private final String nonProxyHosts;
|
||||||
|
|
||||||
|
// Derived (only meaningful when valid()).
|
||||||
|
private final Proxy.Type type;
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private final String username;
|
||||||
|
private final String password;
|
||||||
|
private final boolean valid;
|
||||||
|
private final String error;
|
||||||
|
|
||||||
|
private ProxySettings(boolean enabled, String url, String nonProxyHosts,
|
||||||
|
Proxy.Type type, String host, int port,
|
||||||
|
String username, String password, boolean valid, String error) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
this.url = url;
|
||||||
|
this.nonProxyHosts = nonProxyHosts;
|
||||||
|
this.type = type;
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.valid = valid;
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse persisted values into a validated settings object. Never throws —
|
||||||
|
* an unparseable URL yields {@code valid() == false} with {@link #error()}
|
||||||
|
* populated, so a bad row can't crash startup.
|
||||||
|
*/
|
||||||
|
public static ProxySettings parse(boolean enabled, String url, String nonProxyHosts) {
|
||||||
|
String nph = StringUtils.hasText(nonProxyHosts) ? nonProxyHosts.trim() : DEFAULT_NON_PROXY_HOSTS;
|
||||||
|
if (!StringUtils.hasText(url)) {
|
||||||
|
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||||
|
"proxy url is empty");
|
||||||
|
}
|
||||||
|
String trimmed = url.trim();
|
||||||
|
URI uri;
|
||||||
|
try {
|
||||||
|
uri = new URI(trimmed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||||
|
"malformed proxy url: " + e.getMessage());
|
||||||
|
}
|
||||||
|
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase();
|
||||||
|
Proxy.Type proxyType = switch (scheme) {
|
||||||
|
case "socks", "socks5", "socks4" -> Proxy.Type.SOCKS;
|
||||||
|
case "http", "https" -> Proxy.Type.HTTP;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (proxyType == null) {
|
||||||
|
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||||
|
"unsupported proxy scheme: " + scheme + " (use http/https/socks5)");
|
||||||
|
}
|
||||||
|
String h = uri.getHost();
|
||||||
|
int p = uri.getPort();
|
||||||
|
if (!StringUtils.hasText(h) || p <= 0) {
|
||||||
|
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||||
|
"proxy url must include host and port, e.g. http://127.0.0.1:7890");
|
||||||
|
}
|
||||||
|
String user = null;
|
||||||
|
String pass = null;
|
||||||
|
String userInfo = uri.getUserInfo();
|
||||||
|
if (StringUtils.hasText(userInfo)) {
|
||||||
|
int idx = userInfo.indexOf(':');
|
||||||
|
if (idx >= 0) {
|
||||||
|
user = userInfo.substring(0, idx);
|
||||||
|
pass = userInfo.substring(idx + 1);
|
||||||
|
} else {
|
||||||
|
user = userInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ProxySettings(enabled, trimmed, nph, proxyType, h, p, user, pass, true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the proxy should actually be installed: enabled AND parseable. */
|
||||||
|
public boolean active() {
|
||||||
|
return enabled && valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean valid() {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String error() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String url() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String nonProxyHosts() {
|
||||||
|
return nonProxyHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Proxy.Type type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSocks() {
|
||||||
|
return type == Proxy.Type.SOCKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String host() {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int port() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String username() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String password() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasCredentials() {
|
||||||
|
return StringUtils.hasText(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A {@link Proxy} instance for explicit injection where needed. */
|
||||||
|
public Proxy toProxy() {
|
||||||
|
return new Proxy(type, new InetSocketAddress(host, port));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code --proxy-server} value for Chromium. Chromium accepts
|
||||||
|
* {@code scheme://host:port} but not embedded credentials, so userinfo is
|
||||||
|
* dropped here.
|
||||||
|
*/
|
||||||
|
public String chromeProxyServer() {
|
||||||
|
String scheme = isSocks() ? "socks5" : "http";
|
||||||
|
return scheme + "://" + host + ":" + port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the {@code |}-separated bypass list into individual patterns. */
|
||||||
|
public List<String> bypassPatterns() {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (!StringUtils.hasText(nonProxyHosts)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (String part : nonProxyHosts.split("\\|")) {
|
||||||
|
String t = part.trim();
|
||||||
|
if (!t.isEmpty()) {
|
||||||
|
out.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -67,7 +67,7 @@ public class SystemHealthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HealthResponse(overall, checks);
|
return new HealthResponse(overall, checks, bootstrapRunner.getDatabaseLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
private HealthCheck checkDefaultModel() {
|
private HealthCheck checkDefaultModel() {
|
||||||
@ -190,7 +190,7 @@ public class SystemHealthService {
|
|||||||
|
|
||||||
// ==================== Response Records ====================
|
// ==================== Response Records ====================
|
||||||
|
|
||||||
public record HealthResponse(String overall, List<HealthCheck> checks) {}
|
public record HealthResponse(String overall, List<HealthCheck> checks, String database) {}
|
||||||
|
|
||||||
public record HealthCheck(String name, String status, String message, HealthAction action) {}
|
public record HealthCheck(String name, String status, String message, HealthAction action) {}
|
||||||
|
|
||||||
|
|||||||
@ -365,6 +365,14 @@ public class BrowserLauncher {
|
|||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
args.add("--disable-gpu");
|
args.add("--disable-gpu");
|
||||||
}
|
}
|
||||||
|
// Chromium does not honor the JVM proxy selector / system properties, so
|
||||||
|
// route it explicitly when a global proxy is active. Bypass loopback so
|
||||||
|
// the local CDP endpoint and local services stay reachable.
|
||||||
|
String proxyServer = vip.mate.system.proxy.ProxyManager.chromeProxyServer();
|
||||||
|
if (proxyServer != null && !proxyServer.isBlank()) {
|
||||||
|
args.add("--proxy-server=" + proxyServer);
|
||||||
|
args.add("--proxy-bypass-list=<-loopback>");
|
||||||
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,222 @@
|
|||||||
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.llm.routing.AgentBindingResolver;
|
||||||
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
|
import vip.mate.skill.runtime.SkillScriptExecutionService;
|
||||||
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
import vip.mate.skill.secret.SkillSecretService;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in tool: execute LLM-generated source code inline.
|
||||||
|
* <p>
|
||||||
|
* Unlike {@code runSkillScript}, which runs a pre-existing file under a skill's
|
||||||
|
* {@code scripts/} directory, this tool accepts the code as text and runs it on
|
||||||
|
* the fly. It lets a documentation-only skill (a SKILL.md with no {@code scripts:}
|
||||||
|
* entries) be acted on: the agent reads the instructions, writes the code those
|
||||||
|
* instructions describe, and runs it here.
|
||||||
|
*
|
||||||
|
* <p>Safety:
|
||||||
|
* <ul>
|
||||||
|
* <li>Dangerous patterns in the code trigger ToolGuard approval/blocking — the
|
||||||
|
* tool name is registered as a shell-equivalent guarded tool.</li>
|
||||||
|
* <li>The subprocess does not inherit the server's secret env vars; only a
|
||||||
|
* bound skill's own declared secrets are injected.</li>
|
||||||
|
* <li>When {@code skillName} is given, the calling agent must be bound to that
|
||||||
|
* skill, and execution is scoped to the skill directory.</li>
|
||||||
|
* <li>Timeout defaults to 30s, hard-capped at 300s; output is truncated.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CodeExecuteTool {
|
||||||
|
|
||||||
|
private final SkillRuntimeService runtimeService;
|
||||||
|
private final SkillScriptExecutionService executionService;
|
||||||
|
private final SkillSecretService skillSecretService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
@Autowired
|
||||||
|
private AgentBindingResolver agentBindingResolver;
|
||||||
|
|
||||||
|
@vip.mate.tool.ConcurrencyUnsafe("code execution can have arbitrary side effects on the host process and filesystem")
|
||||||
|
@Tool(name = "execute_code", description = """
|
||||||
|
Execute a snippet of code you write, in python, bash, or node.
|
||||||
|
Use this to act on a skill whose SKILL.md describes steps but ships no runnable script:
|
||||||
|
read the instructions, write the code they describe, and run it here.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- language: one of "python", "bash", "node"
|
||||||
|
- code: the full source code to run
|
||||||
|
- skillName: optional. When set, the code runs inside that skill's directory
|
||||||
|
(so it can read the skill's reference/template files by relative path)
|
||||||
|
and the skill's stored secrets are injected as environment variables.
|
||||||
|
- args: optional positional arguments, given as ONE JSON-encoded string:
|
||||||
|
a JSON array for multiple args, or plain text for a single argument.
|
||||||
|
- timeoutSeconds: optional, default 30, max 300.
|
||||||
|
|
||||||
|
Returns: JSON with exitCode, stdout, stderr.
|
||||||
|
|
||||||
|
Security: dangerous operations trigger security approval. The server's own
|
||||||
|
secret environment variables are not exposed to the code.
|
||||||
|
""")
|
||||||
|
public String execute_code(
|
||||||
|
@JsonProperty(required = true)
|
||||||
|
@JsonPropertyDescription("Language: python, bash, or node")
|
||||||
|
String language,
|
||||||
|
|
||||||
|
@JsonProperty(required = true)
|
||||||
|
@JsonPropertyDescription("The full source code to run")
|
||||||
|
String code,
|
||||||
|
|
||||||
|
@JsonProperty(required = false)
|
||||||
|
@JsonPropertyDescription("Optional skill name to scope execution to and inject secrets from")
|
||||||
|
String skillName,
|
||||||
|
|
||||||
|
@JsonProperty(required = false)
|
||||||
|
@JsonPropertyDescription("Optional positional arguments as ONE JSON-encoded string: a JSON array for multiple args, or plain text for one literal argument.")
|
||||||
|
String args,
|
||||||
|
|
||||||
|
@JsonProperty(required = false)
|
||||||
|
@JsonPropertyDescription("Timeout in seconds, default 30, max 300")
|
||||||
|
Integer timeoutSeconds,
|
||||||
|
|
||||||
|
@Nullable ToolContext ctx
|
||||||
|
) {
|
||||||
|
log.info("[CodeExecute] language={}, skill={}, codeChars={}",
|
||||||
|
language, skillName, code == null ? 0 : code.length());
|
||||||
|
|
||||||
|
Path workingDir;
|
||||||
|
Map<String, String> envVars = Collections.emptyMap();
|
||||||
|
|
||||||
|
if (skillName != null && !skillName.isBlank()) {
|
||||||
|
// Skill-scoped run: validate binding + resolve the skill directory.
|
||||||
|
ResolvedSkill skill = runtimeService.findActiveSkill(skillName);
|
||||||
|
if (skill == null) {
|
||||||
|
return formatError("Skill '" + skillName + "' not found or not enabled");
|
||||||
|
}
|
||||||
|
Long agentId = ChatOrigin.from(ctx).agentId();
|
||||||
|
if (agentId != null) {
|
||||||
|
Set<Long> boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId);
|
||||||
|
if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) {
|
||||||
|
return formatError("Skill '" + skillName + "' is not available for this agent.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Directory-backed skills run inside their own directory so the code
|
||||||
|
// can read the skill's reference/template files by relative path. A
|
||||||
|
// database-backed skill (no directory) is still runnable: fall through
|
||||||
|
// with a null working dir so executeCode uses a private scratch dir.
|
||||||
|
// Either way the skill's stored secrets are injected.
|
||||||
|
workingDir = skill.getSkillDir();
|
||||||
|
if (skill.getId() != null) {
|
||||||
|
envVars = skillSecretService.getDecrypted(skill.getId());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Workspace-scoped run: prefer the agent's workspace base path so any
|
||||||
|
// files the code produces land where the user expects. When the agent
|
||||||
|
// has no workspace dir, pass null — executeCode then runs in a private
|
||||||
|
// temp scratch dir (same tolerance as the shell tool).
|
||||||
|
workingDir = WorkspacePathGuard.getWorkingDirectory(ctx);
|
||||||
|
if (workingDir != null && !Files.isDirectory(workingDir)) {
|
||||||
|
workingDir = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null;
|
||||||
|
List<String> argList = normalizeArgs(args);
|
||||||
|
|
||||||
|
try {
|
||||||
|
SkillScriptExecutionService.ScriptResult result =
|
||||||
|
executionService.executeCode(language, code, workingDir, argList, envVars, timeout);
|
||||||
|
return formatResult(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CodeExecute] Execution failed: {}", e.getMessage());
|
||||||
|
return formatError("Execution failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the JSON-encoded {@code args} string into a positional argument list,
|
||||||
|
* mirroring {@code SkillScriptTool.normalizeArgs}: a JSON array becomes one
|
||||||
|
* argument per element, anything else is forwarded verbatim as a single
|
||||||
|
* argument (so a bare date / version string is never mangled by JSON parsing).
|
||||||
|
*/
|
||||||
|
List<String> normalizeArgs(String args) {
|
||||||
|
if (args == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = args.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
char lead = trimmed.charAt(0);
|
||||||
|
if (lead == '[') {
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.reader()
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||||
|
.readTree(trimmed);
|
||||||
|
if (node != null && node.isArray()) {
|
||||||
|
List<String> out = new ArrayList<>(node.size());
|
||||||
|
for (JsonNode el : node) {
|
||||||
|
out.add(el.isTextual() ? el.asText() : el.toString());
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? null : out;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("execute_code: args not valid JSON array, forwarding verbatim: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatResult(SkillScriptExecutionService.ScriptResult result) {
|
||||||
|
return String.format(
|
||||||
|
"{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}",
|
||||||
|
result.getExitCode(),
|
||||||
|
jsonEscape(result.getStdout()),
|
||||||
|
jsonEscape(result.getStderr())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatError(String message) {
|
||||||
|
return String.format(
|
||||||
|
"{\n \"exitCode\": -1,\n \"stdout\": \"\",\n \"stderr\": %s\n}",
|
||||||
|
jsonEscape(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String jsonEscape(String str) {
|
||||||
|
if (str == null || str.isEmpty()) {
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
return "\"" + str.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
package vip.mate.tool.builtin;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
import cn.hutool.json.JSONArray;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import cn.hutool.json.JSONObject;
|
|
||||||
import cn.hutool.json.JSONUtil;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
@ -14,7 +12,10 @@ import vip.mate.agent.context.ChatOrigin;
|
|||||||
import vip.mate.cron.model.CronJobDTO;
|
import vip.mate.cron.model.CronJobDTO;
|
||||||
import vip.mate.cron.service.CronJobService;
|
import vip.mate.cron.service.CronJobService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in tool: scheduled task (cron job) management via chat.
|
* Built-in tool: scheduled task (cron job) management via chat.
|
||||||
@ -32,6 +33,14 @@ import java.util.List;
|
|||||||
public class CronJobTool {
|
public class CronJobTool {
|
||||||
|
|
||||||
private final CronJobService cronJobService;
|
private final CronJobService cronJobService;
|
||||||
|
/**
|
||||||
|
* The application ObjectMapper serializes every {@code Long} as a JSON string
|
||||||
|
* (see {@code JacksonConfig}). Tool output goes through it so a 19-digit
|
||||||
|
* Snowflake {@code jobId} reaches the model as a string — never a JSON number
|
||||||
|
* that loses its low digits in a double / JS Number round-trip on the way
|
||||||
|
* back into toggle_cron_job / delete_cron_job.
|
||||||
|
*/
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name")
|
@vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name")
|
||||||
@Tool(description = "Create a scheduled task that asks the agent to do something at a specific time — "
|
@Tool(description = "Create a scheduled task that asks the agent to do something at a specific time — "
|
||||||
@ -94,15 +103,15 @@ public class CronJobTool {
|
|||||||
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
||||||
CronJobDTO created = cronJobService.create(dto, workspaceId);
|
CronJobDTO created = cronJobService.create(dto, workspaceId);
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("success", true);
|
result.put("success", true);
|
||||||
result.set("jobId", created.getId());
|
result.put("jobId", created.getId());
|
||||||
result.set("name", created.getName());
|
result.put("name", created.getName());
|
||||||
result.set("cronExpression", created.getCronExpression());
|
result.put("cronExpression", created.getCronExpression());
|
||||||
result.set("timezone", created.getTimezone());
|
result.put("timezone", created.getTimezone());
|
||||||
result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : "");
|
result.put("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : "");
|
||||||
result.set("enabled", created.getEnabled());
|
result.put("enabled", created.getEnabled());
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return writeJson(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronJobTool] create failed: {}", e.getMessage());
|
log.error("[CronJobTool] create failed: {}", e.getMessage());
|
||||||
@ -154,16 +163,16 @@ public class CronJobTool {
|
|||||||
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
||||||
CronJobDTO created = cronJobService.create(dto, workspaceId);
|
CronJobDTO created = cronJobService.create(dto, workspaceId);
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("success", true);
|
result.put("success", true);
|
||||||
result.set("jobId", created.getId());
|
result.put("jobId", created.getId());
|
||||||
result.set("name", created.getName());
|
result.put("name", created.getName());
|
||||||
result.set("taskType", "reminder");
|
result.put("taskType", "reminder");
|
||||||
result.set("cronExpression", created.getCronExpression());
|
result.put("cronExpression", created.getCronExpression());
|
||||||
result.set("timezone", created.getTimezone());
|
result.put("timezone", created.getTimezone());
|
||||||
result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : "");
|
result.put("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : "");
|
||||||
result.set("enabled", created.getEnabled());
|
result.put("enabled", created.getEnabled());
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return writeJson(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronJobTool] create_reminder failed: {}", e.getMessage());
|
log.error("[CronJobTool] create_reminder failed: {}", e.getMessage());
|
||||||
@ -179,23 +188,23 @@ public class CronJobTool {
|
|||||||
// sees the cron jobs of the workspace it's running in.
|
// sees the cron jobs of the workspace it's running in.
|
||||||
Long workspaceId = workspaceFromContext(ctx);
|
Long workspaceId = workspaceFromContext(ctx);
|
||||||
List<CronJobDTO> jobs = cronJobService.list(workspaceId);
|
List<CronJobDTO> jobs = cronJobService.list(workspaceId);
|
||||||
JSONArray arr = new JSONArray();
|
List<Map<String, Object>> arr = new ArrayList<>();
|
||||||
for (CronJobDTO job : jobs) {
|
for (CronJobDTO job : jobs) {
|
||||||
JSONObject obj = new JSONObject();
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
obj.set("jobId", job.getId());
|
obj.put("jobId", job.getId());
|
||||||
obj.set("name", job.getName());
|
obj.put("name", job.getName());
|
||||||
obj.set("cronExpression", job.getCronExpression());
|
obj.put("cronExpression", job.getCronExpression());
|
||||||
obj.set("timezone", job.getTimezone());
|
obj.put("timezone", job.getTimezone());
|
||||||
obj.set("enabled", job.getEnabled());
|
obj.put("enabled", job.getEnabled());
|
||||||
obj.set("nextRunTime", job.getNextRunTime() != null ? job.getNextRunTime().toString() : "");
|
obj.put("nextRunTime", job.getNextRunTime() != null ? job.getNextRunTime().toString() : "");
|
||||||
obj.set("lastRunTime", job.getLastRunTime() != null ? job.getLastRunTime().toString() : "");
|
obj.put("lastRunTime", job.getLastRunTime() != null ? job.getLastRunTime().toString() : "");
|
||||||
obj.set("agentName", job.getAgentName());
|
obj.put("agentName", job.getAgentName());
|
||||||
arr.add(obj);
|
arr.add(obj);
|
||||||
}
|
}
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("totalJobs", jobs.size());
|
result.put("totalJobs", jobs.size());
|
||||||
result.set("jobs", arr);
|
result.put("jobs", arr);
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return writeJson(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronJobTool] list failed: {}", e.getMessage());
|
log.error("[CronJobTool] list failed: {}", e.getMessage());
|
||||||
return errorResult("Failed to list cron jobs: " + e.getMessage());
|
return errorResult("Failed to list cron jobs: " + e.getMessage());
|
||||||
@ -214,13 +223,13 @@ public class CronJobTool {
|
|||||||
Long workspaceId = workspaceFromContext(ctx);
|
Long workspaceId = workspaceFromContext(ctx);
|
||||||
cronJobService.toggle(jobId, enabled, workspaceId);
|
cronJobService.toggle(jobId, enabled, workspaceId);
|
||||||
CronJobDTO updated = cronJobService.getById(jobId, workspaceId);
|
CronJobDTO updated = cronJobService.getById(jobId, workspaceId);
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("success", true);
|
result.put("success", true);
|
||||||
result.set("jobId", jobId);
|
result.put("jobId", jobId);
|
||||||
result.set("name", updated.getName());
|
result.put("name", updated.getName());
|
||||||
result.set("enabled", updated.getEnabled());
|
result.put("enabled", updated.getEnabled());
|
||||||
result.set("nextRunTime", updated.getNextRunTime() != null ? updated.getNextRunTime().toString() : "");
|
result.put("nextRunTime", updated.getNextRunTime() != null ? updated.getNextRunTime().toString() : "");
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return writeJson(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronJobTool] toggle failed: {}", e.getMessage());
|
log.error("[CronJobTool] toggle failed: {}", e.getMessage());
|
||||||
return errorResult("Failed to toggle cron job: " + e.getMessage());
|
return errorResult("Failed to toggle cron job: " + e.getMessage());
|
||||||
@ -239,10 +248,10 @@ public class CronJobTool {
|
|||||||
CronJobDTO job = cronJobService.getById(jobId, workspaceId);
|
CronJobDTO job = cronJobService.getById(jobId, workspaceId);
|
||||||
String jobName = job.getName();
|
String jobName = job.getName();
|
||||||
cronJobService.delete(jobId, workspaceId);
|
cronJobService.delete(jobId, workspaceId);
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("success", true);
|
result.put("success", true);
|
||||||
result.set("deleted", jobName);
|
result.put("deleted", jobName);
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return writeJson(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronJobTool] delete failed: {}", e.getMessage());
|
log.error("[CronJobTool] delete failed: {}", e.getMessage());
|
||||||
return errorResult("Failed to delete cron job: " + e.getMessage());
|
return errorResult("Failed to delete cron job: " + e.getMessage());
|
||||||
@ -250,10 +259,25 @@ public class CronJobTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String errorResult(String message) {
|
private String errorResult(String message) {
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("success", false);
|
result.put("success", false);
|
||||||
result.set("error", message);
|
result.put("error", message);
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return writeJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize tool output through the id-safe application ObjectMapper so every
|
||||||
|
* {@code Long} (notably {@code jobId}) is rendered as a string. Falls back to
|
||||||
|
* a minimal literal on the rare serialization failure rather than throwing
|
||||||
|
* out of a tool call.
|
||||||
|
*/
|
||||||
|
private String writeJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CronJobTool] result serialization failed: {}", e.getMessage());
|
||||||
|
return "{\"success\":false,\"error\":\"result serialization failed\"}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package vip.mate.tool.builtin;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
import cn.hutool.json.JSONArray;
|
|
||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
@ -14,7 +14,9 @@ import vip.mate.datasource.service.DatasourceService;
|
|||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,6 +34,14 @@ public class DatasourceTool {
|
|||||||
|
|
||||||
private final DatasourceService datasourceService;
|
private final DatasourceService datasourceService;
|
||||||
private final DatasourceConnectionManager connectionManager;
|
private final DatasourceConnectionManager connectionManager;
|
||||||
|
/**
|
||||||
|
* The application ObjectMapper, which serializes every {@code Long} as a JSON
|
||||||
|
* string (see {@code JacksonConfig}). Tool output goes through it so 19-digit
|
||||||
|
* Snowflake ids reach the model as strings — exactly like the HTTP API — and
|
||||||
|
* never as JSON numbers that lose their low digits in a double/JS-number
|
||||||
|
* round-trip on the way back into a tool call.
|
||||||
|
*/
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/** SQL identifier whitelist: letters, digits, underscore, dot, hyphen only */
|
/** SQL identifier whitelist: letters, digits, underscore, dot, hyphen only */
|
||||||
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9_][a-zA-Z0-9_.\\-]{0,127}$");
|
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9_][a-zA-Z0-9_.\\-]{0,127}$");
|
||||||
@ -67,22 +77,25 @@ public class DatasourceTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String listDatasources() {
|
private String listDatasources() throws Exception {
|
||||||
List<DatasourceEntity> list = datasourceService.listEnabled();
|
List<DatasourceEntity> list = datasourceService.listEnabled();
|
||||||
JSONArray arr = new JSONArray();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
for (DatasourceEntity ds : list) {
|
for (DatasourceEntity ds : list) {
|
||||||
JSONObject obj = new JSONObject();
|
Map<String, Object> obj = new LinkedHashMap<>();
|
||||||
obj.set("id", ds.getId());
|
// ds.getId() is a Long; the shared ObjectMapper renders it as a JSON
|
||||||
obj.set("name", ds.getName());
|
// string so the model copies an exact id back into list_tables /
|
||||||
obj.set("dbType", ds.getDbType());
|
// execute_sql / describe_table calls.
|
||||||
obj.set("databaseName", ds.getDatabaseName());
|
obj.put("id", ds.getId());
|
||||||
obj.set("description", ds.getDescription());
|
obj.put("name", ds.getName());
|
||||||
arr.add(obj);
|
obj.put("dbType", ds.getDbType());
|
||||||
|
obj.put("databaseName", ds.getDatabaseName());
|
||||||
|
obj.put("description", ds.getDescription());
|
||||||
|
rows.add(obj);
|
||||||
}
|
}
|
||||||
JSONObject result = new JSONObject();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.set("datasources", arr);
|
result.put("datasources", rows);
|
||||||
result.set("count", arr.size());
|
result.put("count", rows.size());
|
||||||
return result.toStringPretty();
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String listTables(Long datasourceId) throws SQLException {
|
private String listTables(Long datasourceId) throws SQLException {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user