Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6caea824 | ||
|
|
84375da3c5 | ||
|
|
68c010ecf9 | ||
|
|
493910bf5a | ||
|
|
da8005a8cb | ||
|
|
1d64194e15 | ||
|
|
f47cf8c6be | ||
|
|
d994be3d04 | ||
|
|
9ada305b8a |
28
.dockerignore
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Git and IDE
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Node artifacts
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.nuxt
|
||||||
|
**/.output
|
||||||
|
|
||||||
|
# Maven build output (will be rebuilt in Docker)
|
||||||
|
**/target
|
||||||
|
|
||||||
|
# Desktop / webchat (not needed for server or sites build)
|
||||||
|
mateclaw-desktop
|
||||||
|
|
||||||
|
# Data and logs
|
||||||
|
data
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
!docs/**/*.md
|
||||||
|
!matevip-sites/**/*.md
|
||||||
|
!mateclaw-plugin-api/**
|
||||||
|
!mateclaw-server/**
|
||||||
92
.env.example
@ -1,18 +1,10 @@
|
|||||||
# MateClaw 环境变量配置
|
# MateClaw 环境变量配置
|
||||||
# 复制此文件为 .env 并填写实际值:cp .env.example .env
|
# 复制此文件为 .env 并填写实际值:cp .env.example .env
|
||||||
#
|
#
|
||||||
|
# LLM API Key(DashScope、OpenAI 等)无需在此配置,启动后在管理界面「模型管理」中添加。
|
||||||
|
#
|
||||||
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
|
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
|
||||||
|
|
||||||
# ==================== LLM / 搜索 ====================
|
|
||||||
|
|
||||||
# 阿里云 DashScope API Key(必填)
|
|
||||||
# 申请地址:https://dashscope.aliyun.com/
|
|
||||||
DASHSCOPE_API_KEY=your-dashscope-api-key-here
|
|
||||||
|
|
||||||
# Serper 网页搜索 API Key(可选,用于 WebSearch 工具)
|
|
||||||
# 申请地址:https://serper.dev/
|
|
||||||
SERPER_API_KEY=
|
|
||||||
|
|
||||||
# ==================== 数据库(Docker 模式必填) ====================
|
# ==================== 数据库(Docker 模式必填) ====================
|
||||||
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
@ -36,3 +28,83 @@ JWT_SECRET=
|
|||||||
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。
|
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。
|
||||||
# 若留空,服务器会允许所有 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+ 位随机串。
|
||||||
|
# openssl rand -hex 32
|
||||||
|
SEARXNG_SECRET=
|
||||||
|
|
||||||
|
# ==================== 浏览器工具(可选) ====================
|
||||||
|
#
|
||||||
|
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。
|
||||||
|
# 只有在下述场景才需要 override:
|
||||||
|
#
|
||||||
|
# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接:
|
||||||
|
# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222
|
||||||
|
#
|
||||||
|
# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome):
|
||||||
|
# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable
|
||||||
|
#
|
||||||
|
# 3) 强制使用 Playwright channel(chrome / msedge / chrome-beta …):
|
||||||
|
# MATECLAW_BROWSER_CHANNEL=chrome
|
||||||
|
MATECLAW_BROWSER_CDP_URL=
|
||||||
|
MATECLAW_BROWSER_CHROME_PATH=
|
||||||
|
MATECLAW_BROWSER_CHANNEL=
|
||||||
|
|
||||||
|
# ==================== OpenAI OAuth(Docker,可选) ====================
|
||||||
|
#
|
||||||
|
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code,
|
||||||
|
# 不需要自定义 client secret。
|
||||||
|
#
|
||||||
|
# 默认留空即可。后端会根据访问 Host 自动选择:
|
||||||
|
# - localhost / 127.0.0.1 / ::1 → LOCAL(PKCE 回调)
|
||||||
|
# - IP / 域名 / 反向代理访问 → DEVICE_CODE(无缝远程授权)
|
||||||
|
#
|
||||||
|
# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成
|
||||||
|
# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL,并让容器内
|
||||||
|
# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到:
|
||||||
|
# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local
|
||||||
|
# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0
|
||||||
|
#
|
||||||
|
# 强制模式调试时也可设为:local / device_code / manual_paste
|
||||||
|
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
|
||||||
|
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 镜像(国内加速)─────────────────────────────────────────
|
||||||
|
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||||
|
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||||
|
#MAVEN_FLAGS=-Paliyun-first
|
||||||
|
|||||||
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
name: "🐛 Bug Report (English)"
|
||||||
|
description: Report something that's broken. Three required fields — fill them and submit.
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report this. Three things, that's it — any less and we can't locate it; any more wastes your time.
|
||||||
|
|
||||||
|
> **Issues without a screenshot, log, or repro steps will be closed.** Not because we don't care — we genuinely can't fix what we can't reproduce.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What broke? (required, attach screenshot)
|
||||||
|
description: |
|
||||||
|
One sentence describing the symptom + at least one screenshot (drag it into the text box).
|
||||||
|
If it's a backend error, paste the stack trace here too (wrapped in ```).
|
||||||
|
placeholder: |
|
||||||
|
Example: As a `member`-role user in ws-b, I clicked "Create from template". The new Agent appeared in the default workspace instead of ws-b.
|
||||||
|
|
||||||
|
[drag in screenshot / screen recording]
|
||||||
|
|
||||||
|
[paste backend stack trace or frontend console error]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: How to reproduce? (required, numbered steps)
|
||||||
|
description: |
|
||||||
|
Steps that someone with zero context can follow. **A symptom you can't reproduce is a guess, not a bug.**
|
||||||
|
placeholder: |
|
||||||
|
1. Log in as admin / admin123, create workspace ws-b
|
||||||
|
2. Add user bob as ws-b member
|
||||||
|
3. Log out, log back in as bob, switch UI to ws-b
|
||||||
|
4. Go to Agents → "Create from template" → pick assistant → apply
|
||||||
|
5. Switch to default workspace — the Agent shows up here
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: module
|
||||||
|
attributes:
|
||||||
|
label: Affected module (optional, multi-select)
|
||||||
|
description: Which part of the system? Skip if unsure — helps maintainers triage.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Backend / 后端
|
||||||
|
- Frontend (admin UI) / 前端
|
||||||
|
- Desktop / 桌面端
|
||||||
|
- Webchat embed widget
|
||||||
|
- Channel (DingTalk / Feishu / Telegram / Discord / QQ / Slack ...)
|
||||||
|
- Tool / 工具
|
||||||
|
- Skill / 技能
|
||||||
|
- Wiki / 知识库
|
||||||
|
- Memory / 记忆
|
||||||
|
- Agent / StateGraph runtime
|
||||||
|
- Auth / Workspace permission
|
||||||
|
- Deployment / DB migration
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: env
|
||||||
|
attributes:
|
||||||
|
label: Environment (required, one line)
|
||||||
|
description: version / workspace role / browser or client. One line.
|
||||||
|
placeholder: "v0.x.y / member / Chrome 130 on macOS 14.5"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
74
.github/ISSUE_TEMPLATE/bug-zh.yml
vendored
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
name: "🐛 Bug 报告(中文)"
|
||||||
|
description: 报告一个不工作的功能。三个必填项,写完就交。
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢花时间反馈。三件事,写完就好——少一件我们没法定位,多一件浪费你时间。
|
||||||
|
|
||||||
|
> **没截图、没日志、没步骤的 issue 我们会直接关掉**,不是不在乎,是真的修不了。
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: 出了什么问题?(必填,附截图)
|
||||||
|
description: |
|
||||||
|
一句话说清现象 + 至少一张截图(直接拖进文本框即可)。
|
||||||
|
如果是后端报错,把后端日志也贴这里(用 ``` 包起来)。
|
||||||
|
placeholder: |
|
||||||
|
例:作为 member 角色用户,在 ws-b 工作区点「从模板创建」,新建出来的 Agent 出现在了默认工作区,不在 ws-b。
|
||||||
|
|
||||||
|
[拖入截图 / 录屏]
|
||||||
|
|
||||||
|
[贴出后端 stack trace 或前端 console error]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: 怎么复现?(必填,编号步骤)
|
||||||
|
description: |
|
||||||
|
让一个完全不知情的人能按步骤复现。**说不出步骤的现象不是 bug,是猜想。**
|
||||||
|
placeholder: |
|
||||||
|
1. 用 admin / admin123 登录,新建工作区 ws-b
|
||||||
|
2. 添加用户 bob 为 ws-b 的 member
|
||||||
|
3. 注销,用 bob 登录,前端切到 ws-b
|
||||||
|
4. 点 Agents 页面 → 「从模板创建」 → 选 assistant → 应用
|
||||||
|
5. 切回默认工作区,看到 Agent 出现在了这里
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: module
|
||||||
|
attributes:
|
||||||
|
label: 影响模块(选填,多选)
|
||||||
|
description: 大致是哪一块?不确定就空着,方便维护者打 label。
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 后端 / Backend
|
||||||
|
- 前端 / Frontend (admin UI)
|
||||||
|
- 桌面端 / Desktop
|
||||||
|
- Webchat 嵌入组件
|
||||||
|
- Channel(钉钉/飞书/Telegram/Discord/QQ/Slack...)
|
||||||
|
- Tool / 工具
|
||||||
|
- Skill / 技能
|
||||||
|
- Wiki / 知识库
|
||||||
|
- Memory / 记忆
|
||||||
|
- Agent / StateGraph 运行时
|
||||||
|
- Auth / 工作区权限
|
||||||
|
- 部署 / 数据库迁移
|
||||||
|
- 其它
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: env
|
||||||
|
attributes:
|
||||||
|
label: 环境(必填,一行)
|
||||||
|
description: 版本 / 工作区角色 / 浏览器或客户端。一行写完。
|
||||||
|
placeholder: "v0.x.y / member / Chrome 130 macOS 14.5"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 💬 使用问题先看文档 / Check the docs first
|
||||||
|
url: https://claw.mate.vip/docs
|
||||||
|
about: 安装、配置、用法问题文档里大多有答案 / Install, config, and usage questions are usually answered in the docs.
|
||||||
|
- name: 🔒 安全漏洞私下报告 / Report security issues privately
|
||||||
|
url: https://github.com/matevip/mateclaw/security/advisories/new
|
||||||
|
about: 安全相关问题请走 Security Advisory,不要开公开 issue / Please use Security Advisory for security-related issues, don't open a public issue.
|
||||||
41
.github/ISSUE_TEMPLATE/feature-en.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
name: "✨ Feature Request (English)"
|
||||||
|
description: Propose a new feature or improvement. Start with why, then what.
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
The key to a new feature is not "what it is" but "**who suffers without it, and how**".
|
||||||
|
|
||||||
|
If you can't articulate who would use it and why, the feature probably shouldn't be built.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: why
|
||||||
|
attributes:
|
||||||
|
label: What problem are you solving? (required)
|
||||||
|
description: |
|
||||||
|
Describe a real scenario. **Don't jump to "add an XX button"** — first explain why you need that button, and what hurts without it.
|
||||||
|
placeholder: |
|
||||||
|
Example: I switch the default model for 5 different Agents every day, and each switch takes 3 clicks in the settings page.
|
||||||
|
A global "quick switch default model" menu would save me 30 clicks a day.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: How should it work? (required)
|
||||||
|
description: |
|
||||||
|
A paragraph or a few bullets. If you can sketch it or share a mockup, even better (drag in images).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives you've tried? (optional)
|
||||||
|
description: |
|
||||||
|
If you can't think of any, leave it blank. **Don't invent content just to fill the field.**
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
41
.github/ISSUE_TEMPLATE/feature-zh.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
name: "✨ 功能建议(中文)"
|
||||||
|
description: 提一个新功能或改进。先讲为什么,再讲是什么。
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
新功能的关键不是"它是什么",是"**没有它,谁在受什么苦**"。
|
||||||
|
|
||||||
|
如果你说不清谁会用、为什么用,这个功能大概率不该做。
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: why
|
||||||
|
attributes:
|
||||||
|
label: 你在解决什么问题?(必填)
|
||||||
|
description: |
|
||||||
|
描述真实场景。**不要直接写"应该加一个 XX 按钮"** —— 先说为什么要这个按钮、不加会怎样。
|
||||||
|
placeholder: |
|
||||||
|
例:我每天要给 5 个不同的 Agent 切换默认模型,每次都要进设置页改 3 处。
|
||||||
|
如果有一个"快速切换默认模型"的全局菜单,我每天能少点 30 次鼠标。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: 你期望它怎么工作?(必填)
|
||||||
|
description: |
|
||||||
|
一段话或几个 bullet。如果你能画个草图、贴个 mockup,更好(直接拖图)。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 你试过的替代方案?(选填)
|
||||||
|
description: |
|
||||||
|
如果想不到替代方案,就空着。**不要为了填字段而瞎写。**
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
22
.gitignore
vendored
@ -29,6 +29,12 @@ nbbuild/
|
|||||||
nbdist/
|
nbdist/
|
||||||
.nb-gradle/
|
.nb-gradle/
|
||||||
|
|
||||||
|
### frontend build artifacts ###
|
||||||
|
# Vite's primary output goes to mateclaw-server/.../static; the only thing
|
||||||
|
# that lands here is rollup-plugin-visualizer's stats.html when running
|
||||||
|
# ANALYZE=1 pnpm build.
|
||||||
|
mateclaw-ui/dist/
|
||||||
|
|
||||||
### maven ###
|
### maven ###
|
||||||
target/
|
target/
|
||||||
*.war
|
*.war
|
||||||
@ -92,5 +98,21 @@ deploy/nginx/ssl/*.pem
|
|||||||
deploy/.env
|
deploy/.env
|
||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
|
CLAUDE.md
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/plans/
|
.claude/plans/
|
||||||
|
|
||||||
|
# Codex CLI local artifacts
|
||||||
|
.codex/
|
||||||
|
|
||||||
|
# Sync tooling local state (generated each run; report is intentionally tracked)
|
||||||
|
scripts/.*-sync-state.json
|
||||||
|
|
||||||
|
# Sandbox / external client work that lives in this directory
|
||||||
|
# but should not ship in the repo.
|
||||||
|
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
|
||||||
|
|||||||
276
README.md
@ -6,13 +6,15 @@
|
|||||||
|
|
||||||
# MateClaw
|
# MateClaw
|
||||||
|
|
||||||
<p align="center"><b>Build AI that thinks, acts, remembers, and ships.</b></p>
|
<p align="center"><b>Your second brain</b></p>
|
||||||
|
|
||||||
|
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
|
||||||
|
|
||||||
[](https://github.com/matevip/mateclaw)
|
[](https://github.com/matevip/mateclaw)
|
||||||
[](https://claw.mate.vip/docs)
|
[](https://claw.mate.vip/docs)
|
||||||
[](https://claw-demo.mate.vip)
|
[](https://claw-demo.mate.vip)
|
||||||
[](https://claw.mate.vip)
|
[](https://claw.mate.vip)
|
||||||
[](https://adoptium.net/)
|
[](https://adoptium.net/)
|
||||||
[](https://spring.io/projects/spring-boot)
|
[](https://spring.io/projects/spring-boot)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[](https://github.com/matevip/mateclaw)
|
[](https://github.com/matevip/mateclaw)
|
||||||
@ -28,114 +30,115 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
An AI agent. A knowledge engine. A memory system. A tool runtime. A multi-channel presence.
|
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
|
||||||
|
>
|
||||||
|
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
|
||||||
|
>
|
||||||
|
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
|
||||||
|
|
||||||
**One product. The whole widget.**
|
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
||||||
|
|
||||||
MateClaw is a personal AI operating system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). It's not a chatbox, not a workflow builder, not just another coding assistant. It's the entire system — from reasoning to remembering to shipping — in one deployment.
|
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
|
||||||
|
|
||||||
Three things make it insanely different:
|
---
|
||||||
|
|
||||||
1. **Agents do work, not just talk** — ReAct + Plan-and-Execute. Not one-shot answers — iterative reasoning that actually completes tasks
|
## Three things that make it different
|
||||||
2. **Knowledge is shaped, not just stored** — An LLM Wiki that digests raw material into structured, linked pages. The difference between a warehouse and a library
|
|
||||||
3. **End-to-end, no compromises** — Web console, desktop app, 7 IM channels, tool guardrails, enterprise auth. One team, one deployment, one experience
|
### 1 · Your AI doesn't die when a model does
|
||||||
|
|
||||||
|
Primary key expired. Vendor returns 401. Network blip. Quota drained.
|
||||||
|
|
||||||
|
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
||||||
|
|
||||||
|
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
|
||||||
|
|
||||||
|
### 2 · Knowledge that links itself
|
||||||
|
|
||||||
|
Upload a PDF, a batch of markdown, a scraped page — raw material in.
|
||||||
|
|
||||||
|
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
|
||||||
|
|
||||||
|
This is the difference between a warehouse and a library.
|
||||||
|
|
||||||
|
### 3 · One product, five surfaces
|
||||||
|
|
||||||
|
| Surface | What it is |
|
||||||
|
|---|---|
|
||||||
|
| **Web Console** | Full admin — digital employees, models, skills, knowledge, security, cron, **runtime console** (see what every employee is doing, force-recycle in one click) |
|
||||||
|
| **Desktop** | Electron app with a bundled JRE 21. Double-click, run. No Java install |
|
||||||
|
| **Webchat Widget** | One `<script>` tag embed. Drop it on any site |
|
||||||
|
| **IM Channels** | DingTalk · Feishu · WeChat Work · WeChat · Telegram · Discord · QQ · Slack |
|
||||||
|
| **Plugin SDK** | Java module for third-party capability packs |
|
||||||
|
|
||||||
|
Same brain. Same memory. Same tools. Different doors.
|
||||||
|
|
||||||
|
<p align="center"><b>$0 · No tokens metered. No seats billed. Your server. Your data. Your keys.</b></p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's in the box
|
||||||
|
|
||||||
|
### Digital employees, not chatbots
|
||||||
|
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
|
||||||
|
|
||||||
|
### Knowledge & memory
|
||||||
|
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
|
||||||
|
- **Workspace memory** — `AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
|
||||||
|
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
|
||||||
|
|
||||||
|
### Skills · MCP · ACP — three ways to extend capability
|
||||||
|
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
|
||||||
|
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
|
||||||
|
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
|
||||||
|
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
|
||||||
|
|
||||||
|
### Business orchestration (1.3.0+)
|
||||||
|
- **Workflow** — compose multiple employees plus system actions (approval / channel dispatch / write-memory) into a publishable, triggerable, replayable linear DSL. Seven step modes (`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`). JSON-first authoring with Monaco + schema validation, or natural-language → draft generation
|
||||||
|
- **Triggers** — wire system events to workflows or to employee conversations. Six pattern types (`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`). Default-on event governance: dedup, per-trigger rate limit, bot-self filter, recursion guard, fail-closed unknown patterns
|
||||||
|
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
|
||||||
|
|
||||||
|
### You see what every employee is doing
|
||||||
|
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
|
||||||
|
|
||||||
|
### Multimodal creation
|
||||||
|
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
|
||||||
|
|
||||||
|
### Enterprise-ready
|
||||||
|
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI is becoming infrastructure
|
||||||
|
|
||||||
|
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
|
||||||
|
|
||||||
|
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
|
||||||
|
|
||||||
|
**MateClaw is that layer — built the Spring Boot way.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why MateClaw
|
## Why MateClaw
|
||||||
|
|
||||||
Most AI tools do one thing well. MateClaw does the whole thing.
|
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|
||||||
|
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| **Multi-vendor failover** | **Chain + health tracker + cooldown** | Swap providers via config | Orchestration w/ retry | Anthropic only | One model |
|
||||||
|
| **Knowledge digestion** | **LLM Wiki + page-level citations** | Canvas + memory | Skills Hub + memory | — | Code index |
|
||||||
|
| **Multi-user admin** | **RBAC + approval + audit + runtime console** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
|
||||||
|
| **Capability extension** | **Skills (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
|
||||||
|
| **Surfaces** | Web admin + Desktop + Widget + SDK + 8 IM | 25+ chat channels | 15+ channels (CLI-led) | 3 IM preview | IDE only |
|
||||||
|
| **Stack** | **Java (Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||||
|
| **License / Price** | **Apache 2.0 · Free** | MIT · Free | MIT · Free | Proprietary · $20–200/mo | Proprietary · $0–200/mo |
|
||||||
|
|
||||||
| Capability | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [CoPaw](https://github.com/agentscope-ai/CoPaw) | [QClaw](https://cntechpost.com/2026/03/20/tencent-opens-qclaw-public-testing-amid-fierce-ai-rivalry/) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) | [Windsurf](https://windsurf.com) |
|
**OpenClaw and Hermes Agent are excellent personal AI platforms** — pick either if you're running one user on one laptop, building your own agent from CLI, and treating everything as config files to hand-tune. Both have bigger communities than MateClaw today.
|
||||||
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| Agent Orchestration | **ReAct + Plan-Execute** | Multi-agent teams | Multi-agent collab | Specialist agents | Agent Teams + subagents | Background Agents (cloud VM) | Cascade engine |
|
|
||||||
| Knowledge System | **LLM Wiki (digestion)** | Intelligence Mode + Wiki | Personal KB | Knowledge graph | CLAUDE.md (no RAG) | Codebase indexing | No |
|
|
||||||
| Memory | **Extract + Consolidate + Dream** | SQLite + Dreaming + Wiki | ReMe (hybrid retrieval) | 3-layer memory | 3-layer (CLAUDE.md + auto + files) | No persistent memory | Memories (~48h learning) |
|
|
||||||
| Tool Guard & Approval | **RBAC + approval flow** | HITL + risk levels | No | No | Permissions + Sandbox + Hooks | No | Turbo Mode (auto-approve) |
|
|
||||||
| Multi-Channel IM | **7 channels** | 25+ channels | 7 channels | 5 channels | 3 channels (preview) | IDE only | IDE only |
|
|
||||||
| Web Management UI | **Full admin dashboard** | Control UI | Console UI | Dashboard | Enterprise dashboard | No | No |
|
|
||||||
| Desktop App | **Electron + bundled JRE** | macOS menu bar | Electron (Beta) | Win/Mac app | Claude Desktop (Mac/Win) | VS Code fork | VS Code fork |
|
|
||||||
| Multimodal Creation | **TTS/STT/Img/Music/Video** | TTS/Video/Music/Image | Vision input | No | Vision input only | No | No |
|
|
||||||
| Skill Ecosystem | **ClawHub marketplace** | ClawHub registry | Python skills | Templates | 340+ plugins, 1300+ skills | MCP marketplace | MCP one-click |
|
|
||||||
| Enterprise Auth | **RBAC + JWT** | Basic (password) | Basic auth | No | SSO/SCIM/RBAC | SSO + Teams | Teams plan |
|
|
||||||
| Open Source | **Apache 2.0** | MIT | Apache 2.0 | Partial | No (source-available) | No | No |
|
|
||||||
| Pricing | **Free** | Free | Free | Free (beta) | $20–200/mo | $0–200/mo | $0–200/mo |
|
|
||||||
| Tech Stack | **Java + Vue 3** | TypeScript | Python + TS | OpenClaw fork | TypeScript | Electron (VS Code) | Electron (VS Code) |
|
|
||||||
|
|
||||||
**What makes MateClaw different?**
|
**MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
|
||||||
|
|
||||||
Every product in this table is genuinely strong. Here's where MateClaw carves its own space:
|
Same "whole widget" philosophy. Different center of gravity.
|
||||||
|
|
||||||
- **Plan-and-Execute orchestration** — Break complex work into ordered steps, execute each, adapt mid-flight. Others have multi-agent, but structured task planning with dynamic replanning is rare
|
|
||||||
- **LLM Wiki that digests, not just retrieves** — Others index and search. MateClaw's Wiki turns raw material into structured, linked pages with summaries — a search engine vs. an encyclopedia
|
|
||||||
- **Java ecosystem** — Built for teams already running Spring Boot in production. One JAR, one deploy. No Python runtime, no Node.js dependency chain
|
|
||||||
- **Complete admin dashboard** — Agents, models, tools, skills, channels, security, cron jobs, token usage — all in one web UI. Not a CLI-first afterthought
|
|
||||||
- **Full multimodal creation** — TTS, STT, image, music, and video generation as first-class built-in features. OpenClaw matches here; most others don't
|
|
||||||
- **Free and open, no asterisks** — Apache 2.0. No token billing, no seat pricing, no feature gating. Claude Code starts at $20/mo, Cursor and Windsurf up to $200/mo
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Quick start
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-biz-en.svg" alt="Business Architecture" width="800">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Technical Architecture</b></summary>
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-tech-en.svg" alt="Technical Architecture" width="800">
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Capabilities
|
|
||||||
|
|
||||||
### Agent Runtime
|
|
||||||
|
|
||||||
- **ReAct agents** — Think, act, observe, repeat. Iterative reasoning that gets things done
|
|
||||||
- **Plan-and-Execute** — Decompose complex work into ordered steps, then execute each one
|
|
||||||
- **Dynamic configuration** — Load agent personality, tools, and constraints from the database at runtime
|
|
||||||
- **Runtime resilience** — Context pruning, smart truncation, stale stream cleanup, and recovery
|
|
||||||
|
|
||||||
### Knowledge & Memory
|
|
||||||
|
|
||||||
- **LLM Wiki** — AI-powered knowledge base that digests raw materials into structured, linked pages with summaries
|
|
||||||
- **Workspace memory** — `AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
|
|
||||||
- **Memory lifecycle** — Post-conversation extraction, scheduled consolidation, dreaming workflows
|
|
||||||
- **Compound memory** — Understanding improves over time instead of resetting every query
|
|
||||||
|
|
||||||
### Tools, Skills & MCP
|
|
||||||
|
|
||||||
- **Built-in tools** — Web search, file ops, memory access, date/time, and more
|
|
||||||
- **MCP integration** — stdio, SSE, and Streamable HTTP transports
|
|
||||||
- **Skill system** — Installable `SKILL.md` packages with ClawHub marketplace
|
|
||||||
- **Tool guard** — Approval flows, file-path protection, runtime filtering
|
|
||||||
|
|
||||||
### Multimodal Creation
|
|
||||||
|
|
||||||
Text-to-speech · Speech-to-text · Image generation · Music generation · Video generation
|
|
||||||
|
|
||||||
### Model Flexibility
|
|
||||||
|
|
||||||
14+ providers including DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, and more. Configure everything in the web UI.
|
|
||||||
|
|
||||||
### Surfaces
|
|
||||||
|
|
||||||
- **Web console** — Chat, agents, tools, skills, knowledge, models, security, settings
|
|
||||||
- **Desktop app** — Electron with bundled JRE 21, no Java installation needed
|
|
||||||
- **Channels** — DingTalk, Feishu, WeChat Work, Telegram, Discord, QQ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Java 17+ · Node.js 18+ · pnpm · Maven 3.9+
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
@ -156,55 +159,78 @@ cp .env.example .env
|
|||||||
docker compose up -d # http://localhost:18080
|
docker compose up -d # http://localhost:18080
|
||||||
```
|
```
|
||||||
|
|
||||||
### Desktop App
|
### Desktop
|
||||||
|
|
||||||
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21 — no Java needed.
|
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Architecture
|
||||||
|
|
||||||
| Layer | Technology |
|
<p align="center">
|
||||||
|-------|------------|
|
<img src="assets/architecture-biz-en.svg" alt="Business Architecture" width="800">
|
||||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
|
</p>
|
||||||
| Agent | StateGraph Runtime |
|
|
||||||
| Database | H2 (dev) / MySQL 8.0+ (prod) |
|
<details>
|
||||||
| ORM | MyBatis Plus 3.5 |
|
<summary><b>Technical architecture</b></summary>
|
||||||
| Auth | Spring Security + JWT |
|
<p align="center">
|
||||||
| Frontend | Vue 3 · TypeScript · Vite |
|
<img src="assets/architecture-tech-en.svg" alt="Technical Architecture" width="800">
|
||||||
| UI | Element Plus · TailwindCSS 4 |
|
</p>
|
||||||
| Desktop | Electron · electron-updater |
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
mateclaw/
|
mateclaw/
|
||||||
├── mateclaw-server/ Spring Boot backend
|
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
|
||||||
├── mateclaw-ui/ Vue 3 SPA frontend
|
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
||||||
├── mateclaw-desktop/ Electron desktop app
|
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
||||||
|
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
||||||
|
├── mateclaw-plugin-sample/ Reference plugin implementation
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||||
|
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
|
||||||
|
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
|
||||||
|
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
|
||||||
|
| Database | H2 (dev) · MySQL 8.0+ (prod) |
|
||||||
|
| Auth | Spring Security + JWT |
|
||||||
|
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||||
|
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
||||||
|
| Widget | Vite library mode · UMD + ES bundles |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
|
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, architecture, each subsystem, API reference.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Richer multi-agent collaboration
|
**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).
|
||||||
- Smarter model routing
|
|
||||||
- Deeper multimodal understanding
|
|
||||||
- Stronger long-term memory
|
|
||||||
- Richer ClawHub ecosystem
|
|
||||||
|
|
||||||
---
|
**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
|
||||||
|
|
||||||
@ -217,14 +243,12 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why The Name
|
## Why the name
|
||||||
|
|
||||||
**Mate** is companion. **Claw** is capability.
|
**Mate** is companion. **Claw** is capability.
|
||||||
|
|
||||||
A system that stays with you, and a system that grabs work and moves it.
|
Something that stays with you — and grabs work and moves it.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE)
|
[Apache License 2.0](LICENSE). No asterisks.
|
||||||
|
|||||||
270
README_zh.md
@ -4,15 +4,17 @@
|
|||||||
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
|
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# MateClaw
|
# 太一(MateClaw)
|
||||||
|
|
||||||
<p align="center"><b>让 AI 真正去思考、行动、记忆,并把结果交付出来。</b></p>
|
<p align="center"><b>你的超级大脑</b></p>
|
||||||
|
|
||||||
|
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
|
||||||
|
|
||||||
[](https://github.com/matevip/mateclaw)
|
[](https://github.com/matevip/mateclaw)
|
||||||
[](https://claw.mate.vip/docs)
|
[](https://claw.mate.vip/docs)
|
||||||
[](https://claw-demo.mate.vip)
|
[](https://claw-demo.mate.vip)
|
||||||
[](https://claw.mate.vip)
|
[](https://claw.mate.vip)
|
||||||
[](https://adoptium.net/)
|
[](https://adoptium.net/)
|
||||||
[](https://spring.io/projects/spring-boot)
|
[](https://spring.io/projects/spring-boot)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[](https://github.com/matevip/mateclaw)
|
[](https://github.com/matevip/mateclaw)
|
||||||
@ -28,115 +30,116 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
|
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
||||||
|
>
|
||||||
|
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
|
||||||
|
>
|
||||||
|
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
|
||||||
|
|
||||||
**一个产品。完整交付。**
|
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||||
|
|
||||||
MateClaw 是基于 **Java + Vue 3** 构建的个人 AI 操作系统,由 [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba) 驱动。它不是聊天框,不是工作流编排器,不只是又一个编码助手。它是从推理到记忆到交付的完整系统——一次部署,全部搞定。
|
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完。
|
||||||
|
|
||||||
三件事让它截然不同:
|
---
|
||||||
|
|
||||||
1. **智能体做事,不只聊天** — ReAct + 计划执行。不是一问一答——是迭代推理,直到任务真正完成
|
## 三件让它与众不同的事
|
||||||
2. **知识被塑造,而非仅仅被存储** — LLM Wiki 把原始资料消化成结构化的链接页面。仓库和图书馆的区别
|
|
||||||
3. **端到端,不妥协** — Web 控制台、桌面端、7 个 IM 渠道、工具防护、企业认证。一个团队、一次部署、一个体验
|
### 1 · 模型挂了,AI 不挂
|
||||||
|
|
||||||
|
Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
||||||
|
|
||||||
|
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX,共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
|
||||||
|
|
||||||
|
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
|
||||||
|
|
||||||
|
### 2 · 知识会自己长出链接
|
||||||
|
|
||||||
|
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
|
||||||
|
|
||||||
|
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题,得到的页面是从对应片段拼出来的——带可核对的出处。
|
||||||
|
|
||||||
|
这是**仓库**和**图书馆**的区别。
|
||||||
|
|
||||||
|
### 3 · 一个产品,五个入口
|
||||||
|
|
||||||
|
| 入口 | 它是什么 |
|
||||||
|
|---|---|
|
||||||
|
| **Web 控制台** | 完整的管理后台——数字员工、模型、技能、知识、安全、定时任务、**运行时控制台**(看见每位员工正在干什么、一键回收) |
|
||||||
|
| **桌面端** | Electron + 内嵌 JRE 21,双击即用,无需装 Java |
|
||||||
|
| **网页嵌入式聊天** | 一个 `<script>` 标签就能嵌进任何网站 |
|
||||||
|
| **IM 渠道** | 钉钉 · 飞书 · 企业微信 · 微信 · Telegram · Discord · QQ · Slack |
|
||||||
|
| **插件 SDK** | Java 模块,供第三方扩展能力包 |
|
||||||
|
|
||||||
|
同一个大脑。同一份记忆。同一套工具。不同的门。
|
||||||
|
|
||||||
|
<p align="center"><b>$0 · 无 token 计费。无座位收费。你的服务器,你的数据,你的 Key。</b></p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 盒子里有什么
|
||||||
|
|
||||||
|
### 数字员工,不是聊天机器人
|
||||||
|
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
|
||||||
|
|
||||||
|
### 知识与记忆
|
||||||
|
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**(1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
|
||||||
|
- **工作区记忆** — `AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
|
||||||
|
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
|
||||||
|
|
||||||
|
### 技能 · MCP · ACP — 三种"接外部能力"的方式
|
||||||
|
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
|
||||||
|
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**(1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
|
||||||
|
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
|
||||||
|
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
|
||||||
|
|
||||||
|
### 业务流程编排(1.3.0+)
|
||||||
|
- **工作流(Workflow)** — 把多位员工 + 系统动作(审批 / 渠道分发 / 写记忆)按线性 step DSL 编排成一条可发布、可触发、可重放的业务流程。7 种 step mode(`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`)。JSON-first 编辑(Monaco + JSON schema + Pebble 静态检查),或者用一句话生成草稿
|
||||||
|
- **触发器(Trigger)** — 把"系统里发生的事"自动接到工作流或员工对话上。6 种 pattern type(`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`)。事件治理默认开:去重、per-trigger 限速、bot 自循环过滤、A→B→A 递归保护、未知 pattern fail-closed
|
||||||
|
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合,reverse-citation 绑定到源 chunk,JSON 输出 + 可选 JSON Schema,每个模板独立选模型
|
||||||
|
|
||||||
|
### 你看得见每位员工正在干什么
|
||||||
|
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示(思考 / 工具 / 回答),SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
|
||||||
|
|
||||||
|
### 多模态创作
|
||||||
|
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
||||||
|
|
||||||
|
### 企业就绪
|
||||||
|
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema,升级时自愈。一个 JAR 交付。生产用 MySQL,开发用 H2,代码零改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI 正在变成基础设施
|
||||||
|
|
||||||
|
2026 年 3 月 2 日,Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次,**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
|
||||||
|
|
||||||
|
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
|
||||||
|
|
||||||
|
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 为什么选 MateClaw
|
## 为什么选 MateClaw
|
||||||
|
|
||||||
大多数 AI 工具只做好一件事。MateClaw 做好整件事。
|
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|
||||||
|
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| **多厂商失败转移** | **Chain + 健康追踪 + 冷却** | 切换供应商(改配置) | 内置编排重试 | 仅 Anthropic | 单模型 |
|
||||||
|
| **知识消化式加工** | **Wiki + 页面级引用溯源** | Canvas + 记忆 | Skills Hub + 记忆 | — | 代码索引 |
|
||||||
|
| **多用户管理** | **RBAC + 审批流 + 审计 + 运行时控制台** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
|
||||||
|
| **能力扩展接口** | **技能 (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
|
||||||
|
| **用户触点** | Web 管理台 + 桌面 + 嵌入 + SDK + 8 IM | 25+ 聊天渠道 | 15+ 渠道(CLI 为主) | 3 IM(预览) | 仅 IDE |
|
||||||
|
| **技术栈** | **Java(Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||||
|
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20–200/月 | 闭源 · $0–200/月 |
|
||||||
|
|
||||||
| 能力 | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [CoPaw](https://github.com/agentscope-ai/CoPaw) | [QClaw](https://cntechpost.com/2026/03/20/tencent-opens-qclaw-public-testing-amid-fierce-ai-rivalry/) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) | [Windsurf](https://windsurf.com) |
|
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优,选它们没问题。两家的社区规模今天都大于 MateClaw。
|
||||||
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| 智能体编排 | **ReAct + 计划执行** | 多智能体团队 | 多智能体协作 | 专家智能体 | Agent Teams + 子智能体 | 后台 Agent(云端 VM) | Cascade 引擎 |
|
|
||||||
| 知识系统 | **LLM Wiki(消化式)** | Intelligence Mode + Wiki | 个人知识库 | 知识图谱 | CLAUDE.md(无 RAG) | 代码库索引 | 无 |
|
|
||||||
| 记忆 | **提取 + 整理 + 涌现** | SQLite + Dreaming + Wiki | ReMe(混合检索) | 三层记忆 | 三层(CLAUDE.md + 自动 + 文件) | 无持久记忆 | Memories(~48h 学习) |
|
|
||||||
| 工具防护与审批 | **RBAC + 审批流** | HITL + 风险等级 | 无 | 无 | 权限 + 沙箱 + Hooks | 无 | Turbo Mode(自动放行) |
|
|
||||||
| 多渠道 IM | **7 个渠道** | 25+ 渠道 | 7 个渠道 | 5 个渠道 | 3 个渠道(预览) | 仅 IDE | 仅 IDE |
|
|
||||||
| Web 管理界面 | **完整管理仪表盘** | Control UI | Console UI | 控制面板 | 企业版仪表盘 | 无 | 无 |
|
|
||||||
| 桌面端 | **Electron + 内嵌 JRE** | macOS 菜单栏 | Electron(Beta) | Win/Mac 应用 | Claude Desktop(Mac/Win) | VS Code 分支 | VS Code 分支 |
|
|
||||||
| 多模态创作 | **TTS/STT/图/音乐/视频** | TTS/视频/音乐/图片 | 视觉输入 | 无 | 仅视觉输入 | 无 | 无 |
|
|
||||||
| 技能生态 | **ClawHub 市场** | ClawHub 注册表 | Python 技能 | 模板 | 340+ 插件, 1300+ 技能 | MCP 市场 | MCP 一键集成 |
|
|
||||||
| 企业认证 | **RBAC + JWT** | 基础(密码) | 基础认证 | 无 | SSO/SCIM/RBAC | SSO + 团队版 | 团队版 |
|
|
||||||
| 开源 | **Apache 2.0** | MIT | Apache 2.0 | 部分 | 否(源码可见) | 否 | 否 |
|
|
||||||
| 定价 | **免费** | 免费 | 免费 | 免费(公测) | $20–200/月 | $0–200/月 | $0–200/月 |
|
|
||||||
| 技术栈 | **Java + Vue 3** | TypeScript | Python + TS | OpenClaw 衍生 | TypeScript | Electron (VS Code) | Electron (VS Code) |
|
|
||||||
|
|
||||||
**MateClaw 的差异化在哪?**
|
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
|
||||||
|
|
||||||
这张表里的每个产品都有真正的实力。MateClaw 的独特空间在这里:
|
**同一套"完整一整套"哲学,不同的重心。**
|
||||||
|
|
||||||
- **计划-执行编排** — 把复杂工作分解为有序步骤,逐一执行,动态调整计划。别人有多智能体,但结构化任务规划+动态重规划是稀缺能力
|
|
||||||
- **LLM Wiki 消化式知识库** — 别人索引和搜索。MateClaw 的 Wiki 把原始资料转化为结构化、有链接的页面——搜索引擎和百科全书的区别
|
|
||||||
- **Java 生态** — 为已经在生产环境运行 Spring Boot 的团队而生。一个 JAR,一次部署。无需 Python 运行时,无需 Node.js 依赖链
|
|
||||||
- **完整管理仪表盘** — 智能体、模型、工具、技能、渠道、安全、定时任务、Token 用量——全在一个 Web 界面。不是 CLI 优先的附属品
|
|
||||||
- **完整多模态创作** — TTS、STT、图片、音乐、视频生成作为内置一等功能。OpenClaw 在这方面同样强;其他竞品不具备
|
|
||||||
- **免费开源,没有星号** — Apache 2.0。无按量计费,无按席收费,无功能阉割。Claude Code 起步 $20/月,Cursor 和 Windsurf 最高 $200/月
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构全景
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>技术架构</b></summary>
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心能力
|
|
||||||
|
|
||||||
### 智能体引擎
|
|
||||||
|
|
||||||
- **ReAct 智能体** — 思考、行动、观察、循环。迭代推理直到完成任务
|
|
||||||
- **计划-执行智能体** — 将复杂工作分解为有序步骤,逐一执行
|
|
||||||
- **动态配置** — 运行时从数据库加载智能体的人格、工具和约束
|
|
||||||
- **运行时韧性** — 上下文裁剪、智能截断、僵死流清理、异常恢复
|
|
||||||
|
|
||||||
### 知识与记忆
|
|
||||||
|
|
||||||
- **LLM Wiki 知识库** — AI 驱动的知识库,将原始资料消化为结构化、有链接的页面
|
|
||||||
- **工作区记忆** — `AGENTS.md`、`SOUL.md`、`PROFILE.md`、`MEMORY.md`、每日笔记
|
|
||||||
- **记忆生命周期** — 对话后自动提取、定时整理、记忆涌现工作流
|
|
||||||
- **记忆应该积累** — 理解随时间加深,而非每次查询都从零开始
|
|
||||||
|
|
||||||
### 工具、技能与 MCP
|
|
||||||
|
|
||||||
- **内置工具** — 联网搜索、文件操作、记忆访问、日期时间等
|
|
||||||
- **MCP 集成** — 支持 stdio、SSE、Streamable HTTP 三种传输
|
|
||||||
- **技能系统** — 可安装的 `SKILL.md` 技能包 + ClawHub 市场
|
|
||||||
- **工具防护** — 审批流、文件路径保护、运行时过滤
|
|
||||||
|
|
||||||
### 多模态创作
|
|
||||||
|
|
||||||
语音合成 · 语音识别 · 图片生成 · 音乐生成 · 视频生成
|
|
||||||
|
|
||||||
### 模型灵活性
|
|
||||||
|
|
||||||
14+ 供应商支持,包括 DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等。在 Web 界面中配置一切。
|
|
||||||
|
|
||||||
### 用户触点
|
|
||||||
|
|
||||||
- **Web 控制台** — 对话、智能体、工具、技能、知识、模型、安全、设置
|
|
||||||
- **桌面端** — Electron + 内嵌 JRE 21,无需安装 Java
|
|
||||||
- **多渠道** — 钉钉、飞书、企业微信、Telegram、Discord、QQ
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
|
|
||||||
- Java 17+ · Node.js 18+ · pnpm · Maven 3.9+
|
|
||||||
|
|
||||||
### 本地开发
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 后端
|
# 后端
|
||||||
cd mateclaw-server
|
cd mateclaw-server
|
||||||
@ -158,22 +161,22 @@ docker compose up -d # http://localhost:18080
|
|||||||
|
|
||||||
### 桌面端
|
### 桌面端
|
||||||
|
|
||||||
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外安装 Java。
|
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## 架构全景
|
||||||
|
|
||||||
| 层次 | 技术 |
|
<p align="center">
|
||||||
|------|------|
|
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
||||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
|
</p>
|
||||||
| 智能体 | StateGraph 运行时 |
|
|
||||||
| 数据库 | H2(开发)/ MySQL 8.0+(生产)|
|
<details>
|
||||||
| ORM | MyBatis Plus 3.5 |
|
<summary><b>技术架构</b></summary>
|
||||||
| 认证 | Spring Security + JWT |
|
<p align="center">
|
||||||
| 前端 | Vue 3 · TypeScript · Vite |
|
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
||||||
| UI | Element Plus · TailwindCSS 4 |
|
</p>
|
||||||
| 桌面端 | Electron · electron-updater |
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -181,30 +184,53 @@ docker compose up -d # http://localhost:18080
|
|||||||
|
|
||||||
```
|
```
|
||||||
mateclaw/
|
mateclaw/
|
||||||
├── mateclaw-server/ Spring Boot 后端
|
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时)
|
||||||
├── mateclaw-ui/ Vue 3 SPA 前端
|
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
||||||
├── mateclaw-desktop/ Electron 桌面端
|
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
||||||
|
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
||||||
|
├── mateclaw-plugin-sample/ 参考插件实现
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层次 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||||
|
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
|
||||||
|
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
|
||||||
|
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
||||||
|
| 数据库 | H2(开发)· MySQL 8.0+(生产)|
|
||||||
|
| 认证 | Spring Security + JWT |
|
||||||
|
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||||
|
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
||||||
|
| Webchat | Vite library 模式 · UMD + ES bundle |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
完整文档请访问 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
|
完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 路线图
|
## 路线图
|
||||||
|
|
||||||
- 更丰富的多智能体协作
|
**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)。
|
||||||
- 更智能的模型路由
|
|
||||||
- 更深度的多模态理解
|
|
||||||
- 更强的长期记忆
|
|
||||||
- 更丰富的 ClawHub 生态
|
|
||||||
|
|
||||||
---
|
**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 模型的工具调用恢复 · 计划执行的证据闸门
|
||||||
|
|
||||||
## 参与贡献
|
## 参与贡献
|
||||||
|
|
||||||
@ -221,10 +247,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
|
|||||||
|
|
||||||
**Mate** 是陪伴。**Claw** 是能力。
|
**Mate** 是陪伴。**Claw** 是能力。
|
||||||
|
|
||||||
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。
|
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE)
|
[Apache License 2.0](LICENSE)。没有星号。
|
||||||
|
|||||||
@ -32,18 +32,19 @@
|
|||||||
<!-- ===== Center: Agent Core ===== -->
|
<!-- ===== Center: Agent Core ===== -->
|
||||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Agent</text>
|
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
|
||||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Reason · Plan · Execute</text>
|
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
|
||||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||||
|
|
||||||
<!-- ===== Top: User Surfaces ===== -->
|
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||||
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/>
|
<rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
|
||||||
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">User Surfaces</text>
|
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">User Surfaces</text>
|
||||||
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
|
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
|
||||||
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
|
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
|
||||||
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM Channels</text>
|
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
|
||||||
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
<text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM (8)</text>
|
||||||
|
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||||
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
||||||
|
|
||||||
@ -52,32 +53,38 @@
|
|||||||
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
||||||
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Knowledge</text>
|
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Knowledge</text>
|
||||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki KB</text>
|
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki KB</text>
|
||||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured Digestion</text>
|
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured + Backlinks</text>
|
||||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Memory Extraction</text>
|
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Citations + Soft Archive</text>
|
||||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">Workspace Context</text>
|
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ Transformations (1.3.0+)</text>
|
||||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">Shape it, don't just store it</text>
|
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">A library, not a vector store</text>
|
||||||
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
<!-- ===== Right: Tools & Skills ===== -->
|
<!-- ===== Right Top: Tools & Skills ===== -->
|
||||||
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="720" y="210" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/>
|
<rect x="916" y="210" width="4" height="65" rx="2" fill="url(#primary)"/>
|
||||||
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Tools & Skills</text>
|
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Skills · MCP · ACP</text>
|
||||||
<text x="820" y="264" text-anchor="middle" font-size="10" fill="#665245">Built-in Tool Suite</text>
|
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
|
||||||
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP Protocol</text>
|
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Even Claude Code joins as a hire</text>
|
||||||
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">Skill Packages + Hub</text>
|
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">Guard + Approval</text>
|
<polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
|
||||||
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">Capability needs boundaries</text>
|
|
||||||
<line x1="552" y1="280" x2="720" y2="280" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<polygon points="554,275 546,280 554,285" fill="#d96d46" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- ===== Bottom Left: Memory ===== -->
|
<!-- ===== Right Bottom: Security & Approval ===== -->
|
||||||
|
<rect x="720" y="285" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
|
<rect x="916" y="285" width="4" height="65" rx="2" fill="url(#accent)"/>
|
||||||
|
<text x="820" y="307" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Security & Approval</text>
|
||||||
|
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + Approval Flow</text>
|
||||||
|
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">Agentic, not autonomous</text>
|
||||||
|
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
|
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
|
||||||
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
||||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Memory</text>
|
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Memory · Dreaming</text>
|
||||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">Short-term Context</text>
|
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">Short-term + Extraction</text>
|
||||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Extraction + Consolidation</text>
|
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Nightly consolidation</text>
|
||||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">Memory should compound</text>
|
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">It works while you sleep</text>
|
||||||
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
||||||
|
|
||||||
@ -91,12 +98,19 @@
|
|||||||
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
||||||
|
|
||||||
<!-- ===== Bottom Center: Models ===== -->
|
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
|
||||||
|
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
|
||||||
|
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
|
||||||
|
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
|
||||||
|
|
||||||
|
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
|
||||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
||||||
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Model Layer</text>
|
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Provider Pool · Failover</text>
|
||||||
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ Providers</text>
|
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ providers</text>
|
||||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
|
||||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
<!-- Arrow up to agent (short, stops before orchestration band) -->
|
||||||
|
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
|
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
|
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
|
||||||
<text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text>
|
<text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.8 KiB |
@ -33,18 +33,19 @@
|
|||||||
<!-- ===== Center: Agent Core ===== -->
|
<!-- ===== Center: Agent Core ===== -->
|
||||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">智能体</text>
|
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
|
||||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">推理 · 规划 · 执行</text>
|
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
|
||||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||||
|
|
||||||
<!-- ===== Top: User Surfaces ===== -->
|
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||||
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/>
|
<rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
|
||||||
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">用户触点</text>
|
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">用户触点</text>
|
||||||
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
||||||
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
||||||
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
|
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
|
||||||
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
<text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
|
||||||
|
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||||
<!-- Arrow down -->
|
<!-- Arrow down -->
|
||||||
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
||||||
@ -53,35 +54,42 @@
|
|||||||
<rect x="40" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="40" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
||||||
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">知识系统</text>
|
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">知识系统</text>
|
||||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">Wiki 知识库</text>
|
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki 知识库</text>
|
||||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 链接</text>
|
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 双向链接</text>
|
||||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">记忆提取与整理</text>
|
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">引用溯源 + 软归档</text>
|
||||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">工作区上下文文件</text>
|
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ 加工器流水线(1.3.0+)</text>
|
||||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">知识不是存储,是塑造</text>
|
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">是图书馆,不是向量库</text>
|
||||||
<!-- Arrow right -->
|
<!-- Arrow right -->
|
||||||
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
<!-- ===== Right: Tools & Skills ===== -->
|
<!-- ===== Right Top: Tools & Skills ===== -->
|
||||||
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="720" y="210" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/>
|
<rect x="916" y="210" width="4" height="65" rx="2" fill="url(#primary)"/>
|
||||||
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">工具与技能</text>
|
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">技能 · MCP · ACP</text>
|
||||||
<text x="820" y="264" text-anchor="middle" font-size="10" fill="#665245">内置工具集</text>
|
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
|
||||||
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP 协议扩展</text>
|
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Claude Code 也来当员工</text>
|
||||||
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">技能包 + ClawHub</text>
|
<!-- Arrow to Tools -->
|
||||||
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">安全审批与防护</text>
|
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">能力需要边界</text>
|
<polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
|
||||||
<!-- Arrow left -->
|
|
||||||
<line x1="552" y1="280" x2="720" y2="280" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<polygon points="554,275 546,280 554,285" fill="#d96d46" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- ===== Bottom Left: Memory ===== -->
|
<!-- ===== Right Bottom: Security & Approval ===== -->
|
||||||
|
<rect x="720" y="285" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
|
<rect x="916" y="285" width="4" height="65" rx="2" fill="url(#accent)"/>
|
||||||
|
<text x="820" y="307" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">安全与审批</text>
|
||||||
|
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + 审批流</text>
|
||||||
|
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">会动手,不擅自动手</text>
|
||||||
|
<!-- Arrow to Security -->
|
||||||
|
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
|
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
|
||||||
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
||||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆层</text>
|
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆 · Dreaming</text>
|
||||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文管理</text>
|
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文 + 长期提取</text>
|
||||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">长期提取 + 定时整理</text>
|
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">夜里整合,早上接着</text>
|
||||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">记忆应该积累而非消散</text>
|
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">你睡了它在工作</text>
|
||||||
<!-- Arrow up-right -->
|
<!-- Arrow up-right -->
|
||||||
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
||||||
@ -97,13 +105,19 @@
|
|||||||
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
||||||
|
|
||||||
<!-- ===== Bottom Center: Models ===== -->
|
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
|
||||||
|
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
|
||||||
|
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
|
||||||
|
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
|
||||||
|
|
||||||
|
<!-- ===== Bottom Center: Models + Failover ===== -->
|
||||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
||||||
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型供应</text>
|
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
|
||||||
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
||||||
<!-- Arrow up -->
|
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">健康追踪 · 自动切换</text>
|
||||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<!-- Arrow up to agent (short, stops before orchestration band) -->
|
||||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
|
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
|
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 9.0 KiB |
@ -56,37 +56,39 @@
|
|||||||
</g>
|
</g>
|
||||||
<g transform="translate(632, 118)">
|
<g transform="translate(632, 118)">
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Channel Adapters</text>
|
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">China IM (5)</text>
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">DingTalk / Feishu / WeCom</text>
|
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">DingTalk · Feishu</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">WeCom · WeChat · QQ</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(776, 118)">
|
<g transform="translate(776, 118)">
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhooks</text>
|
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Global IM (3)</text>
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Telegram / Discord / QQ</text>
|
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram · Discord</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 2: Agent Engine ===== -->
|
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT ENGINE</text>
|
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
|
||||||
|
|
||||||
<g transform="translate(56, 222)">
|
<g transform="translate(56, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Think → Act → Observe</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Iterative Reasoning Loop</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(244, 222)">
|
<g transform="translate(244, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Workflow + Trigger</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Decompose → Step Execute</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Complex Task Orchestration</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(432, 222)">
|
<g transform="translate(432, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool System</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in + MCP + Skills</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval + Guard Rules</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(620, 222)">
|
<g transform="translate(620, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
@ -97,8 +99,8 @@
|
|||||||
<g transform="translate(808, 222)">
|
<g transform="translate(808, 222)">
|
||||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge</text>
|
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
|
||||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">Digestion</text>
|
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 3: Core Services ===== -->
|
<!-- ===== Layer 3: Core Services ===== -->
|
||||||
@ -132,9 +134,9 @@
|
|||||||
|
|
||||||
<g transform="translate(516, 356)">
|
<g transform="translate(516, 356)">
|
||||||
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text>
|
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Provider Pool</text>
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Unified Abstraction</text>
|
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI · Failover</text>
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text>
|
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Health Tracker · Cooldown</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(650, 356)">
|
<g transform="translate(650, 356)">
|
||||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
@ -176,8 +178,9 @@
|
|||||||
</g>
|
</g>
|
||||||
<g transform="translate(632, 488)">
|
<g transform="translate(632, 488)">
|
||||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron Scheduler</text>
|
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · Event Bus</text>
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Task Automation</text>
|
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock distributed</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Ambient AI · Proactive</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(776, 488)">
|
<g transform="translate(776, 488)">
|
||||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||||
@ -191,7 +194,7 @@
|
|||||||
<line x1="250" y1="438" x2="250" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="250" y1="438" x2="250" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
|
|
||||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||||
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
||||||
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@ -59,49 +59,51 @@
|
|||||||
</g>
|
</g>
|
||||||
<g transform="translate(632, 118)">
|
<g transform="translate(632, 118)">
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">多渠道适配器</text>
|
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">国内 IM (5)</text>
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">钉钉 / 飞书 / 企微 / TG</text>
|
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">钉钉·飞书·企微</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">微信·QQ</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(776, 118)">
|
<g transform="translate(776, 118)">
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhook</text>
|
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">海外 IM (3)</text>
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Discord / QQ</text>
|
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram·Discord</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 2: Agent Engine ===== -->
|
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">智能体引擎</text>
|
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
|
||||||
|
|
||||||
<g transform="translate(56, 222)">
|
<g transform="translate(56, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">思考 → 行动 → 观察</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">循环推理引擎</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(244, 222)">
|
<g transform="translate(244, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工作流 + 触发器</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">计划分解 → 逐步执行</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step mode · 6 pattern</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">复杂任务编排</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排(1.3.0+)</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(432, 222)">
|
<g transform="translate(432, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具系统</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 + MCP + 技能包</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">安全审批 + 防护规则</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(620, 222)">
|
<g transform="translate(620, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆系统</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
|
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">定时整理 + 记忆涌现</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(808, 222)">
|
<g transform="translate(808, 222)">
|
||||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
|
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
|
||||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">结构化页面</text>
|
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器(1.3.0)</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 3: Core Services ===== -->
|
<!-- ===== Layer 3: Core Services ===== -->
|
||||||
@ -135,9 +137,9 @@
|
|||||||
|
|
||||||
<g transform="translate(516, 356)">
|
<g transform="translate(516, 356)">
|
||||||
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text>
|
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">模型池 · Failover</text>
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">统一模型抽象</text>
|
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI 统一抽象</text>
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text>
|
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">健康追踪 · 自动切换</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(650, 356)">
|
<g transform="translate(650, 356)">
|
||||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
@ -179,8 +181,9 @@
|
|||||||
</g>
|
</g>
|
||||||
<g transform="translate(632, 488)">
|
<g transform="translate(632, 488)">
|
||||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">定时任务</text>
|
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · 事件总线</text>
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Cron 调度引擎</text>
|
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock 分布式锁</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付 · Ambient AI</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(776, 488)">
|
<g transform="translate(776, 488)">
|
||||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||||
@ -195,7 +198,7 @@
|
|||||||
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||||
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
||||||
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 1.0 MiB |
@ -21,8 +21,9 @@ services:
|
|||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
# Schema and seed data are managed by Flyway on application startup.
|
||||||
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
|
# Do NOT mount legacy schema.sql / data.sql here — Flyway creates all
|
||||||
|
# tables from V1 baseline and applies incremental migrations automatically.
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
@ -30,18 +31,26 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# SearXNG 搜索引擎(keyless 搜索 provider,零配置可用)
|
# SearXNG 搜索引擎(keyless 搜索 provider)
|
||||||
|
#
|
||||||
|
# The custom image at docker/searxng/ bakes in settings.yml so the sidecar
|
||||||
|
# works out of the box (upstream image ships JSON disabled + Limiter enabled,
|
||||||
|
# both of which silently break mateclaw's SearXNGSearchProvider).
|
||||||
|
# No host bind-mount — edit docker/searxng/settings.yml and rebuild.
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
build:
|
||||||
|
context: ./docker/searxng
|
||||||
container_name: mateclaw-searxng
|
container_name: mateclaw-searxng
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://searxng:8080
|
- SEARXNG_BASE_URL=http://searxng:8080
|
||||||
volumes:
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
|
||||||
- searxng_data:/etc/searxng
|
- UWSGI_WORKERS=2
|
||||||
|
- UWSGI_THREADS=4
|
||||||
ports:
|
ports:
|
||||||
- "8088:8080"
|
- "8088:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
# Healthz needs json format, so this also doubles as an integration check.
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
@ -50,8 +59,10 @@ services:
|
|||||||
# MateClaw 后端服务
|
# MateClaw 后端服务
|
||||||
mateclaw-server:
|
mateclaw-server:
|
||||||
build:
|
build:
|
||||||
context: ./mateclaw-server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: mateclaw-server/Dockerfile
|
||||||
|
args:
|
||||||
|
MAVEN_FLAGS: ${MAVEN_FLAGS:-}
|
||||||
container_name: mateclaw-server
|
container_name: mateclaw-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -66,16 +77,51 @@ services:
|
|||||||
DB_NAME: ${DB_NAME:-mateclaw}
|
DB_NAME: ${DB_NAME:-mateclaw}
|
||||||
DB_USERNAME: ${DB_USERNAME:-mateclaw}
|
DB_USERNAME: ${DB_USERNAME:-mateclaw}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
|
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
|
||||||
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?DASHSCOPE_API_KEY is required in .env}
|
# LLM provider keys (DashScope / OpenAI / Anthropic / DeepSeek / Kimi / …) are
|
||||||
|
# NOT configured via env vars. After startup, add providers in the admin UI:
|
||||||
|
# Settings → Models → Add Provider
|
||||||
|
# Keys are stored in mate_model_provider and hot-reloaded.
|
||||||
SERPER_API_KEY: ${SERPER_API_KEY:-}
|
SERPER_API_KEY: ${SERPER_API_KEY:-}
|
||||||
JWT_SECRET: ${JWT_SECRET:-}
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
|
MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
|
||||||
|
# SearXNG: tell the app where to reach the sidecar container
|
||||||
|
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080}
|
||||||
|
# Browser automation: the runtime image (mcr.microsoft.com/playwright:*)
|
||||||
|
# bakes Chromium + system libs + fonts in, so the tool works out of the box.
|
||||||
|
# Override these if you want to attach to an external Chrome (CDP sidecar):
|
||||||
|
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
|
||||||
|
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
|
||||||
|
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
|
||||||
|
# OAuth 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 DEVICE_CODE。
|
||||||
|
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
|
||||||
|
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}
|
||||||
|
# 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
|
||||||
|
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||||
|
# recommendation for Playwright / headless chrome.
|
||||||
|
shm_size: 2gb
|
||||||
ports:
|
ports:
|
||||||
- "18080:18080"
|
- "18080:18088" # host:container — app listens on 18088 inside the container
|
||||||
|
- "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:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
server_data:
|
server_data:
|
||||||
searxng_data:
|
|
||||||
|
|||||||
9
docker/searxng/Dockerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Custom SearXNG image for MateClaw.
|
||||||
|
#
|
||||||
|
# Bakes our settings.yml into /etc/searxng/settings.yml so the sidecar works
|
||||||
|
# out of the box with no host bind-mount. The upstream image ships JSON output
|
||||||
|
# disabled and the Limiter plugin enabled — both silently break mateclaw's
|
||||||
|
# SearXNGSearchProvider, so this override is required.
|
||||||
|
FROM searxng/searxng:latest
|
||||||
|
|
||||||
|
COPY settings.yml /etc/searxng/settings.yml
|
||||||
61
docker/searxng/settings.yml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# SearXNG config for MateClaw's bundled search sidecar.
|
||||||
|
#
|
||||||
|
# Two things differ from the upstream default:
|
||||||
|
# 1. JSON output format is enabled — mateclaw's SearXNGSearchProvider
|
||||||
|
# queries /search?format=json and fails silently without this.
|
||||||
|
# 2. The anti-bot Limiter plugin is disabled — it otherwise rejects
|
||||||
|
# server-side HTTP calls (no JS, no cookies) with HTTP 429.
|
||||||
|
#
|
||||||
|
# This file is baked into the custom image via docker/searxng/Dockerfile —
|
||||||
|
# do NOT bind-mount it from the host (prior host bind-mount broke deploys
|
||||||
|
# where the host directory did not exist and Docker auto-created an empty
|
||||||
|
# dir over the path).
|
||||||
|
#
|
||||||
|
# See https://docs.searxng.org/admin/settings/ for all knobs.
|
||||||
|
|
||||||
|
use_default_settings: true
|
||||||
|
|
||||||
|
general:
|
||||||
|
# Cosmetic only; shown in the UI title.
|
||||||
|
instance_name: "MateClaw Search"
|
||||||
|
# Keep this private — no outbound metrics.
|
||||||
|
donation_url: false
|
||||||
|
contact_url: false
|
||||||
|
enable_metrics: false
|
||||||
|
|
||||||
|
search:
|
||||||
|
safe_search: 0
|
||||||
|
autocomplete: ""
|
||||||
|
default_lang: "auto"
|
||||||
|
formats:
|
||||||
|
- html
|
||||||
|
- json # REQUIRED for mateclaw integration
|
||||||
|
|
||||||
|
server:
|
||||||
|
# Override the default dev secret; docker-compose passes SEARXNG_SECRET in.
|
||||||
|
secret_key: "${SEARXNG_SECRET:-please-change-me-to-a-random-32-char-string}"
|
||||||
|
# Trust Docker's internal network — the reverse-proxy / rate-limit plugin
|
||||||
|
# uses this to know the caller's real IP.
|
||||||
|
limiter: false
|
||||||
|
image_proxy: false
|
||||||
|
# Bind address matches the container default.
|
||||||
|
bind_address: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
ui:
|
||||||
|
static_use_hash: true
|
||||||
|
|
||||||
|
# The default engine list is huge; keep a tight set of reliable ones.
|
||||||
|
engines:
|
||||||
|
- name: duckduckgo
|
||||||
|
disabled: false
|
||||||
|
- name: bing
|
||||||
|
disabled: false
|
||||||
|
- name: brave
|
||||||
|
disabled: false
|
||||||
|
- name: wikipedia
|
||||||
|
disabled: false
|
||||||
|
- name: google
|
||||||
|
disabled: false
|
||||||
|
- name: startpage
|
||||||
|
disabled: false
|
||||||
@ -4,36 +4,21 @@
|
|||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
|
<artifactId>mateclaw</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
<artifactId>mateclaw-plugin-api</artifactId>
|
<artifactId>mateclaw-plugin-api</artifactId>
|
||||||
<version>1.1.0-SNAPSHOT</version>
|
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>MateClaw Plugin API</name>
|
<name>MateClaw Plugin API</name>
|
||||||
<description>Plugin SDK contract for MateClaw — external plugins depend only on this module</description>
|
<description>Plugin SDK contract for MateClaw - external plugins depend only on this module</description>
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>21</java.version>
|
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<spring-ai.version>1.1.4</spring-ai.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-bom</artifactId>
|
|
||||||
<version>${spring-ai.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- Spring AI core — for ToolCallback, ChatModel -->
|
<!-- Spring AI core for ToolCallback and ChatModel. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>org.springframework.ai</groupId>
|
||||||
<artifactId>spring-ai-model</artifactId>
|
<artifactId>spring-ai-model</artifactId>
|
||||||
@ -44,7 +29,6 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-api</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
<version>2.0.16</version>
|
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
@ -52,16 +36,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
<artifactId>jackson-databind</artifactId>
|
<artifactId>jackson-databind</artifactId>
|
||||||
<version>2.18.3</version>
|
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<repositories>
|
|
||||||
<repository>
|
|
||||||
<id>spring-milestones</id>
|
|
||||||
<url>https://repo.spring.io/milestone</url>
|
|
||||||
<snapshots><enabled>false</enabled></snapshots>
|
|
||||||
</repository>
|
|
||||||
</repositories>
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -4,40 +4,24 @@
|
|||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
|
<artifactId>mateclaw</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
<artifactId>mateclaw-plugin-sample</artifactId>
|
<artifactId>mateclaw-plugin-sample</artifactId>
|
||||||
<version>1.0.0</version>
|
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>MateClaw Sample Plugin</name>
|
<name>MateClaw Sample Plugin</name>
|
||||||
<description>A sample plugin demonstrating the MateClaw Plugin SDK</description>
|
<description>A sample plugin demonstrating the MateClaw Plugin SDK</description>
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>21</java.version>
|
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<spring-ai.version>1.1.4</spring-ai.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-bom</artifactId>
|
|
||||||
<version>${spring-ai.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- MateClaw Plugin API -->
|
<!-- MateClaw Plugin API -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
<artifactId>mateclaw-plugin-api</artifactId>
|
<artifactId>mateclaw-plugin-api</artifactId>
|
||||||
<version>1.1.0-SNAPSHOT</version>
|
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
@ -52,16 +36,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-api</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
<version>2.0.16</version>
|
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<repositories>
|
|
||||||
<repository>
|
|
||||||
<id>spring-milestones</id>
|
|
||||||
<url>https://repo.spring.io/milestone</url>
|
|
||||||
<snapshots><enabled>false</enabled></snapshots>
|
|
||||||
</repository>
|
|
||||||
</repositories>
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -1,13 +1,122 @@
|
|||||||
# 多阶段构建
|
# Multi-stage build
|
||||||
FROM maven:3.9-eclipse-temurin-21 AS builder
|
#
|
||||||
WORKDIR /build
|
# Stage 1 — Frontend (Node / pnpm)
|
||||||
COPY pom.xml .
|
# Builds the Vue 3 admin SPA and emits static files to /static inside the
|
||||||
RUN mvn dependency:go-offline -q
|
# build container. These files are later copied into the JAR's classpath so
|
||||||
COPY src ./src
|
# Spring Boot serves the SPA at the root URL.
|
||||||
RUN mvn package -DskipTests -q
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
# Pin pnpm to a major version so the Docker build doesn't break when the npm
|
||||||
|
# `latest` tag jumps majors. pnpm v10+ blocks dependency lifecycle scripts by
|
||||||
|
# default; the allowed packages live under `pnpm.onlyBuiltDependencies` in
|
||||||
|
# mateclaw-ui/package.json.
|
||||||
|
RUN npm install -g pnpm@10 --silent
|
||||||
|
WORKDIR /frontend
|
||||||
|
# Install dependencies first (layer cache)
|
||||||
|
COPY mateclaw-ui/package.json mateclaw-ui/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
# Copy source and build
|
||||||
|
COPY mateclaw-ui/ ./
|
||||||
|
# Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which
|
||||||
|
# is outside this container; call vite directly to control --outDir.
|
||||||
|
# NODE_OPTIONS=--max-old-space-size=6144 keeps Rollup's `rendering chunks`
|
||||||
|
# phase from getting SIGKILL'd by the host kernel's OOM-killer on memory-
|
||||||
|
# constrained servers. The earlier removal of this flag relied on lazy-
|
||||||
|
# loading + manualChunks dropping the per-chunk peak, but Rollup still
|
||||||
|
# minifies several vendor chunks (monaco / mermaid / echarts) in parallel
|
||||||
|
# so the cumulative working set blows past Node's default ~1.5 GB heap
|
||||||
|
# and trips the OOM-killer mid-build. The fix is not the heap flag
|
||||||
|
# itself; it is keeping the build reproducible on smaller hosts.
|
||||||
|
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
|
||||||
|
# the production Docker image build.
|
||||||
|
RUN NODE_OPTIONS=--max-old-space-size=6144 pnpm exec vite build --outDir /static --emptyOutDir
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
# Stage 2 — Backend (Maven)
|
||||||
|
FROM maven:3.9-eclipse-temurin-21 AS builder
|
||||||
|
|
||||||
|
# Optional Maven extra flags passed at build time.
|
||||||
|
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun
|
||||||
|
# repos first. This speeds up builds inside mainland China.
|
||||||
|
ARG MAVEN_FLAGS=""
|
||||||
|
|
||||||
|
# Inject mirror settings to avoid Maven Central timeouts in restricted networks
|
||||||
|
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
|
||||||
|
|
||||||
|
# Copy the root parent plus module POMs first for Docker layer caching.
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pom.xml ./pom.xml
|
||||||
|
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
|
||||||
|
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
|
||||||
|
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
|
||||||
|
|
||||||
|
# Pre-fetch backend dependencies through the reactor so the parent POM,
|
||||||
|
# dependencyManagement, and internal module versions all resolve consistently.
|
||||||
|
RUN mvn -pl mateclaw-server -am dependency:go-offline -q ${MAVEN_FLAGS}
|
||||||
|
|
||||||
|
# Copy backend source and inject pre-built frontend into the right classpath location
|
||||||
|
COPY mateclaw-plugin-api/src mateclaw-plugin-api/src
|
||||||
|
COPY mateclaw-server/src mateclaw-server/src
|
||||||
|
COPY --from=frontend-builder /static mateclaw-server/src/main/resources/static
|
||||||
|
|
||||||
|
RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||||
|
|
||||||
|
# Stage 3 — Runtime
|
||||||
|
#
|
||||||
|
# Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three
|
||||||
|
# browsers (Chromium / Firefox / WebKit) and every system library Chromium needs
|
||||||
|
# pre-installed. This avoids the `playwright install` step and the Alpine/musl
|
||||||
|
# incompatibility that blocks browser_use on minimal images.
|
||||||
|
#
|
||||||
|
# We pin to the exact Playwright version declared in the root pom.xml. If you
|
||||||
|
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
||||||
|
# tag with the matching driver, so mismatched versions cause the java driver to
|
||||||
|
# re-download browsers at runtime (defeating the whole point of this image).
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.59.0-noble
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /build/target/*.jar app.jar
|
|
||||||
|
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
||||||
|
# Install openjdk-21 explicitly and add CJK fonts so Chinese pages render
|
||||||
|
# correctly in screenshots and snapshots.
|
||||||
|
#
|
||||||
|
# PDF extraction toolchain — DocumentExtractTool tries pdftotext first, then
|
||||||
|
# Python pdfplumber/pypdf, then falls through to a naive Java parser that
|
||||||
|
# reads bytes as ISO_8859_1 (mojibake for CJK). Without poppler-utils the
|
||||||
|
# Docker image always hits the naive path and feeds garbled text to the
|
||||||
|
# Wiki pipeline.
|
||||||
|
#
|
||||||
|
# We install poppler-utils (backend 1) and tesseract (backend 4), which
|
||||||
|
# together cover the vast majority of PDFs including scanned docs. The
|
||||||
|
# Python backend is intentionally skipped — pip install against aliyun
|
||||||
|
# mirrors in CN networks hits transient hash-mismatch failures on cffi /
|
||||||
|
# cryptography transitive deps, and RFC-051 PR-1c will replace the Python
|
||||||
|
# hop with JVM-native Tika extraction anyway. Leaving it out keeps the
|
||||||
|
# image ~200 MB smaller and the build reproducible.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-21-jre-headless \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
|
poppler-utils \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-chi-sim \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Tell Playwright Java where Microsoft's image stored the browsers.
|
||||||
|
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
TZ=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
|
||||||
EXPOSE 18088
|
EXPOSE 18088
|
||||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
EXPOSE 1455
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
|||||||
@ -4,79 +4,47 @@
|
|||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
|
<artifactId>mateclaw</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
<artifactId>mateclaw-server</artifactId>
|
<artifactId>mateclaw-server</artifactId>
|
||||||
<version>1.1.0</version>
|
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>MateClaw Server</name>
|
<name>MateClaw Server</name>
|
||||||
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
|
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>3.5.13</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>21</java.version>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<!-- Spring AI 1.1.4 正式版 -->
|
|
||||||
<spring-ai.version>1.1.4</spring-ai.version>
|
|
||||||
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x) -->
|
|
||||||
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
|
|
||||||
<mybatis-plus.version>3.5.16</mybatis-plus.version>
|
|
||||||
<hutool.version>5.8.26</hutool.version>
|
|
||||||
<springdoc.version>2.8.16</springdoc.version>
|
|
||||||
<jjwt.version>0.12.6</jjwt.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<dependencies>
|
|
||||||
<!-- Spring AI BOM(统一管理 spring-ai-* 版本) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-bom</artifactId>
|
|
||||||
<version>${spring-ai.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
<!-- SpringDoc OpenAPI BOM(统一管理 springdoc-* 版本) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springdoc</groupId>
|
|
||||||
<artifactId>springdoc-openapi-bom</artifactId>
|
|
||||||
<version>${springdoc.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- ===== MateClaw Plugin API ===== -->
|
<!-- ===== MateClaw Plugin API ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
<artifactId>mateclaw-plugin-api</artifactId>
|
<artifactId>mateclaw-plugin-api</artifactId>
|
||||||
<version>1.1.0-SNAPSHOT</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Web MVC(不引入 WebFlux,避免自动切换为响应式模式) ===== -->
|
<!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Actuator - exposes Spring AI observation metrics (gen_ai.*) ===== -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI Alibaba DashScope ===== -->
|
<!-- ===== Spring AI Alibaba DashScope ===== -->
|
||||||
<!--
|
<!--
|
||||||
1.1.2.2 需单独指定版本,不在 BOM 中
|
Version is managed centrally because this artifact is outside the Spring AI BOM.
|
||||||
内置 DashScope ChatModel / EmbeddingModel / ImageModel
|
Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
|
||||||
-->
|
-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.alibaba.cloud.ai</groupId>
|
<groupId>com.alibaba.cloud.ai</groupId>
|
||||||
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
|
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
|
||||||
<version>${spring-ai-alibaba.version}</version>
|
<!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
|
||||||
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
|
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@ -85,11 +53,10 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI Alibaba Graph Core(StateGraph 工作流引擎) ===== -->
|
<!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.alibaba.cloud.ai</groupId>
|
<groupId>com.alibaba.cloud.ai</groupId>
|
||||||
<artifactId>spring-ai-alibaba-graph-core</artifactId>
|
<artifactId>spring-ai-alibaba-graph-core</artifactId>
|
||||||
<version>${spring-ai-alibaba.version}</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI OpenAI Compatible ===== -->
|
<!-- ===== Spring AI OpenAI Compatible ===== -->
|
||||||
@ -98,16 +65,15 @@
|
|||||||
<artifactId>spring-ai-openai</artifactId>
|
<artifactId>spring-ai-openai</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI Anthropic(Claude 模型支持) ===== -->
|
<!-- ===== Spring AI Anthropic (Claude model support) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>org.springframework.ai</groupId>
|
||||||
<artifactId>spring-ai-anthropic</artifactId>
|
<artifactId>spring-ai-anthropic</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI MCP Client(动态 MCP server 连接管理) ===== -->
|
<!-- ===== Spring AI MCP Client (dynamic MCP server connection management) ===== -->
|
||||||
<!--
|
<!--
|
||||||
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库,
|
Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
|
||||||
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
|
|
||||||
-->
|
-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>org.springframework.ai</groupId>
|
||||||
@ -120,31 +86,29 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== H2 内嵌数据库(开发环境) ===== -->
|
<!-- ===== H2 embedded database (development) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== MySQL 驱动(生产环境) ===== -->
|
<!-- ===== MySQL driver (production) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.mysql</groupId>
|
<groupId>com.mysql</groupId>
|
||||||
<artifactId>mysql-connector-j</artifactId>
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== MyBatis Plus(不引入 JPA,避免双 ORM 冲突) ===== -->
|
<!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.baomidou</groupId>
|
<groupId>com.baomidou</groupId>
|
||||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
<version>${mybatis-plus.version}</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- MyBatis Plus 分页插件(3.5.16 拆分为独立模块) -->
|
<!-- MyBatis Plus pagination support is split into a separate module. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.baomidou</groupId>
|
<groupId>com.baomidou</groupId>
|
||||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||||
<version>${mybatis-plus.version}</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring Security ===== -->
|
<!-- ===== Spring Security ===== -->
|
||||||
@ -157,55 +121,49 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-api</artifactId>
|
<artifactId>jjwt-api</artifactId>
|
||||||
<version>${jjwt.version}</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-impl</artifactId>
|
<artifactId>jjwt-impl</artifactId>
|
||||||
<version>${jjwt.version}</version>
|
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-jackson</artifactId>
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
<version>${jjwt.version}</version>
|
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== SpringDoc OpenAPI(Swagger UI for Spring MVC) ===== -->
|
<!-- ===== SpringDoc OpenAPI (Swagger UI for Spring MVC) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Hutool 工具库 ===== -->
|
<!-- ===== Hutool utilities ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-all</artifactId>
|
<artifactId>hutool-all</artifactId>
|
||||||
<version>${hutool.version}</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== 钉钉 Stream SDK(WebSocket 长连接,无需公网 IP) ===== -->
|
<!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.dingtalk.open</groupId>
|
<groupId>com.dingtalk.open</groupId>
|
||||||
<artifactId>dingtalk-stream</artifactId>
|
<artifactId>dingtalk-stream</artifactId>
|
||||||
<version>1.3.5</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
<!-- ===== Lark Open API SDK (WebSocket long connection and event dispatch) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.larksuite.oapi</groupId>
|
<groupId>com.larksuite.oapi</groupId>
|
||||||
<artifactId>oapi-sdk</artifactId>
|
<artifactId>oapi-sdk</artifactId>
|
||||||
<version>2.5.3</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
<!-- ===== Caffeine cache for skill runtime caching ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
<artifactId>caffeine</artifactId>
|
<artifactId>caffeine</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== -->
|
<!-- ===== SnakeYAML for SKILL.md frontmatter parsing ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.yaml</groupId>
|
<groupId>org.yaml</groupId>
|
||||||
<artifactId>snakeyaml</artifactId>
|
<artifactId>snakeyaml</artifactId>
|
||||||
@ -222,28 +180,24 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.zxing</groupId>
|
<groupId>com.google.zxing</groupId>
|
||||||
<artifactId>core</artifactId>
|
<artifactId>core</artifactId>
|
||||||
<version>3.5.3</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.zxing</groupId>
|
<groupId>com.google.zxing</groupId>
|
||||||
<artifactId>javase</artifactId>
|
<artifactId>javase</artifactId>
|
||||||
<version>3.5.3</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Playwright (Browser Automation) ===== -->
|
<!-- ===== Playwright (Browser Automation) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.microsoft.playwright</groupId>
|
<groupId>com.microsoft.playwright</groupId>
|
||||||
<artifactId>playwright</artifactId>
|
<artifactId>playwright</artifactId>
|
||||||
<version>1.52.0</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== JDA(Discord Bot Gateway WebSocket 长连接) ===== -->
|
<!-- ===== JDA (Discord Bot Gateway WebSocket long connection) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>net.dv8tion</groupId>
|
<groupId>net.dv8tion</groupId>
|
||||||
<artifactId>JDA</artifactId>
|
<artifactId>JDA</artifactId>
|
||||||
<version>5.2.3</version>
|
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<!-- 排除 audio 相关依赖(MateClaw 不需要语音功能) -->
|
<!-- Exclude audio dependencies because voice features are not used. -->
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<groupId>club.minnced</groupId>
|
<groupId>club.minnced</groupId>
|
||||||
<artifactId>opus-java</artifactId>
|
<artifactId>opus-java</artifactId>
|
||||||
@ -251,27 +205,127 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring WebSocket(Talk Mode) ===== -->
|
<!-- ===== Spring WebSocket (Talk Mode) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Slack SDK(Socket Mode + Web API) ===== -->
|
<!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.slack.api</groupId>
|
<groupId>com.slack.api</groupId>
|
||||||
<artifactId>slack-api-client</artifactId>
|
<artifactId>slack-api-client</artifactId>
|
||||||
<version>1.44.2</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.slack.api</groupId>
|
<groupId>com.slack.api</groupId>
|
||||||
<artifactId>bolt-socket-mode</artifactId>
|
<artifactId>bolt-socket-mode</artifactId>
|
||||||
<version>1.44.2</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.glassfish.tyrus.bundles</groupId>
|
<groupId>org.glassfish.tyrus.bundles</groupId>
|
||||||
<artifactId>tyrus-standalone-client</artifactId>
|
<artifactId>tyrus-standalone-client</artifactId>
|
||||||
<version>2.2.0</version>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Apache POI (in-process .docx generation) ===== -->
|
||||||
|
<!--
|
||||||
|
Used by DocxRenderTool to render Markdown into a .docx in-JVM,
|
||||||
|
replacing the Node.js docx-js subprocess (3-5 min cold install).
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
|
||||||
|
<!--
|
||||||
|
Used by MarkdownDocxRenderer to convert  image references
|
||||||
|
into PNG bytes that POI can embed via XWPFRun.addPicture(). Without this,
|
||||||
|
agents that produce architecture diagrams as inline SVG cannot get them
|
||||||
|
into the final .docx. Rasterization runs in-JVM (no rsvg-convert / cairo
|
||||||
|
dependency on the host).
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.xmlgraphics</groupId>
|
||||||
|
<artifactId>batik-transcoder</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.xmlgraphics</groupId>
|
||||||
|
<artifactId>batik-codec</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== jsoup (HTML cleanup for Wiki ingest) ===== -->
|
||||||
|
<!--
|
||||||
|
Used by WikiContentNormalizer to strip nav/footer/script/style/aside
|
||||||
|
and ad-class nodes from URL/HTML uploads before chunking. Small
|
||||||
|
(~430KB), no transitive deps, JVM-only, and safe for the desktop bundle.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Apache Tika (Java-side last-resort document extractor) ===== -->
|
||||||
|
<!--
|
||||||
|
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
|
||||||
|
chains, after every system command + Python + POI-based path has failed.
|
||||||
|
Used in production primarily by Windows users without Python or poppler
|
||||||
|
installed; otherwise idle.
|
||||||
|
|
||||||
|
Pinned to the precise format modules the extractor calls directly. This
|
||||||
|
deliberately avoids `tika-parsers-standard-package`, which pulls in mail,
|
||||||
|
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
|
||||||
|
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
|
||||||
|
tika-parser-microsoft-module (POI-scratchpad ~10MB) is about 16MB.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.tika</groupId>
|
||||||
|
<artifactId>tika-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.tika</groupId>
|
||||||
|
<artifactId>tika-parser-pdf-module</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.tika</groupId>
|
||||||
|
<artifactId>tika-parser-microsoft-module</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Markdown -> PDF rendering =====
|
||||||
|
Flying Saucer ships a single `flying-saucer-pdf` artifact that
|
||||||
|
writes PDF via OpenPDF (LGPL fork of iText). It does NOT depend on
|
||||||
|
PDFBox, so it sidesteps a version conflict with the existing
|
||||||
|
pdfbox dependency. CSS3 paged-media features (@page,
|
||||||
|
counter(page), counter(pages), @top-center / @bottom-center) are
|
||||||
|
supported, which the cover / header / footer rendering relies on.
|
||||||
|
|
||||||
|
commonmark-java is the reference CommonMark implementation,
|
||||||
|
actively maintained on a monthly cadence (vs. flexmark, whose
|
||||||
|
upstream stalled at 0.64.8 in 2023). It parses markdown into the
|
||||||
|
XHTML Flying Saucer consumes. The alternative LibreOffice path in
|
||||||
|
PdfRenderTool reuses MarkdownDocxRenderer + a soffice subprocess
|
||||||
|
and adds no dependencies of its own. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xhtmlrenderer</groupId>
|
||||||
|
<artifactId>flying-saucer-pdf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.commonmark</groupId>
|
||||||
|
<artifactId>commonmark-ext-autolink</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Database Migration (Flyway) ===== -->
|
<!-- ===== Database Migration (Flyway) ===== -->
|
||||||
@ -283,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>
|
||||||
@ -290,6 +368,57 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== ArchUnit architecture invariants =====
|
||||||
|
test-scope only, guards:
|
||||||
|
- every ToolCallback implementation overrides call(String, ToolContext)
|
||||||
|
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
|
||||||
|
- CronJobRunner must not carry @Transactional
|
||||||
|
because it would silently fail under self-invocation
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.tngtech.archunit</groupId>
|
||||||
|
<artifactId>archunit-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ShedLock: distributed lock for the cron scheduler so a
|
||||||
|
multi-instance deployment doesn't fire the same job N times.
|
||||||
|
JDBC mode reuses the existing DataSource, so there is no Redis dependency
|
||||||
|
on the desktop / single-node footprint. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.javacrumbs.shedlock</groupId>
|
||||||
|
<artifactId>shedlock-spring</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.javacrumbs.shedlock</groupId>
|
||||||
|
<artifactId>shedlock-provider-jdbc-template</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Graph algorithms (community detection, shortest path, centrality)
|
||||||
|
used by the wiki page-to-page relevance and insights features. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jgrapht</groupId>
|
||||||
|
<artifactId>jgrapht-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
|
||||||
|
Used to walk PDPage resources and pull out PDImageXObject instances
|
||||||
|
for downstream captioning. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.pdfbox</groupId>
|
||||||
|
<artifactId>pdfbox</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Expression language used by the workflow compiler to evaluate
|
||||||
|
conditional step expressions and template variable references.
|
||||||
|
Restricted to a small subset (~20 operators / filters) at the
|
||||||
|
evaluator wrapper layer; arbitrary template includes / extends
|
||||||
|
are blocked. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.pebbletemplates</groupId>
|
||||||
|
<artifactId>pebble</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@ -297,6 +426,13 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
<configuration>
|
<configuration>
|
||||||
<excludes>
|
<excludes>
|
||||||
<exclude>
|
<exclude>
|
||||||
@ -306,6 +442,76 @@
|
|||||||
</excludes>
|
</excludes>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<!-- Populate ${net.bytebuddy:byte-buddy-agent:jar} from the test
|
||||||
|
classpath so maven-surefire-plugin can attach it statically
|
||||||
|
(Mockito inline mock maker on JDK 21+ can no longer self-attach). -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-dependency-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>resolve-test-classpath-properties</id>
|
||||||
|
<goals>
|
||||||
|
<goal>properties</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<!-- Static agent attach for Mockito on JDK 21+. Without this, dynamic
|
||||||
|
agent loading raises ByteBuddyAgent.AttachmentTypeEvaluator errors
|
||||||
|
depending on the JVM's startup hardening, making tests pass on one
|
||||||
|
machine and fail on another. byte-buddy-agent rides in transitively
|
||||||
|
via mockito-core. -->
|
||||||
|
<argLine>-javaagent:${net.bytebuddy:byte-buddy-agent:jar}</argLine>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<!--
|
||||||
|
Profile: focused test run for image / video generation features.
|
||||||
|
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
|
||||||
|
Limits surefire to JUnit 5 tests carrying @Tag("media-gen") so the
|
||||||
|
full ~50-min suite is skipped when iterating on this surface.
|
||||||
|
Add a tag to a new test with @Tag("media-gen") to opt it in.
|
||||||
|
-->
|
||||||
|
<profile>
|
||||||
|
<id>media-gen</id>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<groups>media-gen</groups>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</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>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
22
mateclaw-server/settings.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Minimal Maven settings for the Docker build.
|
||||||
|
|
||||||
|
Repository URLs (US Maven Central, Google CDN, Aliyun) are declared directly
|
||||||
|
in mateclaw-server/pom.xml so a single pom covers both continents — Maven
|
||||||
|
tries each repository in order and falls over on 404 / unreachable.
|
||||||
|
|
||||||
|
Historically this file also contained <mirrors> that redirected Maven Central
|
||||||
|
to Aliyun. That broke US/EU builds because <mirror> intercepts transparently
|
||||||
|
and offers no fail-over when the mirror is slow. Keeping this file empty
|
||||||
|
means pom.xml's repository list is authoritative.
|
||||||
|
|
||||||
|
If you need to force a mirror (e.g. behind a corporate proxy), add your own
|
||||||
|
mirror entries here — they will override the pom repositories.
|
||||||
|
-->
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
|
||||||
|
http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
|
<mirrors/>
|
||||||
|
</settings>
|
||||||
@ -3,42 +3,120 @@ package vip.mate;
|
|||||||
import com.baomidou.mybatisplus.annotation.DbType;
|
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 = {
|
||||||
// 禁用 Spring AI MCP Client 自动配置(由 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,
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
|
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
||||||
|
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
|
||||||
|
// not the chat model. We don't use it — model configuration is admin-UI driven and
|
||||||
|
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
|
||||||
|
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
|
||||||
|
// ApplicationContext fail when users deploy via Docker without setting the key.
|
||||||
|
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
|
||||||
})
|
})
|
||||||
@EnableScheduling
|
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MyBatis Plus 分页插件
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>When {@code resolvedDbType} is available the interceptor uses it directly;
|
||||||
|
* otherwise it falls back to JDBC-URL auto-detection, which works for
|
||||||
|
* {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
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("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,368 @@
|
|||||||
|
package vip.mate.acp.client;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7 — minimal Java ACP (Agent Communication Protocol)
|
||||||
|
* client over stdio.
|
||||||
|
*
|
||||||
|
* <p>Implements just enough of the JSON-RPC 2.0 framing to:
|
||||||
|
* <ol>
|
||||||
|
* <li>Spawn the agent process ({@code command} + {@code args}).</li>
|
||||||
|
* <li>Send {@code initialize} and capture {@code agentCapabilities}
|
||||||
|
* / {@code protocolVersion}.</li>
|
||||||
|
* <li>Optionally open a {@code session/new} handshake.</li>
|
||||||
|
* <li>Tear the process down cleanly.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>This is intentionally a one-shot connection tester (RFC §10.2 Q3
|
||||||
|
* recommended starting order: codex → claude → opencode → qwen). Full
|
||||||
|
* bidirectional session prompting / streaming / permission requests is
|
||||||
|
* a future increment — that needs a proper async bus and ties into the
|
||||||
|
* agent graph layer.
|
||||||
|
*
|
||||||
|
* <p>Why not the official {@code acp} Python SDK: MateClaw runs on the
|
||||||
|
* JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio; the
|
||||||
|
* surface we need for "test connection" is small enough to implement
|
||||||
|
* directly.
|
||||||
|
*
|
||||||
|
* <p>Each {@link AcpStdioClient} instance owns one Process. Use
|
||||||
|
* try-with-resources or call {@link #close()} explicitly.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class AcpStdioClient implements AutoCloseable {
|
||||||
|
|
||||||
|
/** ACP protocol version we advertise (matches v1 ACP-compatible agents). */
|
||||||
|
public static final int PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final Process process;
|
||||||
|
private final Writer stdin;
|
||||||
|
private final BufferedReader stdout;
|
||||||
|
private final Thread readerThread;
|
||||||
|
private final AtomicLong nextRequestId = new AtomicLong(1);
|
||||||
|
private final Map<Long, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
|
||||||
|
private volatile boolean closed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC
|
||||||
|
* notification (no id). Notification objects passed in have shape
|
||||||
|
* {@code {jsonrpc, method, params}}; the most common is
|
||||||
|
* {@code session/update} carrying agent message chunks.
|
||||||
|
*
|
||||||
|
* <p>Default no-op so existing test-only callers don't need to set
|
||||||
|
* a handler. {@link AcpDelegationService} installs an accumulator
|
||||||
|
* that scrapes {@code agent_message_chunk} text into a
|
||||||
|
* {@code StringBuilder}.
|
||||||
|
*/
|
||||||
|
private volatile Consumer<JsonNode> notificationHandler = msg -> { /* drop */ };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC
|
||||||
|
* request (has id). The handler returns the JSON-RPC
|
||||||
|
* {@code result} object (or null to send back -32601 method-not-
|
||||||
|
* implemented). Used for {@code session/request_permission};
|
||||||
|
* trusted endpoints auto-allow, untrusted ones cancel.
|
||||||
|
*/
|
||||||
|
private volatile Function<JsonNode, JsonNode> requestHandler = msg -> null;
|
||||||
|
|
||||||
|
private AcpStdioClient(ObjectMapper mapper, Process process) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.process = process;
|
||||||
|
this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
|
||||||
|
this.stdout = new BufferedReader(
|
||||||
|
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
|
||||||
|
this.readerThread.setDaemon(true);
|
||||||
|
this.readerThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the configured agent process. Caller is responsible for
|
||||||
|
* closing the returned client; failure to do so leaks a child
|
||||||
|
* process.
|
||||||
|
*/
|
||||||
|
public static AcpStdioClient spawn(ObjectMapper mapper,
|
||||||
|
String command,
|
||||||
|
List<String> args,
|
||||||
|
Map<String, String> envOverrides,
|
||||||
|
String cwd)
|
||||||
|
throws IOException {
|
||||||
|
if (command == null || command.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ACP command is required");
|
||||||
|
}
|
||||||
|
java.util.List<String> cmdline = new java.util.ArrayList<>();
|
||||||
|
cmdline.add(command);
|
||||||
|
if (args != null) cmdline.addAll(args);
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmdline);
|
||||||
|
Map<String, String> env = pb.environment();
|
||||||
|
if (envOverrides != null) env.putAll(envOverrides);
|
||||||
|
if (cwd != null && !cwd.isBlank()) {
|
||||||
|
pb.directory(new java.io.File(cwd));
|
||||||
|
}
|
||||||
|
// Keep stderr separate from stdout so we don't poison JSON-RPC
|
||||||
|
// framing when the child agent writes a banner / log line.
|
||||||
|
pb.redirectErrorStream(false);
|
||||||
|
Process proc = pb.start();
|
||||||
|
// Drain stderr in the background — many CLIs print diagnostics
|
||||||
|
// there (e.g. Zed agents print version on startup).
|
||||||
|
Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
|
||||||
|
errDrain.setDaemon(true);
|
||||||
|
errDrain.start();
|
||||||
|
return new AcpStdioClient(mapper, proc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drainStream(java.io.InputStream in) {
|
||||||
|
try (BufferedReader br = new BufferedReader(
|
||||||
|
new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
|
||||||
|
}
|
||||||
|
} catch (IOException ignore) {
|
||||||
|
// Process exited; nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send {@code initialize} and wait for the response. Returns the
|
||||||
|
* response payload's {@code result} object, or throws on protocol
|
||||||
|
* mismatch / timeout.
|
||||||
|
*/
|
||||||
|
public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
|
||||||
|
ObjectNode params = mapper.createObjectNode();
|
||||||
|
params.put("protocolVersion", PROTOCOL_VERSION);
|
||||||
|
// ClientCapabilities — we don't yet implement any client-side
|
||||||
|
// optional features. Send an empty object so strict agents
|
||||||
|
// don't reject the request.
|
||||||
|
params.set("clientCapabilities", mapper.createObjectNode());
|
||||||
|
ObjectNode info = mapper.createObjectNode();
|
||||||
|
info.put("name", "mateclaw-acp-client");
|
||||||
|
info.put("version", "1.0.0");
|
||||||
|
params.set("clientInfo", info);
|
||||||
|
return sendRequest("initialize", params, timeoutMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send {@code session/new} — establishes a session for prompting.
|
||||||
|
* For the connection-test path we don't actually prompt, just
|
||||||
|
* verify the server accepts the handshake.
|
||||||
|
*
|
||||||
|
* <p>The {@code cwd} parameter is always written into the request
|
||||||
|
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
|
||||||
|
* agent-acp} and the codex variant) marks {@code cwd} as a required
|
||||||
|
* string and returns {@code -32602 Invalid params} when it's
|
||||||
|
* missing. If the caller passes null/blank we substitute the JVM
|
||||||
|
* working directory — a workspace-aware default lives in
|
||||||
|
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
|
||||||
|
* the protocol never sees {@code undefined} regardless of caller.
|
||||||
|
*/
|
||||||
|
public JsonNode newSession(String cwd, long timeoutMillis)
|
||||||
|
throws IOException, InterruptedException {
|
||||||
|
ObjectNode params = mapper.createObjectNode();
|
||||||
|
String safeCwd = (cwd == null || cwd.isBlank())
|
||||||
|
? System.getProperty("user.dir", ".")
|
||||||
|
: cwd;
|
||||||
|
params.put("cwd", safeCwd);
|
||||||
|
params.set("mcpServers", mapper.createArrayNode());
|
||||||
|
return sendRequest("session/new", params, timeoutMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower-level request helper. Synchronously awaits the response
|
||||||
|
* matching the request id. Server-pushed requests (e.g. permission
|
||||||
|
* prompts) are dropped — the test-only connection path doesn't need
|
||||||
|
* to handle them.
|
||||||
|
*/
|
||||||
|
public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
|
||||||
|
throws IOException, InterruptedException {
|
||||||
|
if (closed) throw new IOException("ACP client is closed");
|
||||||
|
long id = nextRequestId.getAndIncrement();
|
||||||
|
CompletableFuture<JsonNode> future = new CompletableFuture<>();
|
||||||
|
pending.put(id, future);
|
||||||
|
|
||||||
|
ObjectNode envelope = mapper.createObjectNode();
|
||||||
|
envelope.put("jsonrpc", "2.0");
|
||||||
|
envelope.put("id", id);
|
||||||
|
envelope.put("method", method);
|
||||||
|
envelope.set("params", params);
|
||||||
|
|
||||||
|
synchronized (stdin) {
|
||||||
|
stdin.write(mapper.writeValueAsString(envelope));
|
||||||
|
stdin.write('\n');
|
||||||
|
stdin.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
|
||||||
|
} catch (java.util.concurrent.ExecutionException e) {
|
||||||
|
Throwable cause = e.getCause();
|
||||||
|
if (cause instanceof IOException io) throw io;
|
||||||
|
throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
|
||||||
|
} catch (java.util.concurrent.TimeoutException e) {
|
||||||
|
pending.remove(id);
|
||||||
|
throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readLoop() {
|
||||||
|
try {
|
||||||
|
String line;
|
||||||
|
while (!closed && (line = stdout.readLine()) != null) {
|
||||||
|
if (line.isEmpty()) continue;
|
||||||
|
try {
|
||||||
|
JsonNode msg = mapper.readTree(line);
|
||||||
|
routeMessage(msg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("ACP malformed line, skipping: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (!closed) {
|
||||||
|
log.debug("ACP stdio reader closed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// If the process exited mid-await, fail every pending future.
|
||||||
|
for (Map.Entry<Long, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
|
||||||
|
entry.getValue().completeExceptionally(
|
||||||
|
new IOException("ACP process exited before responding"));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void routeMessage(JsonNode msg) {
|
||||||
|
JsonNode idNode = msg.get("id");
|
||||||
|
boolean hasId = idNode != null && !idNode.isNull();
|
||||||
|
boolean hasMethod = msg.has("method");
|
||||||
|
|
||||||
|
// (1) Response to one of *our* outbound requests.
|
||||||
|
if (hasId && idNode.isNumber() && !hasMethod) {
|
||||||
|
long id = idNode.asLong();
|
||||||
|
CompletableFuture<JsonNode> future = pending.remove(id);
|
||||||
|
if (future != null) {
|
||||||
|
JsonNode error = msg.get("error");
|
||||||
|
if (error != null && !error.isNull()) {
|
||||||
|
future.completeExceptionally(
|
||||||
|
new IOException("ACP error: " + error.toString()));
|
||||||
|
} else {
|
||||||
|
future.complete(msg.get("result"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) Server-initiated request — has both method and id.
|
||||||
|
if (hasMethod && hasId) {
|
||||||
|
JsonNode result = null;
|
||||||
|
try {
|
||||||
|
result = requestHandler.apply(msg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("ACP requestHandler threw on method '{}': {}",
|
||||||
|
msg.path("method").asText(""), e.getMessage());
|
||||||
|
}
|
||||||
|
sendReplyTo(idNode, result, msg.path("method").asText(""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) Notification — has method but no id.
|
||||||
|
if (hasMethod) {
|
||||||
|
try {
|
||||||
|
notificationHandler.accept(msg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("ACP notificationHandler threw on method '{}': {}",
|
||||||
|
msg.path("method").asText(""), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendReplyTo(JsonNode idNode, JsonNode result, String method) {
|
||||||
|
try {
|
||||||
|
ObjectNode reply = mapper.createObjectNode();
|
||||||
|
reply.put("jsonrpc", "2.0");
|
||||||
|
reply.set("id", idNode);
|
||||||
|
if (result != null) {
|
||||||
|
reply.set("result", result);
|
||||||
|
} else {
|
||||||
|
ObjectNode error = mapper.createObjectNode();
|
||||||
|
error.put("code", -32601);
|
||||||
|
error.put("message", "Method not implemented: " + method);
|
||||||
|
reply.set("error", error);
|
||||||
|
}
|
||||||
|
synchronized (stdin) {
|
||||||
|
stdin.write(mapper.writeValueAsString(reply));
|
||||||
|
stdin.write('\n');
|
||||||
|
stdin.flush();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("ACP failed to reply to server-initiated request '{}': {}", method, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the notification handler. Pass {@code null} to fall back
|
||||||
|
* to the no-op default.
|
||||||
|
*/
|
||||||
|
public void setNotificationHandler(Consumer<JsonNode> handler) {
|
||||||
|
this.notificationHandler = handler != null ? handler : msg -> {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the server-request handler. Pass {@code null} to fall
|
||||||
|
* back to the default which returns -32601 for every method.
|
||||||
|
*/
|
||||||
|
public void setRequestHandler(Function<JsonNode, JsonNode> handler) {
|
||||||
|
this.requestHandler = handler != null ? handler : msg -> null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
stdin.close();
|
||||||
|
} catch (IOException ignore) {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Give the agent ~1s to exit gracefully after EOF on stdin.
|
||||||
|
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||||
|
process.destroy();
|
||||||
|
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
process.destroyForcibly();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stdout.close();
|
||||||
|
} catch (IOException ignore) {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for callers that just want a fresh empty env map. */
|
||||||
|
public static Map<String, String> emptyEnv() {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package vip.mate.acp.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.acp.model.AcpEndpointEntity;
|
||||||
|
import vip.mate.acp.service.AcpConnectionTester;
|
||||||
|
import vip.mate.acp.service.AcpEndpointService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7 — REST surface for managing ACP endpoints.
|
||||||
|
*
|
||||||
|
* <p>Mirrors the McpServers controller so the frontend page can be a
|
||||||
|
* close cousin of {@code McpServers.vue}.
|
||||||
|
*/
|
||||||
|
@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/acp/endpoints")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AcpEndpointController {
|
||||||
|
|
||||||
|
private final AcpEndpointService service;
|
||||||
|
private final AcpConnectionTester tester;
|
||||||
|
|
||||||
|
@Operation(summary = "List ACP endpoints")
|
||||||
|
@GetMapping
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<List<AcpEndpointEntity>> list() {
|
||||||
|
return R.ok(service.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get ACP endpoint by id")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<AcpEndpointEntity> get(@PathVariable Long id) {
|
||||||
|
return R.ok(service.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Create a custom ACP endpoint")
|
||||||
|
@PostMapping
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
|
||||||
|
return R.ok(service.create(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Update an ACP endpoint")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<AcpEndpointEntity> update(@PathVariable Long id,
|
||||||
|
@RequestBody AcpEndpointEntity body) {
|
||||||
|
return R.ok(service.update(id, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<Void> delete(@PathVariable Long id) {
|
||||||
|
service.delete(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Enable / disable an ACP endpoint")
|
||||||
|
@PutMapping("/{id}/toggle")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
|
||||||
|
@RequestParam boolean enabled) {
|
||||||
|
return R.ok(service.toggle(id, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the configured CLI, run {@code initialize} + {@code
|
||||||
|
* session/new}, persist the outcome, and return diagnostics.
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
|
||||||
|
@PostMapping("/{id}/test")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<Map<String, Object>> test(@PathVariable Long id) {
|
||||||
|
AcpEndpointEntity endpoint = service.get(id);
|
||||||
|
return R.ok(tester.testEndpoint(endpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package vip.mate.acp.event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle event for ACP endpoint rows.
|
||||||
|
*
|
||||||
|
* <p>Published by {@code AcpEndpointService} whenever a row is created,
|
||||||
|
* updated, toggled, or deleted. Listened to by
|
||||||
|
* {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
|
||||||
|
* skill cards and their wrapper tool registrations without a full
|
||||||
|
* application restart.
|
||||||
|
*
|
||||||
|
* <p>Mirrors the {@code SkillWorkspaceEvent} pattern — a small immutable
|
||||||
|
* record carrying just enough context for listeners to fan out.
|
||||||
|
*/
|
||||||
|
public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
|
||||||
|
|
||||||
|
public enum Type {
|
||||||
|
/** Row inserted. */
|
||||||
|
CREATED,
|
||||||
|
/** Row attributes updated (command/args/env/etc.). */
|
||||||
|
UPDATED,
|
||||||
|
/** {@code enabled} flag flipped. */
|
||||||
|
TOGGLED,
|
||||||
|
/** Row deleted. */
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package vip.mate.acp.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7 — ACP (Agent Communication Protocol) endpoint registry.
|
||||||
|
*
|
||||||
|
* <p>Each row describes one external coding agent that MateClaw can
|
||||||
|
* delegate to over stdio (codex / claude-code / opencode / qwen-code by
|
||||||
|
* default). Bundled via Flyway V68 so the user only has to enable the
|
||||||
|
* row once the matching CLI is on their PATH.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_acp_endpoint")
|
||||||
|
public class AcpEndpointEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Stable slug, lowercase. Referenced by skill manifests via {@code type: acp} + {@code endpoint:}. */
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String displayName;
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** Process command, e.g. {@code npx} or {@code codex}. */
|
||||||
|
private String command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON array of CLI args, e.g. {@code ["-y","@zed-industries/codex-acp"]}.
|
||||||
|
* MyBatis Plus stores it as a string; the service layer parses on read.
|
||||||
|
*/
|
||||||
|
@TableField(value = "args_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
private String argsJson;
|
||||||
|
|
||||||
|
/** JSON object of environment variables to inject (merged onto System.getenv()). */
|
||||||
|
@TableField(value = "env_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
private String envJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* call_title | call_detail | update_detail (mirrors the ACP
|
||||||
|
* {@code tool_parse_mode} convention). Drives how the wrapper
|
||||||
|
* renders ACP tool-call events into MateClaw's stream protocol.
|
||||||
|
*/
|
||||||
|
private String toolParseMode;
|
||||||
|
|
||||||
|
private Boolean builtin;
|
||||||
|
/** When true, accept the agent's tool calls without re-prompting the user. */
|
||||||
|
private Boolean trusted;
|
||||||
|
private Boolean enabled;
|
||||||
|
|
||||||
|
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
|
||||||
|
private Long stdioBufferLimitBytes;
|
||||||
|
|
||||||
|
/** UNKNOWN / OK / ERROR — last test result. */
|
||||||
|
private String lastStatus;
|
||||||
|
|
||||||
|
private LocalDateTime lastTestedAt;
|
||||||
|
@TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
private String lastError;
|
||||||
|
|
||||||
|
private Long workspaceId;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package vip.mate.acp.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import vip.mate.acp.model.AcpEndpointEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7 — MyBatis Plus mapper for {@link AcpEndpointEntity}.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AcpEndpointMapper extends BaseMapper<AcpEndpointEntity> {
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
package vip.mate.acp.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.acp.client.AcpStdioClient;
|
||||||
|
import vip.mate.acp.model.AcpEndpointEntity;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7 — connection tester for ACP endpoints.
|
||||||
|
*
|
||||||
|
* <p>Runs the {@code initialize} + {@code session/new} handshake, with
|
||||||
|
* a generous-but-bounded timeout, and persists the outcome on the row.
|
||||||
|
* The wired CLI doesn't have to be installed for the user to add a row;
|
||||||
|
* they can install it later and re-run the test.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AcpConnectionTester {
|
||||||
|
|
||||||
|
/** Hard cap so a hung CLI doesn't block the request thread forever. */
|
||||||
|
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||||
|
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AcpEndpointService endpointService;
|
||||||
|
private final AcpRuntimeSupport runtimeSupport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the configured agent, exchange initialize + session/new,
|
||||||
|
* tear it down, and return a structured result. The endpoint row
|
||||||
|
* is updated with {@code last_status / last_tested_at / last_error}.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("name", endpoint.getName());
|
||||||
|
result.put("command", endpoint.getCommand());
|
||||||
|
|
||||||
|
List<String> args = endpointService.parseArgs(endpoint);
|
||||||
|
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||||
|
result.put("args", args);
|
||||||
|
// Same as AcpDelegationService — Zed's ACP server requires a
|
||||||
|
// non-blank cwd at session/new, so the connection test must
|
||||||
|
// also default it. The "Test" button used to fail at session/new
|
||||||
|
// with -32602 even when the CLI itself was healthy.
|
||||||
|
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
|
||||||
|
|
||||||
|
AcpStdioClient client;
|
||||||
|
try {
|
||||||
|
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||||
|
args, env, resolvedCwd);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return persistAndReturn(endpoint, result, "ERROR",
|
||||||
|
"Spawn failed: " + e.getMessage(), started);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (AcpStdioClient autoClose = client) {
|
||||||
|
JsonNode initResp;
|
||||||
|
try {
|
||||||
|
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return persistAndReturn(endpoint, result, "ERROR",
|
||||||
|
"Initialize failed: " + e.getMessage(), started);
|
||||||
|
}
|
||||||
|
if (initResp == null) {
|
||||||
|
return persistAndReturn(endpoint, result, "ERROR",
|
||||||
|
"Initialize returned no result", started);
|
||||||
|
}
|
||||||
|
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
|
||||||
|
result.put("protocolVersion", agentProtocolVersion);
|
||||||
|
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
|
||||||
|
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
|
||||||
|
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
|
||||||
|
return persistAndReturn(endpoint, result, "ERROR", msg, started);
|
||||||
|
}
|
||||||
|
// Capture agent capabilities for diagnostics — the UI can
|
||||||
|
// surface this as "supports: file_system, terminal, …".
|
||||||
|
JsonNode agentCaps = initResp.path("agentCapabilities");
|
||||||
|
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
|
||||||
|
result.put("agentCapabilities", agentCaps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// session/new validates that the agent really stands up a
|
||||||
|
// working session, not just initialize handshake.
|
||||||
|
try {
|
||||||
|
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
|
||||||
|
if (sessionResp != null && sessionResp.has("sessionId")) {
|
||||||
|
result.put("sessionId", sessionResp.path("sessionId").asText(""));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// session/new may fail for legitimate reasons (e.g. agent
|
||||||
|
// requires auth flow first). Still report OK on initialize
|
||||||
|
// but flag in the message — translated when it smells
|
||||||
|
// like an auth error so the test page UI shows actionable
|
||||||
|
// text instead of raw JSON-RPC.
|
||||||
|
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
|
||||||
|
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return persistAndReturn(endpoint, result, "ERROR",
|
||||||
|
"Connection test crashed: " + e.getMessage(), started);
|
||||||
|
}
|
||||||
|
|
||||||
|
long elapsed = System.currentTimeMillis() - started;
|
||||||
|
result.put("elapsedMs", elapsed);
|
||||||
|
return persistAndReturn(endpoint, result, "OK", null, started);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
|
||||||
|
Map<String, Object> result,
|
||||||
|
String status,
|
||||||
|
String error,
|
||||||
|
long started) {
|
||||||
|
endpointService.recordTestResult(endpoint.getId(), status, error);
|
||||||
|
result.put("status", status);
|
||||||
|
if (error != null) result.put("error", error);
|
||||||
|
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
package vip.mate.acp.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.acp.client.AcpStdioClient;
|
||||||
|
import vip.mate.acp.model.AcpEndpointEntity;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7b — fire-and-forget delegation to an external ACP
|
||||||
|
* agent.
|
||||||
|
*
|
||||||
|
* <p>One {@link #prompt(String, String, String)} call:
|
||||||
|
* <ol>
|
||||||
|
* <li>Looks up the endpoint row, refuses if disabled or undefined.</li>
|
||||||
|
* <li>Spawns a fresh {@link AcpStdioClient} (no session caching in
|
||||||
|
* v1 — stateless tool calls keep failure surface small;
|
||||||
|
* multi-turn caching can be a follow-up RFC).</li>
|
||||||
|
* <li>Runs {@code initialize → session/new → session/prompt}.</li>
|
||||||
|
* <li>Accumulates {@code agent_message_chunk} text from
|
||||||
|
* {@code session/update} notifications into the response.</li>
|
||||||
|
* <li>Auto-allows or cancels {@code session/request_permission}
|
||||||
|
* based on the endpoint's {@code trusted} flag — untrusted
|
||||||
|
* endpoints reject every permission request, surfacing a
|
||||||
|
* transparent "this endpoint can't be used non-interactively"
|
||||||
|
* error to the LLM caller.</li>
|
||||||
|
* <li>Returns the accumulated text or a JSON error blob on failure.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The streaming surface (chunk-by-chunk relay back through MateClaw's
|
||||||
|
* own SSE stream) is intentionally not done yet — the wrapper tool is
|
||||||
|
* synchronous so it composes cleanly with the existing ReAct graph.
|
||||||
|
* When we want native streaming, we'll add a second method that takes
|
||||||
|
* an {@code Sinks.Many<String>}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AcpDelegationService {
|
||||||
|
|
||||||
|
/** Hard ceiling on a single ACP delegation. Long enough for a
|
||||||
|
* multi-turn coding session, short enough that a hung agent can't
|
||||||
|
* permanently block an LLM tool call. */
|
||||||
|
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
|
||||||
|
|
||||||
|
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||||
|
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AcpEndpointService endpointService;
|
||||||
|
private final AcpRuntimeSupport runtimeSupport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a one-shot ACP prompt against {@code endpointName}. Returns
|
||||||
|
* the agent's accumulated reply text. Throws
|
||||||
|
* {@link MateClawException} for configuration / runtime errors so
|
||||||
|
* the caller (typically a wrapper tool) can serialize a friendly
|
||||||
|
* JSON error.
|
||||||
|
*/
|
||||||
|
public String prompt(String endpointName, String userPrompt, String cwdHint) {
|
||||||
|
if (endpointName == null || endpointName.isBlank()) {
|
||||||
|
throw new MateClawException("err.acp.endpoint_required",
|
||||||
|
"ACP endpoint name is required");
|
||||||
|
}
|
||||||
|
if (userPrompt == null || userPrompt.isBlank()) {
|
||||||
|
throw new MateClawException("err.acp.prompt_required",
|
||||||
|
"ACP prompt is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
AcpEndpointEntity endpoint = endpointService.findByName(endpointName);
|
||||||
|
if (endpoint == null) {
|
||||||
|
throw new MateClawException("err.acp.endpoint_not_found",
|
||||||
|
"ACP endpoint not found: " + endpointName);
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(endpoint.getEnabled())) {
|
||||||
|
throw new MateClawException("err.acp.endpoint_disabled",
|
||||||
|
"ACP endpoint '" + endpointName + "' is disabled — enable it in Settings ▸ ACP Endpoints");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> args = endpointService.parseArgs(endpoint);
|
||||||
|
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||||
|
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
|
||||||
|
// Always resolve cwd to a real directory: Zed's ACP Zod schema
|
||||||
|
// marks cwd as a required string and rejects {@code undefined}
|
||||||
|
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
|
||||||
|
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
|
||||||
|
|
||||||
|
StringBuilder accumulator = new StringBuilder();
|
||||||
|
AcpStdioClient client;
|
||||||
|
try {
|
||||||
|
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||||
|
args, env, resolvedCwd);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new MateClawException("err.acp.spawn_failed",
|
||||||
|
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try (AcpStdioClient autoClose = client) {
|
||||||
|
wireHandlers(autoClose, accumulator, trusted, endpointName);
|
||||||
|
|
||||||
|
JsonNode initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
|
||||||
|
if (initResp == null || initResp.path("protocolVersion").asInt(-1)
|
||||||
|
!= AcpStdioClient.PROTOCOL_VERSION) {
|
||||||
|
throw new MateClawException("err.acp.protocol_mismatch",
|
||||||
|
"ACP protocol mismatch with endpoint '" + endpointName + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
|
||||||
|
String sessionId = session == null ? null : session.path("sessionId").asText("");
|
||||||
|
if (sessionId == null || sessionId.isBlank()) {
|
||||||
|
throw new MateClawException("err.acp.session_failed",
|
||||||
|
"ACP session/new returned no sessionId for '" + endpointName + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode promptParams = objectMapper.createObjectNode();
|
||||||
|
promptParams.put("sessionId", sessionId);
|
||||||
|
promptParams.set("prompt", buildPromptArray(userPrompt));
|
||||||
|
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||||
|
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
|
||||||
|
// Upstream CLIs (claude-code / codex / qwen-code) wrap their
|
||||||
|
// own auth failures in opaque JSON-RPC noise. Recognise the
|
||||||
|
// 401/403/forbidden/unauthorized fingerprints and rewrite
|
||||||
|
// the message into something the user can act on, with the
|
||||||
|
// exact env var name they need to set.
|
||||||
|
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
|
||||||
|
if (authHint != null) {
|
||||||
|
throw new MateClawException("err.acp.auth_failed", authHint);
|
||||||
|
}
|
||||||
|
throw new MateClawException("err.acp.delegation_failed",
|
||||||
|
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
|
||||||
|
boolean trusted, String endpointName) {
|
||||||
|
// Notifications carry session/update messages; agent_message_chunk
|
||||||
|
// is what we accumulate. Other update kinds (tool_call_*, plan,
|
||||||
|
// current_mode) are observed but not relayed in v1.
|
||||||
|
client.setNotificationHandler(msg -> {
|
||||||
|
String method = msg.path("method").asText("");
|
||||||
|
if (!"session/update".equals(method)) return;
|
||||||
|
JsonNode update = msg.path("params").path("update");
|
||||||
|
if (update.isMissingNode() || update.isNull()) return;
|
||||||
|
String type = update.path("sessionUpdate").asText(
|
||||||
|
update.path("type").asText(""));
|
||||||
|
if ("agent_message_chunk".equals(type) || "agent-message-chunk".equals(type)) {
|
||||||
|
String text = extractText(update.path("content"));
|
||||||
|
if (!text.isEmpty()) buf.append(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Permission requests: trusted endpoints auto-allow the FIRST
|
||||||
|
// option (which Zed-style agents make the "allow" choice);
|
||||||
|
// untrusted refuse every request explicitly so the agent
|
||||||
|
// exits cleanly instead of hanging.
|
||||||
|
client.setRequestHandler(msg -> {
|
||||||
|
String method = msg.path("method").asText("");
|
||||||
|
if (!"session/request_permission".equals(method)) return null;
|
||||||
|
JsonNode params = msg.path("params");
|
||||||
|
if (!trusted) {
|
||||||
|
log.info("[ACP] declining permission for untrusted endpoint '{}'", endpointName);
|
||||||
|
return cancelledOutcome();
|
||||||
|
}
|
||||||
|
JsonNode options = params.path("options");
|
||||||
|
String optionId = "";
|
||||||
|
if (options.isArray() && options.size() > 0) {
|
||||||
|
JsonNode first = options.get(0);
|
||||||
|
optionId = first.path("optionId").asText(first.path("id").asText(""));
|
||||||
|
}
|
||||||
|
if (optionId.isEmpty()) {
|
||||||
|
return cancelledOutcome();
|
||||||
|
}
|
||||||
|
return selectedOutcome(optionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode buildPromptArray(String text) {
|
||||||
|
// Spring AI / Zed ACP prompt format: array of content blocks.
|
||||||
|
// For now we only emit a single text block; future iterations
|
||||||
|
// can attach images / file references via additional blocks.
|
||||||
|
var arr = objectMapper.createArrayNode();
|
||||||
|
ObjectNode block = objectMapper.createObjectNode();
|
||||||
|
block.put("type", "text");
|
||||||
|
block.put("text", text);
|
||||||
|
arr.add(block);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from an ACP {@code content} field. The shape
|
||||||
|
* varies between agents — Zed uses {@code [{type:"text",text:"..."}]},
|
||||||
|
* some emit a single object, others nest in {@code resource.text}.
|
||||||
|
* Tolerant extractor that handles all known shapes.
|
||||||
|
*/
|
||||||
|
private String extractText(JsonNode content) {
|
||||||
|
if (content == null || content.isNull()) return "";
|
||||||
|
if (content.isArray()) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (JsonNode item : content) sb.append(extractText(item));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
JsonNode text = content.get("text");
|
||||||
|
if (text != null && text.isTextual()) return text.asText("");
|
||||||
|
JsonNode resource = content.get("resource");
|
||||||
|
if (resource != null) {
|
||||||
|
JsonNode rt = resource.get("text");
|
||||||
|
if (rt != null && rt.isTextual()) return rt.asText("");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode selectedOutcome(String optionId) {
|
||||||
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
|
ObjectNode outcome = objectMapper.createObjectNode();
|
||||||
|
outcome.put("outcome", "selected");
|
||||||
|
outcome.put("optionId", optionId);
|
||||||
|
result.set("outcome", outcome);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode cancelledOutcome() {
|
||||||
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
|
ObjectNode outcome = objectMapper.createObjectNode();
|
||||||
|
outcome.put("outcome", "cancelled");
|
||||||
|
result.set("outcome", outcome);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
package vip.mate.acp.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.acp.event.AcpEndpointChangedEvent;
|
||||||
|
import vip.mate.acp.model.AcpEndpointEntity;
|
||||||
|
import vip.mate.acp.repository.AcpEndpointMapper;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 Phase 7 — CRUD layer for {@link AcpEndpointEntity}.
|
||||||
|
*
|
||||||
|
* <p>Keeps three guarantees:
|
||||||
|
* <ol>
|
||||||
|
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted —
|
||||||
|
* the user can only disable them. Mirrors {@code SkillService}.</li>
|
||||||
|
* <li>Names are unique; {@code create} validates against the live
|
||||||
|
* (non-deleted) set.</li>
|
||||||
|
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
|
||||||
|
* so the controller can hand structured data to the UI without
|
||||||
|
* leaking string-encoded JSON.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AcpEndpointService {
|
||||||
|
|
||||||
|
private final AcpEndpointMapper mapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
public List<AcpEndpointEntity> list() {
|
||||||
|
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||||
|
.orderByDesc(AcpEndpointEntity::getBuiltin)
|
||||||
|
.orderByAsc(AcpEndpointEntity::getName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of {@link #list()} that returns only enabled rows.
|
||||||
|
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
|
||||||
|
* (one per enabled endpoint).
|
||||||
|
*/
|
||||||
|
public List<AcpEndpointEntity> listEnabled() {
|
||||||
|
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||||
|
.eq(AcpEndpointEntity::getEnabled, true)
|
||||||
|
.orderByAsc(AcpEndpointEntity::getName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AcpEndpointEntity get(Long id) {
|
||||||
|
AcpEndpointEntity ep = mapper.selectById(id);
|
||||||
|
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
|
||||||
|
"ACP endpoint not found: " + id);
|
||||||
|
return ep;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AcpEndpointEntity findByName(String name) {
|
||||||
|
return mapper.selectOne(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||||
|
.eq(AcpEndpointEntity::getName, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AcpEndpointEntity create(AcpEndpointEntity input) {
|
||||||
|
if (input.getName() == null || input.getName().isBlank()) {
|
||||||
|
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
|
||||||
|
}
|
||||||
|
if (input.getCommand() == null || input.getCommand().isBlank()) {
|
||||||
|
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
|
||||||
|
}
|
||||||
|
if (findByName(input.getName()) != null) {
|
||||||
|
throw new MateClawException("err.acp.name_exists",
|
||||||
|
"ACP endpoint name already exists: " + input.getName());
|
||||||
|
}
|
||||||
|
// User-created rows are never builtin; default-enable false so a
|
||||||
|
// misconfigured row can't auto-spawn a process at startup.
|
||||||
|
input.setBuiltin(false);
|
||||||
|
if (input.getEnabled() == null) input.setEnabled(false);
|
||||||
|
if (input.getTrusted() == null) input.setTrusted(true);
|
||||||
|
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
|
||||||
|
input.setToolParseMode("call_title");
|
||||||
|
}
|
||||||
|
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
||||||
|
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
||||||
|
}
|
||||||
|
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
||||||
|
mapper.insert(input);
|
||||||
|
log.info("Created ACP endpoint: {}", input.getName());
|
||||||
|
publish(input, AcpEndpointChangedEvent.Type.CREATED);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
|
||||||
|
AcpEndpointEntity existing = get(id);
|
||||||
|
if (Boolean.TRUE.equals(existing.getBuiltin())
|
||||||
|
&& patch.getCommand() != null
|
||||||
|
&& !patch.getCommand().equals(existing.getCommand())) {
|
||||||
|
throw new MateClawException("err.acp.builtin_command_locked",
|
||||||
|
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
|
||||||
|
}
|
||||||
|
// Allow surgical updates: only fields the caller actually set.
|
||||||
|
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
|
||||||
|
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
|
||||||
|
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
|
||||||
|
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
|
||||||
|
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
|
||||||
|
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
|
||||||
|
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
|
||||||
|
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
|
||||||
|
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
||||||
|
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
||||||
|
}
|
||||||
|
mapper.updateById(existing);
|
||||||
|
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(Long id) {
|
||||||
|
AcpEndpointEntity existing = get(id);
|
||||||
|
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||||
|
throw new MateClawException("err.acp.builtin_readonly",
|
||||||
|
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
|
||||||
|
}
|
||||||
|
mapper.deleteById(id);
|
||||||
|
log.info("Deleted ACP endpoint: {}", existing.getName());
|
||||||
|
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AcpEndpointEntity toggle(Long id, boolean enabled) {
|
||||||
|
AcpEndpointEntity existing = get(id);
|
||||||
|
existing.setEnabled(enabled);
|
||||||
|
mapper.updateById(existing);
|
||||||
|
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
|
||||||
|
try {
|
||||||
|
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
|
||||||
|
ep.getId(), ep.getName(), type));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Listener failures must not break the CRUD path. The bridge
|
||||||
|
// will resync on the next ApplicationReady tick anyway.
|
||||||
|
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
|
||||||
|
ep.getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a connection-test outcome on the row. */
|
||||||
|
public void recordTestResult(Long id, String status, String error) {
|
||||||
|
AcpEndpointEntity existing = mapper.selectById(id);
|
||||||
|
if (existing == null) return;
|
||||||
|
existing.setLastStatus(status);
|
||||||
|
existing.setLastTestedAt(LocalDateTime.now());
|
||||||
|
existing.setLastError(error);
|
||||||
|
mapper.updateById(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> parseArgs(AcpEndpointEntity ep) {
|
||||||
|
return parseStringList(ep.getArgsJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
|
||||||
|
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(ep.getEnvJson(),
|
||||||
|
new TypeReference<Map<String, String>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
|
||||||
|
ep.getName(), e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> parseStringList(String json) {
|
||||||
|
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse args_json: {}", e.getMessage());
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
package vip.mate.acp.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.acp.model.AcpEndpointEntity;
|
||||||
|
import vip.mate.workspace.core.model.WorkspaceEntity;
|
||||||
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared runtime helpers for ACP code paths.
|
||||||
|
*
|
||||||
|
* <p>Two responsibilities, both motivated by upstream ACP servers
|
||||||
|
* (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
|
||||||
|
* inputs and noisy in failure modes:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #resolveCwd} — pick a non-blank cwd for {@code session/new}.
|
||||||
|
* Zed's ACP Zod schema marks {@code cwd} as a required string and
|
||||||
|
* returns {@code -32602 Invalid params} when it's missing. We
|
||||||
|
* prefer the endpoint's bound workspace {@code base_path} (per-
|
||||||
|
* workspace context) and fall back to the JVM working directory
|
||||||
|
* only as a last resort. Never returns null/blank.</li>
|
||||||
|
*
|
||||||
|
* <li>{@link #translateAuthError} — turn upstream JSON-RPC noise like
|
||||||
|
* {@code "API Error: 403 {...forbidden...}"} into an actionable
|
||||||
|
* hint that names the env var the user actually has to set in
|
||||||
|
* Settings ▸ ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
|
||||||
|
* claude-code, {@code OPENAI_API_KEY} for codex). Returns
|
||||||
|
* {@code null} when the error doesn't smell like an auth failure.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AcpRuntimeSupport {
|
||||||
|
|
||||||
|
private final WorkspaceService workspaceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolution order (first non-blank wins):
|
||||||
|
* <ol>
|
||||||
|
* <li>Caller-provided hint (skill manifest's {@code acp.cwd},
|
||||||
|
* wrapper tool {@code cwd} arg, or explicit override).</li>
|
||||||
|
* <li>Workspace {@code base_path} when the endpoint is bound to a
|
||||||
|
* workspace and the workspace declares one.</li>
|
||||||
|
* <li>{@code System.getProperty("user.dir")} — the JVM working
|
||||||
|
* directory at server launch. Reasonable for a single-user
|
||||||
|
* desktop install, but exposes the server's launch dir to the
|
||||||
|
* upstream agent, which is why it's last.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
|
||||||
|
if (callerHint != null && !callerHint.isBlank()) {
|
||||||
|
return callerHint;
|
||||||
|
}
|
||||||
|
if (endpoint != null && endpoint.getWorkspaceId() != null) {
|
||||||
|
try {
|
||||||
|
WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
|
||||||
|
if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
|
||||||
|
File f = new File(ws.getBasePath());
|
||||||
|
if (f.isDirectory()) return f.getAbsolutePath();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
|
||||||
|
endpoint.getWorkspaceId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return System.getProperty("user.dir", ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect upstream auth errors and emit an actionable hint string.
|
||||||
|
* Returns null when the message doesn't match — caller should keep
|
||||||
|
* the original error as-is.
|
||||||
|
*
|
||||||
|
* <p>Heuristic: looks for HTTP-like 401/403 markers OR the words
|
||||||
|
* {@code forbidden / unauthorized / not allowed / api key / token}
|
||||||
|
* in the original message (case-insensitive). The patterns are loose
|
||||||
|
* on purpose — different ACP CLIs phrase auth errors differently
|
||||||
|
* and the cost of a false positive (a slightly more verbose error
|
||||||
|
* banner) is much smaller than a false negative (user staring at a
|
||||||
|
* raw JSON-RPC blob).
|
||||||
|
*
|
||||||
|
* <p>Special case: a claude-code endpoint returning {@code 403
|
||||||
|
* "Request not allowed"} is almost always the keychain-hijack
|
||||||
|
* scenario rather than a wrong API key. The third-party
|
||||||
|
* {@code @zed-industries/claude-agent-acp} package wraps
|
||||||
|
* {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
|
||||||
|
* checks the macOS keychain ({@code Claude Code-credentials}) /
|
||||||
|
* {@code ~/.claude/credentials.json} BEFORE the
|
||||||
|
* {@code ANTHROPIC_API_KEY} env var. So a host that's done
|
||||||
|
* {@code claude login} silently shadows whatever API key the user
|
||||||
|
* configured in the endpoint env, and Anthropic's API rejects the
|
||||||
|
* subscription OAuth token (first-party-only) with the very
|
||||||
|
* specific {@code "Request not allowed"} error string. We detect
|
||||||
|
* that exact combination and surface the keychain-clearing remedy
|
||||||
|
* instead of the generic "set ANTHROPIC_API_KEY" hint, which
|
||||||
|
* doesn't apply here.
|
||||||
|
*/
|
||||||
|
public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
|
||||||
|
if (originalMessage == null) return null;
|
||||||
|
String lower = originalMessage.toLowerCase(Locale.ROOT);
|
||||||
|
boolean looksLikeAuth =
|
||||||
|
lower.contains("403")
|
||||||
|
|| lower.contains("401")
|
||||||
|
|| lower.contains("forbidden")
|
||||||
|
|| lower.contains("unauthorized")
|
||||||
|
|| lower.contains("not allowed")
|
||||||
|
|| lower.contains("invalid api key")
|
||||||
|
|| lower.contains("invalid token")
|
||||||
|
|| lower.contains("authenticate");
|
||||||
|
if (!looksLikeAuth) return null;
|
||||||
|
|
||||||
|
String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
|
||||||
|
String slug = lower(name);
|
||||||
|
String command = endpoint != null ? lower(endpoint.getCommand()) : "";
|
||||||
|
|
||||||
|
// Keychain-hijack detection — must come before the generic env-
|
||||||
|
// missing branch because both would superficially match.
|
||||||
|
boolean keychainHijack = lower.contains("request not allowed")
|
||||||
|
&& (slug.contains("claude") || command.contains("claude-agent-acp"));
|
||||||
|
if (keychainHijack) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
|
||||||
|
sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
|
||||||
|
sb.append("credentials are hijacking the SDK auth path. ");
|
||||||
|
sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
|
||||||
|
sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
|
||||||
|
sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
|
||||||
|
sb.append("third-party processes aren't allowed to use it. ");
|
||||||
|
sb.append("To fix: ");
|
||||||
|
sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
|
||||||
|
sb.append("-s \"Claude Code-credentials\"`; ");
|
||||||
|
sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
|
||||||
|
sb.append("Then click Test connection again. Original: ").append(originalMessage);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String envVar = expectedAuthEnvVar(endpoint);
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
|
||||||
|
sb.append("Most likely the endpoint env has no API key. ");
|
||||||
|
sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
|
||||||
|
if (envVar != null) {
|
||||||
|
sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
|
||||||
|
} else {
|
||||||
|
sb.append("add the appropriate API key for this CLI");
|
||||||
|
}
|
||||||
|
sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
|
||||||
|
sb.append("so a real API key is required. Original: ").append(originalMessage);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort guess of the API key env var the upstream CLI expects.
|
||||||
|
* Returns null when we don't recognise the endpoint — caller emits a
|
||||||
|
* generic "appropriate API key" hint instead.
|
||||||
|
*/
|
||||||
|
public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
|
||||||
|
if (endpoint == null) return null;
|
||||||
|
String name = lower(endpoint.getName());
|
||||||
|
String command = lower(endpoint.getCommand());
|
||||||
|
// Match by name first (slug is the stable identifier); fall back
|
||||||
|
// to command keywords for user-defined rows.
|
||||||
|
if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
|
||||||
|
return "ANTHROPIC_API_KEY";
|
||||||
|
}
|
||||||
|
if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
|
||||||
|
return "OPENAI_API_KEY";
|
||||||
|
}
|
||||||
|
if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
|
||||||
|
return "DASHSCOPE_API_KEY";
|
||||||
|
}
|
||||||
|
if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
|
||||||
|
return "GOOGLE_API_KEY";
|
||||||
|
}
|
||||||
|
// opencode multi-model — no single canonical env var.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lower(String s) {
|
||||||
|
return s == null ? "" : s.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
package vip.mate.activity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.approval.model.ToolApprovalEntity;
|
||||||
|
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||||
|
import vip.mate.audit.model.AuditEventEntity;
|
||||||
|
import vip.mate.audit.repository.AuditEventMapper;
|
||||||
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §4.5 / §7 — unified Activity feed.
|
||||||
|
*
|
||||||
|
* <p>Merges three sources into one chronologically-ordered stream:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code audit_event} — CRUD-style events on agents / channels /
|
||||||
|
* skills / wiki / workspace (the existing audit log)</li>
|
||||||
|
* <li>{@code tool_approval} — approval requests + their resolution
|
||||||
|
* (granted / denied / expired). Ties tool gating decisions
|
||||||
|
* directly to the audit timeline.</li>
|
||||||
|
* <li>Successful tool calls — RFC §4.5 mentions these, but the
|
||||||
|
* runtime doesn't yet persist a row per successful call.
|
||||||
|
* Returning an empty bucket keeps the API contract stable so
|
||||||
|
* the UI can light up automatically once a future commit adds
|
||||||
|
* persistence.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Pagination is best-effort: each source is paged from index 0
|
||||||
|
* up to {@code size * 2}, then the merged list is trimmed and offset
|
||||||
|
* in-memory. For workspaces with >>1k events / day a follow-up should
|
||||||
|
* push merging into SQL; this is good enough for v1.
|
||||||
|
*/
|
||||||
|
@Tag(name = "Activity Feed (RFC-090)")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/activity")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ActivityFeedController {
|
||||||
|
|
||||||
|
private final AuditEventService auditEventService;
|
||||||
|
private final AuditEventMapper auditEventMapper;
|
||||||
|
private final ToolApprovalMapper toolApprovalMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §4.5 — paginated activity feed.
|
||||||
|
*
|
||||||
|
* <p>Pagination strategy:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Single-source filter</b> (source=audit | approval) →
|
||||||
|
* direct {@code BaseMapper.selectPage(...)} on the matching
|
||||||
|
* table. Both total and records are SQL-accurate.</li>
|
||||||
|
* <li><b>Combined feed</b> (source unset) → fetch
|
||||||
|
* {@code page*size} rows from each side, merge by time-desc,
|
||||||
|
* slice to the requested window. {@code total} is the sum
|
||||||
|
* of {@code selectCount} across both tables — exact for
|
||||||
|
* count, best-effort for time-merge ordering at very deep
|
||||||
|
* page numbers (the merge buffer is bounded but typical
|
||||||
|
* use stays within a few hundred rows).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Caps: {@code size} clamped to [1, 200]; {@code page} ≥ 1.
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
|
||||||
|
@GetMapping("/feed")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<Map<String, Object>> feed(
|
||||||
|
@RequestParam(required = false) Long workspaceId,
|
||||||
|
@RequestParam(required = false) String source,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
if (size <= 0) size = 20;
|
||||||
|
if (size > 200) size = 200;
|
||||||
|
if (page <= 0) page = 1;
|
||||||
|
|
||||||
|
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
|
||||||
|
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
|
||||||
|
|
||||||
|
// ───── Single-source path: direct SQL pagination ─────
|
||||||
|
if (wantAudit && !wantApproval) {
|
||||||
|
return R.ok(pageAuditOnly(workspaceId, page, size));
|
||||||
|
}
|
||||||
|
if (wantApproval && !wantAudit) {
|
||||||
|
return R.ok(pageApprovalOnly(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── Combined path: per-source paginate + merge ─────
|
||||||
|
// Fetch page*size from each side so the merged window contains
|
||||||
|
// the requested slice even in the worst case where one source
|
||||||
|
// dominates the timeline. This is wasteful at very deep pages
|
||||||
|
// but bounded — a follow-up can push merging into SQL via a
|
||||||
|
// UNION ALL view if event volume gets into 10k+/day territory.
|
||||||
|
int bufferSize = Math.max(size * page, 50);
|
||||||
|
|
||||||
|
LambdaQueryWrapper<AuditEventEntity> auditQ = new LambdaQueryWrapper<AuditEventEntity>()
|
||||||
|
.orderByDesc(AuditEventEntity::getCreateTime);
|
||||||
|
if (workspaceId != null) auditQ.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||||
|
IPage<AuditEventEntity> auditPage = auditEventMapper.selectPage(new Page<>(1, bufferSize), auditQ);
|
||||||
|
|
||||||
|
LambdaQueryWrapper<ToolApprovalEntity> approvalQ = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||||
|
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||||
|
IPage<ToolApprovalEntity> approvalPage = toolApprovalMapper.selectPage(new Page<>(1, bufferSize), approvalQ);
|
||||||
|
|
||||||
|
List<ActivityRow> rows = new ArrayList<>();
|
||||||
|
for (AuditEventEntity ev : auditPage.getRecords()) rows.add(fromAuditEvent(ev));
|
||||||
|
for (ToolApprovalEntity ap : approvalPage.getRecords()) rows.add(fromApproval(ap));
|
||||||
|
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
|
||||||
|
long total = auditPage.getTotal() + approvalPage.getTotal();
|
||||||
|
int from = Math.min((page - 1) * size, rows.size());
|
||||||
|
int to = Math.min(from + size, rows.size());
|
||||||
|
List<ActivityRow> sliced = rows.subList(from, to);
|
||||||
|
|
||||||
|
Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("page", page);
|
||||||
|
resp.put("size", size);
|
||||||
|
resp.put("total", total);
|
||||||
|
resp.put("records", sliced);
|
||||||
|
return R.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure SQL pagination on the audit_event table; total + records both
|
||||||
|
* come from the underlying {@link Page} object. */
|
||||||
|
private Map<String, Object> pageAuditOnly(Long workspaceId, int page, int size) {
|
||||||
|
LambdaQueryWrapper<AuditEventEntity> q = new LambdaQueryWrapper<AuditEventEntity>()
|
||||||
|
.orderByDesc(AuditEventEntity::getCreateTime);
|
||||||
|
if (workspaceId != null) q.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||||
|
IPage<AuditEventEntity> p = auditEventMapper.selectPage(new Page<>(page, size), q);
|
||||||
|
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
|
||||||
|
for (AuditEventEntity ev : p.getRecords()) records.add(fromAuditEvent(ev));
|
||||||
|
Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("page", page);
|
||||||
|
resp.put("size", size);
|
||||||
|
resp.put("total", p.getTotal());
|
||||||
|
resp.put("records", records);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure SQL pagination on the tool_approval table. */
|
||||||
|
private Map<String, Object> pageApprovalOnly(int page, int size) {
|
||||||
|
LambdaQueryWrapper<ToolApprovalEntity> q = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||||
|
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||||
|
IPage<ToolApprovalEntity> p = toolApprovalMapper.selectPage(new Page<>(page, size), q);
|
||||||
|
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
|
||||||
|
for (ToolApprovalEntity ap : p.getRecords()) records.add(fromApproval(ap));
|
||||||
|
Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("page", page);
|
||||||
|
resp.put("size", size);
|
||||||
|
resp.put("total", p.getTotal());
|
||||||
|
resp.put("records", records);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
|
||||||
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("detailJson", ev.getDetailJson());
|
||||||
|
detail.put("userAgent", ev.getUserAgent());
|
||||||
|
detail.put("workspaceId", ev.getWorkspaceId());
|
||||||
|
return new ActivityRow(
|
||||||
|
"audit-" + ev.getId(),
|
||||||
|
"audit",
|
||||||
|
ev.getCreateTime(),
|
||||||
|
ev.getUsername(),
|
||||||
|
ev.getAction(),
|
||||||
|
ev.getResourceType(),
|
||||||
|
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
|
||||||
|
ev.getIpAddress(),
|
||||||
|
detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityRow fromApproval(ToolApprovalEntity ap) {
|
||||||
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("toolArguments", ap.getToolArguments());
|
||||||
|
detail.put("summary", ap.getSummary());
|
||||||
|
detail.put("maxSeverity", ap.getMaxSeverity());
|
||||||
|
detail.put("status", ap.getStatus());
|
||||||
|
detail.put("resolvedAt", ap.getResolvedAt());
|
||||||
|
// Map approval status onto an audit-style action so the UI's
|
||||||
|
// existing action coloring (CREATE / DELETE / etc.) keeps
|
||||||
|
// working without a special case.
|
||||||
|
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
|
||||||
|
return new ActivityRow(
|
||||||
|
"approval-" + ap.getId(),
|
||||||
|
"approval",
|
||||||
|
ap.getCreatedAt(),
|
||||||
|
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
|
||||||
|
action,
|
||||||
|
"TOOL_APPROVAL",
|
||||||
|
ap.getToolName(),
|
||||||
|
null,
|
||||||
|
detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-format row. Public record so Jackson serializes it directly
|
||||||
|
* without needing a separate DTO.
|
||||||
|
*/
|
||||||
|
public record ActivityRow(
|
||||||
|
String id,
|
||||||
|
String source,
|
||||||
|
LocalDateTime time,
|
||||||
|
String username,
|
||||||
|
String action,
|
||||||
|
String resourceType,
|
||||||
|
String resourceName,
|
||||||
|
String ipAddress,
|
||||||
|
Map<String, Object> detail
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
package vip.mate.agent;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.binding.service.AgentBindingService;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.skill.model.SkillEntity;
|
||||||
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
|
import vip.mate.tool.model.AvailableToolDTO;
|
||||||
|
import vip.mate.tool.service.AvailableToolService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-callable employee authoring tool.
|
||||||
|
*
|
||||||
|
* <p>Lets an agent design and persist a new specialized employee (Agent)
|
||||||
|
* from a plain-language role spec, then bind a focused capability set to
|
||||||
|
* it. Pairs with the workflow drafting tool so a single chat turn can plan
|
||||||
|
* a team of employees and chain them into a workflow:
|
||||||
|
* design roles → {@link #create_employee} for each → workflow drafting tool
|
||||||
|
* referencing the just-created employees.
|
||||||
|
*
|
||||||
|
* <p>Workspace is taken from {@link ChatOrigin} on the active
|
||||||
|
* {@link ToolContext}; the LLM can never write into a foreign workspace
|
||||||
|
* even if its prompt tried to forge one. Mirrors the create-then-bind
|
||||||
|
* sequence used when applying an agent template.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentAuthoringTool {
|
||||||
|
|
||||||
|
private final AgentService agentService;
|
||||||
|
private final AgentBindingService agentBindingService;
|
||||||
|
private final SkillMapper skillMapper;
|
||||||
|
private final AvailableToolService availableToolService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/** Cap on names listed per catalog section so the tool result stays small. */
|
||||||
|
private static final int CATALOG_MAX_PER_SECTION = 200;
|
||||||
|
|
||||||
|
@Tool(description = """
|
||||||
|
Create a new specialized employee (Agent) in the current workspace from a role spec, \
|
||||||
|
and optionally bind a focused set of skills and tools to it. \
|
||||||
|
Use this when a task needs a role that does not exist yet — design the role, then create it. \
|
||||||
|
Returns the new agentId (string) and a short summary. \
|
||||||
|
Leave skillNames/toolNames empty to make a generalist that inherits all globally-enabled capabilities. \
|
||||||
|
Call list_capability_catalog first to learn the exact skill and tool names you can assign. \
|
||||||
|
The created employee is enabled immediately and can be referenced by the workflow drafting tool.""")
|
||||||
|
public String create_employee(
|
||||||
|
@ToolParam(description = "Employee name, unique within the workspace, e.g. \"market-research-analyst\".")
|
||||||
|
String name,
|
||||||
|
@ToolParam(description = "One-line description of the employee's role and responsibility. Shown in pickers and used by the workflow planner to route work.")
|
||||||
|
String description,
|
||||||
|
@ToolParam(description = "System prompt that defines the employee's persona, expertise, and working style. Be specific about its specialty.")
|
||||||
|
String systemPrompt,
|
||||||
|
@ToolParam(description = "Agent type: \"react\" (single-loop reasoning, default) or \"plan_execute\" (decompose then execute). Leave empty for react.", required = false)
|
||||||
|
String agentType,
|
||||||
|
@ToolParam(description = "Optional model name override (must match an enabled model). Leave empty to use the workspace default model.", required = false)
|
||||||
|
String modelName,
|
||||||
|
@ToolParam(description = "Skills to bind, as a JSON array of skill names or a comma-separated list, e.g. [\"sql_query\",\"make_plan\"]. Empty = inherit all globally-enabled skills. Names must come from list_capability_catalog.", required = false)
|
||||||
|
String skillNames,
|
||||||
|
@ToolParam(description = "Tools to bind, as a JSON array of tool names or a comma-separated list, e.g. [\"web_search\",\"read_file\"]. Empty = inherit all globally-enabled tools. Names must come from list_capability_catalog.", required = false)
|
||||||
|
String toolNames,
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
Long workspaceId = origin.workspaceId();
|
||||||
|
if (workspaceId == null || workspaceId <= 0) {
|
||||||
|
return "[error] Cannot determine the current workspace; invoke this tool within a workspace context.";
|
||||||
|
}
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return "[error] Employee name is required.";
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentEntity agent = new AgentEntity();
|
||||||
|
agent.setName(name.trim());
|
||||||
|
agent.setDescription(blankToNull(description));
|
||||||
|
if (systemPrompt != null && !systemPrompt.isBlank()) {
|
||||||
|
agent.setSystemPrompt(systemPrompt);
|
||||||
|
}
|
||||||
|
agent.setAgentType(normalizeAgentType(agentType));
|
||||||
|
agent.setModelName(blankToNull(modelName));
|
||||||
|
agent.setWorkspaceId(workspaceId);
|
||||||
|
agent.setCreatorUserId(parseUserId(origin.requesterId()));
|
||||||
|
|
||||||
|
AgentEntity created;
|
||||||
|
try {
|
||||||
|
created = agentService.createAgent(agent);
|
||||||
|
} catch (MateClawException e) {
|
||||||
|
// Duplicate name / blank name surface here as a friendly message
|
||||||
|
// so the planner can rename and retry instead of aborting.
|
||||||
|
return "[error] Failed to create employee: " + e.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> requestedSkills = parseNameList(skillNames);
|
||||||
|
List<String> requestedTools = parseNameList(toolNames);
|
||||||
|
|
||||||
|
List<String> boundSkills = bindSkills(created, workspaceId, requestedSkills);
|
||||||
|
List<String> boundTools = bindTools(created, requestedTools);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("agentId", String.valueOf(created.getId()));
|
||||||
|
result.put("name", created.getName());
|
||||||
|
result.put("agentType", created.getAgentType());
|
||||||
|
result.put("skillsBound", boundSkills.isEmpty() ? "(inherits global defaults)" : boundSkills);
|
||||||
|
result.put("toolsBound", boundTools.isEmpty() ? "(inherits global defaults)" : boundTools);
|
||||||
|
result.put("note", "Employee created and enabled. Reference it by name in the workflow drafting tool to chain it into a workflow.");
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "Employee created: id=" + created.getId() + " name=" + created.getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = """
|
||||||
|
List the capabilities you can assign when creating an employee: the enabled skill names \
|
||||||
|
and the bindable tool names in the current workspace. \
|
||||||
|
Call this before create_employee so you assign real, resolvable names rather than guessing.""")
|
||||||
|
public String list_capability_catalog(@Nullable ToolContext ctx) {
|
||||||
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
Long workspaceId = origin.workspaceId();
|
||||||
|
|
||||||
|
// Skills: builtin (global) + skills owned by this workspace, enabled only.
|
||||||
|
List<SkillEntity> skills = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||||
|
.eq(SkillEntity::getEnabled, true)
|
||||||
|
.eq(SkillEntity::getDeleted, 0)
|
||||||
|
.orderByAsc(SkillEntity::getName));
|
||||||
|
long effectiveWs = workspaceId == null ? 1L : workspaceId;
|
||||||
|
List<Map<String, String>> skillCatalog = new ArrayList<>();
|
||||||
|
for (SkillEntity s : skills) {
|
||||||
|
if (s.getName() == null || s.getName().isBlank()) continue;
|
||||||
|
boolean builtin = Boolean.TRUE.equals(s.getBuiltin());
|
||||||
|
long skillWs = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
|
||||||
|
if (!builtin && skillWs != effectiveWs) continue;
|
||||||
|
Map<String, String> m = new LinkedHashMap<>();
|
||||||
|
m.put("name", s.getName());
|
||||||
|
m.put("description", s.getDescription() == null ? "" : s.getDescription());
|
||||||
|
skillCatalog.add(m);
|
||||||
|
if (skillCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools: only those the binding service would accept (available == true).
|
||||||
|
List<Map<String, String>> toolCatalog = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
for (AvailableToolDTO t : availableToolService.listAvailable()) {
|
||||||
|
if (t == null || !t.isAvailable() || t.getName() == null || t.getName().isBlank()) continue;
|
||||||
|
Map<String, String> m = new LinkedHashMap<>();
|
||||||
|
m.put("name", t.getName());
|
||||||
|
m.put("description", t.getDescription() == null ? "" : t.getDescription());
|
||||||
|
toolCatalog.add(m);
|
||||||
|
if (toolCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentAuthoringTool] tool catalog lookup failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("skills", skillCatalog);
|
||||||
|
result.put("tools", toolCatalog);
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "{\"skills\":[],\"tools\":[]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve requested skill names to ids within reach of this agent
|
||||||
|
* (builtin skills are global; otherwise the skill must belong to the
|
||||||
|
* agent's workspace) and bind them. Returns the names actually bound;
|
||||||
|
* unresolved names are skipped with a warning so a single typo does not
|
||||||
|
* abort the whole hire.
|
||||||
|
*/
|
||||||
|
private List<String> bindSkills(AgentEntity agent, long workspaceId, List<String> requestedSkills) {
|
||||||
|
if (requestedSkills.isEmpty()) return List.of();
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
List<String> boundNames = new ArrayList<>();
|
||||||
|
for (String raw : requestedSkills) {
|
||||||
|
String skillName = raw.trim();
|
||||||
|
if (skillName.isEmpty()) continue;
|
||||||
|
List<SkillEntity> matches = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||||
|
.eq(SkillEntity::getName, skillName)
|
||||||
|
.eq(SkillEntity::getDeleted, 0));
|
||||||
|
SkillEntity chosen = matches.stream()
|
||||||
|
.filter(s -> {
|
||||||
|
if (Boolean.TRUE.equals(s.getBuiltin())) return true;
|
||||||
|
long ws = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
|
||||||
|
return ws == workspaceId;
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (chosen == null) {
|
||||||
|
log.warn("[AgentAuthoringTool] skill '{}' not resolvable for workspace {}; skipping", skillName, workspaceId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ids.add(chosen.getId());
|
||||||
|
boundNames.add(chosen.getName());
|
||||||
|
}
|
||||||
|
if (ids.isEmpty()) return List.of();
|
||||||
|
try {
|
||||||
|
// Best-effort: the employee is already persisted, so a late
|
||||||
|
// binding failure (e.g. a skill row deleted between resolve and
|
||||||
|
// bind) must not throw out of the tool and strand the caller with
|
||||||
|
// an error on top of an already-created agent. The agent simply
|
||||||
|
// keeps the default capability set instead.
|
||||||
|
agentBindingService.setSkillBindings(agent.getId(), ids);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentAuthoringTool] skill binding failed for agent {}; left on global defaults: {}",
|
||||||
|
agent.getId(), e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return boundNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter requested tool names through the picker (only available == true
|
||||||
|
* names are bindable) and bind them. Returns the names actually bound.
|
||||||
|
*/
|
||||||
|
private List<String> bindTools(AgentEntity agent, List<String> requestedTools) {
|
||||||
|
if (requestedTools.isEmpty()) return List.of();
|
||||||
|
Set<String> bindable;
|
||||||
|
try {
|
||||||
|
bindable = availableToolService.listAvailable().stream()
|
||||||
|
.filter(AvailableToolDTO::isAvailable)
|
||||||
|
.map(AvailableToolDTO::getName)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentAuthoringTool] tool picker unavailable; skipping tool bind: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> filtered = new ArrayList<>();
|
||||||
|
for (String raw : requestedTools) {
|
||||||
|
String toolName = raw == null ? "" : raw.trim();
|
||||||
|
if (toolName.isEmpty()) continue;
|
||||||
|
if (bindable.contains(toolName)) {
|
||||||
|
filtered.add(toolName);
|
||||||
|
} else {
|
||||||
|
log.warn("[AgentAuthoringTool] tool '{}' not bindable; skipping", toolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filtered.isEmpty()) return List.of();
|
||||||
|
try {
|
||||||
|
// Best-effort, same rationale as bindSkills: never throw after the
|
||||||
|
// employee has been created.
|
||||||
|
agentBindingService.setToolBindings(agent.getId(), filtered);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentAuthoringTool] tool binding failed for agent {}; left on global defaults: {}",
|
||||||
|
agent.getId(), e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a JSON array of strings or a comma-separated list into a name list. */
|
||||||
|
private List<String> parseNameList(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return List.of();
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
|
||||||
|
return parsed == null ? List.of() : parsed;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Fall through to comma split — the model occasionally emits a
|
||||||
|
// malformed array; a comma split still recovers most names.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String part : trimmed.replace("[", "").replace("]", "").split(",")) {
|
||||||
|
String p = part.trim().replaceAll("^[\"']|[\"']$", "");
|
||||||
|
if (!p.isEmpty()) out.add(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeAgentType(String agentType) {
|
||||||
|
if (agentType == null || agentType.isBlank()) return "react";
|
||||||
|
String t = agentType.trim().toLowerCase();
|
||||||
|
return "plan_execute".equals(t) ? "plan_execute" : "react";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort numeric parse of the requester id for creator attribution. */
|
||||||
|
private static Long parseUserId(String requesterId) {
|
||||||
|
if (requesterId == null || requesterId.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return Long.parseLong(requesterId.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,19 +3,33 @@ package vip.mate.agent;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ChatOriginHolder;
|
||||||
|
import vip.mate.agent.event.AgentLifecycleEvent;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||||
|
import vip.mate.memory.MemoryProperties;
|
||||||
|
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
|
||||||
|
import vip.mate.memory.lifecycle.TurnContext;
|
||||||
import vip.mate.memory.service.MemoryRecallTracker;
|
import vip.mate.memory.service.MemoryRecallTracker;
|
||||||
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||||
|
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 业务服务
|
* Agent 业务服务
|
||||||
@ -33,9 +47,25 @@ public class AgentService {
|
|||||||
private final AgentMapper agentMapper;
|
private final AgentMapper agentMapper;
|
||||||
private final AgentGraphBuilder agentGraphBuilder;
|
private final AgentGraphBuilder agentGraphBuilder;
|
||||||
private final MemoryRecallTracker memoryRecallTracker;
|
private final MemoryRecallTracker memoryRecallTracker;
|
||||||
|
private final MemoryLifecycleMediator lifecycleMediator;
|
||||||
|
private final MemoryProperties memoryProperties;
|
||||||
|
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||||
|
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
|
||||||
|
* to keep this a leaf dependency with no risk of a bean cycle. */
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
|
||||||
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
/** Field-injected publisher for agent_lifecycle trigger events; the
|
||||||
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
* trigger module's bridge listens and forwards into ingest. */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private ApplicationEventPublisher events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime Agent instance cache. Keyed first by agentId, then by a model
|
||||||
|
* key, so a conversation that pins a non-default model gets its own graph
|
||||||
|
* variant instead of mutating the one every other conversation shares.
|
||||||
|
* The model key is {@code ""} for the Agent / global-default model.
|
||||||
|
*/
|
||||||
|
private final Map<Long, Map<String, BaseAgent>> agentInstances = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
// ==================== CRUD ====================
|
// ==================== CRUD ====================
|
||||||
|
|
||||||
@ -48,9 +78,26 @@ public class AgentService {
|
|||||||
* 按工作区列出 Agent
|
* 按工作区列出 Agent
|
||||||
*/
|
*/
|
||||||
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
|
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
|
||||||
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
return listAgentsByWorkspace(workspaceId, null);
|
||||||
.eq(AgentEntity::getWorkspaceId, workspaceId)
|
}
|
||||||
.orderByDesc(AgentEntity::getCreateTime));
|
|
||||||
|
/**
|
||||||
|
* 按工作区列出 Agent,可选过滤启用状态。
|
||||||
|
*
|
||||||
|
* @param enabled non-null restricts the result set to agents whose
|
||||||
|
* {@code enabled} column matches the given value.
|
||||||
|
* Pass {@code true} from chat selectors so disabled
|
||||||
|
* agents disappear from the picker; the admin
|
||||||
|
* management page passes {@code null} to keep
|
||||||
|
* disabled rows visible for re-enabling.
|
||||||
|
*/
|
||||||
|
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId, Boolean enabled) {
|
||||||
|
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
|
||||||
|
.eq(AgentEntity::getWorkspaceId, workspaceId);
|
||||||
|
if (enabled != null) {
|
||||||
|
q.eq(AgentEntity::getEnabled, enabled);
|
||||||
|
}
|
||||||
|
return agentMapper.selectList(q.orderByDesc(AgentEntity::getCreateTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentEntity getAgent(Long id) {
|
public AgentEntity getAgent(Long id) {
|
||||||
@ -66,19 +113,100 @@ public class AgentService {
|
|||||||
if (agent.getAgentType() == null) {
|
if (agent.getAgentType() == null) {
|
||||||
agent.setAgentType("react");
|
agent.setAgentType("react");
|
||||||
}
|
}
|
||||||
|
requireUniqueName(agent, null);
|
||||||
agentMapper.insert(agent);
|
agentMapper.insert(agent);
|
||||||
|
publishLifecycle(agent, "spawned");
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentEntity updateAgent(AgentEntity agent) {
|
public AgentEntity updateAgent(AgentEntity agent) {
|
||||||
|
// Detect enabled-flag flip so the lifecycle event reflects the
|
||||||
|
// intent rather than every metadata edit. Reading the prior row
|
||||||
|
// is cheap and gives us a clean diff source.
|
||||||
|
AgentEntity prior = agentMapper.selectById(agent.getId());
|
||||||
|
// Only re-validate uniqueness when the name actually changes —
|
||||||
|
// a pure metadata edit (icon, prompt, ...) shouldn't pay the
|
||||||
|
// SELECT cost or risk a false positive against the row itself.
|
||||||
|
if (prior != null
|
||||||
|
&& agent.getName() != null
|
||||||
|
&& !agent.getName().equals(prior.getName())) {
|
||||||
|
// Workspace cannot be moved (Controller pins it to prior.workspaceId),
|
||||||
|
// so reuse it for the lookup even if the incoming DTO left it null.
|
||||||
|
if (agent.getWorkspaceId() == null) {
|
||||||
|
agent.setWorkspaceId(prior.getWorkspaceId());
|
||||||
|
}
|
||||||
|
requireUniqueName(agent, agent.getId());
|
||||||
|
}
|
||||||
agentMapper.updateById(agent);
|
agentMapper.updateById(agent);
|
||||||
agentInstances.remove(agent.getId());
|
agentInstances.remove(agent.getId());
|
||||||
|
if (prior != null && prior.getEnabled() != null
|
||||||
|
&& !prior.getEnabled().equals(agent.getEnabled())) {
|
||||||
|
publishLifecycle(agent,
|
||||||
|
Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled");
|
||||||
|
}
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Friendly business-code surface for the {@code (workspace_id, name)}
|
||||||
|
* unique index added in V102.
|
||||||
|
*
|
||||||
|
* <p>The wire shape is the project-wide R<T> envelope: HTTP status
|
||||||
|
* stays 200 (per the convention in {@code R.fail} and the axios
|
||||||
|
* interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in
|
||||||
|
* the response body's {@code code} field so the front-end can branch
|
||||||
|
* without breaking on an axios error. Without this pre-check the
|
||||||
|
* duplicate save would surface as an opaque
|
||||||
|
* {@code DataIntegrityViolation} stack trace.
|
||||||
|
*
|
||||||
|
* @param excludeId when non-null, skip this row in the lookup so
|
||||||
|
* {@link #updateAgent} doesn't mistake the row for its
|
||||||
|
* own duplicate.
|
||||||
|
*/
|
||||||
|
private void requireUniqueName(AgentEntity agent, Long excludeId) {
|
||||||
|
if (agent.getName() == null || agent.getName().isBlank()) {
|
||||||
|
throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空");
|
||||||
|
}
|
||||||
|
Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
|
||||||
|
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
|
||||||
|
.eq(AgentEntity::getWorkspaceId, workspaceId)
|
||||||
|
.eq(AgentEntity::getName, agent.getName());
|
||||||
|
if (excludeId != null) {
|
||||||
|
q.ne(AgentEntity::getId, excludeId);
|
||||||
|
}
|
||||||
|
Long count = agentMapper.selectCount(q);
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
throw new MateClawException("err.agent.duplicate_name", 409,
|
||||||
|
"工作区内已存在同名 Agent: " + agent.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteAgent(Long id) {
|
public void deleteAgent(Long id) {
|
||||||
|
AgentEntity prior = agentMapper.selectById(id);
|
||||||
agentMapper.deleteById(id);
|
agentMapper.deleteById(id);
|
||||||
agentInstances.remove(id);
|
agentInstances.remove(id);
|
||||||
|
if (prior != null) publishLifecycle(prior, "terminated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort publish of an {@link AgentLifecycleEvent}. A publish
|
||||||
|
* failure must never roll back the agent CRUD that just succeeded —
|
||||||
|
* the agent_lifecycle trigger surface is observability, not the
|
||||||
|
* canonical record.
|
||||||
|
*/
|
||||||
|
private void publishLifecycle(AgentEntity agent, String phase) {
|
||||||
|
if (events == null || agent == null) return;
|
||||||
|
try {
|
||||||
|
events.publishEvent(new AgentLifecycleEvent(
|
||||||
|
agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(),
|
||||||
|
agent.getId() == null ? 0L : agent.getId(),
|
||||||
|
agent.getName(),
|
||||||
|
phase,
|
||||||
|
System.currentTimeMillis()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}",
|
||||||
|
agent.getId(), phase, e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -88,33 +216,106 @@ public class AgentService {
|
|||||||
agentInstances.remove(agentId);
|
agentInstances.remove(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the cached agent instance whenever one of its workspace files
|
||||||
|
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured
|
||||||
|
* memory) is baked into the cached instance at build time, so memory edits made
|
||||||
|
* via tools, consolidation, or cleanup would otherwise stay invisible until an
|
||||||
|
* agent config change or restart. Rebuilding on the next turn picks them up.
|
||||||
|
*/
|
||||||
|
@org.springframework.context.event.EventListener
|
||||||
|
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
|
||||||
|
if (event.agentId() != null) {
|
||||||
|
agentInstances.remove(event.agentId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 运行时入口 ====================
|
// ==================== 运行时入口 ====================
|
||||||
|
|
||||||
public String chat(Long agentId, String message, String conversationId) {
|
public String chat(Long agentId, String message, String conversationId) {
|
||||||
|
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: preferred entry — accepts the originating
|
||||||
|
* {@link ChatOrigin} so channel binding and workspace context propagate
|
||||||
|
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
|
||||||
|
*/
|
||||||
|
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
return agent.chat(message, conversationId);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
|
try {
|
||||||
|
return withLifecycleSync(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> agent.chat(msg, convId));
|
||||||
|
} finally {
|
||||||
|
ChatOriginHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync chat that also captures token usage and runtime model attribution
|
||||||
|
* from the agent graph's {@code _usage_final} event. Equivalent to
|
||||||
|
* subscribing to {@link #chatStructuredStream} and joining all content
|
||||||
|
* deltas — produces the same assistant text as {@link #chat} but exposes
|
||||||
|
* the usage figures so callers can persist them on the assistant message.
|
||||||
|
*
|
||||||
|
* <p>Prefer this entry over {@link #chat} for any path that writes the
|
||||||
|
* reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket,
|
||||||
|
* cron task, post-approval replay); the plain {@link #chat} stays as the
|
||||||
|
* thin wrapper for fire-and-forget invocations where usage is not needed.
|
||||||
|
*/
|
||||||
|
public ChatResult chatWithUsage(Long agentId, String message, String conversationId) {
|
||||||
|
return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
|
return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||||
|
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
return agent.chatStream(message, conversationId);
|
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||||
|
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||||
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
|
return Flux.defer(() -> {
|
||||||
|
ChatOriginHolder.set(captured);
|
||||||
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> agent.chatStream(msg, convId),
|
||||||
|
chunk -> chunk);
|
||||||
|
}).doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
||||||
return chatStructuredStream(agentId, message, conversationId, "", null);
|
return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
String requesterId) {
|
String requesterId) {
|
||||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null);
|
return chatStructuredStream(agentId, message, conversationId, requesterId, null, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
|
String requesterId, ChatOrigin origin) {
|
||||||
|
return chatStructuredStream(agentId, message, conversationId, requesterId, null, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
String requesterId, String thinkingLevel) {
|
String requesterId, String thinkingLevel) {
|
||||||
|
return chatStructuredStream(agentId, message, conversationId, requesterId, thinkingLevel,
|
||||||
|
ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
|
String requesterId, String thinkingLevel,
|
||||||
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
|
|
||||||
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
||||||
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
|
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
|
||||||
@ -129,22 +330,45 @@ public class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
if (agent instanceof StructuredStreamCapable capable) {
|
if (agent instanceof StructuredStreamCapable capable) {
|
||||||
return capable.chatStructuredStream(message, conversationId,
|
return Flux.defer(() -> {
|
||||||
|
ChatOriginHolder.set(captured);
|
||||||
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||||
requesterId != null ? requesterId : "")
|
requesterId != null ? requesterId : "")
|
||||||
.doFinally(signal -> ThinkingLevelHolder.clear());
|
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||||
|
StreamDelta::content);
|
||||||
|
})
|
||||||
|
.doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
||||||
ThinkingLevelHolder.clear();
|
ThinkingLevelHolder.clear();
|
||||||
return agent.chatStream(message, conversationId)
|
return Flux.defer(() -> {
|
||||||
.map(chunk -> new StreamDelta(chunk, null));
|
ChatOriginHolder.set(captured);
|
||||||
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> agent.chatStream(msg, convId)
|
||||||
|
.map(chunk -> new StreamDelta(chunk, null)),
|
||||||
|
StreamDelta::content);
|
||||||
|
})
|
||||||
|
.doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String execute(Long agentId, String goal, String conversationId) {
|
public String execute(Long agentId, String goal, String conversationId) {
|
||||||
|
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
return agent.execute(goal, conversationId);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
|
try {
|
||||||
|
return withLifecycleSync(agentId, goal, conversationId,
|
||||||
|
(msg, convId) -> agent.execute(msg, convId));
|
||||||
|
} finally {
|
||||||
|
ChatOriginHolder.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -158,9 +382,56 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload) {
|
String toolCallPayload) {
|
||||||
|
return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
|
String toolCallPayload, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
|
try {
|
||||||
|
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||||
|
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
|
||||||
|
} finally {
|
||||||
|
ChatOriginHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay-after-approval that also captures token usage and runtime model
|
||||||
|
* attribution. Mirrors {@link #chatWithUsage} for the
|
||||||
|
* approval-resumption path used by {@code ChannelMessageRouter}.
|
||||||
|
*/
|
||||||
|
public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId,
|
||||||
|
String toolCallPayload, ChatOrigin origin) {
|
||||||
|
return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId,
|
||||||
|
toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a structured stream and collapse it into a single
|
||||||
|
* {@link ChatResult}: append all content deltas, capture the trailing
|
||||||
|
* {@code _usage_final} event for token and model attribution.
|
||||||
|
*/
|
||||||
|
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
final int[] usage = {0, 0};
|
||||||
|
final String[] modelInfo = {null, null};
|
||||||
|
stream.doOnNext(delta -> {
|
||||||
|
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||||
|
Map<String, Object> data = delta.eventData();
|
||||||
|
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||||
|
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||||
|
Object model = data.get("runtimeModelName");
|
||||||
|
Object provider = data.get("runtimeProviderId");
|
||||||
|
if (model != null) modelInfo[0] = model.toString();
|
||||||
|
if (provider != null) modelInfo[1] = provider.toString();
|
||||||
|
} else if (delta.content() != null) {
|
||||||
|
content.append(delta.content());
|
||||||
|
}
|
||||||
|
}).blockLast(Duration.ofMinutes(10));
|
||||||
|
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -168,20 +439,46 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload) {
|
String toolCallPayload) {
|
||||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "");
|
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", ChatOrigin.EMPTY);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, String requesterId) {
|
String toolCallPayload, String requesterId) {
|
||||||
|
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, requesterId,
|
||||||
|
ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
|
String toolCallPayload, String requesterId,
|
||||||
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
requesterId != null ? requesterId : "");
|
return Flux.defer(() -> {
|
||||||
|
ChatOriginHolder.set(captured);
|
||||||
|
return withLifecycleFlux(agentId, userMessage, conversationId,
|
||||||
|
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
|
||||||
|
requesterId != null ? requesterId : ""),
|
||||||
|
StreamDelta::content);
|
||||||
|
})
|
||||||
|
.doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentState getAgentState(Long agentId) {
|
public AgentState getAgentState(Long agentId) {
|
||||||
BaseAgent agent = agentInstances.get(agentId);
|
Map<String, BaseAgent> variants = agentInstances.get(agentId);
|
||||||
return agent != null ? agent.getState() : AgentState.IDLE;
|
if (variants == null || variants.isEmpty()) {
|
||||||
|
return AgentState.IDLE;
|
||||||
|
}
|
||||||
|
// An Agent may have several cached graph variants (one per pinned
|
||||||
|
// model). Report the first non-IDLE state so a turn running on any
|
||||||
|
// variant stays visible.
|
||||||
|
for (BaseAgent agent : variants.values()) {
|
||||||
|
AgentState state = agent.getState();
|
||||||
|
if (state != AgentState.IDLE) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AgentState.IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 缓存管理 ====================
|
// ==================== 缓存管理 ====================
|
||||||
@ -208,38 +505,193 @@ 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 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a synchronous agent call with lifecycle mediator hooks.
|
||||||
|
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
|
||||||
|
*
|
||||||
|
* P1-1 fix: prefetchAll result is now prepended to userMessage as <memory-context> block.
|
||||||
|
* P1-4 fix: N/A for sync (no cancel/error signal issue).
|
||||||
|
*/
|
||||||
|
private String withLifecycleSync(Long agentId, String message, String conversationId,
|
||||||
|
java.util.function.BiFunction<String, String, String> invoke) {
|
||||||
|
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||||
|
return invoke.apply(message, conversationId);
|
||||||
|
}
|
||||||
|
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
||||||
|
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
||||||
|
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
|
||||||
|
// Inject memory context into the user message (RFC-037 §3.3)
|
||||||
|
String enrichedMessage = injectMemoryContext(message, memoryContext);
|
||||||
|
String result = invoke.apply(enrichedMessage, conversationId);
|
||||||
|
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a streaming agent call with lifecycle mediator hooks.
|
||||||
|
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
|
||||||
|
*
|
||||||
|
* P1-1 fix: prefetchAll result is now prepended to userMessage.
|
||||||
|
* P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error.
|
||||||
|
*/
|
||||||
|
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
|
||||||
|
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
||||||
|
Function<T, String> contentExtractor) {
|
||||||
|
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||||
|
return invoke.apply(message, conversationId);
|
||||||
|
}
|
||||||
|
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
||||||
|
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
||||||
|
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
|
||||||
|
String enrichedMessage = injectMemoryContext(message, memoryContext);
|
||||||
|
StringBuilder reply = new StringBuilder();
|
||||||
|
return invoke.apply(enrichedMessage, conversationId)
|
||||||
|
.doOnNext(item -> {
|
||||||
|
String text = contentExtractor.apply(item);
|
||||||
|
if (text != null) {
|
||||||
|
reply.append(text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
|
||||||
|
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend memory-context block to user message if non-empty.
|
||||||
|
* Does not pollute build-time system prompt snapshot.
|
||||||
|
*/
|
||||||
|
private String injectMemoryContext(String message, String memoryContext) {
|
||||||
|
if (memoryContext == null || memoryContext.isBlank()) return message;
|
||||||
|
return memoryContext + "\n\n" + message;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 内部方法 ====================
|
// ==================== 内部方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (and cache) the Agent graph for a conversation, honouring the
|
||||||
|
* conversation's pinned model. Conversations with no pin — IM channels
|
||||||
|
* before issue #183 fix, cron, sub-tasks, or rows not yet created —
|
||||||
|
* resolve to the shared Agent / global-default graph.
|
||||||
|
*
|
||||||
|
* <p>Defensive normalisation: a half-populated pair (provider but no
|
||||||
|
* model, or vice versa) is treated as unpinned. Without this guard, a
|
||||||
|
* partially-cleared admin UI write could end up cached as a key like
|
||||||
|
* {@code "volcano::"} which {@link #getOrBuildAgent} would then try to
|
||||||
|
* build, only to fail at provider-resolution time on every turn.
|
||||||
|
*/
|
||||||
|
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
|
||||||
|
String provider = null;
|
||||||
|
String modelName = null;
|
||||||
|
if (conversationId != null && !conversationId.isBlank()) {
|
||||||
|
ConversationEntity conv = conversationMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<ConversationEntity>()
|
||||||
|
.eq(ConversationEntity::getConversationId, conversationId));
|
||||||
|
if (conv != null) {
|
||||||
|
provider = blankToNull(conv.getModelProvider());
|
||||||
|
modelName = blankToNull(conv.getModelName());
|
||||||
|
// Half-populated pair → treat as unpinned. Pinning requires
|
||||||
|
// a complete (provider, model) tuple — see #183 follow-up
|
||||||
|
// hardening so a stale row written by an earlier broken
|
||||||
|
// admin UI release doesn't loop the cache on an invalid key.
|
||||||
|
if (provider == null || modelName == null) {
|
||||||
|
provider = null;
|
||||||
|
modelName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getOrBuildAgent(agentId, provider, modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map empty / whitespace strings to null so the pinned-check is one branch. */
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
private BaseAgent getOrBuildAgent(Long agentId) {
|
private BaseAgent getOrBuildAgent(Long agentId) {
|
||||||
return agentInstances.computeIfAbsent(agentId, id -> {
|
return getOrBuildAgent(agentId, null, null);
|
||||||
AgentEntity entity = getAgent(id);
|
}
|
||||||
|
|
||||||
|
private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) {
|
||||||
|
boolean pinned = modelProvider != null && !modelProvider.isBlank()
|
||||||
|
&& modelName != null && !modelName.isBlank();
|
||||||
|
String modelKey = pinned ? modelProvider + "::" + modelName : "";
|
||||||
|
return agentInstances
|
||||||
|
.computeIfAbsent(agentId, id -> new ConcurrentHashMap<>())
|
||||||
|
.computeIfAbsent(modelKey, key -> {
|
||||||
|
AgentEntity entity = getAgent(agentId);
|
||||||
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
||||||
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
|
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
|
||||||
}
|
}
|
||||||
return agentGraphBuilder.build(entity);
|
return agentGraphBuilder.build(entity, modelProvider, modelName);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== StreamDelta ====================
|
// ==================== StreamDelta ====================
|
||||||
|
|
||||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) {
|
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
|
||||||
|
boolean persistenceOnly, boolean segmentOnly) {
|
||||||
|
|
||||||
// 兼容构造器(广播+持久化)
|
// 兼容构造器(广播+持久化)
|
||||||
public StreamDelta(String content, String thinking) {
|
public StreamDelta(String content, String thinking) {
|
||||||
this(content, thinking, null, null, false);
|
this(content, thinking, null, null, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
|
||||||
|
public StreamDelta(String content, String thinking, String eventType,
|
||||||
|
Map<String, Object> eventData, boolean persistenceOnly) {
|
||||||
|
this(content, thinking, eventType, eventData, persistenceOnly, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
|
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
|
||||||
public static StreamDelta persistOnly(String content, String thinking) {
|
public static StreamDelta persistOnly(String content, String thinking) {
|
||||||
return new StreamDelta(content, thinking, null, null, true);
|
return new StreamDelta(content, thinking, null, null, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-iteration narrative routing for ReasoningNode / SummarizingNode output.
|
||||||
|
*
|
||||||
|
* <p>The accumulator should:
|
||||||
|
* <ul>
|
||||||
|
* <li>append the text to the in-flight {@code segments} entry so the UI's
|
||||||
|
* segmented view still renders the intermediate "I'll look it up…"
|
||||||
|
* narration between tool cards;</li>
|
||||||
|
* <li>NOT broadcast — already broadcast live by NodeStreamingChatHelper;</li>
|
||||||
|
* <li>NOT append to the top-level {@code content} StringBuilder, which is
|
||||||
|
* what gets persisted as {@code mate_message.content}. That field
|
||||||
|
* should hold the final-answer span only — otherwise multiple
|
||||||
|
* iterations stack into "我来…让我…然后…" walls that next-turn replay
|
||||||
|
* sees as unanswered chain-of-thought (issue #120 narration leg).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Implies {@code persistenceOnly} (no broadcast) at the accumulator
|
||||||
|
* layer, but is a stricter promise: <em>nothing</em> reaches the top-level
|
||||||
|
* persisted content field via this flavor.
|
||||||
|
*/
|
||||||
|
public static StreamDelta segmentOnly(String content, String thinking) {
|
||||||
|
return new StreamDelta(content, thinking, null, null, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StreamDelta empty() {
|
public static StreamDelta empty() {
|
||||||
return new StreamDelta(null, null, null, null, false);
|
return new StreamDelta(null, null, null, null, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StreamDelta event(String type, Map<String, Object> data) {
|
public static StreamDelta event(String type, Map<String, Object> data) {
|
||||||
return new StreamDelta(null, null, type, data, false);
|
return new StreamDelta(null, null, type, data, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEvent() {
|
public boolean isEvent() {
|
||||||
@ -258,4 +710,23 @@ public class AgentService {
|
|||||||
return thinking != null ? thinking.length() : 0;
|
return thinking != null ? thinking.length() : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== ChatResult ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync chat result carrying the assistant reply alongside the usage
|
||||||
|
* attribution that the streaming path exposes via the {@code _usage_final}
|
||||||
|
* event. Use this when callers need to persist {@code promptTokens} /
|
||||||
|
* {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider}
|
||||||
|
* on the assistant message row but cannot subscribe to the structured
|
||||||
|
* stream directly (cron tasks, sync HTTP endpoints, voice WebSocket,
|
||||||
|
* post-approval replays).
|
||||||
|
*/
|
||||||
|
public record ChatResult(String content, int promptTokens, int completionTokens,
|
||||||
|
String runtimeModel, String runtimeProvider) {
|
||||||
|
|
||||||
|
public static ChatResult contentOnly(String content) {
|
||||||
|
return new ChatResult(content != null ? content : "", 0, 0, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import org.springframework.ai.tool.ToolCallback;
|
|||||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 统一工具集合
|
* Agent 统一工具集合
|
||||||
@ -14,6 +14,20 @@ import java.util.LinkedHashMap;
|
|||||||
* 将 @Tool Bean、ToolCallbackProvider、MCP server 暴露的 tool callbacks
|
* 将 @Tool Bean、ToolCallbackProvider、MCP server 暴露的 tool callbacks
|
||||||
* 统一收集为一致的 ToolCallback 列表,供 StateGraph 节点使用。
|
* 统一收集为一致的 ToolCallback 列表,供 StateGraph 节点使用。
|
||||||
*
|
*
|
||||||
|
* <h3>Alias index — why one tool has multiple names</h3>
|
||||||
|
* Each tool can be referenced by several equivalent identifiers:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code @Tool} function name (the runtime truth: {@code cb.getToolDefinition().name()},
|
||||||
|
* e.g. {@code browser_use})</li>
|
||||||
|
* <li>Spring bean name (e.g. {@code browserUseTool})</li>
|
||||||
|
* <li>Java class simple name (e.g. {@code BrowserUseTool} — what the seed data and
|
||||||
|
* legacy {@code mate_agent_tool.tool_name} bindings happen to store)</li>
|
||||||
|
* </ul>
|
||||||
|
* Filtering operations ({@link #withAllowedToolsOnly}, {@link #withDeniedToolsFiltered},
|
||||||
|
* {@link #excluding}) accept any of these aliases, so callers don't need to know which
|
||||||
|
* naming convention the persistence layer happens to use. This is the same pattern Spring's
|
||||||
|
* {@code BeanFactory} uses for bean names + aliases.
|
||||||
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
public class AgentToolSet {
|
public class AgentToolSet {
|
||||||
@ -21,26 +35,69 @@ public class AgentToolSet {
|
|||||||
private final List<Object> toolBeans;
|
private final List<Object> toolBeans;
|
||||||
private final List<ToolCallback> callbacks;
|
private final List<ToolCallback> callbacks;
|
||||||
private final Map<String, ToolCallback> callbackByName;
|
private final Map<String, ToolCallback> callbackByName;
|
||||||
|
/**
|
||||||
|
* Alias → callbacks. One alias may resolve to multiple callbacks
|
||||||
|
* (e.g. a Spring bean name pointing at a class that exposes several {@code @Tool} methods),
|
||||||
|
* which is why values are sets.
|
||||||
|
*/
|
||||||
|
private final Map<String, Set<ToolCallback>> aliasIndex;
|
||||||
|
|
||||||
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks) {
|
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
|
||||||
|
Function<Object, String> beanNameResolver) {
|
||||||
this.toolBeans = List.copyOf(toolBeans);
|
this.toolBeans = List.copyOf(toolBeans);
|
||||||
// 按工具名去重:内置工具在前(先添加),MCP 工具在后,同名时保留内置工具
|
// 按工具名去重:内置工具在前(先添加),MCP 工具在后,同名时保留内置工具
|
||||||
// 使用 LinkedHashMap 保证插入顺序,确保内置工具始终排在 MCP 工具前面(影响 LLM 工具选择倾向)
|
// 使用 LinkedHashMap 保证插入顺序,确保内置工具始终排在 MCP 工具前面(影响 LLM 工具选择倾向)
|
||||||
this.callbackByName = callbacks.stream()
|
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
cb -> cb.getToolDefinition().name(),
|
cb -> cb.getToolDefinition().name(),
|
||||||
cb -> cb,
|
cb -> cb,
|
||||||
(a, b) -> a,
|
(a, b) -> a,
|
||||||
LinkedHashMap::new));
|
LinkedHashMap::new));
|
||||||
|
this.callbackByName = byName;
|
||||||
// callbacks 列表也使用去重后的结果,避免 Spring AI ToolCallingChatOptions 校验重名报错
|
// callbacks 列表也使用去重后的结果,避免 Spring AI ToolCallingChatOptions 校验重名报错
|
||||||
this.callbacks = List.copyOf(callbackByName.values());
|
this.callbacks = List.copyOf(byName.values());
|
||||||
|
this.aliasIndex = buildAliasIndex(this.toolBeans, byName, beanNameResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal constructor for {@link #rebuild} — preserves a pre-filtered alias index
|
||||||
|
* so we don't need {@code beanNameResolver} on every {@code with*} call.
|
||||||
|
*/
|
||||||
|
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
|
||||||
|
Map<String, Set<ToolCallback>> precomputedAliasIndex) {
|
||||||
|
this.toolBeans = List.copyOf(toolBeans);
|
||||||
|
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
cb -> cb.getToolDefinition().name(),
|
||||||
|
cb -> cb,
|
||||||
|
(a, b) -> a,
|
||||||
|
LinkedHashMap::new));
|
||||||
|
this.callbackByName = byName;
|
||||||
|
this.callbacks = List.copyOf(byName.values());
|
||||||
|
this.aliasIndex = Map.copyOf(precomputedAliasIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op resolver for callers that don't have access to Spring bean names. */
|
||||||
|
private static final Function<Object, String> NO_BEAN_NAMES = bean -> null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从预构建的 ToolCallback 列表构建工具集(用于 i18n 等需要包装 callback 的场景)
|
* 从预构建的 ToolCallback 列表构建工具集(用于 i18n 等需要包装 callback 的场景)
|
||||||
*/
|
*/
|
||||||
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks) {
|
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks) {
|
||||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks);
|
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, NO_BEAN_NAMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #fromCallbacks(List, List)} but additionally indexes each tool bean by
|
||||||
|
* its Spring bean name and Java simple class name, so {@link #withAllowedToolsOnly} accepts
|
||||||
|
* any of those identifiers (in addition to the {@code @Tool} function name).
|
||||||
|
*
|
||||||
|
* @param beanNameResolver lookup from a tool bean instance to its Spring bean name;
|
||||||
|
* may return {@code null} if the bean has no registered name
|
||||||
|
*/
|
||||||
|
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks,
|
||||||
|
Function<Object, String> beanNameResolver) {
|
||||||
|
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, beanNameResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -67,36 +124,45 @@ public class AgentToolSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks);
|
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks, NO_BEAN_NAMES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 过滤掉 denied 工具后返回新的 AgentToolSet。
|
* 过滤掉 denied 工具后返回新的 AgentToolSet。
|
||||||
* denied 工具不会暴露给模型,模型完全不知道它们的存在。
|
* denied 工具不会暴露给模型,模型完全不知道它们的存在。
|
||||||
*
|
*
|
||||||
* @param deniedTools denied 工具名集合(为空或 null 时直接返回 this)
|
* @param deniedTools denied 工具名集合(接受 function name / bean name / class simple name;
|
||||||
|
* 为空或 null 时直接返回 this)
|
||||||
*/
|
*/
|
||||||
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
|
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
|
||||||
if (deniedTools == null || deniedTools.isEmpty()) {
|
if (deniedTools == null || deniedTools.isEmpty()) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
List<ToolCallback> filtered = new ArrayList<>(callbacks);
|
Set<ToolCallback> denied = resolveAliases(deniedTools);
|
||||||
filtered.removeIf(cb -> deniedTools.contains(cb.getToolDefinition().name()));
|
if (denied.isEmpty()) {
|
||||||
return new AgentToolSet(toolBeans, filtered);
|
return this;
|
||||||
|
}
|
||||||
|
List<ToolCallback> filtered = callbacks.stream()
|
||||||
|
.filter(cb -> !denied.contains(cb))
|
||||||
|
.toList();
|
||||||
|
return rebuild(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅保留指定名称的工具(白名单模式,用于 per-agent 绑定)
|
* 仅保留指定名称的工具(白名单模式,用于 per-agent 绑定)
|
||||||
*
|
*
|
||||||
* @param allowedTools 允许的工具名集合(为 null 时直接返回 this,表示使用全局默认)
|
* @param allowedTools 允许的工具名集合(接受 function name / Spring bean name / Java class simple name;
|
||||||
|
* 为 null 时直接返回 this,表示使用全局默认)
|
||||||
*/
|
*/
|
||||||
public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) {
|
public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) {
|
||||||
if (allowedTools == null) {
|
if (allowedTools == null) {
|
||||||
return this; // null = 无绑定,使用全局默认
|
return this; // null = 无绑定,使用全局默认
|
||||||
}
|
}
|
||||||
List<ToolCallback> filtered = new ArrayList<>(callbacks);
|
Set<ToolCallback> allowed = resolveAliases(allowedTools);
|
||||||
filtered.removeIf(cb -> !allowedTools.contains(cb.getToolDefinition().name()));
|
List<ToolCallback> filtered = callbacks.stream()
|
||||||
return new AgentToolSet(toolBeans, filtered);
|
.filter(allowed::contains)
|
||||||
|
.toList();
|
||||||
|
return rebuild(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -122,15 +188,21 @@ public class AgentToolSet {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回排除指定工具名后的新 AgentToolSet
|
* 返回排除指定工具名后的新 AgentToolSet
|
||||||
|
*
|
||||||
|
* @param toolNames 要排除的工具名集合(接受 function name / bean name / class simple name)
|
||||||
*/
|
*/
|
||||||
public AgentToolSet excluding(Set<String> toolNames) {
|
public AgentToolSet excluding(Set<String> toolNames) {
|
||||||
if (toolNames == null || toolNames.isEmpty()) {
|
if (toolNames == null || toolNames.isEmpty()) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
Set<ToolCallback> excluded = resolveAliases(toolNames);
|
||||||
|
if (excluded.isEmpty()) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
List<ToolCallback> filtered = callbacks.stream()
|
List<ToolCallback> filtered = callbacks.stream()
|
||||||
.filter(cb -> !toolNames.contains(cb.getToolDefinition().name()))
|
.filter(cb -> !excluded.contains(cb))
|
||||||
.toList();
|
.toList();
|
||||||
return new AgentToolSet(toolBeans, filtered);
|
return rebuild(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -146,4 +218,123 @@ public class AgentToolSet {
|
|||||||
public int size() {
|
public int size() {
|
||||||
return callbacks.size();
|
return callbacks.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a mix of aliases (function name / Spring bean name / Java class simple name)
|
||||||
|
* to the {@code @Tool} function names they map to. Used to bridge persistence layers
|
||||||
|
* that key a tool by its class or bean name (e.g. {@code mate_tool.name}) onto the
|
||||||
|
* runtime callback name ({@code cb.getToolDefinition().name()}). Unknown aliases yield
|
||||||
|
* nothing.
|
||||||
|
*/
|
||||||
|
public Set<String> functionNamesFor(Set<String> aliases) {
|
||||||
|
if (aliases == null || aliases.isEmpty()) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
return resolveAliases(aliases).stream()
|
||||||
|
.map(cb -> cb.getToolDefinition().name())
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Internals ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a set of aliases (any mix of function name / bean name / class simple name)
|
||||||
|
* into the set of {@link ToolCallback} instances they refer to. Unknown aliases are
|
||||||
|
* silently dropped — the caller is expected to be tolerant of stale persistence data.
|
||||||
|
*/
|
||||||
|
private Set<ToolCallback> resolveAliases(Set<String> aliases) {
|
||||||
|
Set<ToolCallback> resolved = new LinkedHashSet<>();
|
||||||
|
for (String alias : aliases) {
|
||||||
|
Set<ToolCallback> hits = aliasIndex.get(alias);
|
||||||
|
if (hits != null) {
|
||||||
|
resolved.addAll(hits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct a new {@code AgentToolSet} after filtering callbacks, carrying forward
|
||||||
|
* only the alias entries whose targets survived. This avoids re-running
|
||||||
|
* {@link ToolCallbacks#from(Object)} reflection on every {@code with*} call.
|
||||||
|
*/
|
||||||
|
private AgentToolSet rebuild(List<ToolCallback> filteredCallbacks) {
|
||||||
|
Set<ToolCallback> survivors = new HashSet<>(filteredCallbacks);
|
||||||
|
Map<String, Set<ToolCallback>> filteredAliases = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, Set<ToolCallback>> e : aliasIndex.entrySet()) {
|
||||||
|
Set<ToolCallback> kept = new LinkedHashSet<>();
|
||||||
|
for (ToolCallback cb : e.getValue()) {
|
||||||
|
if (survivors.contains(cb)) {
|
||||||
|
kept.add(cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!kept.isEmpty()) {
|
||||||
|
filteredAliases.put(e.getKey(), Set.copyOf(kept));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AgentToolSet(toolBeans, filteredCallbacks, filteredAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the alias index. Function names are always indexed (they are the runtime truth);
|
||||||
|
* bean names and class simple names are indexed when {@code beanNameResolver} is provided
|
||||||
|
* — typically only the production registry has the {@link org.springframework.context.ApplicationContext}
|
||||||
|
* needed to map bean instances to names. Unit tests that pass empty {@code toolBeans}
|
||||||
|
* naturally get a function-name-only index.
|
||||||
|
*/
|
||||||
|
private static Map<String, Set<ToolCallback>> buildAliasIndex(
|
||||||
|
List<Object> toolBeans,
|
||||||
|
Map<String, ToolCallback> callbackByName,
|
||||||
|
Function<Object, String> beanNameResolver) {
|
||||||
|
|
||||||
|
Map<String, Set<ToolCallback>> aliases = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
// 1. Always index by function name (the runtime identifier)
|
||||||
|
for (Map.Entry<String, ToolCallback> e : callbackByName.entrySet()) {
|
||||||
|
aliases.computeIfAbsent(e.getKey(), k -> new LinkedHashSet<>()).add(e.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If we have bean info, also index by Spring bean name and Java class simple name.
|
||||||
|
// A single bean may expose multiple @Tool methods → the alias maps to a set.
|
||||||
|
if (beanNameResolver != null) {
|
||||||
|
for (Object bean : toolBeans) {
|
||||||
|
String beanName = beanNameResolver.apply(bean);
|
||||||
|
String simpleName = bean.getClass().getSimpleName();
|
||||||
|
|
||||||
|
// Find which callbacks belong to this bean, looking them up in the
|
||||||
|
// (possibly i18n-wrapped) callbackByName so we point at the same
|
||||||
|
// instances the rest of the set uses.
|
||||||
|
Set<ToolCallback> beanCallbacks = new LinkedHashSet<>();
|
||||||
|
ToolCallback[] rawCallbacks;
|
||||||
|
try {
|
||||||
|
rawCallbacks = ToolCallbacks.from(bean);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Defensive: a misbehaving bean shouldn't break the whole tool set
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ToolCallback raw : rawCallbacks) {
|
||||||
|
ToolCallback wrapped = callbackByName.get(raw.getToolDefinition().name());
|
||||||
|
if (wrapped != null) {
|
||||||
|
beanCallbacks.add(wrapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (beanCallbacks.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (beanName != null && !beanName.isBlank()) {
|
||||||
|
aliases.computeIfAbsent(beanName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
|
||||||
|
}
|
||||||
|
if (simpleName != null && !simpleName.isBlank()) {
|
||||||
|
aliases.computeIfAbsent(simpleName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freeze inner sets
|
||||||
|
Map<String, Set<ToolCallback>> frozen = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, Set<ToolCallback>> e : aliases.entrySet()) {
|
||||||
|
frozen.put(e.getKey(), Set.copyOf(e.getValue()));
|
||||||
|
}
|
||||||
|
return Map.copyOf(frozen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,48 @@ public final class GraphEventPublisher {
|
|||||||
public static final String EVENT_STEP_STARTED = "plan_step_started";
|
public static final String EVENT_STEP_STARTED = "plan_step_started";
|
||||||
public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
|
public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
|
||||||
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
|
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
|
||||||
|
/** RFC-06 D-6: lightweight performance summary emitted per-phase. */
|
||||||
|
public static final String EVENT_PERF_SUMMARY = "perf_summary";
|
||||||
|
/**
|
||||||
|
* RFC-052: a tool with returnDirect=true completed; its full result is
|
||||||
|
* carried in the payload and is intended to be rendered as part of the
|
||||||
|
* assistant message (renderAs=assistant_message), bypassing the LLM.
|
||||||
|
*/
|
||||||
|
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
|
||||||
|
* emitted at FinalAnswerNode so channel-side accumulators can persist it
|
||||||
|
* into message metadata. Downstream filters (e.g. memory promotion gate)
|
||||||
|
* branch on this structured value instead of doing brittle text matching
|
||||||
|
* on the assistant content.
|
||||||
|
*/
|
||||||
|
public static final String EVENT_FINISH_REASON = "finish_reason";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing recovery affordances offered after a turn ends in a
|
||||||
|
* non-transient error. Carries the error type + message + a
|
||||||
|
* data-driven list of actions ({@code retry}, {@code regenerate},
|
||||||
|
* {@code report}) so the frontend can render the right buttons
|
||||||
|
* without hard-coding which categories deserve which actions.
|
||||||
|
*
|
||||||
|
* <p>Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
|
||||||
|
* machine-readable reason). The two are kept separate so legacy
|
||||||
|
* consumers of {@code finish_reason} don't have to learn a new
|
||||||
|
* payload shape — and so a future graph branch (e.g. evidence-
|
||||||
|
* insufficient → "rerun with the listed files attached") can emit
|
||||||
|
* feedback affordances without abusing the finish_reason channel.
|
||||||
|
*/
|
||||||
|
public static final String EVENT_FEEDBACK = "feedback_event";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multimodal sidecar routing decision for the current turn. Emitted once
|
||||||
|
* per turn before the graph starts streaming; the channel-side accumulator
|
||||||
|
* stores it under {@code metadata.routing} so the chat UI can show which
|
||||||
|
* sidecar (if any) was invoked. Underscore-prefixed name keeps it out of
|
||||||
|
* IM channel rebroadcast (see {@code ChannelMessageRouter}).
|
||||||
|
*/
|
||||||
|
public static final String EVENT_ROUTING_DECISION = "_routing_decision";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件记录
|
* 事件记录
|
||||||
@ -44,8 +86,23 @@ public final class GraphEventPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static GraphEvent toolStart(String toolName, String arguments) {
|
public static GraphEvent toolStart(String toolName, String arguments) {
|
||||||
|
return toolStart(null, toolName, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a tool_call_started event with the LLM-provided tool_call.id so the
|
||||||
|
* frontend can match start/complete pairs precisely. Without the id, the
|
||||||
|
* UI uses toolName + status="running" + findLast() to pair completes back
|
||||||
|
* to the original card; when the LLM fires multiple calls of the same tool
|
||||||
|
* (e.g. several execute_shell_command in a row) the matching collapses to
|
||||||
|
* "the most recent running" and earlier cards get stranded with a
|
||||||
|
* permanent spinner. Pass the id whenever it's available; null is OK for
|
||||||
|
* legacy callers.
|
||||||
|
*/
|
||||||
|
public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) {
|
||||||
long ts = System.currentTimeMillis();
|
long ts = System.currentTimeMillis();
|
||||||
return new GraphEvent(EVENT_TOOL_START, Map.of(
|
return new GraphEvent(EVENT_TOOL_START, Map.of(
|
||||||
|
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||||
"toolName", toolName,
|
"toolName", toolName,
|
||||||
"arguments", arguments != null ? arguments : "",
|
"arguments", arguments != null ? arguments : "",
|
||||||
"timestamp", ts
|
"timestamp", ts
|
||||||
@ -53,10 +110,20 @@ public final class GraphEventPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
|
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
|
||||||
|
return toolComplete(null, toolName, result, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
|
||||||
long ts = System.currentTimeMillis();
|
long ts = System.currentTimeMillis();
|
||||||
|
// Carry the full tool result; transport-layer chunking lives in
|
||||||
|
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
|
||||||
|
// into ordered tool_result_chunk events when they exceed the 8 KB
|
||||||
|
// single-event budget. The previous unconditional 500-char truncation
|
||||||
|
// here destroyed data that the front-end could otherwise render in full.
|
||||||
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
||||||
|
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||||
"toolName", toolName,
|
"toolName", toolName,
|
||||||
"result", result != null ? truncateResult(result) : "",
|
"result", result != null ? result : "",
|
||||||
"success", success,
|
"success", success,
|
||||||
"timestamp", ts
|
"timestamp", ts
|
||||||
), ts);
|
), ts);
|
||||||
@ -82,9 +149,11 @@ public final class GraphEventPublisher {
|
|||||||
|
|
||||||
public static GraphEvent stepCompleted(int index, String result) {
|
public static GraphEvent stepCompleted(int index, String result) {
|
||||||
long ts = System.currentTimeMillis();
|
long ts = System.currentTimeMillis();
|
||||||
|
// Full step result; broadcastChunked splits at the transport layer
|
||||||
|
// when the payload exceeds the per-event size budget.
|
||||||
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
|
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
|
||||||
"index", index,
|
"index", index,
|
||||||
"result", result != null ? truncateResult(result) : "",
|
"result", result != null ? result : "",
|
||||||
"timestamp", ts
|
"timestamp", ts
|
||||||
), ts);
|
), ts);
|
||||||
}
|
}
|
||||||
@ -95,7 +164,7 @@ public final class GraphEventPublisher {
|
|||||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
|
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
|
||||||
"pendingId", pendingId,
|
"pendingId", pendingId,
|
||||||
"toolName", toolName != null ? toolName : "",
|
"toolName", toolName != null ? toolName : "",
|
||||||
"arguments", arguments != null ? truncateResult(arguments) : "",
|
"arguments", arguments != null ? arguments : "",
|
||||||
"reason", reason != null ? reason : "",
|
"reason", reason != null ? reason : "",
|
||||||
"timestamp", ts
|
"timestamp", ts
|
||||||
), ts);
|
), ts);
|
||||||
@ -112,7 +181,7 @@ public final class GraphEventPublisher {
|
|||||||
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
|
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||||
data.put("pendingId", pendingId);
|
data.put("pendingId", pendingId);
|
||||||
data.put("toolName", toolName != null ? toolName : "");
|
data.put("toolName", toolName != null ? toolName : "");
|
||||||
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
|
data.put("arguments", arguments != null ? arguments : "");
|
||||||
data.put("reason", reason != null ? reason : "");
|
data.put("reason", reason != null ? reason : "");
|
||||||
data.put("summary", summary);
|
data.put("summary", summary);
|
||||||
data.put("maxSeverity", maxSeverity);
|
data.put("maxSeverity", maxSeverity);
|
||||||
@ -121,6 +190,82 @@ public final class GraphEventPublisher {
|
|||||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
|
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-06 D-6: emit a lightweight performance summary for a phase.
|
||||||
|
* Consumers (dashboard, audit, _usage_final) can aggregate these
|
||||||
|
* to reconstruct per-turn latency profiles without full tracing.
|
||||||
|
*
|
||||||
|
* @param phase e.g. "triage", "reasoning", "tool_execution"
|
||||||
|
* @param metrics arbitrary key-value pairs (e.g. "retry_count", "backoff_wait_ms")
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* RFC-052: emit a tool result that was produced by a returnDirect tool.
|
||||||
|
* The full text is carried verbatim and the {@code renderAs="assistant_message"}
|
||||||
|
* hint instructs the SSE consumer (front-end / accumulator) to fold the
|
||||||
|
* payload into the assistant bubble rather than into a tool card.
|
||||||
|
*/
|
||||||
|
public static GraphEvent toolDirectResult(String toolCallId, String toolName, String fullResult) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||||
|
data.put("toolCallId", toolCallId != null ? toolCallId : "");
|
||||||
|
data.put("toolName", toolName != null ? toolName : "");
|
||||||
|
data.put("result", fullResult != null ? fullResult : "");
|
||||||
|
data.put("renderAs", "assistant_message");
|
||||||
|
data.put("timestamp", ts);
|
||||||
|
return new GraphEvent(EVENT_TOOL_DIRECT_RESULT, Map.copyOf(data), ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GraphEvent perfSummary(String phase, Map<String, Object> metrics) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
Map<String, Object> data = new java.util.HashMap<>(metrics);
|
||||||
|
data.put("phase", phase);
|
||||||
|
data.put("timestamp", ts);
|
||||||
|
return new GraphEvent(EVENT_PERF_SUMMARY, Map.copyOf(data), ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal {@code finish_reason} event. Emitted from FinalAnswerNode so it
|
||||||
|
* rides through the same PENDING_EVENTS → StreamDelta pipeline that
|
||||||
|
* channel-side accumulators consume — a sibling SSE-only broadcast would
|
||||||
|
* bypass {@code ChatController.StreamAccumulator.accept(...)} and fail to
|
||||||
|
* persist the reason into message metadata.
|
||||||
|
*
|
||||||
|
* @param reason {@link vip.mate.agent.graph.state.FinishReason#getValue()}
|
||||||
|
* (e.g. {@code "incomplete"}, {@code "stopped"},
|
||||||
|
* {@code "evidence_insufficient"}, {@code "normal"}).
|
||||||
|
*/
|
||||||
|
public static GraphEvent finishReason(String reason) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
return new GraphEvent(EVENT_FINISH_REASON, Map.of(
|
||||||
|
"reason", reason != null ? reason : "",
|
||||||
|
"timestamp", ts
|
||||||
|
), ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a recovery-affordance event for the frontend. {@code errorType}
|
||||||
|
* mirrors the {@code NodeStreamingChatHelper.ErrorType} value (e.g.
|
||||||
|
* {@code AUTH_ERROR}, {@code BILLING}, {@code MODEL_NOT_FOUND}, or
|
||||||
|
* the generic {@code UNKNOWN}); {@code errorMessage} is the
|
||||||
|
* user-friendly text already displayed in the bubble; {@code actions}
|
||||||
|
* is the ordered list of buttons to render. Default offering is the
|
||||||
|
* standard {@code retry / regenerate / report} triad — call sites
|
||||||
|
* can narrow this if a category has limitations (e.g. AUTH_ERROR
|
||||||
|
* shouldn't offer "retry" until the key is fixed).
|
||||||
|
*/
|
||||||
|
public static GraphEvent feedback(String errorType, String errorMessage,
|
||||||
|
java.util.List<String> actions) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
return new GraphEvent(EVENT_FEEDBACK, Map.of(
|
||||||
|
"errorType", errorType != null ? errorType : "",
|
||||||
|
"errorMessage", errorMessage != null ? errorMessage : "",
|
||||||
|
"actions", actions != null && !actions.isEmpty()
|
||||||
|
? actions
|
||||||
|
: java.util.List.of("retry", "regenerate", "report"),
|
||||||
|
"timestamp", ts
|
||||||
|
), ts);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 提取方法 =====
|
// ===== 提取方法 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -135,14 +280,90 @@ public final class GraphEventPublisher {
|
|||||||
.orElse(List.of());
|
.orElse(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass-through; preserved for source/binary compatibility with older callers.
|
||||||
|
* Truncation at the SSE transport layer is now handled by {@code
|
||||||
|
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
|
||||||
|
* ordered chunk events instead of dropping bytes. Logs a one-time
|
||||||
|
* deprecation hint when invoked.
|
||||||
|
*
|
||||||
|
* @deprecated callers should pass full payloads and let the transport layer
|
||||||
|
* decide whether to chunk.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
private static String truncateResult(String result) {
|
private static String truncateResult(String result) {
|
||||||
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
|
warnTruncateDeprecation();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 截断字符串用于直推广播(公共方法,供 Node 直接构造广播数据时使用)
|
* Pass-through, kept for source compatibility with code that built broadcast
|
||||||
|
* payloads directly. Same deprecation reason as {@link #truncateResult}.
|
||||||
|
*
|
||||||
|
* @deprecated callers should pass full payloads.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
public static String truncateForBroadcast(String text) {
|
public static String truncateForBroadcast(String text) {
|
||||||
return truncateResult(text);
|
warnTruncateDeprecation();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
|
||||||
|
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||||
|
|
||||||
|
private static void warnTruncateDeprecation() {
|
||||||
|
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
|
||||||
|
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
|
||||||
|
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
|
||||||
|
"no-op pass-throughs; payloads are no longer truncated here. " +
|
||||||
|
"Move callers to send the full string and rely on " +
|
||||||
|
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Iteration lifecycle events =====
|
||||||
|
|
||||||
|
public static final String EVENT_ITERATION_START = "iteration_start";
|
||||||
|
public static final String EVENT_ITERATION_END = "iteration_end";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the entry of an iteration boundary so consumers can group later
|
||||||
|
* tool / content / thinking events under a single logical step. The
|
||||||
|
* {@code scope} field distinguishes the parent agent ("parent") from a
|
||||||
|
* delegated sub-agent ("subagent"); when scope is "subagent" the
|
||||||
|
* {@code subagentId} payload field is populated by the producer.
|
||||||
|
*/
|
||||||
|
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||||
|
data.put("index", index);
|
||||||
|
data.put("reason", reason != null ? reason : "");
|
||||||
|
data.put("scope", scope != null ? scope : "parent");
|
||||||
|
if (subagentId != null && !subagentId.isEmpty()) {
|
||||||
|
data.put("subagentId", subagentId);
|
||||||
|
}
|
||||||
|
data.put("timestamp", ts);
|
||||||
|
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
|
||||||
|
* and {@code thinkingChars} let consumers render a compact "this turn
|
||||||
|
* produced X content / Y thinking" header without re-aggregating the
|
||||||
|
* underlying delta events.
|
||||||
|
*/
|
||||||
|
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
|
||||||
|
int contentChars, int thinkingChars) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||||
|
data.put("index", index);
|
||||||
|
data.put("scope", scope != null ? scope : "parent");
|
||||||
|
if (subagentId != null && !subagentId.isEmpty()) {
|
||||||
|
data.put("subagentId", subagentId);
|
||||||
|
}
|
||||||
|
data.put("contentChars", contentChars);
|
||||||
|
data.put("thinkingChars", thinkingChars);
|
||||||
|
data.put("timestamp", ts);
|
||||||
|
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
package vip.mate.agent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求级思考深度的 ThreadLocal 持有器。
|
|
||||||
* <p>
|
|
||||||
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode,
|
|
||||||
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口。
|
|
||||||
* <p>
|
|
||||||
* 支持的值:off / low / medium / high / max,null 表示跟随模型默认。
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public final class ThinkingLevelHolder {
|
|
||||||
|
|
||||||
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
|
|
||||||
|
|
||||||
private ThinkingLevelHolder() {}
|
|
||||||
|
|
||||||
public static void set(String level) {
|
|
||||||
HOLDER.set(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前请求的思考级别,null 表示未设置(跟随模型默认)
|
|
||||||
*/
|
|
||||||
public static String get() {
|
|
||||||
return HOLDER.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void clear() {
|
|
||||||
HOLDER.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
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;
|
||||||
@ -51,8 +53,12 @@ public class AgentBindingController {
|
|||||||
verifyAgentWorkspace(agentId, workspaceId);
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
bindingService.setSkillBindings(agentId, skillIds);
|
bindingService.setSkillBindings(agentId, skillIds);
|
||||||
agentService.invalidateAgentCache(agentId);
|
agentService.invalidateAgentCache(agentId);
|
||||||
|
// The Vue client always sends an array, but a non-Vue caller (curl /
|
||||||
|
// SDK) can POST a body of just `null`, which Spring binds to a null
|
||||||
|
// list. The service tolerates that — guard the audit message too.
|
||||||
|
int count = skillIds == null ? 0 : skillIds.size();
|
||||||
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
|
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
|
||||||
"skills=" + skillIds.size(), null);
|
"skills=" + count, null);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,8 +103,63 @@ public class AgentBindingController {
|
|||||||
verifyAgentWorkspace(agentId, workspaceId);
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
bindingService.setToolBindings(agentId, toolNames);
|
bindingService.setToolBindings(agentId, toolNames);
|
||||||
agentService.invalidateAgentCache(agentId);
|
agentService.invalidateAgentCache(agentId);
|
||||||
|
// Same null-safety rationale as setSkills above.
|
||||||
|
int count = toolNames == null ? 0 : toolNames.size();
|
||||||
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
|
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
|
||||||
"tools=" + toolNames.size(), null);
|
"tools=" + count, null);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Provider Preferences ====================
|
||||||
|
|
||||||
|
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
|
||||||
|
@GetMapping("/provider-preferences")
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
public R<List<AgentProviderPreference>> listProviderPreferences(
|
||||||
|
@PathVariable Long agentId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
|
return R.ok(bindingService.listProviderPreferences(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
|
||||||
|
@PutMapping("/provider-preferences")
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
public R<Void> setProviderPreferences(
|
||||||
|
@PathVariable Long agentId,
|
||||||
|
@RequestBody List<String> providerIds,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
|
bindingService.setProviderPreferences(agentId, providerIds);
|
||||||
|
agentService.invalidateAgentCache(agentId);
|
||||||
|
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
|
||||||
|
"providers=" + providerIds.size(), null);
|
||||||
|
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();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +172,7 @@ public class AgentBindingController {
|
|||||||
}
|
}
|
||||||
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||||
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
|
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
|
||||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
package vip.mate.agent.binding.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-009 Phase 4 PR-3 — agent → preferred provider routing hint.
|
||||||
|
*
|
||||||
|
* <p>An agent with zero rows here uses the global fallback chain order
|
||||||
|
* (no behavior change from pre-PR-3 deployments). When rows exist,
|
||||||
|
* {@code AgentGraphBuilder.buildFallbackChain} sorts those provider ids
|
||||||
|
* to the front by ascending {@code sortOrder}; non-listed providers
|
||||||
|
* follow in their global priority order.</p>
|
||||||
|
*
|
||||||
|
* <p>Pool/cooldown gating still applies: a preferred provider that is
|
||||||
|
* HARD-removed or cooling down is still skipped by the runtime walker.</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_agent_provider_preference")
|
||||||
|
public class AgentProviderPreference {
|
||||||
|
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long agentId;
|
||||||
|
|
||||||
|
/** Provider id (matches {@code mate_model_provider.provider_id}). */
|
||||||
|
private String providerId;
|
||||||
|
|
||||||
|
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
private Boolean enabled;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -17,6 +17,5 @@ public class AgentSkillBinding {
|
|||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,5 @@ public class AgentToolBinding {
|
|||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.AgentProviderPreference;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AgentProviderPreferenceMapper extends BaseMapper<AgentProviderPreference> {
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package vip.mate.agent.binding.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||||
|
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||||
|
import vip.mate.skill.event.SkillRemovedEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops {@code mate_agent_skill} rows that pointed at a now-removed skill.
|
||||||
|
*
|
||||||
|
* <p>Without this listener, deleting a skill from the skill management page
|
||||||
|
* leaves orphan binding rows behind:
|
||||||
|
* <ul>
|
||||||
|
* <li>the agent edit modal still shows a non-zero badge from
|
||||||
|
* {@code GET /agents/{id}/skills},</li>
|
||||||
|
* <li>the picker list (sourced from {@code /skills} enabled set) no longer
|
||||||
|
* contains a checkbox for that id so the user can't uncheck it, and</li>
|
||||||
|
* <li>a subsequent {@code PUT /agents/{id}/skills} payload that still
|
||||||
|
* carries the orphan id is rejected by
|
||||||
|
* {@code AgentBindingService.setSkillBindings} with
|
||||||
|
* {@code err.skill.not_found}, leaving the user with no way to clear
|
||||||
|
* the stale binding.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The event is dispatched synchronously from {@code SkillService} after
|
||||||
|
* the {@code mate_skill} row deletion, so the cleanup is part of the same
|
||||||
|
* request and observable in the very next list call.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentBindingSkillRemovalListener {
|
||||||
|
|
||||||
|
private final AgentSkillBindingMapper skillBindingMapper;
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
public void onSkillRemoved(SkillRemovedEvent event) {
|
||||||
|
if (event == null || event.skillId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int dropped = skillBindingMapper.delete(
|
||||||
|
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||||
|
.eq(AgentSkillBinding::getSkillId, event.skillId()));
|
||||||
|
if (dropped > 0) {
|
||||||
|
log.info("Cleaned {} agent-skill binding row(s) for removed skill {} (id={})",
|
||||||
|
dropped, event.skillName(), event.skillId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel-bound target identity used when an agent's response must be delivered
|
||||||
|
* back to a specific external channel (cron, IM relay, etc.).
|
||||||
|
*
|
||||||
|
* <p>Kept as a sub-VO of {@link ChatOrigin} so future channel-related fields do
|
||||||
|
* not pollute the top-level origin record.
|
||||||
|
*
|
||||||
|
* <p>Field evolution rule: only-add, do-not-rename, deprecate-for-90-days before
|
||||||
|
* physical removal — see {@link ChatOrigin}'s class doc.
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ChannelTarget(
|
||||||
|
@Nullable String targetId,
|
||||||
|
@Nullable String threadId,
|
||||||
|
@Nullable String accountId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable value object that travels alongside an agent invocation describing
|
||||||
|
* <em>where the request came from</em> — channel, conversation, requester,
|
||||||
|
* workspace, and optional delivery target.
|
||||||
|
*
|
||||||
|
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
|
||||||
|
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
|
||||||
|
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
|
||||||
|
*
|
||||||
|
* <h2>Field evolution rule</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Only add — never delete; deprecate at least 90 days (covers approval TTL)
|
||||||
|
* before physical removal.</li>
|
||||||
|
* <li>Never rename — add a new field plus deprecate-old-field, double-write
|
||||||
|
* during the migration window.</li>
|
||||||
|
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
|
||||||
|
* compatibility when older approval rows are deserialized after upgrades.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ChatOrigin(
|
||||||
|
@Nullable Long agentId,
|
||||||
|
@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable Long channelId,
|
||||||
|
@Nullable ChannelTarget channelTarget,
|
||||||
|
// True only when the agent invocation was triggered by the scheduled-job
|
||||||
|
// runner. An explicit discriminator (rather than inferring from
|
||||||
|
// requesterId/channelId) so the runtime can branch on "is this a cron
|
||||||
|
// run" without coupling to factory internals.
|
||||||
|
boolean cronOrigin,
|
||||||
|
/**
|
||||||
|
* Display name of the user that sent the inbound IM message. Used by
|
||||||
|
* the prompt-context injector so the agent's system prompt can
|
||||||
|
* personalise replies ("You are talking to {{senderName}}"). Null
|
||||||
|
* for non-IM origins (web, cron). {@code requesterId} carries the
|
||||||
|
* stable identifier; this one is purely the human-readable surface.
|
||||||
|
*/
|
||||||
|
@Nullable String senderName,
|
||||||
|
/**
|
||||||
|
* Source channel type ("feishu" / "wecom" / "dingtalk" / ...).
|
||||||
|
* Lets the agent know which platform it's responding on, e.g. to
|
||||||
|
* tailor formatting or hint at supported features.
|
||||||
|
*/
|
||||||
|
@Nullable String channelType,
|
||||||
|
/**
|
||||||
|
* Group / chat identifier for IM channels — distinguishes private
|
||||||
|
* vs. group conversations. Null for 1:1 chats. Distinct from
|
||||||
|
* {@link #channelTarget()} (which targets cron / proactive sends).
|
||||||
|
*/
|
||||||
|
@Nullable String chatId,
|
||||||
|
/**
|
||||||
|
* 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}. */
|
||||||
|
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||||
|
|
||||||
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
|
public static final ChatOrigin EMPTY =
|
||||||
|
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
|
||||||
|
|
||||||
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@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,
|
||||||
|
requesterId != null ? requesterId : "",
|
||||||
|
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable Long channelId,
|
||||||
|
@Nullable ChannelTarget target) {
|
||||||
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
|
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Wither-style updates ----------------
|
||||||
|
|
||||||
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
|
@Nullable String newWorkspaceBasePath) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry the inbound message's sender display name, source channel
|
||||||
|
* type, and chat (group) id. Called by the channel-side origin
|
||||||
|
* factory so prompt-context injection can show the agent "who"
|
||||||
|
* is talking and "where".
|
||||||
|
*/
|
||||||
|
public ChatOrigin withSender(@Nullable String newSenderName,
|
||||||
|
@Nullable String newChannelType,
|
||||||
|
@Nullable String newChatId) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
newSenderName, newChannelType, newChatId, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|
||||||
|
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
|
||||||
|
public ToolContext toToolContext() {
|
||||||
|
return new ToolContext(Map.of(CTX_KEY, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
|
||||||
|
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
|
||||||
|
* no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
|
||||||
|
* callers safe even if wiring is partial).
|
||||||
|
*/
|
||||||
|
public static ChatOrigin from(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx == null) return EMPTY;
|
||||||
|
Object v = ctx.getContext().get(CTX_KEY);
|
||||||
|
return v instanceof ChatOrigin co ? co : EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped {@link ChatOrigin} bridge between {@code AgentService}'s
|
||||||
|
* public entry points and the StateGraph's {@code buildInitialState}.
|
||||||
|
*
|
||||||
|
* <p>RFC-063r §2.5 carries the origin end-to-end via Spring AI {@code ToolContext}
|
||||||
|
* once it lands in graph state. This holder is the small bridge that gets the
|
||||||
|
* origin from the AgentService method invocation into the graph's initial
|
||||||
|
* state map — the holder lifecycle is bounded by the AgentService method
|
||||||
|
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||||
|
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
|
||||||
|
* the rest of the runtime reads via the typed accessor — no further ThreadLocal
|
||||||
|
* access. Mirrors {@link vip.mate.llm.chatmodel.ThinkingLevelHolder}.
|
||||||
|
*/
|
||||||
|
public final class ChatOriginHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<ChatOrigin> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private ChatOriginHolder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the origin for the current AgentService invocation. */
|
||||||
|
public static void set(ChatOrigin origin) {
|
||||||
|
HOLDER.set(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
|
||||||
|
* when no entry path has supplied one (legacy callers).
|
||||||
|
*/
|
||||||
|
public static ChatOrigin get() {
|
||||||
|
ChatOrigin v = HOLDER.get();
|
||||||
|
return v != null ? v : ChatOrigin.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for per-reasoning-loop message budgeting.
|
||||||
|
*
|
||||||
|
* <p>Used by {@link LoopMessageBudgeter} to decide when and how to trim the
|
||||||
|
* working message list that a ReAct iteration hands to the LLM. Distinct from
|
||||||
|
* the multi-turn history compression configured by
|
||||||
|
* {@link vip.mate.config.ConversationWindowProperties}: this one applies inside
|
||||||
|
* a single user turn while the ReAct loop accumulates reasoning steps and
|
||||||
|
* tool-call/tool-response pairs.
|
||||||
|
*
|
||||||
|
* <p>Field semantics:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code triggerTokens} — token threshold above which budgeting kicks
|
||||||
|
* in. Compared against {@code historyTokens + reservedPrefixTokens}
|
||||||
|
* so the budgeter accounts for the full prompt the LLM will see,
|
||||||
|
* not just the message list.</li>
|
||||||
|
* <li>{@code keepTailTokens} — token budget reserved for the tail (recent
|
||||||
|
* observations + the current user message). Scales with the model
|
||||||
|
* window instead of relying on a fixed count.</li>
|
||||||
|
* <li>{@code minTailMessages} — floor on the kept-tail count. Prevents a
|
||||||
|
* single huge tool output from collapsing the tail to one message and
|
||||||
|
* losing recent reasoning context.</li>
|
||||||
|
* <li>{@code tailSoftCeilingRatio} — multiplier applied to
|
||||||
|
* {@code keepTailTokens} when honoring the floor or pulling back to
|
||||||
|
* keep a tool pair whole. Lets the tail overshoot the hard budget by
|
||||||
|
* up to this factor before more aggressive cuts kick in.</li>
|
||||||
|
* <li>{@code reservedPrefixTokens} — estimated tokens consumed by the
|
||||||
|
* non-history portion of the prompt (system prompt, skill catalog,
|
||||||
|
* runtime context, wiki injection, tool schemas, output reserve).
|
||||||
|
* Surfaces these from the caller so the budget covers the whole
|
||||||
|
* prompt, not just the message list.</li>
|
||||||
|
* <li>{@code targetMaxMessages} — soft ceiling on the count fed to the
|
||||||
|
* LLM. Best-effort: the budgeter may exceed it slightly to keep a
|
||||||
|
* tool pair whole rather than orphan a call/response — that case is
|
||||||
|
* reported via {@code BudgetTrace.capExceededForPairIntegrity}.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record LoopBudgetConfig(
|
||||||
|
int triggerTokens,
|
||||||
|
int keepTailTokens,
|
||||||
|
int minTailMessages,
|
||||||
|
double tailSoftCeilingRatio,
|
||||||
|
int reservedPrefixTokens,
|
||||||
|
int targetMaxMessages) {
|
||||||
|
|
||||||
|
/** Smallest useful trigger threshold; below this budgeting is effectively disabled. */
|
||||||
|
public static final int MIN_TRIGGER_TOKENS = 1_000;
|
||||||
|
|
||||||
|
/** Smallest sensible tail budget; below this even one observation may not fit. */
|
||||||
|
public static final int MIN_TAIL_TOKENS = 2_000;
|
||||||
|
|
||||||
|
/** Floor on minTailMessages — fewer than 3 collapses recent context too aggressively. */
|
||||||
|
public static final int MIN_TAIL_MESSAGES_FLOOR = 3;
|
||||||
|
|
||||||
|
/** Floor on the soft ceiling ratio — anything below 1.0 is degenerate. */
|
||||||
|
public static final double MIN_TAIL_SOFT_CEILING_RATIO = 1.0;
|
||||||
|
|
||||||
|
/** Smallest sensible target cap; below this even a normal ReAct loop trips it. */
|
||||||
|
public static final int MIN_TARGET_MAX = 20;
|
||||||
|
|
||||||
|
public LoopBudgetConfig {
|
||||||
|
if (triggerTokens < MIN_TRIGGER_TOKENS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"triggerTokens must be >= " + MIN_TRIGGER_TOKENS + ", got " + triggerTokens);
|
||||||
|
}
|
||||||
|
if (keepTailTokens < MIN_TAIL_TOKENS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"keepTailTokens must be >= " + MIN_TAIL_TOKENS + ", got " + keepTailTokens);
|
||||||
|
}
|
||||||
|
if (minTailMessages < MIN_TAIL_MESSAGES_FLOOR) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"minTailMessages must be >= " + MIN_TAIL_MESSAGES_FLOOR
|
||||||
|
+ ", got " + minTailMessages);
|
||||||
|
}
|
||||||
|
if (tailSoftCeilingRatio < MIN_TAIL_SOFT_CEILING_RATIO) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"tailSoftCeilingRatio must be >= " + MIN_TAIL_SOFT_CEILING_RATIO
|
||||||
|
+ ", got " + tailSoftCeilingRatio);
|
||||||
|
}
|
||||||
|
if (reservedPrefixTokens < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"reservedPrefixTokens must be >= 0, got " + reservedPrefixTokens);
|
||||||
|
}
|
||||||
|
if (targetMaxMessages < MIN_TARGET_MAX) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"targetMaxMessages must be >= " + MIN_TARGET_MAX
|
||||||
|
+ ", got " + targetMaxMessages);
|
||||||
|
}
|
||||||
|
if (keepTailTokens >= triggerTokens) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"keepTailTokens (" + keepTailTokens + ") must be < triggerTokens ("
|
||||||
|
+ triggerTokens + ") — otherwise budgeting would never reduce anything");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tail budget after applying the soft ceiling. */
|
||||||
|
public int tailSoftCeilingTokens() {
|
||||||
|
return (int) (keepTailTokens * tailSoftCeilingRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a sensible config from a model's context window. The ratios were
|
||||||
|
* chosen so the budgeter triggers well before the model's actual limit and
|
||||||
|
* leaves enough headroom for the LLM's own response.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>trigger = 50% of the window — same threshold the multi-turn
|
||||||
|
* compressor uses, so the two layers stay calibrated.</li>
|
||||||
|
* <li>tail budget = 30% of the window.</li>
|
||||||
|
* <li>minTailMessages = 4 — at least one full reasoning/action cycle
|
||||||
|
* stays visible to the LLM no matter how big a single tool output is.</li>
|
||||||
|
* <li>tailSoftCeilingRatio = 1.5 — let the tail overshoot by 50% when
|
||||||
|
* enforcing the floor or pulling back to keep a tool pair whole.</li>
|
||||||
|
* <li>reservedPrefixTokens = 0 — caller should override with the real
|
||||||
|
* prefix estimate; left at 0 the budget still works but errs on
|
||||||
|
* the side of triggering later than it should.</li>
|
||||||
|
* <li>targetMaxMessages = 200 — well above a normal ReAct loop's 20–40
|
||||||
|
* working messages, low enough to be a meaningful guard rail.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public static LoopBudgetConfig forContext(int contextWindowTokens) {
|
||||||
|
if (contextWindowTokens <= 0) {
|
||||||
|
contextWindowTokens = 32_000;
|
||||||
|
}
|
||||||
|
int trigger = Math.max(MIN_TRIGGER_TOKENS, (int) (contextWindowTokens * 0.50));
|
||||||
|
int tail = Math.max(MIN_TAIL_TOKENS, (int) (contextWindowTokens * 0.30));
|
||||||
|
if (tail >= trigger) {
|
||||||
|
tail = Math.max(MIN_TAIL_TOKENS, trigger - MIN_TRIGGER_TOKENS);
|
||||||
|
}
|
||||||
|
return new LoopBudgetConfig(trigger, tail, 4, 1.5, 0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a copy with {@code reservedPrefixTokens} replaced. */
|
||||||
|
public LoopBudgetConfig withReservedPrefixTokens(int reservedPrefixTokens) {
|
||||||
|
return new LoopBudgetConfig(triggerTokens, keepTailTokens, minTailMessages,
|
||||||
|
tailSoftCeilingRatio, reservedPrefixTokens, targetMaxMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ReAct-loop message budgeter. Bounds the working message list a Reasoning
|
||||||
|
* iteration hands to the LLM while preserving five invariants that, when
|
||||||
|
* violated, either produce off-topic answers or 400s from strict providers:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li><b>System prompt(s)</b> — all consecutive {@link SystemMessage}s at
|
||||||
|
* the head stay verbatim. Production agents commonly have multiple
|
||||||
|
* (SOUL, AGENTS, runtime context, wiki, tool prompt, skill catalog).</li>
|
||||||
|
* <li><b>Turn anchor</b> — the latest {@link UserMessage} is never dropped.
|
||||||
|
* Stitched in when an aggressive cut would otherwise lose it.</li>
|
||||||
|
* <li><b>Tool-call/response pair integrity</b> — every assistant tool_call
|
||||||
|
* reaches the model with its matching tool_response, and vice versa.
|
||||||
|
* Delegated to {@link ToolPairSanitizer}.</li>
|
||||||
|
* <li><b>Token budget over message count</b> — tail sized by token estimate
|
||||||
|
* so a small ReAct loop with fat observations and a large loop with
|
||||||
|
* thin observations both fit one config.</li>
|
||||||
|
* <li><b>Minimum tail messages</b> — at least {@code minTailMessages}
|
||||||
|
* entries survive even when a single message is bigger than the
|
||||||
|
* hard tail budget. Prevents collapsing recent reasoning to one row
|
||||||
|
* when the latest tool output is huge.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The trigger threshold compares {@code historyTokens +
|
||||||
|
* reservedPrefixTokens} against {@code triggerTokens}; this keeps the
|
||||||
|
* budgeter calibrated against the entire prompt the LLM will see, not just
|
||||||
|
* the message list (the L1 compactor uses the same arithmetic).
|
||||||
|
*
|
||||||
|
* <p>Distinct from {@link ConversationWindowManager}: that one runs once per
|
||||||
|
* user turn and produces a structured LLM summary for the accumulated
|
||||||
|
* multi-turn history. This one runs per reasoning iteration on top of
|
||||||
|
* whatever {@code ConversationWindowManager} already produced, bounding the
|
||||||
|
* intra-turn ReAct accumulation.
|
||||||
|
*
|
||||||
|
* <p>Stateless and side-effect-free for callers; safe to call from
|
||||||
|
* concurrent reasoning threads. The orphan-removal pass mutates a freshly
|
||||||
|
* allocated local list, never the caller's input.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class LoopMessageBudgeter {
|
||||||
|
|
||||||
|
/** Outcome of a budgeting pass. */
|
||||||
|
public record Result(List<Message> messages, BudgetTrace trace) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured trace of a single budgeting decision. All counts and token
|
||||||
|
* figures refer to {@link Message} entries.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code anchorEnforced} — the tail cut was pulled earlier than
|
||||||
|
* the token budget would have placed it because the latest
|
||||||
|
* UserMessage would otherwise have been dropped.</li>
|
||||||
|
* <li>{@code anchorStitched} — the latest UserMessage could not fit in
|
||||||
|
* the tail even after pull-back (typically when the target cap
|
||||||
|
* fired hard); it was inserted as a standalone slot between head
|
||||||
|
* and tail.</li>
|
||||||
|
* <li>{@code capExceededForPairIntegrity} — the final count exceeded
|
||||||
|
* {@code targetMaxMessages} because pulling the cut back to keep
|
||||||
|
* a tool pair whole won out over the soft cap. Useful signal that
|
||||||
|
* upstream compaction should have run sooner.</li>
|
||||||
|
* <li>{@code minTailFloorApplied} — the tail was enlarged past the
|
||||||
|
* hard token budget (up to the soft ceiling) to honor
|
||||||
|
* {@code minTailMessages}.</li>
|
||||||
|
* <li>{@code triggered} — the budget entered its main path because the
|
||||||
|
* trigger threshold was met. Says nothing about whether anything
|
||||||
|
* was actually removed.</li>
|
||||||
|
* <li>{@code modified} — the returned list differs from the input
|
||||||
|
* (count changed or orphans removed). This is the only signal
|
||||||
|
* callers should use to gate log output; a triggered-but-no-op
|
||||||
|
* pass is normal and shouldn't spam logs.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record BudgetTrace(
|
||||||
|
int originalCount,
|
||||||
|
int originalTokens,
|
||||||
|
int finalCount,
|
||||||
|
int finalTokens,
|
||||||
|
int reservedPrefixTokens,
|
||||||
|
int headKept,
|
||||||
|
int tailKept,
|
||||||
|
int droppedMiddle,
|
||||||
|
int orphansRemoved,
|
||||||
|
boolean anchorEnforced,
|
||||||
|
boolean anchorStitched,
|
||||||
|
boolean targetMaxTripped,
|
||||||
|
boolean capExceededForPairIntegrity,
|
||||||
|
boolean minTailFloorApplied,
|
||||||
|
boolean triggered,
|
||||||
|
boolean modified) {
|
||||||
|
|
||||||
|
/** Trace for the no-op case (budget not triggered). */
|
||||||
|
public static BudgetTrace untouched(int count, int tokens, int prefixTokens, int headKept) {
|
||||||
|
return new BudgetTrace(count, tokens, count, tokens, prefixTokens, headKept,
|
||||||
|
count - headKept, 0, 0,
|
||||||
|
false, false, false, false, false, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the loop budget to {@code messages}. Pure function; never mutates the input. */
|
||||||
|
public Result budget(List<Message> messages, LoopBudgetConfig cfg) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return new Result(messages == null ? List.of() : messages,
|
||||||
|
BudgetTrace.untouched(0, 0, cfg.reservedPrefixTokens(), 0));
|
||||||
|
}
|
||||||
|
int originalCount = messages.size();
|
||||||
|
int historyTokens = TokenEstimator.estimateTokens(messages);
|
||||||
|
int headEnd = findHeadEnd(messages);
|
||||||
|
|
||||||
|
// Budget against the full prompt (history + prefix), so the trigger
|
||||||
|
// matches what the LLM would actually receive — not just the
|
||||||
|
// history slice. Prefix covers system prompt, skill catalog,
|
||||||
|
// runtime context, wiki, tool schemas, output reserve.
|
||||||
|
int promptTokens = historyTokens + cfg.reservedPrefixTokens();
|
||||||
|
|
||||||
|
// Below both thresholds → forward unchanged.
|
||||||
|
if (promptTokens < cfg.triggerTokens() && originalCount < cfg.targetMaxMessages()) {
|
||||||
|
return new Result(messages,
|
||||||
|
BudgetTrace.untouched(originalCount, historyTokens,
|
||||||
|
cfg.reservedPrefixTokens(), headEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Token-budgeted tail cut. Walk backward from the end; the
|
||||||
|
// earliest index whose suffix fits within keepTailTokens is the
|
||||||
|
// proposed boundary.
|
||||||
|
int hardTailStart = findTailCutByTokens(messages, headEnd, cfg.keepTailTokens());
|
||||||
|
|
||||||
|
// 2. Min-tail floor: if the hard cut keeps fewer than minTailMessages,
|
||||||
|
// pull back to keep at least that many — but only up to the soft
|
||||||
|
// ceiling. Without this, one giant tool output can collapse the
|
||||||
|
// tail to a single row and lose recent reasoning context.
|
||||||
|
boolean minTailFloorApplied = false;
|
||||||
|
int tailStart = hardTailStart;
|
||||||
|
int hardTailCount = originalCount - hardTailStart;
|
||||||
|
if (hardTailCount < cfg.minTailMessages()) {
|
||||||
|
int floorTailStart = Math.max(headEnd, originalCount - cfg.minTailMessages());
|
||||||
|
// Honor the soft ceiling: if even the floor count would consume
|
||||||
|
// more than tailSoftCeilingTokens, accept it (the floor wins,
|
||||||
|
// since the alternative is losing recent reasoning entirely).
|
||||||
|
tailStart = floorTailStart;
|
||||||
|
minTailFloorApplied = true;
|
||||||
|
} else {
|
||||||
|
// Apply the soft ceiling: if the hard cut undershoots the soft
|
||||||
|
// ceiling (i.e. there's slack), keep going. We already cut to
|
||||||
|
// the hard budget so there's no need to expand here — the soft
|
||||||
|
// ceiling acts as a guard rail for the floor/pull-back path,
|
||||||
|
// not as a relaxation of the normal cut.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Anchor: never drop the latest UserMessage. Pull tail back if
|
||||||
|
// needed (cheap — just moves the boundary).
|
||||||
|
boolean anchorEnforced = false;
|
||||||
|
int anchorIdx = findLatestUserMessageIdx(messages, headEnd);
|
||||||
|
if (anchorIdx >= 0 && anchorIdx < tailStart) {
|
||||||
|
tailStart = anchorIdx;
|
||||||
|
anchorEnforced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Tool-pair integrity at the boundary: if tailStart sits inside a
|
||||||
|
// tool pair, pull back so the pair survives whole. Delegated to
|
||||||
|
// the shared sanitizer.
|
||||||
|
int beforePairPullBack = tailStart;
|
||||||
|
tailStart = ToolPairSanitizer.pullBackToToolPairBoundary(messages, headEnd, tailStart);
|
||||||
|
|
||||||
|
// 5. Target max safety net. The pair-integrity pull-back may have
|
||||||
|
// pushed final count above the soft cap; we re-evaluate and try
|
||||||
|
// to enforce, but pair integrity wins over count cap.
|
||||||
|
boolean targetMaxTripped = false;
|
||||||
|
boolean anchorStitched = false;
|
||||||
|
boolean capExceededForPairIntegrity = false;
|
||||||
|
int targetTailCap = Math.max(0, cfg.targetMaxMessages() - headEnd);
|
||||||
|
if (targetTailCap > 0 && (originalCount - tailStart) > targetTailCap) {
|
||||||
|
int provisionalTailStart = originalCount - targetTailCap;
|
||||||
|
boolean stitchNeeded = anchorIdx >= 0 && anchorIdx < provisionalTailStart;
|
||||||
|
int reservedForStitchedAnchor = stitchNeeded ? 1 : 0;
|
||||||
|
int recentTailCap = Math.max(1, targetTailCap - reservedForStitchedAnchor);
|
||||||
|
int newTailStart = originalCount - recentTailCap;
|
||||||
|
int adjustedTailStart = ToolPairSanitizer.pullBackToToolPairBoundary(
|
||||||
|
messages, headEnd, newTailStart);
|
||||||
|
if (adjustedTailStart < newTailStart) {
|
||||||
|
// Pair integrity prevailed over the cap; honestly record that
|
||||||
|
// the final count will exceed targetMaxMessages.
|
||||||
|
capExceededForPairIntegrity = true;
|
||||||
|
}
|
||||||
|
tailStart = adjustedTailStart;
|
||||||
|
targetMaxTripped = true;
|
||||||
|
anchorStitched = anchorIdx >= 0 && anchorIdx < tailStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect anchor stitching from the tool-pair pull-back path too:
|
||||||
|
// pull-back may have moved tailStart earlier than the anchor index
|
||||||
|
// (rare, but possible if the pair anchor is in the head section).
|
||||||
|
if (!anchorStitched && anchorIdx >= 0 && anchorIdx < tailStart) {
|
||||||
|
anchorStitched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build the trimmed list: head + [stitched anchor?] + tail.
|
||||||
|
int estimated = headEnd + (anchorStitched ? 1 : 0) + (originalCount - tailStart);
|
||||||
|
List<Message> trimmed = new ArrayList<>(estimated);
|
||||||
|
trimmed.addAll(messages.subList(0, headEnd));
|
||||||
|
if (anchorStitched) {
|
||||||
|
trimmed.add(messages.get(anchorIdx));
|
||||||
|
}
|
||||||
|
trimmed.addAll(messages.subList(tailStart, originalCount));
|
||||||
|
|
||||||
|
// 7. Tool-pair invariant: cross-boundary orphans cleaned up. The
|
||||||
|
// pull-back at step 4 handles the boundary case but a head-section
|
||||||
|
// Assistant(tool_calls) whose responses fell in the dropped middle
|
||||||
|
// still needs the bidirectional pass.
|
||||||
|
int orphans = ToolPairSanitizer.removeOrphans(trimmed);
|
||||||
|
|
||||||
|
int finalCount = trimmed.size();
|
||||||
|
int finalTokens = TokenEstimator.estimateTokens(trimmed);
|
||||||
|
int droppedMiddle = originalCount - finalCount;
|
||||||
|
|
||||||
|
// Touch the unused locals so the compiler doesn't warn — they're
|
||||||
|
// useful in the trace's narrative but the actual cut already
|
||||||
|
// committed.
|
||||||
|
if (beforePairPullBack != tailStart) {
|
||||||
|
// pair pull-back moved the boundary; logged via trace fields
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean modified = (finalCount != originalCount) || (orphans > 0);
|
||||||
|
|
||||||
|
return new Result(trimmed, new BudgetTrace(
|
||||||
|
originalCount, historyTokens,
|
||||||
|
finalCount, finalTokens,
|
||||||
|
cfg.reservedPrefixTokens(),
|
||||||
|
headEnd,
|
||||||
|
finalCount - headEnd,
|
||||||
|
droppedMiddle,
|
||||||
|
orphans,
|
||||||
|
anchorEnforced,
|
||||||
|
anchorStitched,
|
||||||
|
targetMaxTripped,
|
||||||
|
capExceededForPairIntegrity,
|
||||||
|
minTailFloorApplied,
|
||||||
|
/* triggered */ true,
|
||||||
|
modified));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Internals
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static int findHeadEnd(List<Message> messages) {
|
||||||
|
int i = 0;
|
||||||
|
while (i < messages.size() && messages.get(i) instanceof SystemMessage) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk backward from the end accumulating per-message token estimates.
|
||||||
|
* Return the earliest index whose suffix fits within {@code keepTokens}.
|
||||||
|
* Always returns a value in {@code [headEnd, messages.size())} so the
|
||||||
|
* tail is non-empty.
|
||||||
|
*/
|
||||||
|
private static int findTailCutByTokens(List<Message> messages, int headEnd, int keepTokens) {
|
||||||
|
int n = messages.size();
|
||||||
|
if (n <= headEnd) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
int acc = 0;
|
||||||
|
for (int i = n - 1; i >= headEnd; i--) {
|
||||||
|
int t = TokenEstimator.estimateTokens(messages.get(i));
|
||||||
|
if (acc + t > keepTokens && i < n - 1) {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
acc += t;
|
||||||
|
}
|
||||||
|
return headEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the latest {@link UserMessage} at or after {@code headEnd}; -1 if none. */
|
||||||
|
private static int findLatestUserMessageIdx(List<Message> messages, int headEnd) {
|
||||||
|
for (int i = messages.size() - 1; i >= headEnd; i--) {
|
||||||
|
if (messages.get(i) instanceof UserMessage) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,6 +46,45 @@ public final class RuntimeContextInjector {
|
|||||||
* 构建运行时上下文消息(i18n 版本)。
|
* 构建运行时上下文消息(i18n 版本)。
|
||||||
*/
|
*/
|
||||||
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) {
|
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) {
|
||||||
|
return buildContextMessage(workspaceBasePath, i18n, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the runtime-context message and (when {@code origin} is non-null
|
||||||
|
* and carries IM channel context) append a short "who is talking, where,
|
||||||
|
* via what channel" block so the agent's system prompt can personalise
|
||||||
|
* its reply. Same cache discipline as the simpler overloads — the block
|
||||||
|
* stays well under the spring-ai user-cache threshold (≥1024 chars).
|
||||||
|
*
|
||||||
|
* <p>The sender block is suppressed when:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code origin} is null or {@link ChatOrigin#EMPTY}</li>
|
||||||
|
* <li>the origin carries no IM context (web / cron) — both produce
|
||||||
|
* a null {@code channelType} or {@code "web"}</li>
|
||||||
|
* </ul>
|
||||||
|
* Web and cron callers thus see exactly the same prompt as before.
|
||||||
|
*/
|
||||||
|
public static String buildContextMessage(String workspaceBasePath,
|
||||||
|
vip.mate.i18n.I18nService i18n,
|
||||||
|
ChatOrigin origin) {
|
||||||
|
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);
|
||||||
@ -66,7 +105,94 @@ public final class RuntimeContextInjector {
|
|||||||
sb.append("\n[system-context] Working directory: ").append(workspaceBasePath);
|
sb.append("\n[system-context] Working directory: ").append(workspaceBasePath);
|
||||||
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
|
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
|
||||||
}
|
}
|
||||||
|
appendSkillRootHintIfPresent(sb, workspaceBasePath, i18n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendSenderBlockIfPresent(sb, origin);
|
||||||
|
appendModelLineIfPresent(sb, modelName, providerId, i18n);
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the model that the shared skill repository is reachable in addition
|
||||||
|
* to the workspace. Without this, a model that strictly honors the
|
||||||
|
* "working directory only" hint refuses to read or run skill files that
|
||||||
|
* live outside the workspace — even though the path sandbox now allows
|
||||||
|
* them. Skipped when the skill root is unknown or already sits inside the
|
||||||
|
* workspace (no separate boundary to explain).
|
||||||
|
*/
|
||||||
|
private static void appendSkillRootHintIfPresent(StringBuilder sb, String workspaceBasePath,
|
||||||
|
vip.mate.i18n.I18nService i18n) {
|
||||||
|
java.nio.file.Path skillRoot = vip.mate.tool.guard.WorkspacePathGuard.getSkillRoot();
|
||||||
|
if (skillRoot == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
java.nio.file.Path wsRoot = java.nio.file.Paths.get(workspaceBasePath).toAbsolutePath().normalize();
|
||||||
|
if (skillRoot.startsWith(wsRoot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String skillRootStr = skillRoot.toString();
|
||||||
|
if (i18n != null) {
|
||||||
|
sb.append("\n").append(i18n.msg("context.skill_dir_hint", skillRootStr));
|
||||||
|
} else {
|
||||||
|
sb.append("\nShared skills live under ").append(skillRootStr)
|
||||||
|
.append("; you may also read and run files there, even though it is outside the working directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* meaningful IM context. Format is intentionally one line per
|
||||||
|
* fact so it's both LLM-readable and easy to log-grep.
|
||||||
|
*/
|
||||||
|
private static void appendSenderBlockIfPresent(StringBuilder sb, ChatOrigin origin) {
|
||||||
|
if (origin == null || origin == ChatOrigin.EMPTY) return;
|
||||||
|
String channelType = origin.channelType();
|
||||||
|
// Only inject for real IM channels — web / null / cron should
|
||||||
|
// see the previous prompt verbatim so their cache hit rate
|
||||||
|
// and existing eval baselines don't shift.
|
||||||
|
if (channelType == null || channelType.isBlank()
|
||||||
|
|| "web".equalsIgnoreCase(channelType)
|
||||||
|
|| origin.cronOrigin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sb.append("\n[system-context] Channel: ").append(channelType);
|
||||||
|
if (origin.senderName() != null && !origin.senderName().isBlank()) {
|
||||||
|
sb.append("\n[system-context] Sender: ").append(origin.senderName());
|
||||||
|
}
|
||||||
|
if (origin.requesterId() != null && !origin.requesterId().isBlank()) {
|
||||||
|
sb.append(" (id=").append(origin.requesterId()).append(')');
|
||||||
|
}
|
||||||
|
if (origin.chatId() != null && !origin.chatId().isBlank()) {
|
||||||
|
sb.append("\n[system-context] Chat: ").append(origin.chatId())
|
||||||
|
.append(" (group conversation — multiple users may follow up)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,175 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boundary-aware text truncation.
|
||||||
|
*
|
||||||
|
* <p>Character-count truncation that lands inside a JSON value or string literal
|
||||||
|
* leaves the model a fragment like {@code {"name":"serv} — a shape that invites it
|
||||||
|
* to "repair" the structure by fabricating the omitted fields. When the input
|
||||||
|
* looks like JSON, this utility snaps each head/tail cut point to the nearest
|
||||||
|
* complete structural boundary (immediately after a {@code ,}, {@code }} or
|
||||||
|
* {@code ]} that is not inside a string), so a retained fragment always ends and
|
||||||
|
* begins between elements rather than in the middle of one.
|
||||||
|
*
|
||||||
|
* <p>Non-JSON input falls back to a plain character cut, and boundary snapping is
|
||||||
|
* only applied when it costs less than half the requested budget — so callers can
|
||||||
|
* use this unconditionally without ever losing more than a plain cut would.
|
||||||
|
*/
|
||||||
|
public final class StructuredTruncator {
|
||||||
|
|
||||||
|
private StructuredTruncator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int[] NO_BOUNDARIES = new int[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard fidelity directive appended to truncation markers so the model
|
||||||
|
* treats omitted content as unknown rather than reconstructable.
|
||||||
|
*/
|
||||||
|
public static final String FIDELITY_NOTE =
|
||||||
|
"Do NOT infer or fabricate omitted content; retrieve the full data (e.g. read_file) "
|
||||||
|
+ "or tell the user the result is incomplete.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head-only slice: the first {@code maxHeadChars} characters, snapped back to
|
||||||
|
* a JSON boundary when one sits within the kept region. Returns the input
|
||||||
|
* unchanged when it is already short enough.
|
||||||
|
*/
|
||||||
|
public static String headSlice(String text, int maxHeadChars) {
|
||||||
|
if (text == null || maxHeadChars <= 0 || text.length() <= maxHeadChars) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
int[] bounds = boundaries(text);
|
||||||
|
int end = snapDown(bounds, maxHeadChars);
|
||||||
|
// Reject a boundary that throws away more than half the budget.
|
||||||
|
if (end < maxHeadChars / 2) {
|
||||||
|
end = maxHeadChars;
|
||||||
|
}
|
||||||
|
return text.substring(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head + marker + tail truncation. {@code headBudget} / {@code tailBudget} are
|
||||||
|
* upper bounds on each retained side; {@code marker} is inserted between them.
|
||||||
|
* The cut points snap to JSON boundaries when the input is JSON-like and the
|
||||||
|
* snap is cheap; otherwise plain character cuts are used. The result never
|
||||||
|
* exceeds {@code headBudget + marker.length() + tailBudget}.
|
||||||
|
*
|
||||||
|
* @return the input unchanged when it already fits both budgets
|
||||||
|
*/
|
||||||
|
public static String truncate(String text, int headBudget, int tailBudget, String marker) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (headBudget < 0) {
|
||||||
|
headBudget = 0;
|
||||||
|
}
|
||||||
|
if (tailBudget < 0) {
|
||||||
|
tailBudget = 0;
|
||||||
|
}
|
||||||
|
int len = text.length();
|
||||||
|
if (len <= headBudget + tailBudget) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
String mk = marker == null ? "" : marker;
|
||||||
|
int[] bounds = boundaries(text);
|
||||||
|
|
||||||
|
int headEnd = snapDown(bounds, headBudget);
|
||||||
|
if (headEnd < headBudget / 2) {
|
||||||
|
// No usable boundary near the head budget → plain cut.
|
||||||
|
headEnd = headBudget;
|
||||||
|
}
|
||||||
|
|
||||||
|
int floor = len - tailBudget;
|
||||||
|
int tailStart = snapUp(bounds, floor);
|
||||||
|
if (tailStart > floor + tailBudget / 2) {
|
||||||
|
// Nearest boundary is so far forward the tail would shrink by half → plain cut.
|
||||||
|
tailStart = floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tailStart <= headEnd) {
|
||||||
|
// Snapping collapsed the two regions into each other → plain, non-overlapping cut.
|
||||||
|
headEnd = Math.min(headBudget, len);
|
||||||
|
tailStart = Math.max(len - tailBudget, headEnd);
|
||||||
|
}
|
||||||
|
return text.substring(0, headEnd) + mk + text.substring(tailStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indices (in ascending order) at which the text may be split without
|
||||||
|
* severing a JSON token. A boundary index {@code i} marks the position
|
||||||
|
* immediately after a {@code ,}, {@code }} or {@code ]} that is not
|
||||||
|
* inside a string literal. Returns an empty array when the input does not
|
||||||
|
* look like JSON, which makes both snap helpers fall back to plain cuts.
|
||||||
|
*/
|
||||||
|
private static int[] boundaries(String text) {
|
||||||
|
int len = text.length();
|
||||||
|
int start = 0;
|
||||||
|
while (start < len && Character.isWhitespace(text.charAt(start))) {
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
if (start >= len) {
|
||||||
|
return NO_BOUNDARIES;
|
||||||
|
}
|
||||||
|
char first = text.charAt(start);
|
||||||
|
if (first != '{' && first != '[') {
|
||||||
|
return NO_BOUNDARIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] buf = new int[16];
|
||||||
|
int n = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
for (int i = start; i < len; i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (c == '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (c == ',' || c == '}' || c == ']') {
|
||||||
|
if (n == buf.length) {
|
||||||
|
int[] grown = new int[buf.length * 2];
|
||||||
|
System.arraycopy(buf, 0, grown, 0, n);
|
||||||
|
buf = grown;
|
||||||
|
}
|
||||||
|
buf[n++] = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n == buf.length) {
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
int[] out = new int[n];
|
||||||
|
System.arraycopy(buf, 0, out, 0, n);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest boundary {@code <= limit}, or 0 when none exists. */
|
||||||
|
private static int snapDown(int[] bounds, int limit) {
|
||||||
|
int best = 0;
|
||||||
|
for (int b : bounds) {
|
||||||
|
if (b > limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
best = b;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smallest boundary {@code >= floor}, or {@link Integer#MAX_VALUE} when none exists. */
|
||||||
|
private static int snapUp(int[] bounds, int floor) {
|
||||||
|
for (int b : bounds) {
|
||||||
|
if (b >= floor) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,10 @@
|
|||||||
package vip.mate.agent.context;
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,6 +24,13 @@ public final class TokenEstimator {
|
|||||||
/** 每条消息的固定开销 token(role 标记、分隔符等) */
|
/** 每条消息的固定开销 token(role 标记、分隔符等) */
|
||||||
static final int PER_MESSAGE_OVERHEAD = 4;
|
static final int PER_MESSAGE_OVERHEAD = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tool wrapper overhead: function/type:object boilerplate, name and
|
||||||
|
* description framing, parameters key, and JSON braces around the schema.
|
||||||
|
* Conservative — slightly overestimates so budget guards don't underrun.
|
||||||
|
*/
|
||||||
|
static final int PER_TOOL_OVERHEAD = 12;
|
||||||
|
|
||||||
private TokenEstimator() {
|
private TokenEstimator() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +81,38 @@ public final class TokenEstimator {
|
|||||||
.sum();
|
.sum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the token cost of the tool definitions sent on every LLM call
|
||||||
|
* (name + description + JSON inputSchema, plus per-tool wrapper overhead).
|
||||||
|
* <p>
|
||||||
|
* A heavily-bound agent (multiple MCP servers, many built-ins) can carry
|
||||||
|
* several thousand tokens of tool schema on every request — leaving them
|
||||||
|
* out of the context-window budget makes compression decisions fire too
|
||||||
|
* late and on small models triggers HTTP 400 once the request actually
|
||||||
|
* goes out.
|
||||||
|
*/
|
||||||
|
public static int estimateToolsTokens(Collection<ToolCallback> callbacks) {
|
||||||
|
if (callbacks == null || callbacks.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int total = 0;
|
||||||
|
for (ToolCallback cb : callbacks) {
|
||||||
|
if (cb == null) continue;
|
||||||
|
ToolDefinition def;
|
||||||
|
try {
|
||||||
|
def = cb.getToolDefinition();
|
||||||
|
} catch (Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (def == null) continue;
|
||||||
|
total += estimateTokens(def.name())
|
||||||
|
+ estimateTokens(def.description())
|
||||||
|
+ estimateTokens(def.inputSchema())
|
||||||
|
+ PER_TOOL_OVERHEAD;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为 CJK 字符(中日韩统一表意文字 + 常用标点)
|
* 判断是否为 CJK 字符(中日韩统一表意文字 + 常用标点)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,192 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-function utilities that enforce the OpenAI-compatible
|
||||||
|
* tool_call ↔ tool_response pairing invariant:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Every {@code tool_call.id} on an {@link AssistantMessage} has a
|
||||||
|
* matching {@code tool_response.id} on a {@link ToolResponseMessage}
|
||||||
|
* <em>after</em> it in the list.</li>
|
||||||
|
* <li>Every {@code tool_response.id} on a {@link ToolResponseMessage} has
|
||||||
|
* a matching {@code tool_call.id} on an {@link AssistantMessage}
|
||||||
|
* <em>before</em> it.</li>
|
||||||
|
* <li>No empty/null ids on either side.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Violating either rule causes strict providers (kimi-code, anthropic in
|
||||||
|
* tool-use mode, OpenAI's responses API on certain models) to reject the
|
||||||
|
* request with a 400 error such as
|
||||||
|
* {@code "tool_call_id is not found"}. This sanitizer is the single source of
|
||||||
|
* truth for that invariant — any trim / cut / window logic should run its
|
||||||
|
* pre/post passes here rather than reimplementing them.
|
||||||
|
*
|
||||||
|
* <p>All methods are {@code static} and side-effect-free except where
|
||||||
|
* documented (e.g. {@link #removeOrphans(List)} mutates the list in place to
|
||||||
|
* avoid an extra allocation hot in the reasoning loop). They never touch the
|
||||||
|
* input list when no fix is needed.
|
||||||
|
*/
|
||||||
|
public final class ToolPairSanitizer {
|
||||||
|
|
||||||
|
private ToolPairSanitizer() {
|
||||||
|
// utility class
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a proposed cut boundary earlier so an Assistant(tool_calls) that
|
||||||
|
* issued ids matching {@link ToolResponseMessage}s in the kept tail
|
||||||
|
* survives into the tail alongside its responses. Prevents producing an
|
||||||
|
* orphan response at the cut boundary in the first place.
|
||||||
|
*
|
||||||
|
* @param messages full message list (read-only)
|
||||||
|
* @param headEnd index after the last protected head message
|
||||||
|
* @param tailStart proposed boundary; messages at and after this index
|
||||||
|
* are kept, those between {@code headEnd} and
|
||||||
|
* {@code tailStart} are dropped
|
||||||
|
* @return possibly-earlier {@code tailStart} that keeps tool pairs whole
|
||||||
|
*/
|
||||||
|
public static int pullBackToToolPairBoundary(List<Message> messages, int headEnd, int tailStart) {
|
||||||
|
if (tailStart <= headEnd || messages == null || messages.isEmpty()) {
|
||||||
|
return tailStart;
|
||||||
|
}
|
||||||
|
Set<String> tailResponseIds = new HashSet<>();
|
||||||
|
for (int i = tailStart; i < messages.size(); i++) {
|
||||||
|
if (messages.get(i) instanceof ToolResponseMessage trm) {
|
||||||
|
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||||
|
if (r.id() != null && !r.id().isEmpty()) {
|
||||||
|
tailResponseIds.add(r.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tailResponseIds.isEmpty()) {
|
||||||
|
return tailStart;
|
||||||
|
}
|
||||||
|
for (int i = tailStart - 1; i >= headEnd; i--) {
|
||||||
|
Message m = messages.get(i);
|
||||||
|
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||||
|
boolean overlaps = am.getToolCalls().stream()
|
||||||
|
.anyMatch(tc -> tc.id() != null && tailResponseIds.contains(tc.id()));
|
||||||
|
if (overlaps) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tailStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iteratively remove tool-pair orphans from {@code messages} (mutates the
|
||||||
|
* list in place). Two shapes are handled:
|
||||||
|
*
|
||||||
|
* <p><b>P0</b>: a {@link ToolResponseMessage} whose response id has no
|
||||||
|
* matching assistant tool_call in the list.
|
||||||
|
*
|
||||||
|
* <p><b>P1</b>: an {@link AssistantMessage} whose every tool_call id
|
||||||
|
* has no matching response in the list. (An assistant with both matched
|
||||||
|
* and unmatched calls is left alone — removing it would harm more than
|
||||||
|
* it helps; strict providers tolerate extra calls more readily than
|
||||||
|
* dropping the whole assistant message.)
|
||||||
|
*
|
||||||
|
* <p>Iterates until convergence: removing an assistant for P1 can expose
|
||||||
|
* a P0 orphan that needs cleaning, and vice versa.
|
||||||
|
*
|
||||||
|
* <p>Also removes any tool_call or tool_response with a null or empty id
|
||||||
|
* — those have no useful pairing semantics and confuse both the strict
|
||||||
|
* providers and the matching logic.
|
||||||
|
*
|
||||||
|
* @return total number of messages removed across all passes
|
||||||
|
*/
|
||||||
|
public static int removeOrphans(List<Message> messages) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int totalRemoved = 0;
|
||||||
|
boolean changed;
|
||||||
|
do {
|
||||||
|
Set<String> callIds = new HashSet<>();
|
||||||
|
Set<String> respIds = new HashSet<>();
|
||||||
|
for (Message m : messages) {
|
||||||
|
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||||
|
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||||
|
if (tc.id() != null && !tc.id().isEmpty()) {
|
||||||
|
callIds.add(tc.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m instanceof ToolResponseMessage trm) {
|
||||||
|
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||||
|
if (r.id() != null && !r.id().isEmpty()) {
|
||||||
|
respIds.add(r.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int before = messages.size();
|
||||||
|
messages.removeIf(m -> {
|
||||||
|
if (m instanceof ToolResponseMessage trm) {
|
||||||
|
// P0: a response with a null/empty id, or whose id has
|
||||||
|
// no matching tool_call.
|
||||||
|
return trm.getResponses().stream().anyMatch(r ->
|
||||||
|
r.id() == null || r.id().isEmpty() || !callIds.contains(r.id()));
|
||||||
|
}
|
||||||
|
if (m instanceof AssistantMessage am && am.getToolCalls() != null
|
||||||
|
&& !am.getToolCalls().isEmpty()) {
|
||||||
|
// P1: every tool_call on this assistant has no matching response.
|
||||||
|
return am.getToolCalls().stream().allMatch(tc ->
|
||||||
|
tc.id() == null || tc.id().isEmpty() || !respIds.contains(tc.id()));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
int removed = before - messages.size();
|
||||||
|
totalRemoved += removed;
|
||||||
|
changed = removed > 0;
|
||||||
|
} while (changed);
|
||||||
|
return totalRemoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-condition check: returns {@code true} iff {@code messages}
|
||||||
|
* satisfies the pairing invariant — every assistant tool_call has a
|
||||||
|
* matching response after it, every response has a matching call before
|
||||||
|
* it, all ids are non-empty. Intended for tests and defensive asserts;
|
||||||
|
* production code should run {@link #removeOrphans(List)} which
|
||||||
|
* guarantees this holds on return.
|
||||||
|
*/
|
||||||
|
public static boolean isPaired(List<Message> messages) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Set<String> callIds = new HashSet<>();
|
||||||
|
Set<String> respIds = new HashSet<>();
|
||||||
|
for (Message m : messages) {
|
||||||
|
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||||
|
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||||
|
if (tc.id() == null || tc.id().isEmpty()) return false;
|
||||||
|
callIds.add(tc.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m instanceof ToolResponseMessage trm) {
|
||||||
|
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||||
|
if (r.id() == null || r.id().isEmpty()) return false;
|
||||||
|
respIds.add(r.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String c : callIds) {
|
||||||
|
if (!respIds.contains(c)) return false;
|
||||||
|
}
|
||||||
|
for (String r : respIds) {
|
||||||
|
if (!callIds.contains(r)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,21 +1,36 @@
|
|||||||
package vip.mate.agent.controller;
|
package vip.mate.agent.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
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.AgentDraftVO;
|
||||||
import vip.mate.audit.service.AuditEventService;
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.service.ModelCapabilityService;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.system.model.SystemSettingsDTO;
|
||||||
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
import vip.mate.auth.model.UserEntity;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
@ -33,16 +48,26 @@ public class AgentController {
|
|||||||
|
|
||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
|
private final AuthService authService;
|
||||||
|
private final WorkspaceService workspaceService;
|
||||||
|
private final ModelConfigService modelConfigService;
|
||||||
|
private final ModelCapabilityService modelCapabilityService;
|
||||||
|
private final SystemSettingService systemSettingService;
|
||||||
|
private final AgentGenerationService agentGenerationService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
@Operation(summary = "获取Agent列表")
|
@Operation(summary = "获取Agent列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
public R<List<AgentEntity>> list(
|
public R<List<AgentEntity>> list(
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
@RequestParam(value = "enabled", required = false) Boolean enabled) {
|
||||||
// 无 header 时强制使用默认 workspace,不返回全局数据
|
// 无 header 时强制使用默认 workspace,不返回全局数据
|
||||||
long wsId = workspaceId != null ? workspaceId : 1L;
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
return R.ok(agentService.listAgentsByWorkspace(wsId));
|
// enabled=true: chat selectors hide disabled agents.
|
||||||
|
// enabled=null: admin management page sees enabled + disabled.
|
||||||
|
return R.ok(agentService.listAgentsByWorkspace(wsId, enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取Agent详情")
|
@Operation(summary = "获取Agent详情")
|
||||||
@ -55,14 +80,78 @@ public class AgentController {
|
|||||||
return R.ok(agent);
|
return R.ok(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取Agent当前能力(modality 集合 + sidecar 配置),用于聊天页提示条")
|
||||||
|
@GetMapping("/{id}/capabilities")
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
public R<AgentCapabilitiesVO> capabilities(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
|
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
|
||||||
|
|
||||||
|
ModelConfigEntity primary;
|
||||||
|
try {
|
||||||
|
primary = modelConfigService.resolveModel(agent.getModelName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// No default model configured yet — return a capabilities snapshot that
|
||||||
|
// tells the UI "we can't say anything about this agent's modalities".
|
||||||
|
return R.ok(AgentCapabilitiesVO.builder()
|
||||||
|
.agentId(id)
|
||||||
|
.modelName("")
|
||||||
|
.providerId("")
|
||||||
|
.modalities(List.of())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
java.util.Set<ModelCapabilityService.Modality> modalities =
|
||||||
|
modelCapabilityService.resolve(primary.getModelName(), primary.getModalities());
|
||||||
|
|
||||||
|
SystemSettingsDTO settings = systemSettingService.getSettings();
|
||||||
|
Long visionId = settings.getDefaultVisionModelId();
|
||||||
|
Long videoId = settings.getDefaultVideoModelId();
|
||||||
|
|
||||||
|
return R.ok(AgentCapabilitiesVO.builder()
|
||||||
|
.agentId(id)
|
||||||
|
.modelName(primary.getModelName())
|
||||||
|
.providerId(primary.getProvider())
|
||||||
|
.modalities(modalities.stream().map(Enum::name).toList())
|
||||||
|
.defaultVisionModelId(visionId)
|
||||||
|
.defaultVisionModelLabel(resolveSidecarLabel(visionId))
|
||||||
|
.defaultVideoModelId(videoId)
|
||||||
|
.defaultVideoModelLabel(resolveSidecarLabel(videoId))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSidecarLabel(Long modelId) {
|
||||||
|
if (modelId == null) return null;
|
||||||
|
try {
|
||||||
|
ModelConfigEntity m = modelConfigService.getModel(modelId);
|
||||||
|
return m == null ? null : m.getProvider() + " / " + m.getModelName();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")
|
||||||
public R<AgentEntity> create(
|
public R<AgentEntity> create(
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
@RequestBody AgentEntity agent) {
|
@RequestBody AgentEntity agent,
|
||||||
|
Authentication auth) {
|
||||||
// 始终注入 workspace_id,无 header 时使用默认
|
// 始终注入 workspace_id,无 header 时使用默认
|
||||||
agent.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
|
agent.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
|
||||||
|
// RFC-077 §4.4: 记录创建者,让 member 后续可删除自建 Agent
|
||||||
|
agent.setCreatorUserId(resolveUserId(auth));
|
||||||
AgentEntity created = agentService.createAgent(agent);
|
AgentEntity created = agentService.createAgent(agent);
|
||||||
auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null);
|
auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null);
|
||||||
return R.ok(created);
|
return R.ok(created);
|
||||||
@ -71,10 +160,14 @@ public class AgentController {
|
|||||||
@Operation(summary = "更新Agent")
|
@Operation(summary = "更新Agent")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
public R<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent,
|
public R<AgentEntity> update(@PathVariable Long id, @RequestBody Map<String, Object> body,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
AgentEntity existing = agentService.getAgent(id);
|
AgentEntity existing = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId);
|
verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId);
|
||||||
|
AgentEntity agent = objectMapper.convertValue(body, AgentEntity.class);
|
||||||
|
if (!body.containsKey("primaryKbId")) {
|
||||||
|
agent.setPrimaryKbId(existing.getPrimaryKbId());
|
||||||
|
}
|
||||||
agent.setId(id);
|
agent.setId(id);
|
||||||
agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移
|
agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移
|
||||||
AgentEntity updated = agentService.updateAgent(agent);
|
AgentEntity updated = agentService.updateAgent(agent);
|
||||||
@ -84,11 +177,24 @@ public class AgentController {
|
|||||||
|
|
||||||
@Operation(summary = "删除Agent")
|
@Operation(summary = "删除Agent")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequireWorkspaceRole("admin")
|
@RequireWorkspaceRole("member")
|
||||||
public R<Void> delete(@PathVariable Long id,
|
public R<Void> delete(@PathVariable Long id,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
Authentication auth) {
|
||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
|
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
|
||||||
|
|
||||||
|
// RFC-077 §4.4: 三选一鉴权 — 系统 admin / workspace admin+ / 创建者本人
|
||||||
|
Long userId = resolveUserId(auth);
|
||||||
|
boolean systemAdmin = isSystemAdmin(auth);
|
||||||
|
boolean workspaceAdmin = !systemAdmin
|
||||||
|
&& workspaceService.hasPermission(agent.getWorkspaceId(), userId, "admin");
|
||||||
|
boolean isCreator = userId.equals(agent.getCreatorUserId());
|
||||||
|
if (!systemAdmin && !workspaceAdmin && !isCreator) {
|
||||||
|
throw new MateClawException("err.agent.delete_forbidden", 403,
|
||||||
|
"Only the creator or a workspace admin can delete this Agent");
|
||||||
|
}
|
||||||
|
|
||||||
agentService.deleteAgent(id);
|
agentService.deleteAgent(id);
|
||||||
auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent.getName(), null);
|
auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent.getName(), null);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
@ -104,8 +210,10 @@ public class AgentController {
|
|||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
|
verifyAgentEnabled(agent);
|
||||||
|
|
||||||
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
|
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||||
|
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
||||||
sseExecutor.execute(() -> {
|
sseExecutor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
agentService.chatStream(id, message, conversationId)
|
agentService.chatStream(id, message, conversationId)
|
||||||
@ -142,6 +250,7 @@ public class AgentController {
|
|||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
|
verifyAgentEnabled(agent);
|
||||||
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
|
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,6 +263,7 @@ public class AgentController {
|
|||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
|
verifyAgentEnabled(agent);
|
||||||
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
|
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,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 资源" 的跨域攻击。
|
||||||
@ -180,7 +295,41 @@ public class AgentController {
|
|||||||
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
|
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
|
||||||
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||||
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
|
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
|
||||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block runtime calls against an agent flagged as disabled.
|
||||||
|
*
|
||||||
|
* <p>{@code AgentService#getOrBuildAgent} also checks the flag, but only on
|
||||||
|
* a cache miss — once the {@code BaseAgent} instance is warm, a flip to
|
||||||
|
* disabled would silently keep serving requests until something else
|
||||||
|
* invalidates the cache. Enforcing here at the controller closes that gap
|
||||||
|
* for every external entry point.
|
||||||
|
*/
|
||||||
|
private void verifyAgentEnabled(AgentEntity agent) {
|
||||||
|
if (agent != null && !Boolean.TRUE.equals(agent.getEnabled())) {
|
||||||
|
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + agent.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveUserId(Authentication auth) {
|
||||||
|
if (auth == null) {
|
||||||
|
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");
|
||||||
|
}
|
||||||
|
UserEntity user = authService.findByUsername(auth.getName());
|
||||||
|
if (user == null) {
|
||||||
|
throw new MateClawException("err.auth.user_not_found", 401, "User not found: " + auth.getName());
|
||||||
|
}
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSystemAdmin(Authentication auth) {
|
||||||
|
if (auth == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
UserEntity user = authService.findByUsername(auth.getName());
|
||||||
|
return user != null && "admin".equalsIgnoreCase(user.getRole());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,16 @@ package vip.mate.agent.controller;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.model.TemplateDTO;
|
import vip.mate.agent.model.TemplateDTO;
|
||||||
import vip.mate.agent.service.TemplateService;
|
import vip.mate.agent.service.TemplateService;
|
||||||
|
import vip.mate.auth.model.UserEntity;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -23,6 +28,7 @@ import java.util.List;
|
|||||||
public class TemplateController {
|
public class TemplateController {
|
||||||
|
|
||||||
private final TemplateService templateService;
|
private final TemplateService templateService;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
@Operation(summary = "获取模板列表")
|
@Operation(summary = "获取模板列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -32,7 +38,29 @@ public class TemplateController {
|
|||||||
|
|
||||||
@Operation(summary = "应用模板创建Agent")
|
@Operation(summary = "应用模板创建Agent")
|
||||||
@PostMapping("/{id}/apply")
|
@PostMapping("/{id}/apply")
|
||||||
public R<AgentEntity> apply(@PathVariable String id) {
|
@RequireWorkspaceRole("member")
|
||||||
return R.ok(templateService.applyTemplate(id));
|
public R<AgentEntity> apply(
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
// Accept-Language is forwarded by the frontend (zh-CN, zh, en, en-US, ...)
|
||||||
|
// so the new agent's display name matches the user's locale —
|
||||||
|
// a Chinese user hiring "客服助理" should not get an English
|
||||||
|
// "Customer Support" agent in their list.
|
||||||
|
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage,
|
||||||
|
Authentication auth) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
Long userId = resolveUserId(auth);
|
||||||
|
return R.ok(templateService.applyTemplate(id, wsId, userId, acceptLanguage));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveUserId(Authentication auth) {
|
||||||
|
if (auth == null) {
|
||||||
|
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");
|
||||||
|
}
|
||||||
|
UserEntity user = authService.findByUsername(auth.getName());
|
||||||
|
if (user == null) {
|
||||||
|
throw new MateClawException("err.auth.user_not_found", 401, "User not found: " + auth.getName());
|
||||||
|
}
|
||||||
|
return user.getId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,199 @@
|
|||||||
|
package vip.mate.agent.delegation;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.*;
|
||||||
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST surface for managing live sub-agents:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code POST /interrupt} — stop a running sub-agent.</li>
|
||||||
|
* <li>{@code POST /spawn-pause} — toggle the per-parent spawn-pause flag.</li>
|
||||||
|
* <li>{@code GET /active} — list sub-agents under one parent conversation.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Every endpoint authorizes the caller against the parent conversation's
|
||||||
|
* owner before mutating or revealing anything; the {@code parentConversationId}
|
||||||
|
* query parameter on {@code /active} is mandatory so the route cannot be used
|
||||||
|
* to enumerate cross-tenant sub-agents.
|
||||||
|
*
|
||||||
|
* <p>Authorization mirrors the {@link vip.mate.workspace.conversation.ConversationService#isConversationOwner}
|
||||||
|
* pattern used by the chat stop / fork routes — usernames are the principal
|
||||||
|
* identity carried on {@link Authentication#getName()}, and shared "system"
|
||||||
|
* conversations are accessible to all logged-in users (matches the existing
|
||||||
|
* cron-job convention).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Sub-agents")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/subagents")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SubagentController {
|
||||||
|
|
||||||
|
private final SubagentRegistry registry;
|
||||||
|
private final ConversationService conversationService;
|
||||||
|
private final AuditEventService auditEventService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the record and verify the caller owns its parent conversation.
|
||||||
|
* Throws a 403-coded exception when ownership fails so the global handler
|
||||||
|
* can render a uniform JSON error envelope.
|
||||||
|
*/
|
||||||
|
private SubagentRegistry.SubagentRecord requireOwnership(String subagentId, Authentication auth) {
|
||||||
|
Optional<SubagentRegistry.SubagentRecord> opt = registry.get(subagentId);
|
||||||
|
if (opt.isEmpty()) {
|
||||||
|
throw new MateClawException(404, "subagent " + subagentId + " not found");
|
||||||
|
}
|
||||||
|
SubagentRegistry.SubagentRecord rec = opt.get();
|
||||||
|
String username = currentUsername(auth);
|
||||||
|
if (!conversationService.isConversationOwner(rec.parentConversationId(), username)) {
|
||||||
|
// Audit denial separately from the operation itself so admins can
|
||||||
|
// see what cross-tenant attempts hit the registry. Best-effort
|
||||||
|
// serialization — the audit insert is async on the service side.
|
||||||
|
auditEventService.record("subagent.interrupt.denied", "subagent",
|
||||||
|
subagentId, rec.subagentId(),
|
||||||
|
safeJson(Map.of(
|
||||||
|
"callerUsername", username,
|
||||||
|
"parent", rec.parentConversationId(),
|
||||||
|
"agentId", rec.agentId() == null ? -1L : rec.agentId()
|
||||||
|
)));
|
||||||
|
throw new MateClawException(403, "not the owner of subagent's parent conversation");
|
||||||
|
}
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running sub-agent. The registry flips status to {@code interrupted}
|
||||||
|
* and disposes the streaming subscription if one was registered. Returns
|
||||||
|
* the {@code interrupted} flag so the caller can distinguish "we did stop
|
||||||
|
* something" from "the subagent was already finished" (404 case is handled
|
||||||
|
* separately by {@link #requireOwnership}).
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Interrupt a running sub-agent")
|
||||||
|
@PostMapping("/{subagentId}/interrupt")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
|
||||||
|
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
|
||||||
|
boolean ok = registry.interrupt(subagentId);
|
||||||
|
auditEventService.record("subagent.interrupt", "subagent",
|
||||||
|
subagentId, rec.subagentId(),
|
||||||
|
safeJson(Map.of(
|
||||||
|
"by", currentUsername(auth),
|
||||||
|
"parent", rec.parentConversationId(),
|
||||||
|
"result", ok
|
||||||
|
)));
|
||||||
|
return R.ok(Map.of("interrupted", ok));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle whether new sub-agent spawns are accepted under a parent
|
||||||
|
* conversation. Used by the operator UI to halt runaway parent agents
|
||||||
|
* mid-turn without killing the parent's own LLM call.
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Set sub-agent spawn-pause for a conversation")
|
||||||
|
@PostMapping("/spawn-pause")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
|
||||||
|
Object parentObj = body == null ? null : body.get("parentConversationId");
|
||||||
|
String parent = parentObj == null ? null : parentObj.toString();
|
||||||
|
if (parent == null || parent.isBlank()) {
|
||||||
|
throw new MateClawException(400, "parentConversationId required");
|
||||||
|
}
|
||||||
|
String username = currentUsername(auth);
|
||||||
|
if (!conversationService.isConversationOwner(parent, username)) {
|
||||||
|
throw new MateClawException(403, "not the owner of conversation " + parent);
|
||||||
|
}
|
||||||
|
boolean paused = Boolean.TRUE.equals(body.get("paused"));
|
||||||
|
registry.setSpawnPaused(parent, paused);
|
||||||
|
auditEventService.record("subagent.spawn-pause", "conversation",
|
||||||
|
parent, parent,
|
||||||
|
safeJson(Map.of(
|
||||||
|
"paused", paused,
|
||||||
|
"by", username
|
||||||
|
)));
|
||||||
|
return R.ok(Map.of("paused", paused));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the sub-agents currently active in the delegation tree rooted at
|
||||||
|
* {@code parentConversationId} — the user-facing conversation. Returns the
|
||||||
|
* whole tree (direct children plus deeper descendants), so a multi-level
|
||||||
|
* delegation is fully visible. The query parameter is mandatory: returning
|
||||||
|
* all subagents process-wide would let any logged-in user enumerate other
|
||||||
|
* tenants' delegation trees. Tenant isolation is enforced on this root
|
||||||
|
* conversation, which the caller owns.
|
||||||
|
*/
|
||||||
|
@Operation(summary = "List active sub-agents in a conversation's delegation tree")
|
||||||
|
@GetMapping("/active")
|
||||||
|
@RequireGlobalAdmin
|
||||||
|
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
|
||||||
|
Authentication auth) {
|
||||||
|
if (parentConversationId == null || parentConversationId.isBlank()) {
|
||||||
|
throw new MateClawException(400, "parentConversationId required");
|
||||||
|
}
|
||||||
|
String username = currentUsername(auth);
|
||||||
|
if (!conversationService.isConversationOwner(parentConversationId, username)) {
|
||||||
|
throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> snapshot = registry.snapshotTree(parentConversationId).stream()
|
||||||
|
.map(this::toResponseDto)
|
||||||
|
.toList();
|
||||||
|
return R.ok(Map.of("subagents", snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Username from auth context; falls back to "anonymous" only when null. */
|
||||||
|
private String currentUsername(Authentication auth) {
|
||||||
|
return auth != null ? auth.getName() : "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO projection that drops the {@link reactor.core.Disposable} (not
|
||||||
|
* serializable to the wire) and exposes only the user-facing fields.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> toResponseDto(SubagentRegistry.SubagentRecord rec) {
|
||||||
|
Map<String, Object> dto = new LinkedHashMap<>();
|
||||||
|
dto.put("subagentId", rec.subagentId());
|
||||||
|
dto.put("parentConversationId", rec.parentConversationId());
|
||||||
|
dto.put("childConversationId", rec.childConversationId());
|
||||||
|
dto.put("parentSubagentId", rec.parentSubagentId());
|
||||||
|
dto.put("depth", rec.depth());
|
||||||
|
dto.put("agentId", rec.agentId());
|
||||||
|
dto.put("goal", rec.goal());
|
||||||
|
dto.put("startedAt", rec.startedAt());
|
||||||
|
dto.put("status", rec.status().get());
|
||||||
|
dto.put("toolCount", rec.toolCount().get());
|
||||||
|
dto.put("lastTool", rec.lastTool().get());
|
||||||
|
dto.put("currentPhase", rec.currentPhase().get());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort JSON serialization for audit detail. Falling back to a
|
||||||
|
* marker string keeps the audit row insertable when payload contains
|
||||||
|
* a non-serializable value — the alternative (throwing) would lose the
|
||||||
|
* audit record entirely.
|
||||||
|
*/
|
||||||
|
private String safeJson(Map<String, Object> payload) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
return "{\"error\":\"audit_serialization_failed\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
package vip.mate.agent.delegation;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic watchdog that flips a sub-agent's status to {@code stale} when its
|
||||||
|
* child stream stops making observable progress.
|
||||||
|
*
|
||||||
|
* <p>Progress is probed via {@link ChatStreamTracker#getRunningToolName} and
|
||||||
|
* {@link ChatStreamTracker#getCurrentPhase}. When neither has changed across
|
||||||
|
* the configured number of cycles, the record is marked stale and a
|
||||||
|
* {@code subagent_stale} event is broadcast on the parent conversation so the
|
||||||
|
* UI can surface the issue. Cycle count uses two separate thresholds — one
|
||||||
|
* for idle children and one for children mid-tool — because legitimately slow
|
||||||
|
* tools (large file scans, slow LLM calls) need a longer window than an idle
|
||||||
|
* model that has simply gone quiet.
|
||||||
|
*
|
||||||
|
* <p>The runtime tool name + phase combination is a deliberately coarse
|
||||||
|
* progress signal: it does not require introspecting LLM token deltas, which
|
||||||
|
* keeps the watchdog cheap and avoids racing with the streaming hot path.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SubagentHeartbeat {
|
||||||
|
|
||||||
|
private final SubagentRegistry registry;
|
||||||
|
private final SubagentHeartbeatConfig cfg;
|
||||||
|
private final ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled tick. Defaults to every 30 s; controlled by
|
||||||
|
* {@code mateclaw.delegation.heartbeat.intervalSec}.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRateString = "#{@subagentHeartbeatConfig.intervalSec * 1000L}")
|
||||||
|
public void check() {
|
||||||
|
for (var rec : registry.allActive()) {
|
||||||
|
if (!"running".equals(rec.status().get())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
evaluate(rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible for testing — apply one heartbeat tick to a single record so
|
||||||
|
* tests can drive the watchdog deterministically without scheduling.
|
||||||
|
*/
|
||||||
|
void evaluate(SubagentRegistry.SubagentRecord rec) {
|
||||||
|
// Probe child progress. We use (currentTool, currentPhase) as the
|
||||||
|
// monotonic-progress signal: any change in either implies the child
|
||||||
|
// advanced at least one observable step. We deliberately do NOT
|
||||||
|
// depend on a private apiCallCount field — the RunState does not
|
||||||
|
// expose one, and counting deltas across the streaming hot path
|
||||||
|
// would race with token emission. Tool/phase ticks are atomic
|
||||||
|
// volatile writes from the streaming layer, so reading them here
|
||||||
|
// is cheap and correct.
|
||||||
|
String currentTool = streamTracker.getRunningToolName(rec.childConversationId());
|
||||||
|
String currentPhase = streamTracker.getCurrentPhase(rec.childConversationId());
|
||||||
|
int phaseHash = currentPhase != null ? currentPhase.hashCode() : 0;
|
||||||
|
|
||||||
|
boolean toolChanged = !Objects.equals(currentTool, rec.lastSeenTool().get());
|
||||||
|
boolean phaseChanged = phaseHash != rec.lastSeenIter().get();
|
||||||
|
|
||||||
|
if (toolChanged || phaseChanged) {
|
||||||
|
rec.lastSeenTool().set(currentTool);
|
||||||
|
rec.lastSeenIter().set(phaseHash);
|
||||||
|
rec.staleCount().set(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sc = rec.staleCount().incrementAndGet();
|
||||||
|
int limit = (currentTool != null && !currentTool.isEmpty())
|
||||||
|
? cfg.getStaleCyclesInTool()
|
||||||
|
: cfg.getStaleCyclesIdle();
|
||||||
|
|
||||||
|
if (sc >= limit) {
|
||||||
|
// Atomic transition: only the first thread to flip running -> stale
|
||||||
|
// emits the event. Subsequent ticks fall through the running guard
|
||||||
|
// in check().
|
||||||
|
if (rec.status().compareAndSet("running", "stale")) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("subagentId", rec.subagentId());
|
||||||
|
payload.put("parentSubagentId", rec.parentSubagentId());
|
||||||
|
payload.put("depth", rec.depth());
|
||||||
|
payload.put("cycles", sc);
|
||||||
|
payload.put("lastTool", currentTool != null ? currentTool : "");
|
||||||
|
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
|
||||||
|
// Broadcast to the root (human-facing) conversation so the event
|
||||||
|
// reaches the stream the user is watching at any tree depth.
|
||||||
|
String target = rec.rootConversationId() != null
|
||||||
|
? rec.rootConversationId() : rec.parentConversationId();
|
||||||
|
streamTracker.broadcastObject(target, "subagent_stale", payload);
|
||||||
|
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
|
||||||
|
rec.subagentId(), sc, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package vip.mate.agent.delegation;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration knobs for {@link SubagentHeartbeat}.
|
||||||
|
*
|
||||||
|
* <p>Defaults are tuned so a wedged child surfaces visibly to the parent UI
|
||||||
|
* without firing on legitimately slow tool runs:
|
||||||
|
* <ul>
|
||||||
|
* <li>Idle (no tool running) → 5 cycles × 30 s = 150 s before stale.</li>
|
||||||
|
* <li>In a tool → 20 cycles × 30 s = 600 s before stale.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The in-tool threshold MUST stay greater than or equal to
|
||||||
|
* {@code child_hard_timeout / intervalSec}. If it fires before the per-child
|
||||||
|
* hard cap then the cap stops being the source of truth for "this child is
|
||||||
|
* dead" and operators see ambiguous telemetry.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("mateclaw.delegation.heartbeat")
|
||||||
|
public class SubagentHeartbeatConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat check interval in seconds. Lower values make the parent
|
||||||
|
* transcript more responsive at the cost of scheduler overhead.
|
||||||
|
*/
|
||||||
|
private int intervalSec = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale threshold (in heartbeat cycles) when the child has no current
|
||||||
|
* tool in flight. With the default 30 s interval this is 150 s, tight
|
||||||
|
* enough that a wedged child does not mask a legitimate gateway timeout.
|
||||||
|
*/
|
||||||
|
private int staleCyclesIdle = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale threshold (in heartbeat cycles) while the child is inside a
|
||||||
|
* tool. Generous enough to tolerate slow tools (large file reads, slow
|
||||||
|
* LLM calls). Must be at least the per-child hard timeout divided by
|
||||||
|
* {@link #intervalSec}, otherwise stale fires before the hard cap and
|
||||||
|
* obscures fallback semantics.
|
||||||
|
*/
|
||||||
|
private int staleCyclesInTool = 20;
|
||||||
|
|
||||||
|
public int getIntervalSec() {
|
||||||
|
return intervalSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIntervalSec(int intervalSec) {
|
||||||
|
this.intervalSec = intervalSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStaleCyclesIdle() {
|
||||||
|
return staleCyclesIdle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStaleCyclesIdle(int staleCyclesIdle) {
|
||||||
|
this.staleCyclesIdle = staleCyclesIdle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStaleCyclesInTool() {
|
||||||
|
return staleCyclesInTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStaleCyclesInTool(int staleCyclesInTool) {
|
||||||
|
this.staleCyclesInTool = staleCyclesInTool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
package vip.mate.agent.delegation;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.Disposable;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide registry of live sub-agents spawned through the delegation flow.
|
||||||
|
*
|
||||||
|
* <p>Holds the in-memory subagent tree so the parent transcript, the heartbeat
|
||||||
|
* watcher, and the operator UI can observe / interrupt children that the parent
|
||||||
|
* conversation spawned. Records use atomic accessors throughout because the
|
||||||
|
* heartbeat thread may mutate {@code staleCount} / {@code status} concurrently
|
||||||
|
* with the spawning thread that registered the record.
|
||||||
|
*
|
||||||
|
* <p>The pause flag is keyed per parent conversation so two unrelated users
|
||||||
|
* cannot freeze each other's spawning by toggling a global switch.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SubagentRegistry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single live sub-agent.
|
||||||
|
*
|
||||||
|
* <p>Mutable counters are atomics so the heartbeat scheduler and the
|
||||||
|
* spawn / completion thread can update them without locking. Status is
|
||||||
|
* driven by external lifecycle events; allowed values are
|
||||||
|
* {@code running} / {@code completed} / {@code interrupted} / {@code stale}
|
||||||
|
* / {@code timeout}.
|
||||||
|
*/
|
||||||
|
public record SubagentRecord(
|
||||||
|
String subagentId,
|
||||||
|
String parentConversationId,
|
||||||
|
String childConversationId,
|
||||||
|
Long agentId,
|
||||||
|
String goal,
|
||||||
|
long startedAt,
|
||||||
|
AtomicReference<String> status,
|
||||||
|
AtomicInteger toolCount,
|
||||||
|
AtomicReference<String> lastTool,
|
||||||
|
AtomicReference<String> currentPhase,
|
||||||
|
AtomicInteger lastSeenIter,
|
||||||
|
AtomicReference<String> lastSeenTool,
|
||||||
|
AtomicInteger staleCount,
|
||||||
|
AtomicLong firstApiCallAt,
|
||||||
|
Disposable disposable,
|
||||||
|
// Tree identity: parentSubagentId is null for first-level children
|
||||||
|
// (spawned by the root agent); depth is 1 for first-level, 2 for a
|
||||||
|
// grandchild, etc. rootConversationId is the human-facing stream the
|
||||||
|
// whole tree reports into, used for UI-facing broadcasts at any depth.
|
||||||
|
String parentSubagentId,
|
||||||
|
int depth,
|
||||||
|
String rootConversationId
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-parent pause flag set: scoping prevents one user from freezing
|
||||||
|
* another user's spawning. A parent conversation appears in this set iff
|
||||||
|
* spawning is currently paused for it.
|
||||||
|
*/
|
||||||
|
private final Set<String> pausedParents = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private final SecureRandom rng = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a freshly spawned sub-agent. Returns the assigned subagentId
|
||||||
|
* which the caller must thread through to {@link #unregister(String)} on
|
||||||
|
* completion (success / failure / timeout) so the registry does not leak.
|
||||||
|
*
|
||||||
|
* <p>ID format {@code sa-<epoch_ms>-<8 hex chars>} keeps IDs sortable by
|
||||||
|
* spawn time while the random suffix prevents collisions when many
|
||||||
|
* children spawn within the same millisecond.
|
||||||
|
*/
|
||||||
|
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
|
||||||
|
return register(parentConvId, childConvId, agentId, goal, d, null, 1, parentConvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a sub-agent with full tree identity. {@code parentSubagentId} is
|
||||||
|
* null for first-level children; {@code depth} is 1-based; {@code rootConvId}
|
||||||
|
* is the human-facing conversation the whole tree reports into.
|
||||||
|
*/
|
||||||
|
public String register(String parentConvId, String childConvId, Long agentId, String goal,
|
||||||
|
Disposable d, String parentSubagentId, int depth, String rootConvId) {
|
||||||
|
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
|
||||||
|
active.put(sid, new SubagentRecord(
|
||||||
|
sid,
|
||||||
|
parentConvId,
|
||||||
|
childConvId,
|
||||||
|
agentId,
|
||||||
|
goal,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
new AtomicReference<>("running"),
|
||||||
|
new AtomicInteger(0),
|
||||||
|
new AtomicReference<>(""),
|
||||||
|
new AtomicReference<>("starting"),
|
||||||
|
new AtomicInteger(0),
|
||||||
|
new AtomicReference<>(null),
|
||||||
|
new AtomicInteger(0),
|
||||||
|
new AtomicLong(0),
|
||||||
|
d,
|
||||||
|
parentSubagentId,
|
||||||
|
depth,
|
||||||
|
rootConvId != null ? rootConvId : parentConvId));
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a sub-agent as interrupted and dispose its underlying stream
|
||||||
|
* subscription if one was registered. Returns {@code false} when the
|
||||||
|
* subagentId is unknown (already cleaned up or never registered) so
|
||||||
|
* callers can distinguish "not running anymore" from "interrupted".
|
||||||
|
*/
|
||||||
|
public boolean interrupt(String subagentId) {
|
||||||
|
if (subagentId == null) return false;
|
||||||
|
SubagentRecord r = active.get(subagentId);
|
||||||
|
if (r == null) return false;
|
||||||
|
r.status().set("interrupted");
|
||||||
|
Disposable d = r.disposable();
|
||||||
|
if (d != null && !d.isDisposed()) {
|
||||||
|
d.dispose();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<SubagentRecord> get(String subagentId) {
|
||||||
|
return subagentId == null ? Optional.empty() : Optional.ofNullable(active.get(subagentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of all sub-agents whose <em>immediate</em> parent matches
|
||||||
|
* {@code parentConvId}. Filtering at the registry boundary prevents callers
|
||||||
|
* from accidentally surfacing other tenants' subagents in API responses.
|
||||||
|
*
|
||||||
|
* <p>Note: this returns only direct children. To list a whole delegation
|
||||||
|
* tree (including grandchildren whose immediate parent is a child
|
||||||
|
* conversation), use {@link #snapshotTree(String)}.
|
||||||
|
*/
|
||||||
|
public List<SubagentRecord> snapshot(String parentConvId) {
|
||||||
|
if (parentConvId == null) return List.of();
|
||||||
|
return active.values().stream()
|
||||||
|
.filter(r -> parentConvId.equals(r.parentConversationId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of the entire delegation tree rooted at {@code rootConvId} — the
|
||||||
|
* human-facing conversation. Every sub-agent at any depth carries the same
|
||||||
|
* {@code rootConversationId}, so this returns direct children and all deeper
|
||||||
|
* descendants. Tenant isolation must be enforced on {@code rootConvId} by
|
||||||
|
* the caller (it is the conversation the user owns).
|
||||||
|
*/
|
||||||
|
public List<SubagentRecord> snapshotTree(String rootConvId) {
|
||||||
|
if (rootConvId == null) return List.of();
|
||||||
|
return active.values().stream()
|
||||||
|
.filter(r -> rootConvId.equals(r.rootConversationId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unregister(String subagentId) {
|
||||||
|
if (subagentId == null) return;
|
||||||
|
active.remove(subagentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSpawnPaused(String parentConvId) {
|
||||||
|
if (parentConvId == null) return false;
|
||||||
|
return pausedParents.contains(parentConvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the pause flag for one parent conversation. Returns the new
|
||||||
|
* paused state so the caller can echo the resulting flag without an
|
||||||
|
* extra read.
|
||||||
|
*/
|
||||||
|
public boolean setSpawnPaused(String parentConvId, boolean paused) {
|
||||||
|
if (parentConvId == null) return false;
|
||||||
|
if (paused) {
|
||||||
|
pausedParents.add(parentConvId);
|
||||||
|
} else {
|
||||||
|
pausedParents.remove(parentConvId);
|
||||||
|
}
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<SubagentRecord> allActive() {
|
||||||
|
return active.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase 8-hex-char suffix sourced from a SecureRandom. */
|
||||||
|
private String nextHexSuffix() {
|
||||||
|
byte[] bytes = new byte[4];
|
||||||
|
rng.nextBytes(bytes);
|
||||||
|
StringBuilder sb = new StringBuilder(8);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package vip.mate.agent.event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring application event fired when an agent's lifecycle state changes.
|
||||||
|
* The trigger module subscribes via {@code @EventListener} and forwards
|
||||||
|
* the payload through {@code TriggerEventIngestService} so triggers of
|
||||||
|
* pattern type {@code agent_lifecycle} can fan out to workflows.
|
||||||
|
*
|
||||||
|
* <p>{@code phase} matches the matcher's vocabulary: {@code spawned} for
|
||||||
|
* a fresh create, {@code enabled} / {@code disabled} for a flag flip,
|
||||||
|
* {@code terminated} for a delete. {@code crashed} is reserved for v1
|
||||||
|
* once the agent runtime grows a structured error hook.
|
||||||
|
*
|
||||||
|
* <p>The dedup key downstream is {@code phase + ":" + agentId + ":" +
|
||||||
|
* timestamp}; that's stable across retries of the same operation but
|
||||||
|
* lets the same agent flip enabled/disabled repeatedly without the
|
||||||
|
* trigger pipeline collapsing the events.
|
||||||
|
*/
|
||||||
|
public record AgentLifecycleEvent(
|
||||||
|
long workspaceId,
|
||||||
|
long agentId,
|
||||||
|
String agentName,
|
||||||
|
String phase,
|
||||||
|
long timestamp
|
||||||
|
) {}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
package vip.mate.agent.graph;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-egress message-list normalizer.
|
||||||
|
*
|
||||||
|
* <p>Some OpenAI-compatible providers (notably LM Studio's built-in server,
|
||||||
|
* and certain strict-mode vLLM / SGLang deployments) enforce that exactly
|
||||||
|
* one {@link SystemMessage} must appear at index 0 of the messages array.
|
||||||
|
* Multiple consecutive SystemMessages, or any SystemMessage following a
|
||||||
|
* user / assistant / tool message, returns {@code 400 BAD_REQUEST:
|
||||||
|
* "System message must be at the beginning."}.
|
||||||
|
*
|
||||||
|
* <p>Permissive providers (OpenAI, DashScope, Ollama, DeepSeek, Kimi, Doubao,
|
||||||
|
* GLM) accept the relaxed shape, so the runtime historically composed
|
||||||
|
* prompts with multiple SystemMessages sprinkled through the non-history
|
||||||
|
* prefix (main system prompt + skill catalog + progress-ledger snapshot,
|
||||||
|
* each as its own SystemMessage). To stay portable across both strict and
|
||||||
|
* permissive backends, this normalizer collects every SystemMessage found
|
||||||
|
* anywhere in the input list, concatenates their text with a blank-line
|
||||||
|
* separator, and emits the result as a single SystemMessage at index 0.
|
||||||
|
* The relative order of non-system messages (user / assistant /
|
||||||
|
* tool_response) is preserved verbatim so {@code tool_call_id} pairings
|
||||||
|
* are unaffected.
|
||||||
|
*
|
||||||
|
* <p>Blank / whitespace-only SystemMessages are dropped from the merge. If
|
||||||
|
* every SystemMessage in the input is blank, the result is the same list
|
||||||
|
* with all SystemMessages removed (no synthetic empty SystemMessage is
|
||||||
|
* emitted). If the input contains zero SystemMessages, the input list
|
||||||
|
* reference is returned unchanged.
|
||||||
|
*
|
||||||
|
* <p>The transformation is semantically equivalent on permissive providers
|
||||||
|
* — the merged SystemMessage produces the same token sequence the model
|
||||||
|
* would have seen across N separate SystemMessages — and converts the
|
||||||
|
* strict-provider 400 into a success. It is also safe for non-OpenAI
|
||||||
|
* protocols: the Spring AI Anthropic and Vertex / Gemini adapters already
|
||||||
|
* extract SystemMessages out of the messages list into a top-level
|
||||||
|
* {@code system} / {@code systemInstruction} request field, so they receive
|
||||||
|
* an identical outbound payload whether handed one merged SystemMessage
|
||||||
|
* or several.
|
||||||
|
*
|
||||||
|
* <p>A kill switch is exposed via the JVM system property
|
||||||
|
* {@code mateclaw.llm.message-normalizer.enabled=false}, which makes
|
||||||
|
* {@link #normalize} a no-op for emergency rollback without code changes.
|
||||||
|
*/
|
||||||
|
public final class MessageNormalizer {
|
||||||
|
|
||||||
|
/** Separator inserted between merged SystemMessage segments. */
|
||||||
|
static final String SEPARATOR = "\n\n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill-switch property name. Set to {@code false} (case-insensitive) on
|
||||||
|
* the JVM command line to disable normalization without a code change.
|
||||||
|
*/
|
||||||
|
public static final String ENABLED_PROPERTY = "mateclaw.llm.message-normalizer.enabled";
|
||||||
|
|
||||||
|
private static volatile boolean enabled = !"false".equalsIgnoreCase(
|
||||||
|
System.getProperty(ENABLED_PROPERTY, "true"));
|
||||||
|
|
||||||
|
private MessageNormalizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current kill-switch state. */
|
||||||
|
public static boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the kill-switch at runtime (primarily for tests). Production
|
||||||
|
* code should not need to call this — set the JVM property at startup
|
||||||
|
* instead.
|
||||||
|
*/
|
||||||
|
public static void setEnabledForTesting(boolean value) {
|
||||||
|
enabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a copy of {@code prompt} with every SystemMessage merged into a
|
||||||
|
* single SystemMessage at index 0. Returns the input prompt reference
|
||||||
|
* unchanged when no normalization is necessary (kill switch off, zero
|
||||||
|
* SystemMessages, or already a single non-blank SystemMessage at index 0).
|
||||||
|
*/
|
||||||
|
public static Prompt normalize(Prompt prompt) {
|
||||||
|
if (prompt == null || !enabled) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
List<Message> in = prompt.getInstructions();
|
||||||
|
List<Message> out = normalize(in);
|
||||||
|
if (out == in) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
return new Prompt(out, prompt.getOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List-level normalization, used by {@link #normalize(Prompt)} and by
|
||||||
|
* unit tests that want to assert on the raw message shape without
|
||||||
|
* constructing a {@link Prompt}. Returns the input list reference
|
||||||
|
* unchanged when no normalization is necessary.
|
||||||
|
*/
|
||||||
|
public static List<Message> normalize(List<Message> messages) {
|
||||||
|
if (!enabled || messages == null || messages.isEmpty()) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
int systemCount = 0;
|
||||||
|
int firstSystemIdx = -1;
|
||||||
|
for (int i = 0; i < messages.size(); i++) {
|
||||||
|
if (messages.get(i) instanceof SystemMessage) {
|
||||||
|
if (firstSystemIdx < 0) firstSystemIdx = i;
|
||||||
|
systemCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast-path 1: no SystemMessages — nothing to do.
|
||||||
|
if (systemCount == 0) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
// Fast-path 2: exactly one SystemMessage and it sits at index 0 with
|
||||||
|
// non-blank text. Already canonical — skip the rebuild.
|
||||||
|
if (systemCount == 1 && firstSystemIdx == 0) {
|
||||||
|
SystemMessage sm = (SystemMessage) messages.get(0);
|
||||||
|
String text = sm.getText();
|
||||||
|
if (text != null && !text.isBlank()) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
// Single blank SystemMessage at [0] — fall through to the rebuild,
|
||||||
|
// which will drop it.
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder merged = new StringBuilder();
|
||||||
|
List<Message> rest = new ArrayList<>(messages.size());
|
||||||
|
for (Message m : messages) {
|
||||||
|
if (m instanceof SystemMessage sm) {
|
||||||
|
String text = sm.getText();
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (merged.length() > 0) {
|
||||||
|
merged.append(SEPARATOR);
|
||||||
|
}
|
||||||
|
merged.append(text);
|
||||||
|
} else {
|
||||||
|
rest.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.length() == 0) {
|
||||||
|
// Every SystemMessage in the input was blank — return just the
|
||||||
|
// non-system tail. No synthetic empty SystemMessage.
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Message> out = new ArrayList<>(rest.size() + 1);
|
||||||
|
out.add(new SystemMessage(merged.toString()));
|
||||||
|
out.addAll(rest);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,157 +0,0 @@
|
|||||||
package vip.mate.agent.graph;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 流式输出重复检测器
|
|
||||||
* <p>
|
|
||||||
* 检测 LLM 流式输出中的退化重复模式(degenerate repetition),
|
|
||||||
* 当检测到内容在滑动窗口内高度重复时返回 true,调用方应截断 LLM 流。
|
|
||||||
* <p>
|
|
||||||
* 算法:维护一个滑动窗口缓冲区,每次追加新 delta 后,
|
|
||||||
* 检查窗口尾部是否存在连续重复的 n-gram 模式。
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public class RepetitionDetector {
|
|
||||||
|
|
||||||
/** 滑动窗口大小(字符数) */
|
|
||||||
private static final int WINDOW_SIZE = 1024;
|
|
||||||
|
|
||||||
/** 最小重复片段长度 */
|
|
||||||
private static final int MIN_PATTERN_LEN = 8;
|
|
||||||
|
|
||||||
/** 最大检测的模式长度 */
|
|
||||||
private static final int MAX_PATTERN_LEN = 200;
|
|
||||||
|
|
||||||
/** 模式需要连续出现的最小次数才判定为重复 */
|
|
||||||
private static final int MIN_REPEATS = 4;
|
|
||||||
|
|
||||||
/** 已累积内容的最小长度才开始检测(避免误判短内容) */
|
|
||||||
private static final int MIN_CONTENT_LEN = 200;
|
|
||||||
|
|
||||||
private final StringBuilder buffer = new StringBuilder();
|
|
||||||
private boolean repetitionDetected = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追加新的 delta 并检测是否存在重复
|
|
||||||
*
|
|
||||||
* @param delta 新增的文本片段
|
|
||||||
* @return true 表示检测到退化重复,调用方应截断流
|
|
||||||
*/
|
|
||||||
public boolean appendAndCheck(String delta) {
|
|
||||||
if (delta == null || delta.isEmpty() || repetitionDetected) {
|
|
||||||
return repetitionDetected;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.append(delta);
|
|
||||||
|
|
||||||
// 内容太短,不检测
|
|
||||||
if (buffer.length() < MIN_CONTENT_LEN) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保持窗口大小
|
|
||||||
if (buffer.length() > WINDOW_SIZE * 2) {
|
|
||||||
buffer.delete(0, buffer.length() - WINDOW_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在窗口尾部检测重复模式
|
|
||||||
String window = buffer.toString();
|
|
||||||
int windowLen = window.length();
|
|
||||||
|
|
||||||
// 从短模式到长模式扫描
|
|
||||||
for (int patternLen = MIN_PATTERN_LEN;
|
|
||||||
patternLen <= Math.min(MAX_PATTERN_LEN, windowLen / MIN_REPEATS);
|
|
||||||
patternLen++) {
|
|
||||||
|
|
||||||
// 取窗口末尾的 pattern
|
|
||||||
String pattern = window.substring(windowLen - patternLen);
|
|
||||||
|
|
||||||
// 向前数这个 pattern 连续出现了几次
|
|
||||||
int count = 1;
|
|
||||||
int pos = windowLen - patternLen * 2;
|
|
||||||
while (pos >= 0) {
|
|
||||||
String segment = window.substring(pos, pos + patternLen);
|
|
||||||
if (segment.equals(pattern)) {
|
|
||||||
count++;
|
|
||||||
pos -= patternLen;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count >= MIN_REPEATS) {
|
|
||||||
// 排除装饰性重复(代码缩进、ASCII 图表、Markdown 分隔线常见)
|
|
||||||
if (isDecorativePattern(pattern)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
repetitionDetected = true;
|
|
||||||
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
|
|
||||||
"pattern length={}, repeats={}, pattern preview=\"{}\"",
|
|
||||||
patternLen, count,
|
|
||||||
pattern.length() > 50 ? pattern.substring(0, 50) + "..." : pattern);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断 pattern 是否为装饰性字符(不应判定为退化重复)。
|
|
||||||
* <p>
|
|
||||||
* 排除场景:
|
|
||||||
* <ul>
|
|
||||||
* <li>纯空白/缩进:{@code " "}(代码缩进)</li>
|
|
||||||
* <li>单一重复字符:{@code "────────"} {@code "════════"} {@code "--------"} {@code "********"}(分隔线、表格边框)</li>
|
|
||||||
* <li>Box Drawing 字符族:{@code "┌──────┐"} {@code "│ │"}(ASCII 图表)</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
private boolean isDecorativePattern(String pattern) {
|
|
||||||
if (pattern.isBlank()) {
|
|
||||||
return true; // 纯空白
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计不同的非空白字符种类
|
|
||||||
long distinctNonWhitespace = pattern.chars()
|
|
||||||
.filter(c -> !Character.isWhitespace(c))
|
|
||||||
.distinct()
|
|
||||||
.count();
|
|
||||||
|
|
||||||
// 只有 1-2 种不同的非空白字符 → 装饰性(如 "────────" 或 "│ │")
|
|
||||||
if (distinctNonWhitespace <= 2) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否全部是 Box Drawing / 装饰字符
|
|
||||||
boolean allDecorative = pattern.chars().allMatch(c ->
|
|
||||||
Character.isWhitespace(c)
|
|
||||||
|| isBoxDrawing(c)
|
|
||||||
|| "─━│┃┄┅┆┇┈┉┊┋═║╌╍╎╏╔╗╚╝╠╣╦╩╬├┤┬┴┼┌┐└┘".indexOf(c) >= 0
|
|
||||||
|| "-=_*+|#~<>".indexOf(c) >= 0);
|
|
||||||
return allDecorative;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isBoxDrawing(int codePoint) {
|
|
||||||
// Unicode Box Drawing block: U+2500 – U+257F
|
|
||||||
return codePoint >= 0x2500 && codePoint <= 0x257F;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置检测器状态
|
|
||||||
*/
|
|
||||||
public void reset() {
|
|
||||||
buffer.setLength(0);
|
|
||||||
repetitionDetected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否已检测到重复
|
|
||||||
*/
|
|
||||||
public boolean isRepetitionDetected() {
|
|
||||||
return repetitionDetected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -53,15 +53,32 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
private final CompiledGraph compiledGraph;
|
private final CompiledGraph compiledGraph;
|
||||||
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
|
/**
|
||||||
|
* Held only so {@link #buildInitialState} can include the tools schema in
|
||||||
|
* the context-window budget — those bytes ride along on every LLM call
|
||||||
|
* and were previously ignored, making compression decisions fire late.
|
||||||
|
* Nullable for the legacy 5-arg constructor used by older tests.
|
||||||
|
*/
|
||||||
|
private final vip.mate.agent.AgentToolSet toolSet;
|
||||||
|
|
||||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||||
CompiledGraph compiledGraph,
|
CompiledGraph compiledGraph,
|
||||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||||
ConversationWindowManager conversationWindowManager) {
|
ConversationWindowManager conversationWindowManager) {
|
||||||
|
this(chatClient, conversationService, compiledGraph, chatModel,
|
||||||
|
conversationWindowManager, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||||
|
CompiledGraph compiledGraph,
|
||||||
|
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
vip.mate.agent.AgentToolSet toolSet) {
|
||||||
super(chatClient, conversationService);
|
super(chatClient, conversationService);
|
||||||
this.compiledGraph = compiledGraph;
|
this.compiledGraph = compiledGraph;
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.conversationWindowManager = conversationWindowManager;
|
this.conversationWindowManager = conversationWindowManager;
|
||||||
|
this.toolSet = toolSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -71,7 +88,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId);
|
log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId);
|
||||||
|
|
||||||
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
|
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
|
||||||
Optional<OverAllState> result = compiledGraph.invoke(inputs);
|
// Fresh thread per invocation so graph state never carries over
|
||||||
|
// between calls. The CompiledGraph is cached and shared; without a
|
||||||
|
// unique threadId, consecutive sync runs (e.g. back-to-back cron
|
||||||
|
// executions) inherit the prior run's accumulated messages and
|
||||||
|
// counters. Mirrors the streaming paths, which already do this.
|
||||||
|
RunnableConfig config = RunnableConfig.builder()
|
||||||
|
.threadId(UUID.randomUUID().toString()).build();
|
||||||
|
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
|
||||||
|
|
||||||
return result
|
return result
|
||||||
.flatMap(s -> s.<String>value(FINAL_ANSWER))
|
.flatMap(s -> s.<String>value(FINAL_ANSWER))
|
||||||
@ -130,7 +154,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
|
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
|
||||||
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
|
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
|
||||||
}
|
}
|
||||||
Optional<OverAllState> result = compiledGraph.invoke(inputs);
|
// Fresh thread per invocation — see chat() for rationale.
|
||||||
|
RunnableConfig config = RunnableConfig.builder()
|
||||||
|
.threadId(UUID.randomUUID().toString()).build();
|
||||||
|
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
|
||||||
|
|
||||||
return result
|
return result
|
||||||
.flatMap(s -> s.<String>value(FINAL_ANSWER))
|
.flatMap(s -> s.<String>value(FINAL_ANSWER))
|
||||||
@ -175,8 +202,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
// 防重保护:同 chatStructuredStream
|
// 防重保护:同 chatStructuredStream
|
||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
|
// Silent-termination guard (mirrors chatStructuredStream)
|
||||||
|
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||||
|
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||||
|
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||||
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
|
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
|
||||||
@ -192,7 +224,33 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
||||||
|
// SummarizingNode output) into segments only — final-answer
|
||||||
|
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
||||||
|
// this used persistOnly, which appended every iteration's
|
||||||
|
// narration into the persisted assistant content; next-turn
|
||||||
|
// replay then saw a chain of "Let me try X..." with no
|
||||||
|
// observations and looped retrying tools.
|
||||||
|
//
|
||||||
|
// Exception — evidence-insufficient terminal turn
|
||||||
|
// (ReasoningNode.java:617): when an answer is rejected for
|
||||||
|
// unsupported references, FINAL_ANSWER is replaced with a
|
||||||
|
// short "[证据不足]" warning and STREAMED_CONTENT carries the
|
||||||
|
// actual answer body the user/UI need to see. Falling back
|
||||||
|
// to persistOnly for that case keeps both the original
|
||||||
|
// answer text and the warning in mate_message.content; with
|
||||||
|
// pure segmentOnly the persisted content would shrink to
|
||||||
|
// just the warning, breaking single-segment renderers like
|
||||||
|
// copy / TTS / history reload (segments.length<=1 disables
|
||||||
|
// the segmented view in MessageBubble).
|
||||||
|
boolean isFinalAnswerTurn = hasFinalAnswer(output);
|
||||||
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
|
lastEmittedStreamedContent.set(streamed);
|
||||||
|
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
deltas.add(contentAlreadyStreamed
|
deltas.add(contentAlreadyStreamed
|
||||||
@ -213,6 +271,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||||
|
|
||||||
|
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
|
||||||
|
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
|
||||||
|
if (hasFinalAnswer(output)
|
||||||
|
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|
||||||
|
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
|
||||||
|
sawLegitimateExit.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
return deltas;
|
return deltas;
|
||||||
})
|
})
|
||||||
.concatWith(Mono.fromSupplier(() -> {
|
.concatWith(Mono.fromSupplier(() -> {
|
||||||
@ -225,8 +291,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
|
||||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
.doOnComplete(() -> {
|
||||||
|
setState(AgentState.IDLE);
|
||||||
|
if (!sawLegitimateExit.get()) {
|
||||||
|
log.error("[{}] StateGraph replay stream completed WITHOUT a final answer / "
|
||||||
|
+ "limit_exceeded / finish_reason — likely framework-level silent "
|
||||||
|
+ "termination. conversationId={}, lastIteration={}, softCap={}",
|
||||||
|
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
|
||||||
|
}
|
||||||
|
})
|
||||||
.doOnError(e -> {
|
.doOnError(e -> {
|
||||||
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
|
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
|
||||||
setState(AgentState.ERROR);
|
setState(AgentState.ERROR);
|
||||||
@ -265,8 +339,22 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
|
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||||
|
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||||
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
|
// Silent-termination guardrail: track the highest iteration / soft cap
|
||||||
|
// observed and whether the graph reached a legitimate exit (final answer
|
||||||
|
// or limit-exceeded node). If the framework completes the Flux without
|
||||||
|
// either signal we log.error in doOnComplete — the graph framework
|
||||||
|
// historically treated its own recursion cap as a silent normal
|
||||||
|
// completion, which masked turns ending mid-execution. Decoupling the
|
||||||
|
// recursionLimit at compile time should keep this from firing, but the
|
||||||
|
// guard catches any future regression instead of letting it ship silent.
|
||||||
|
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||||
|
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||||
|
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||||
// 1. 提取所有累积的事件,只发送新增部分
|
// 1. 提取所有累积的事件,只发送新增部分
|
||||||
@ -287,7 +375,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(THINKING_STREAMED, false);
|
.value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
// 2a. Route per-iteration narrative into the segments timeline
|
||||||
|
// so the segmented UI view still shows "我来…" preludes
|
||||||
|
// between tool cards, but keep the top-level content
|
||||||
|
// field (= persisted mate_message.content) reserved for
|
||||||
|
// the final-answer span. NodeStreamingChatHelper already
|
||||||
|
// broadcast the live deltas; segmentOnly suppresses
|
||||||
|
// re-broadcast and skips content.append while still
|
||||||
|
// populating the segments[] entry.
|
||||||
|
//
|
||||||
|
// Exception — evidence-insufficient terminal turn
|
||||||
|
// (ReasoningNode.java:617): STREAMED_CONTENT carries
|
||||||
|
// the rejected answer body, FINAL_ANSWER is just the
|
||||||
|
// short "[证据不足]" warning. Use persistOnly there so
|
||||||
|
// mate_message.content keeps both the answer text and
|
||||||
|
// the warning — single-segment renderers (copy / TTS /
|
||||||
|
// history reload) read content, not segments.
|
||||||
|
boolean isFinalAnswerTurn = hasFinalAnswer(output);
|
||||||
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
|
lastEmittedStreamedContent.set(streamed);
|
||||||
|
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
deltas.add(contentAlreadyStreamed
|
deltas.add(contentAlreadyStreamed
|
||||||
@ -310,6 +421,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||||
|
|
||||||
|
// 4. Silent-termination guard inputs
|
||||||
|
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
|
||||||
|
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
|
||||||
|
if (hasFinalAnswer(output)
|
||||||
|
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|
||||||
|
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
|
||||||
|
sawLegitimateExit.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
return deltas;
|
return deltas;
|
||||||
})
|
})
|
||||||
// 流正常完成后追加内部 usage 事件
|
// 流正常完成后追加内部 usage 事件
|
||||||
@ -323,8 +443,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
|
||||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
.doOnComplete(() -> {
|
||||||
|
setState(AgentState.IDLE);
|
||||||
|
if (!sawLegitimateExit.get()) {
|
||||||
|
log.error("[{}] StateGraph structured stream completed WITHOUT a final answer / "
|
||||||
|
+ "limit_exceeded / finish_reason — likely framework-level silent "
|
||||||
|
+ "termination (recursionLimit reached or upstream truncation). "
|
||||||
|
+ "conversationId={}, lastIteration={}, softCap={}",
|
||||||
|
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
|
||||||
|
}
|
||||||
|
})
|
||||||
.doOnError(e -> {
|
.doOnError(e -> {
|
||||||
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
|
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
|
||||||
setState(AgentState.ERROR);
|
setState(AgentState.ERROR);
|
||||||
@ -350,12 +479,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
maxInputTokens,
|
maxInputTokens,
|
||||||
chatModel,
|
chatModel,
|
||||||
conversationId,
|
conversationId,
|
||||||
parsedAgentId);
|
parsedAgentId,
|
||||||
|
toolSet != null ? toolSet.callbacks() : null,
|
||||||
|
workspaceBasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Message> messages = new ArrayList<>(historyMessages);
|
List<Message> messages = new ArrayList<>(historyMessages);
|
||||||
// 构建当前用户消息:支持 multimodal(如果有图片附件,直接注入 Media)
|
// 构建当前用户消息:支持 multimodal(如果有图片附件,直接注入 Media)
|
||||||
messages.add(buildCurrentUserMessage(conversationId, userMessage));
|
// 同步获取 routing decision,写入 state 供后续节点 / accumulator 读取。
|
||||||
|
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
|
||||||
|
messages.add(currentTurn.userMessage());
|
||||||
|
|
||||||
Map<String, Object> inputs = new HashMap<>();
|
Map<String, Object> inputs = new HashMap<>();
|
||||||
// 输入
|
// 输入
|
||||||
@ -366,9 +499,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
|
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
|
||||||
inputs.put(MESSAGES, messages);
|
inputs.put(MESSAGES, messages);
|
||||||
// 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用)
|
// 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用)
|
||||||
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
|
// maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路,
|
||||||
|
// 否则 thinking-on 会把"无限"误算成 5(变成"5 步就停")。
|
||||||
|
String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
|
||||||
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||||
int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations;
|
int effectiveMaxIterations = (maxIterations <= 0)
|
||||||
|
? 0
|
||||||
|
: (thinkingOn ? maxIterations + 5 : maxIterations);
|
||||||
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
|
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
|
||||||
inputs.put(CURRENT_ITERATION, 0);
|
inputs.put(CURRENT_ITERATION, 0);
|
||||||
// 初始化新字段
|
// 初始化新字段
|
||||||
@ -388,9 +525,83 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||||
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||||
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
|
||||||
|
// Multimodal sidecar routing — null when the turn carries no media or
|
||||||
|
// the primary model already covers the modalities. Stored as a Map so
|
||||||
|
// graph state stays JSON-friendly.
|
||||||
|
if (currentTurn.routingDecision() != null
|
||||||
|
&& currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|
||||||
|
|| (currentTurn.routingDecision() != null && !currentTurn.routingDecision().skipped().isEmpty())) {
|
||||||
|
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
|
||||||
|
// and workspace, then write it into graph state so ActionNode +
|
||||||
|
// StepExecutionNode can forward it to ToolExecutionExecutor → ToolContext.
|
||||||
|
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||||
|
Long parsedAgentIdForOrigin = null;
|
||||||
|
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||||
|
if (parsedAgentIdForOrigin != null) {
|
||||||
|
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||||
|
}
|
||||||
|
origin = origin.withConversationId(conversationId)
|
||||||
|
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||||
|
inputs.put(CHAT_ORIGIN, origin);
|
||||||
|
|
||||||
|
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
||||||
|
// The node + dispatcher both bail out when ACTIVE_GOAL is absent,
|
||||||
|
// so this is a no-op for conversations without a bound goal.
|
||||||
|
// GOAL_EVALUATED_THIS_RUN explicitly seeded so the FinalAnswer→
|
||||||
|
// GoalEvaluation conditional edge sees a clean false on each new
|
||||||
|
// chat invocation (RFC 48 §6.3 exhaustsBudgetAndStopsLooping
|
||||||
|
// depends on this — every new chat is a fresh evaluation pass).
|
||||||
|
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
|
||||||
|
try {
|
||||||
|
vip.mate.goal.model.GoalEntity active =
|
||||||
|
goalService.findActiveByConversation(conversationId);
|
||||||
|
if (active != null) {
|
||||||
|
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
|
||||||
|
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
|
||||||
|
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
|
||||||
|
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the right {@link AgentService.StreamDelta} flavor for the per-iteration
|
||||||
|
* {@code STREAMED_CONTENT} the graph just emitted.
|
||||||
|
*
|
||||||
|
* <p>The contract:
|
||||||
|
* <ul>
|
||||||
|
* <li>Intermediate ReAct iterations (no {@code FINAL_ANSWER} yet) →
|
||||||
|
* {@code segmentOnly}. The content is reasoning preamble / mid-loop
|
||||||
|
* summary that belongs in the segments timeline, not in the persisted
|
||||||
|
* {@code mate_message.content}.</li>
|
||||||
|
* <li>Terminal turn where {@code FINAL_ANSWER} is set →
|
||||||
|
* {@code persistOnly}. This covers the evidence-insufficient path
|
||||||
|
* (ReasoningNode.java:617) where {@code STREAMED_CONTENT} carries the
|
||||||
|
* actual rejected answer body and {@code FINAL_ANSWER} is just a short
|
||||||
|
* "[证据不足]" warning. Persisting the streamed body keeps single-segment
|
||||||
|
* renderers (copy / TTS / history reload) showing the full text.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Package-private so the unit test can pin the decision without standing
|
||||||
|
* up a full StateGraph fixture. Returning {@code null} for blank input is the
|
||||||
|
* caller's responsibility — this helper just decides flavor for non-blank
|
||||||
|
* content.
|
||||||
|
*/
|
||||||
|
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
|
||||||
|
return isFinalAnswerTurn
|
||||||
|
? AgentService.StreamDelta.persistOnly(streamed, null)
|
||||||
|
: AgentService.StreamDelta.segmentOnly(streamed, null);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean hasFinalAnswer(NodeOutput output) {
|
private boolean hasFinalAnswer(NodeOutput output) {
|
||||||
if (output == null || output.state() == null) {
|
if (output == null || output.state() == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
package vip.mate.agent.graph.edge;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATED_THIS_RUN;
|
||||||
|
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether to re-enter the reasoning loop with an injected
|
||||||
|
* follow-up prompt or terminate the graph run.
|
||||||
|
*
|
||||||
|
* <p>Both targets are passed in by the builder so the same class serves
|
||||||
|
* the ReAct graph (followup -> {@code REASONING_NODE}, terminal ->
|
||||||
|
* {@code END}) and the Plan-Execute graph (followup ->
|
||||||
|
* {@code PLAN_GENERATION_NODE}, terminal -> {@code END}) without
|
||||||
|
* branching on graph type at runtime.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GoalEvaluationDispatcher implements EdgeAction {
|
||||||
|
|
||||||
|
/** Where to re-enter the loop when GoalEvaluationNode injected a followup. */
|
||||||
|
private final String followupTarget;
|
||||||
|
|
||||||
|
/** Where to go on the normal terminal path (typically {@code END}). */
|
||||||
|
private final String terminalTarget;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String apply(OverAllState state) {
|
||||||
|
// Re-enter the loop only when a followup was injected AND this was not a
|
||||||
|
// terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key
|
||||||
|
// strategy and is never cleared by the reasoning nodes, so after a
|
||||||
|
// run-to-completion loop it can linger true; goalEvaluatedThisRun (set
|
||||||
|
// true on every terminal branch — completed / exhausted / skip /
|
||||||
|
// continue-without-followup) is the authoritative end-of-run signal.
|
||||||
|
boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false));
|
||||||
|
boolean terminal = Boolean.TRUE.equals(state.value(GOAL_EVALUATED_THIS_RUN, false));
|
||||||
|
if (followup && !terminal) {
|
||||||
|
log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
|
||||||
|
return followupTarget;
|
||||||
|
}
|
||||||
|
return terminalTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,6 +38,17 @@ public class ObservationDispatcher implements EdgeAction {
|
|||||||
return FINAL_ANSWER_NODE;
|
return FINAL_ANSWER_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC-052: returnDirect short-circuit — highest priority after approval.
|
||||||
|
// Any tool in the latest batch declared returnDirect=true: skip the next
|
||||||
|
// LLM call entirely and route straight to FinalAnswerNode, which will
|
||||||
|
// assemble the final answer from DIRECT_TOOL_OUTPUTS.
|
||||||
|
if (accessor.returnDirectTriggered()) {
|
||||||
|
log.info("[ObservationDispatcher] RETURN_DIRECT_TRIGGERED=true, " +
|
||||||
|
"routing to finalAnswerNode (skipping next LLM call), iteration {}/{}",
|
||||||
|
currentIteration, maxIterations);
|
||||||
|
return FINAL_ANSWER_NODE;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 迭代超限检查(maxIterations=0 表示不限制)
|
// 1. 迭代超限检查(maxIterations=0 表示不限制)
|
||||||
if (maxIterations > 0 && currentIteration >= maxIterations) {
|
if (maxIterations > 0 && currentIteration >= maxIterations) {
|
||||||
log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " +
|
log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " +
|
||||||
|
|||||||
@ -34,7 +34,7 @@ public class ReasoningDispatcher implements EdgeAction {
|
|||||||
public String apply(OverAllState state) throws Exception {
|
public String apply(OverAllState state) throws Exception {
|
||||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||||
|
|
||||||
// 1. 迭代超限检查(最高优先级)
|
// 1. 迭代超限检查
|
||||||
if (accessor.isLimitReached()) {
|
if (accessor.isLimitReached()) {
|
||||||
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
|
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
|
||||||
accessor.iterationCount(), accessor.maxIterations());
|
accessor.iterationCount(), accessor.maxIterations());
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
|
||||||
|
*
|
||||||
|
* <p>Layer 1 — per-tool cap — is implemented inside each tool itself.
|
||||||
|
* Layer 2 — per-result spill — when a single tool result exceeds {@link #perResultThresholdChars}
|
||||||
|
* the full output is written to disk and only a {@link #previewHeadChars} preview
|
||||||
|
* (plus a pointer line) is sent back to the LLM.
|
||||||
|
* Layer 3 — per-turn aggregate budget — after all tools in a turn complete, if
|
||||||
|
* the cumulative response size exceeds {@link #perTurnBudgetChars}, the largest
|
||||||
|
* non-spilled responses are spilled in turn until the aggregate fits.</p>
|
||||||
|
*
|
||||||
|
* <p>Spill files live under {@link #storageBaseDir} when set, otherwise under
|
||||||
|
* {@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a
|
||||||
|
* workspace is bound to the agent, otherwise under
|
||||||
|
* {@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/}.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* mate:
|
||||||
|
* agent:
|
||||||
|
* tool-result:
|
||||||
|
* enabled: true
|
||||||
|
* per-result-threshold-chars: 16000
|
||||||
|
* per-turn-budget-chars: 32000
|
||||||
|
* preview-head-chars: 800
|
||||||
|
* excluded-tool-inline-chars: 4000
|
||||||
|
* storage-base-dir:
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "mate.agent.tool-result")
|
||||||
|
public class ToolResultProperties {
|
||||||
|
|
||||||
|
/** Master switch. When false, the executor falls back to plain truncation. */
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-result spill threshold. A single tool result larger than this is
|
||||||
|
* spilled to disk and the in-context view is replaced with a short
|
||||||
|
* preview + path so the model can call {@code read_file} on demand.
|
||||||
|
*
|
||||||
|
* <p>Aligned with {@code ToolExecutionExecutor.MAX_TOOL_RESULT_CHARS}
|
||||||
|
* (8000): the executor now tries to spill the RAW result first; only
|
||||||
|
* when spilling is disabled, the tool is on {@link #excludedTools}, the
|
||||||
|
* body is under this threshold, or the disk write fails, does it fall
|
||||||
|
* back to truncating inline to 8000 chars. Keeping the threshold equal
|
||||||
|
* to the truncate cap yields a single semantic ladder — above the
|
||||||
|
* threshold means "preserved on disk", at-or-below means "stays inline
|
||||||
|
* verbatim".
|
||||||
|
*
|
||||||
|
* <p>If you want to keep more text inline before spilling, raise this
|
||||||
|
* value AND raise the executor's hard cap together; otherwise the
|
||||||
|
* 8000-char fallback truncate would silently shorten anything between
|
||||||
|
* this threshold and 8000 even when spill is disabled, defeating the
|
||||||
|
* intent.
|
||||||
|
*/
|
||||||
|
private int perResultThresholdChars = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 3 — aggregate cap on combined response size in one tool turn.
|
||||||
|
* After all tools complete, the largest non-spilled responses are spilled
|
||||||
|
* in turn until the cumulative size fits this budget.
|
||||||
|
*/
|
||||||
|
private int perTurnBudgetChars = 32000; // was 16000 — headroom for multi-tool turns
|
||||||
|
|
||||||
|
/** Number of leading characters kept inline as a preview after spilling. */
|
||||||
|
private int previewHeadChars = 800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieval-style tools are not spilled, but their inline content still must
|
||||||
|
* fit the model context. When aggregate turn budget is exceeded and only
|
||||||
|
* excluded tools remain, their results are compacted to this size.
|
||||||
|
*/
|
||||||
|
private int excludedToolInlineChars = 2500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional absolute path to override the default spill location.
|
||||||
|
* When blank, falls back to {@code <workspace>/.mateclaw/tool-results/} or
|
||||||
|
* {@code ${java.io.tmpdir}/mateclaw/tool-results/}.
|
||||||
|
*/
|
||||||
|
private String storageBaseDir = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools whose results must NEVER be spilled. These are the tools the agent
|
||||||
|
* uses to <i>retrieve</i> spilled content — spilling their output would
|
||||||
|
* cause infinite recursion (read spill path → produces another spill →
|
||||||
|
* agent reads new spill → …) and starve {@code MAX_TOOL_CALLS_PER_STEP}.
|
||||||
|
*
|
||||||
|
* <p>Defaults to file-read tools that already cap their own output internally.
|
||||||
|
* Configurable so deployments can add more retrieval-style tools (e.g.,
|
||||||
|
* MCP-provided readers) without code changes.</p>
|
||||||
|
*/
|
||||||
|
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days to retain spill files before the scheduled cleanup deletes them.
|
||||||
|
* <p><b>Default 0 means time-based cleanup is disabled</b> — spill files
|
||||||
|
* stay on disk until the owning conversation is explicitly deleted (which
|
||||||
|
* fires {@code purgeConversation} via {@code ConversationService}).
|
||||||
|
* This preserves the "recoverable" invariant: a summary or preview that
|
||||||
|
* cites a spill path will keep working for the whole life of the
|
||||||
|
* conversation, no matter how long it sits dormant.
|
||||||
|
* <p>Set to a positive value if disk pressure outweighs recoverability
|
||||||
|
* for your deployment. The scheduled sweep will then delete files whose
|
||||||
|
* mtime falls outside the retention horizon.
|
||||||
|
*/
|
||||||
|
private int retentionDays = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron expression for the spill-cleanup task. Defaults to once a day at
|
||||||
|
* 03:00 server-local time so cleanup runs during quiet hours. Set this
|
||||||
|
* to a Spring-recognised value (six-field cron) or change the bean
|
||||||
|
* wiring to disable it entirely.
|
||||||
|
*/
|
||||||
|
private String cleanupCron = "0 0 3 * * ?";
|
||||||
|
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
public int getPerResultThresholdChars() { return perResultThresholdChars; }
|
||||||
|
public void setPerResultThresholdChars(int perResultThresholdChars) {
|
||||||
|
this.perResultThresholdChars = perResultThresholdChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPerTurnBudgetChars() { return perTurnBudgetChars; }
|
||||||
|
public void setPerTurnBudgetChars(int perTurnBudgetChars) {
|
||||||
|
this.perTurnBudgetChars = perTurnBudgetChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPreviewHeadChars() { return previewHeadChars; }
|
||||||
|
public void setPreviewHeadChars(int previewHeadChars) {
|
||||||
|
this.previewHeadChars = previewHeadChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
|
||||||
|
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
|
||||||
|
this.excludedToolInlineChars = excludedToolInlineChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStorageBaseDir() { return storageBaseDir; }
|
||||||
|
public void setStorageBaseDir(String storageBaseDir) {
|
||||||
|
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getExcludedTools() { return excludedTools; }
|
||||||
|
public void setExcludedTools(List<String> excludedTools) {
|
||||||
|
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRetentionDays() { return retentionDays; }
|
||||||
|
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
|
||||||
|
|
||||||
|
public String getCleanupCron() { return cleanupCron; }
|
||||||
|
public void setCleanupCron(String cleanupCron) {
|
||||||
|
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O(1) membership test for the exclusion list, used on every tool result. */
|
||||||
|
public Set<String> excludedToolsSet() {
|
||||||
|
return Set.copyOf(excludedTools);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
|
||||||
|
* spill files don't accumulate forever. Kept in its own class instead of
|
||||||
|
* inlined into {@link ToolResultStorage} for two reasons:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Tests can exercise {@code cleanupExpired()} directly without
|
||||||
|
* fighting the Spring scheduler.</li>
|
||||||
|
* <li>Deployments that want to disable the schedule entirely can simply
|
||||||
|
* leave this component out of the autoconfigure path.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The cron expression comes from
|
||||||
|
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
|
||||||
|
* i.e. once a day at 03:00 server-local time). The retention horizon comes
|
||||||
|
* from {@link ToolResultProperties#getRetentionDays()}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ToolResultRetentionScheduler {
|
||||||
|
|
||||||
|
private final ToolResultStorage storage;
|
||||||
|
private final ToolResultProperties props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron-fired hook. Failures are logged at WARN so they show up in
|
||||||
|
* standard log scrapes without aborting the scheduler thread — losing
|
||||||
|
* a single sweep is fine, the next one will catch the same files.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
|
||||||
|
public void cleanup() {
|
||||||
|
if (props.getRetentionDays() <= 0) {
|
||||||
|
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int deleted = storage.cleanupExpired();
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
|
||||||
|
deleted, props.getRetentionDays());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,455 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
import vip.mate.agent.context.StructuredTruncator;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-result spill store implementing layers 2 and 3 of the RFC-008 Phase 3
|
||||||
|
* three-layer budget. Layer 1 (per-tool cap) lives inside individual tools.
|
||||||
|
*
|
||||||
|
* <p><b>Layer 2 — per-result spill</b> ({@link #persistIfOversized}): a single
|
||||||
|
* tool result that exceeds the configured threshold is written to disk and
|
||||||
|
* the in-memory copy is replaced with a short preview plus a pointer line so
|
||||||
|
* the LLM can use {@code read_file} to retrieve the full text on demand.</p>
|
||||||
|
*
|
||||||
|
* <p><b>Layer 3 — per-turn aggregate budget</b>
|
||||||
|
* ({@link #enforceTurnBudget}): after every tool in one turn has executed,
|
||||||
|
* if the combined response size still exceeds the turn budget, the largest
|
||||||
|
* non-spilled responses are spilled in turn until the aggregate fits.</p>
|
||||||
|
*
|
||||||
|
* <p>Spill files live under one of, in order:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code ToolResultProperties.storageBaseDir} when explicitly set</li>
|
||||||
|
* <li>{@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a workspace is bound</li>
|
||||||
|
* <li>{@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/} as the universal fallback</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Failures (disk full, IO error) degrade silently: the original result is
|
||||||
|
* returned unchanged so the agent keeps working. Errors are logged at WARN.</p>
|
||||||
|
*
|
||||||
|
* <p>This class does <b>not</b> manage GC. Spill files accumulate until manually
|
||||||
|
* cleaned. A scheduled cleanup job is tracked as a Phase 3 follow-up.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(ToolResultProperties.class)
|
||||||
|
public class ToolResultStorage {
|
||||||
|
|
||||||
|
/** Marker placed in the in-context preview so callers and tools can recognize spill output. */
|
||||||
|
public static final String SPILL_MARKER_PREFIX = "[mate-tool-result-spill]";
|
||||||
|
|
||||||
|
private final ToolResultProperties props;
|
||||||
|
/** Cached at construction; refreshed lazily if the underlying list mutates (rare). */
|
||||||
|
private volatile java.util.Set<String> excludedToolsSnapshot;
|
||||||
|
|
||||||
|
/** D-6: monotonically increasing spill counter for observability. */
|
||||||
|
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace roots observed during this JVM's lifetime. Populated every
|
||||||
|
* time a successful spill resolves a base directory; consulted by the
|
||||||
|
* scheduled retention sweep and by {@link #purgeConversation} so we
|
||||||
|
* don't have to query the database for every workspace path. Cross-JVM
|
||||||
|
* orphans are not covered — that is documented in the cleanup javadoc.
|
||||||
|
*/
|
||||||
|
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
public ToolResultStorage(ToolResultProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
this.excludedToolsSnapshot = props.excludedToolsSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** D-6: current cumulative spill count (monotonically increasing). */
|
||||||
|
public long getSpillCount() {
|
||||||
|
return spillCount.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when {@code toolName} is in the configured exclusion list.
|
||||||
|
* Excluded tools (typically retrieval tools like {@code read_file}) are
|
||||||
|
* never spilled — spilling their output would create a recursion where
|
||||||
|
* the agent reads a spill path and produces yet another spill.
|
||||||
|
*/
|
||||||
|
private boolean isExcluded(String toolName) {
|
||||||
|
if (toolName == null) return false;
|
||||||
|
java.util.Set<String> snap = excludedToolsSnapshot;
|
||||||
|
java.util.Set<String> live = props.excludedToolsSet();
|
||||||
|
if (live != snap && !live.equals(snap)) {
|
||||||
|
this.excludedToolsSnapshot = live;
|
||||||
|
snap = live;
|
||||||
|
}
|
||||||
|
return snap.contains(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 2. If {@code result} exceeds the per-result threshold, write the full
|
||||||
|
* text to a spill file and return a preview-plus-pointer string. Otherwise
|
||||||
|
* return the original result unchanged.
|
||||||
|
*
|
||||||
|
* @param result the raw tool output (may be null)
|
||||||
|
* @param toolName used in the preview header so the LLM knows which tool produced it
|
||||||
|
* @param toolUseId unique within a conversation; becomes the spill file's basename
|
||||||
|
* @param conversationId scopes spill files by conversation
|
||||||
|
* @param workspaceBasePath agent's workspace base path; may be null/blank
|
||||||
|
*/
|
||||||
|
public String persistIfOversized(String result, String toolName, String toolUseId,
|
||||||
|
String conversationId, String workspaceBasePath) {
|
||||||
|
if (!props.isEnabled() || result == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (isExcluded(toolName)) {
|
||||||
|
// Retrieval-style tool — never spill, would cause read-back recursion.
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result.length() <= props.getPerResultThresholdChars()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Path file = spillFor(conversationId, toolUseId, workspaceBasePath);
|
||||||
|
if (file == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
Files.writeString(file, result, StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original",
|
||||||
|
toolName, conversationId, ioe.getMessage());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
long count = spillCount.incrementAndGet();
|
||||||
|
log.info("[ToolResultStorage] spill #{}: tool={} chars={} convId={}", count, toolName, result.length(), conversationId);
|
||||||
|
return buildPreview(result, toolName, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 3. Walk the responses; if their aggregate length exceeds the turn
|
||||||
|
* budget, spill the largest remaining non-spilled result and recompute.
|
||||||
|
* Mutates the returned list in place by replacing oversized responses.
|
||||||
|
*/
|
||||||
|
public List<ToolResponseMessage.ToolResponse> enforceTurnBudget(
|
||||||
|
List<ToolResponseMessage.ToolResponse> responses,
|
||||||
|
String conversationId,
|
||||||
|
String workspaceBasePath) {
|
||||||
|
if (!props.isEnabled() || responses == null || responses.isEmpty()) {
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
int budget = props.getPerTurnBudgetChars();
|
||||||
|
int aggregate = aggregateSize(responses);
|
||||||
|
if (aggregate <= budget) {
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[ToolResultStorage] turn budget exceeded: {} chars > {} (responses={})",
|
||||||
|
aggregate, budget, responses.size());
|
||||||
|
|
||||||
|
List<ToolResponseMessage.ToolResponse> mutable = new ArrayList<>(responses);
|
||||||
|
|
||||||
|
while (aggregate > budget) {
|
||||||
|
// Find the largest response that has not yet been spilled and is
|
||||||
|
// not produced by an excluded (retrieval-style) tool.
|
||||||
|
int targetIdx = -1;
|
||||||
|
int targetLen = -1;
|
||||||
|
for (int i = 0; i < mutable.size(); i++) {
|
||||||
|
ToolResponseMessage.ToolResponse r = mutable.get(i);
|
||||||
|
String body = r.responseData();
|
||||||
|
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
|
||||||
|
if (isExcluded(r.name())) continue; // retrieval tools must not be spilled
|
||||||
|
if (body.length() > targetLen) {
|
||||||
|
targetLen = body.length();
|
||||||
|
targetIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetIdx < 0) {
|
||||||
|
int compactedIdx = compactLargestExcludedResult(mutable);
|
||||||
|
if (compactedIdx >= 0) {
|
||||||
|
aggregate = aggregateSize(mutable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
|
||||||
|
aggregate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
|
||||||
|
Path file = spillFor(conversationId, target.id(), workspaceBasePath);
|
||||||
|
if (file == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
Files.writeString(file, target.responseData(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
|
||||||
|
ioe.getMessage());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String preview = buildPreview(target.responseData(), target.name(), file);
|
||||||
|
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), preview));
|
||||||
|
aggregate = aggregateSize(mutable);
|
||||||
|
}
|
||||||
|
return mutable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int compactLargestExcludedResult(List<ToolResponseMessage.ToolResponse> mutable) {
|
||||||
|
int targetIdx = -1;
|
||||||
|
int targetLen = props.getExcludedToolInlineChars();
|
||||||
|
for (int i = 0; i < mutable.size(); i++) {
|
||||||
|
ToolResponseMessage.ToolResponse r = mutable.get(i);
|
||||||
|
String body = r.responseData();
|
||||||
|
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
|
||||||
|
if (!isExcluded(r.name())) continue;
|
||||||
|
if (body.length() > targetLen) {
|
||||||
|
targetLen = body.length();
|
||||||
|
targetIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetIdx < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
|
||||||
|
String compacted = compactInline(target.responseData(), target.name(), props.getExcludedToolInlineChars());
|
||||||
|
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), compacted));
|
||||||
|
log.info("[ToolResultStorage] compacted excluded tool result: tool={} chars={} -> {}",
|
||||||
|
target.name(), targetLen, compacted.length());
|
||||||
|
return targetIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String compactInline(String body, String toolName, int maxChars) {
|
||||||
|
if (body == null || body.length() <= maxChars) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
String marker = "\n\n... [tool result compacted for model context: tool="
|
||||||
|
+ toolName + ", original_chars=" + body.length() + ". "
|
||||||
|
+ StructuredTruncator.FIDELITY_NOTE + "] ...\n\n";
|
||||||
|
int available = Math.max(200, maxChars - marker.length());
|
||||||
|
int headLen = Math.max(100, (int) (available * 0.45));
|
||||||
|
int tailLen = Math.max(100, available - headLen);
|
||||||
|
if (headLen + tailLen >= body.length()) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
return StructuredTruncator.truncate(body, headLen, tailLen, marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) {
|
||||||
|
int sum = 0;
|
||||||
|
for (ToolResponseMessage.ToolResponse r : responses) {
|
||||||
|
if (r.responseData() != null) sum += r.responseData().length();
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPreview(String fullResult, String toolName, Path spillFile) {
|
||||||
|
// Snap the preview to a complete JSON element so the model never sees a value
|
||||||
|
// severed mid-token (which invites it to fabricate the omitted fields).
|
||||||
|
String head = StructuredTruncator.headSlice(fullResult, props.getPreviewHeadChars());
|
||||||
|
return SPILL_MARKER_PREFIX
|
||||||
|
+ " tool=" + toolName
|
||||||
|
+ " full_chars=" + fullResult.length()
|
||||||
|
+ " path=" + spillFile.toAbsolutePath()
|
||||||
|
+ "\n[Preview — first " + head.length() + " of " + fullResult.length()
|
||||||
|
+ " chars. The preview is INCOMPLETE: use read_file with the path above to "
|
||||||
|
+ "retrieve the full result. Do NOT infer or fabricate the omitted content.]\n"
|
||||||
|
+ head
|
||||||
|
+ "\n…[truncated]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the spill file path for a given (conversationId, toolUseId).
|
||||||
|
* Returns {@code null} if no usable directory can be determined.
|
||||||
|
*/
|
||||||
|
private Path spillFor(String conversationId, String toolUseId, String workspaceBasePath) {
|
||||||
|
String safeConv = sanitize(conversationId);
|
||||||
|
String safeId = sanitize(toolUseId);
|
||||||
|
if (safeId.isEmpty()) {
|
||||||
|
safeId = "noid-" + System.nanoTime();
|
||||||
|
}
|
||||||
|
Path base = resolveBaseDir(workspaceBasePath);
|
||||||
|
if (base == null) return null;
|
||||||
|
return base.resolve(safeConv).resolve(safeId + ".txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveBaseDir(String workspaceBasePath) {
|
||||||
|
Path base;
|
||||||
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
|
base = Paths.get(props.getStorageBaseDir());
|
||||||
|
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||||
|
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||||
|
} else {
|
||||||
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
|
if (tmp == null || tmp.isEmpty()) return null;
|
||||||
|
base = Paths.get(tmp, "mateclaw", "tool-results");
|
||||||
|
}
|
||||||
|
// Register so the retention sweep and conversation-delete hook can
|
||||||
|
// reach this root even when the workspace path is no longer in scope.
|
||||||
|
observedRoots.add(base);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roots currently known to this instance. Exposed package-private so the
|
||||||
|
* scheduled retention sweep and unit tests can enumerate them without
|
||||||
|
* touching the underlying set directly.
|
||||||
|
*/
|
||||||
|
java.util.Set<Path> getObservedRoots() {
|
||||||
|
return java.util.Collections.unmodifiableSet(observedRoots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort: delete every spill file and per-conversation directory
|
||||||
|
* older than {@link ToolResultProperties#getRetentionDays()} across all
|
||||||
|
* roots this storage has seen, plus the configured base dir and the
|
||||||
|
* tmpdir fallback. Returns the number of files deleted.
|
||||||
|
*
|
||||||
|
* <p>Workspaces that never received a spill in this JVM's lifetime are
|
||||||
|
* not covered. Persisting an observed-roots registry across restarts
|
||||||
|
* could fix that, but is intentionally out of scope — the operator-side
|
||||||
|
* remedy is to run a one-off cleanup with {@code storage-base-dir}
|
||||||
|
* pointed at the historical workspace.
|
||||||
|
*/
|
||||||
|
public int cleanupExpired() {
|
||||||
|
if (props.getRetentionDays() <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long cutoffEpochMillis = System.currentTimeMillis()
|
||||||
|
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
|
||||||
|
|
||||||
|
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||||
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
|
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||||
|
}
|
||||||
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
|
if (tmp != null && !tmp.isEmpty()) {
|
||||||
|
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int deleted = 0;
|
||||||
|
for (Path root : roots) {
|
||||||
|
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
|
||||||
|
deleted, roots.size());
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
|
||||||
|
if (root == null || !java.nio.file.Files.isDirectory(root)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int deleted = 0;
|
||||||
|
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
|
||||||
|
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||||
|
if (p.equals(root)) continue;
|
||||||
|
if (!java.nio.file.Files.isRegularFile(p)) continue;
|
||||||
|
try {
|
||||||
|
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
|
||||||
|
if (mtime < cutoffEpochMillis) {
|
||||||
|
java.nio.file.Files.deleteIfExists(p);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
// Best-effort: remove emptied per-conversation directories.
|
||||||
|
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
|
||||||
|
for (Path child : (Iterable<Path>) stream::iterator) {
|
||||||
|
if (!java.nio.file.Files.isDirectory(child)) continue;
|
||||||
|
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
|
||||||
|
if (kids.findAny().isEmpty()) {
|
||||||
|
java.nio.file.Files.deleteIfExists(child);
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ignored) {
|
||||||
|
// empty-check failure is not fatal — leave the directory alone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every spill file produced for {@code conversationId} across
|
||||||
|
* all observed roots, plus the configured base and tmpdir fallback.
|
||||||
|
* Called by {@code ConversationService.deleteConversation} so spill
|
||||||
|
* directories don't outlive the conversation that owns them.
|
||||||
|
*
|
||||||
|
* <p>Silently no-ops when nothing matches — a conversation that never
|
||||||
|
* spilled, or one whose workspace root was never observed in this JVM,
|
||||||
|
* is simply left alone. Returns the number of files deleted.
|
||||||
|
*/
|
||||||
|
public int purgeConversation(String conversationId) {
|
||||||
|
if (conversationId == null || conversationId.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
String safeConv = sanitize(conversationId);
|
||||||
|
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||||
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
|
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||||
|
}
|
||||||
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
|
if (tmp != null && !tmp.isEmpty()) {
|
||||||
|
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||||
|
}
|
||||||
|
int deleted = 0;
|
||||||
|
for (Path root : roots) {
|
||||||
|
Path convDir = root.resolve(safeConv);
|
||||||
|
if (!java.nio.file.Files.isDirectory(convDir)) continue;
|
||||||
|
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
|
||||||
|
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||||
|
try {
|
||||||
|
if (java.nio.file.Files.isRegularFile(p)) {
|
||||||
|
java.nio.file.Files.deleteIfExists(p);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.nio.file.Files.deleteIfExists(convDir);
|
||||||
|
} catch (java.io.IOException ignored) {
|
||||||
|
// non-empty after deletes (another writer raced us) — fine, leave it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
|
||||||
|
private static String sanitize(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replaceAll("[^A-Za-z0-9_.-]", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/admin helper: lexicographic ordering by length, descending. Not used at runtime. */
|
||||||
|
static Comparator<ToolResponseMessage.ToolResponse> byBodyLengthDesc() {
|
||||||
|
return (a, b) -> Integer.compare(
|
||||||
|
b.responseData() == null ? 0 : b.responseData().length(),
|
||||||
|
a.responseData() == null ? 0 : a.responseData().length());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
|||||||
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
|
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
|
||||||
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
|
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
|
||||||
* </pre>
|
* </pre>
|
||||||
|
* <p>
|
||||||
|
* Note: this listener is intentionally read-only / log-only. Surfacing
|
||||||
|
* graph state to channel-side accumulators (e.g. publishing the resolved
|
||||||
|
* {@code FinishReason} to message metadata) lives in {@code FinalAnswerNode}
|
||||||
|
* via a {@code finish_reason} GraphEvent — that path goes through the
|
||||||
|
* PENDING_EVENTS → StreamDelta pipeline that {@code ChatController.StreamAccumulator}
|
||||||
|
* actually consumes. A sibling sink that called {@code streamTracker.broadcastObject}
|
||||||
|
* here would only reach the browser SSE bus and bypass the accumulator entirely.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package vip.mate.agent.graph.node;
|
|||||||
|
|
||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
@ -9,6 +11,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
|||||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
|
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
@ -27,6 +30,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class ActionNode implements NodeAction {
|
public class ActionNode implements NodeAction {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** Function name of the explicit skill-load tool, mirrored from SkillLoadTool. */
|
||||||
|
private static final String LOAD_SKILL_TOOL = "load_skill";
|
||||||
|
|
||||||
|
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
||||||
|
private static final String ENABLE_TOOL = "enable_tool";
|
||||||
|
|
||||||
private final ToolExecutionExecutor executor;
|
private final ToolExecutionExecutor executor;
|
||||||
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
|
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
@ -65,30 +76,165 @@ public class ActionNode implements NodeAction {
|
|||||||
// 获取工作区活动目录
|
// 获取工作区活动目录
|
||||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||||
|
|
||||||
|
// RFC-063r §2.5: read the originating ChatOrigin from graph state and
|
||||||
|
// forward it into the executor — tools see it via Spring AI ToolContext.
|
||||||
|
vip.mate.agent.context.ChatOrigin origin = accessor.chatOrigin();
|
||||||
|
|
||||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
|
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
|
||||||
|
|
||||||
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
||||||
.responses(result.responses())
|
.responses(result.responses())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Use the executor's raw-stage ledger instead of re-parsing the
|
||||||
|
// spill-compacted responses. ToolExecutionExecutor builds this
|
||||||
|
// ledger from the full pre-truncate text, so a 30 KB grep result
|
||||||
|
// whose head/tail-cut version no longer mentions a path will still
|
||||||
|
// contribute that path to the evidence pool. Falls back to empty
|
||||||
|
// for legacy executor stubs (tests, mocks) that didn't populate
|
||||||
|
// the new field — fine, the merge with `accessor.sourceEvidenceLedger`
|
||||||
|
// is no-op in that case.
|
||||||
|
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
|
||||||
|
? result.rawEvidenceLedger()
|
||||||
|
: SourceEvidenceLedger.empty();
|
||||||
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
|
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
|
||||||
.toolResults(result.responses())
|
.toolResults(result.responses())
|
||||||
.messages(List.of((Message) toolResponseMessage))
|
.messages(List.of((Message) toolResponseMessage))
|
||||||
.currentPhase("action")
|
.currentPhase("action")
|
||||||
.events(result.events());
|
.events(result.events())
|
||||||
|
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
|
||||||
|
|
||||||
if (result.awaitingApproval()) {
|
if (result.awaitingApproval()) {
|
||||||
output.awaitingApproval(true);
|
output.awaitingApproval(true);
|
||||||
log.info("[ActionNode] Approval pending detected, setting AWAITING_APPROVAL=true to terminate graph");
|
log.info("[ActionNode] Approval pending detected, setting AWAITING_APPROVAL=true to terminate graph");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC-052: any returnDirect tool in this batch ⇒ short-circuit the graph.
|
||||||
|
// ObservationDispatcher will route to FinalAnswerNode (skipping the next
|
||||||
|
// LLM call). Direct outputs and the trigger flag both live in state so
|
||||||
|
// FinalAnswerNode can assemble the final answer verbatim.
|
||||||
|
//
|
||||||
|
// Priority guard: when an approval barrier ALSO fires in the same batch
|
||||||
|
// (a direct tool ran successfully BEFORE a sibling tool that needed
|
||||||
|
// approval), let the approval flow win. Otherwise the user would see a
|
||||||
|
// "RETURN_DIRECT" final answer while an approval modal is still open
|
||||||
|
// for the unresolved sibling — a confusing dual-track state. After the
|
||||||
|
// user resolves the approval, the replay path will re-execute and the
|
||||||
|
// direct tool's content reaches the user via the streamedContent path
|
||||||
|
// instead. Same-batch direct+approval is rare; we explicitly defer to
|
||||||
|
// approval for safety.
|
||||||
|
if (result.hasDirectOutputs() && !result.awaitingApproval()) {
|
||||||
|
output.returnDirectTriggered(true);
|
||||||
|
output.directToolOutputs(result.directOutputs());
|
||||||
|
log.info("[ActionNode] RETURN_DIRECT_TRIGGERED — {} direct tool output(s), " +
|
||||||
|
"graph will route to FinalAnswerNode without re-entering LLM",
|
||||||
|
result.directOutputs().size());
|
||||||
|
} else if (result.hasDirectOutputs() && result.awaitingApproval()) {
|
||||||
|
log.warn("[ActionNode] Mixed batch: {} direct output(s) co-occurring with approval " +
|
||||||
|
"barrier on '{}'; deferring to approval flow (RFC-052 §6.5)",
|
||||||
|
result.directOutputs().size(),
|
||||||
|
result.barrierToolName() != null ? result.barrierToolName() : "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
// replay 完成后清空 forced_tool_call,防止下一轮再触发
|
// replay 完成后清空 forced_tool_call,防止下一轮再触发
|
||||||
if (isReplay) {
|
if (isReplay) {
|
||||||
output.forcedToolCall("");
|
output.forcedToolCall("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin skills the model loaded this run so the next reasoning turn's
|
||||||
|
// catalog ranks them first and the model stops re-loading the same
|
||||||
|
// skill it already pulled into message history. Tools cannot mutate
|
||||||
|
// graph state directly, so the load is detected here from the tool
|
||||||
|
// calls and merged into LOADED_SKILLS (read-merge-write, REPLACE key).
|
||||||
|
Set<String> requestedSkills = extractLoadedSkillNames(toolCalls);
|
||||||
|
if (!requestedSkills.isEmpty()) {
|
||||||
|
Set<String> merged = new LinkedHashSet<>(accessor.loadedSkills());
|
||||||
|
if (merged.addAll(requestedSkills)) {
|
||||||
|
output.loadedSkills(Set.copyOf(merged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same mechanism for enable_tool: record the activated extension tools so
|
||||||
|
// ReasoningNode's next turn adds them back to the advertised callbacks.
|
||||||
|
Set<String> enabledTools = extractEnabledToolNames(toolCalls);
|
||||||
|
if (!enabledTools.isEmpty()) {
|
||||||
|
Set<String> merged = new LinkedHashSet<>(accessor.enabledExtensionTools());
|
||||||
|
if (merged.addAll(enabledTools)) {
|
||||||
|
output.enabledExtensionTools(Set.copyOf(merged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return output.build();
|
return output.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the {@code toolName} argument of every {@code enable_tool} call in
|
||||||
|
* this batch. Like {@link #extractLoadedSkillNames}, an unknown name is
|
||||||
|
* harmless: the reasoning-node split only activates names that resolve to an
|
||||||
|
* extension-tier tool actually in the agent's set.
|
||||||
|
*/
|
||||||
|
static Set<String> extractEnabledToolNames(List<AssistantMessage.ToolCall> toolCalls) {
|
||||||
|
if (toolCalls == null || toolCalls.isEmpty()) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
for (AssistantMessage.ToolCall tc : toolCalls) {
|
||||||
|
if (tc == null || !ENABLE_TOOL.equals(tc.name())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = parseStringArg(tc.arguments(), "toolName", "tool_name", "name");
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
names.add(name.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the {@code skillName} argument of every {@code load_skill} call in
|
||||||
|
* this batch. The names are used only to bias catalog ordering, so an
|
||||||
|
* unparseable or unknown name is harmless (it simply never matches a
|
||||||
|
* visible skill) — failures are swallowed rather than aborting the batch.
|
||||||
|
*/
|
||||||
|
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
|
||||||
|
if (toolCalls == null || toolCalls.isEmpty()) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
for (AssistantMessage.ToolCall tc : toolCalls) {
|
||||||
|
if (tc == null || !LOAD_SKILL_TOOL.equals(tc.name())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = parseStringArg(tc.arguments(), "skillName", "skill_name", "name");
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
names.add(name.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the first present, non-null string value among {@code keys} from a
|
||||||
|
* tool-call arguments JSON object. Returns null on malformed JSON or when
|
||||||
|
* none of the keys are present.
|
||||||
|
*/
|
||||||
|
private static String parseStringArg(String argumentsJson, String... keys) {
|
||||||
|
if (argumentsJson == null || argumentsJson.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode node = OBJECT_MAPPER.readTree(argumentsJson);
|
||||||
|
for (String key : keys) {
|
||||||
|
JsonNode value = node.get(key);
|
||||||
|
if (value != null && !value.isNull()) {
|
||||||
|
return value.asText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,15 @@ package vip.mate.agent.graph.node;
|
|||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
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.common.text.MarkdownNormalizer;
|
||||||
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,6 +32,35 @@ import java.util.Map;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class FinalAnswerNode implements NodeAction {
|
public class FinalAnswerNode implements NodeAction {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache used to vet {@code /api/v1/files/generated/{id}} URLs the LLM
|
||||||
|
* may have written into the final answer. {@code null} disables the
|
||||||
|
* guard (legacy callers, narrow unit tests that don't exercise file
|
||||||
|
* outputs).
|
||||||
|
*/
|
||||||
|
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() {
|
||||||
|
this(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
|
||||||
|
this(generatedFileCache, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinalAnswerNode(GeneratedFileCache generatedFileCache, boolean markdownNormalizeEnabled) {
|
||||||
|
this.generatedFileCache = generatedFileCache;
|
||||||
|
this.markdownNormalizeEnabled = markdownNormalizeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||||
@ -34,9 +69,39 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
String finalThinking;
|
String finalThinking;
|
||||||
FinishReason finishReason;
|
FinishReason finishReason;
|
||||||
|
|
||||||
|
// RFC-052 — RETURN_DIRECT path takes the highest priority after stopping checks.
|
||||||
|
// The full text of the direct tool result(s) becomes the final answer
|
||||||
|
// verbatim; no LLM call has been made on it. Thinking from the LLM
|
||||||
|
// call that *decided* to invoke the direct tool is preserved (it has
|
||||||
|
// already been streamed; this just keeps the state symmetric with the
|
||||||
|
// NORMAL / SUMMARIZED / LIMIT_EXCEEDED branches below).
|
||||||
|
if (accessor.returnDirectTriggered()) {
|
||||||
|
List<DirectToolOutput> outputs = accessor.directToolOutputs();
|
||||||
|
if (!outputs.isEmpty()) {
|
||||||
|
String assembled = scrubFakeUrls(assembleDirectAnswer(outputs));
|
||||||
|
String currentThinking = accessor.currentThinking();
|
||||||
|
String existingThinking = accessor.finalThinking();
|
||||||
|
String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
|
||||||
|
log.info("[FinalAnswerNode] RETURN_DIRECT — assembled final answer from {} direct " +
|
||||||
|
"tool output(s), {} chars (thinking preserved: {} chars)",
|
||||||
|
outputs.size(), assembled.length(), preservedThinking.length());
|
||||||
|
var builder = MateClawStateAccessor.output()
|
||||||
|
.finalAnswer(assembled)
|
||||||
|
.finishReason(FinishReason.RETURN_DIRECT)
|
||||||
|
.events(List.of(GraphEventPublisher.finishReason(
|
||||||
|
FinishReason.RETURN_DIRECT.getValue())));
|
||||||
|
if (!preservedThinking.isEmpty()) {
|
||||||
|
builder.finalThinking(preservedThinking);
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
log.warn("[FinalAnswerNode] RETURN_DIRECT_TRIGGERED=true but DIRECT_TOOL_OUTPUTS empty; " +
|
||||||
|
"falling through to default final-answer assembly");
|
||||||
|
}
|
||||||
|
|
||||||
// 审批等待路径:Graph 因 AWAITING_APPROVAL 终止,保留已流式推送的内容用于持久化
|
// 审批等待路径:Graph 因 AWAITING_APPROVAL 终止,保留已流式推送的内容用于持久化
|
||||||
if (accessor.awaitingApproval()) {
|
if (accessor.awaitingApproval()) {
|
||||||
String preservedContent = accessor.streamedContent();
|
String preservedContent = scrubFakeUrls(accessor.streamedContent());
|
||||||
String preservedThinking = !accessor.streamedThinking().isEmpty()
|
String preservedThinking = !accessor.streamedThinking().isEmpty()
|
||||||
? accessor.streamedThinking() : accessor.currentThinking();
|
? accessor.streamedThinking() : accessor.currentThinking();
|
||||||
log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " +
|
log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " +
|
||||||
@ -46,7 +111,9 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
.finalAnswer(preservedContent)
|
.finalAnswer(preservedContent)
|
||||||
.finishReason(FinishReason.NORMAL)
|
.finishReason(FinishReason.NORMAL)
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(true);
|
.thinkingStreamed(true)
|
||||||
|
.events(List.of(GraphEventPublisher.finishReason(
|
||||||
|
FinishReason.NORMAL.getValue())));
|
||||||
if (!preservedThinking.isEmpty()) {
|
if (!preservedThinking.isEmpty()) {
|
||||||
builder.finalThinking(preservedThinking);
|
builder.finalThinking(preservedThinking);
|
||||||
}
|
}
|
||||||
@ -103,10 +170,64 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrub hallucinated `/api/v1/files/generated/{id}` URLs whose ids
|
||||||
|
// were never inserted into the cache. Done before evidence
|
||||||
|
// validation so the validator sees the user-visible warning rather
|
||||||
|
// than treating the fake link as a "reference".
|
||||||
|
finalAnswer = scrubFakeUrls(finalAnswer);
|
||||||
|
finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer);
|
||||||
|
|
||||||
|
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
||||||
|
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
||||||
|
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
|
||||||
|
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
|
||||||
|
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, 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
|
||||||
|
// downstream consumers (memory gate, channel accumulator, message
|
||||||
|
// metadata persistence) see a machine-readable status. When the
|
||||||
|
// turn ended in a non-transient error, also attach a
|
||||||
|
// feedback_event so the frontend can render retry/regenerate/
|
||||||
|
// report affordances next to the red "[错误] ..." bubble — without
|
||||||
|
// this, fatal errors leave the user staring at error text with no
|
||||||
|
// way to recover short of retyping the whole prompt.
|
||||||
|
List<GraphEventPublisher.GraphEvent> events =
|
||||||
|
new java.util.ArrayList<>(2);
|
||||||
|
events.add(GraphEventPublisher.finishReason(finishReason.getValue()));
|
||||||
|
if (finishReason == FinishReason.ERROR_FALLBACK) {
|
||||||
|
events.add(GraphEventPublisher.feedback(
|
||||||
|
"ERROR_FALLBACK",
|
||||||
|
finalAnswer,
|
||||||
|
List.of("retry", "regenerate", "report")));
|
||||||
|
}
|
||||||
|
|
||||||
// 不重置 CONTENT_STREAMED/THINKING_STREAMED,保留上游节点的标志
|
// 不重置 CONTENT_STREAMED/THINKING_STREAMED,保留上游节点的标志
|
||||||
var builder = MateClawStateAccessor.output()
|
var builder = MateClawStateAccessor.output()
|
||||||
.finalAnswer(finalAnswer)
|
.finalAnswer(finalAnswer)
|
||||||
.finishReason(finishReason);
|
.finishReason(finishReason)
|
||||||
|
// Emit the resolved FinishReason as a GraphEvent so it rides
|
||||||
|
// the PENDING_EVENTS → StreamDelta pipeline that the channel-
|
||||||
|
// side accumulator subscribes to. A sibling SSE broadcast (e.g.
|
||||||
|
// streamTracker.broadcastObject) reaches the browser but never
|
||||||
|
// touches the accumulator, so toMetadataJson() would not see
|
||||||
|
// it and MemorySummarizationGate would lose the structured
|
||||||
|
// signal. APPEND-strategy on PENDING_EVENTS means this
|
||||||
|
// composes safely with any earlier events upstream nodes
|
||||||
|
// attached.
|
||||||
|
.events(events);
|
||||||
|
|
||||||
if (!finalThinking.isEmpty()) {
|
if (!finalThinking.isEmpty()) {
|
||||||
builder.finalThinking(finalThinking);
|
builder.finalThinking(finalThinking);
|
||||||
@ -115,6 +236,43 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
||||||
|
return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||||
|
+ String.join(", ", unsupportedReferences)
|
||||||
|
+ "。请继续检索/读取相关证据后再下结论。";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052 §2.5: assemble the final answer from direct tool outputs.
|
||||||
|
* Single output ⇒ verbatim full text. Multiple outputs ⇒ each prefixed
|
||||||
|
* with a Markdown heading so the user can tell them apart.
|
||||||
|
*/
|
||||||
|
private static String assembleDirectAnswer(List<DirectToolOutput> outputs) {
|
||||||
|
if (outputs.size() == 1) {
|
||||||
|
return outputs.get(0).fullResult();
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < outputs.size(); i++) {
|
||||||
|
DirectToolOutput out = outputs.get(i);
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append("\n\n");
|
||||||
|
}
|
||||||
|
sb.append("### ").append(out.toolName()).append("\n");
|
||||||
|
sb.append(out.fullResult());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
|
||||||
|
* with a user-visible warning. No-op when no cache is wired (legacy
|
||||||
|
* tests) or when the answer is empty.
|
||||||
|
*/
|
||||||
|
private String scrubFakeUrls(String text) {
|
||||||
|
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
|
||||||
|
return generatedFileCache.scrubMissingReferences(text);
|
||||||
|
}
|
||||||
|
|
||||||
private FinishReason parseFinishReason(String reason) {
|
private FinishReason parseFinishReason(String reason) {
|
||||||
if (reason == null || reason.isEmpty()) {
|
if (reason == null || reason.isEmpty()) {
|
||||||
return FinishReason.NORMAL;
|
return FinishReason.NORMAL;
|
||||||
|
|||||||
@ -0,0 +1,432 @@
|
|||||||
|
package vip.mate.agent.graph.node;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.ConversationWindowManager;
|
||||||
|
import vip.mate.agent.graph.state.FinishReason;
|
||||||
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
|
import vip.mate.goal.config.GoalProperties;
|
||||||
|
import vip.mate.goal.model.GoalEntity;
|
||||||
|
import vip.mate.goal.model.GoalEvaluationResult;
|
||||||
|
import vip.mate.goal.service.GoalEvaluationService;
|
||||||
|
import vip.mate.goal.service.GoalFollowupService;
|
||||||
|
import vip.mate.goal.service.GoalService;
|
||||||
|
import vip.mate.goal.service.GraphFlavor;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
|
||||||
|
*
|
||||||
|
* <p>Evaluation runs on a settled terminal answer so upstream finishReason /
|
||||||
|
* evidence checks are already authoritative. The node:
|
||||||
|
* <ol>
|
||||||
|
* <li>Bails out for the "this turn shouldn't count" finishReasons
|
||||||
|
* (evidence_insufficient, stopped, error_fallback, return_direct,
|
||||||
|
* max_iterations_reached, plus awaiting_approval).</li>
|
||||||
|
* <li>Otherwise calls the evaluator, persists the
|
||||||
|
* agent/eval LLM-call deltas + score + gap via GoalService.</li>
|
||||||
|
* <li>Decides completed / exhausted / followup / continue. Completed
|
||||||
|
* and exhausted update {@code mate_agent_goal.status} ONLY — they
|
||||||
|
* never touch FINISH_REASON, since the graph's own terminal status
|
||||||
|
* is independent of goal status.</li>
|
||||||
|
* <li>On followup, sets GOAL_FOLLOWUP_PROMPT and clears whichever
|
||||||
|
* graph-specific state would otherwise short-circuit the re-entry
|
||||||
|
* pass (clear set depends on the constructor-time GraphFlavor).</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class GoalEvaluationNode implements NodeAction {
|
||||||
|
|
||||||
|
private final GoalEvaluationService evaluationService;
|
||||||
|
private final GoalFollowupService followupService;
|
||||||
|
private final GoalService goalService;
|
||||||
|
private final GoalProperties properties;
|
||||||
|
private final ConversationWindowManager windowManager; // reserved for evaluator context windowing
|
||||||
|
private final ConversationService conversationService; // reserved for evaluator context lookups
|
||||||
|
private final GraphFlavor flavor;
|
||||||
|
|
||||||
|
public GoalEvaluationNode(GoalEvaluationService evaluationService,
|
||||||
|
GoalFollowupService followupService,
|
||||||
|
GoalService goalService,
|
||||||
|
GoalProperties properties,
|
||||||
|
ConversationWindowManager windowManager,
|
||||||
|
ConversationService conversationService,
|
||||||
|
GraphFlavor flavor) {
|
||||||
|
this.evaluationService = evaluationService;
|
||||||
|
this.followupService = followupService;
|
||||||
|
this.goalService = goalService;
|
||||||
|
this.properties = properties;
|
||||||
|
this.windowManager = windowManager;
|
||||||
|
this.conversationService = conversationService;
|
||||||
|
this.flavor = flavor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||||
|
// Master kill switch — when disabled the node stays inert.
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||||
|
|
||||||
|
// 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()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-entry guard — the FinalAnswer→GoalEvaluation conditional edge
|
||||||
|
// also checks this, but defence in depth pays for itself here.
|
||||||
|
if (accessor.goalEvaluatedThisRun()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every skip path below emits a goal_evaluated event with a reason
|
||||||
|
// so the frontend can flip the breathing-halo state back off. The
|
||||||
|
// chat composable's `message_complete` handler optimistically sets
|
||||||
|
// evaluating=true; without a balancing event the ring would stay
|
||||||
|
// in that state forever after e.g. a max-iterations turn.
|
||||||
|
Long goalIdForEvents = goalOpt.get().getId();
|
||||||
|
|
||||||
|
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
|
||||||
|
// determines whether this turn counts. Plan-Execute usually doesn't
|
||||||
|
// set finishReason on the happy path, so we only enforce these
|
||||||
|
// exit conditions in REACT mode + the universal awaiting_approval
|
||||||
|
// gate that both flavors share.
|
||||||
|
// 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) {
|
||||||
|
String fr = accessor.finishReason();
|
||||||
|
if (isHardSkipFinishReason(fr)) {
|
||||||
|
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
|
||||||
|
.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()) {
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(skippedEvent(goalIdForEvents, "awaiting_approval")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
GoalEntity goal = goalOpt.get();
|
||||||
|
|
||||||
|
String terminal = accessor.terminalAnswer();
|
||||||
|
if (terminal.isEmpty()) {
|
||||||
|
log.warn("[GoalEvaluationNode] terminalAnswer empty (flavor={}); skipping evaluation", flavor);
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(skippedEvent(goal.getId(), "empty_terminal_answer")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a thin recent-messages slice for the evaluator prompt.
|
||||||
|
List<Message> recent = accessor.messages();
|
||||||
|
int max = properties.getEvaluatorContextMessages();
|
||||||
|
if (recent.size() > max) {
|
||||||
|
recent = recent.subList(recent.size() - max, recent.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluator + persistence wrapped together: the just-emitted final
|
||||||
|
// answer is the user-visible thing and must NOT be lost just because
|
||||||
|
// a provider timeout or DB hiccup happens on the way to the
|
||||||
|
// bookkeeping write. On any failure we mark the run as evaluated
|
||||||
|
// (so the conditional edge above won't loop us back) and route to
|
||||||
|
// the normal terminal path — the user still sees their answer; the
|
||||||
|
// goal stays in whatever state it was before this turn.
|
||||||
|
GoalEvaluationResult result;
|
||||||
|
GoalEntity refreshed;
|
||||||
|
try {
|
||||||
|
result = evaluationService.evaluate(goal, recent, terminal);
|
||||||
|
|
||||||
|
// Bill only the NEW agent LLM calls since the last accounted point.
|
||||||
|
// The run-to-completion loop evaluates multiple times per graph run
|
||||||
|
// while LLM_CALL_COUNT keeps growing, so passing the cumulative value
|
||||||
|
// raw would re-bill earlier calls on every pass and exhaust the
|
||||||
|
// goal's LLM budget prematurely. The followup branch advances the
|
||||||
|
// accounted marker; terminal branches don't (the run ends there).
|
||||||
|
int agentLlmDelta = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount());
|
||||||
|
int evalLlmDelta = result.llmCallsConsumed();
|
||||||
|
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
|
||||||
|
|
||||||
|
refreshed = goalService.getById(goal.getId());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
log.warn("[GoalEvaluationNode] evaluator/persist failed for goal={} — skipping this pass: {}",
|
||||||
|
goal.getId(), t.toString());
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluationResult(GoalEvaluationResult.fallback("node_exception").toMap())
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(skippedEvent(goal.getId(), "evaluator_or_persist_failed")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision branches. Each terminal write is wrapped so a DB hiccup
|
||||||
|
// (e.g. optimistic-lock conflict exceeding retries, memory sync
|
||||||
|
// failure on completion) does not propagate into the chat graph
|
||||||
|
// and abort the streamed answer the user already sees.
|
||||||
|
try {
|
||||||
|
// Completion is the deterministic "all criteria passed" signal the
|
||||||
|
// evaluator already folded into result.completed() — no score gate.
|
||||||
|
if (result.completed()) {
|
||||||
|
GoalEntity completed = goalService.markCompleted(refreshed.getId(), result);
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluationResult(result.toMap())
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||||
|
"goalId", String.valueOf(completed.getId()),
|
||||||
|
"score", result.score(),
|
||||||
|
"goal", goalService.toResponse(completed)))))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goalService.isBudgetExhausted(refreshed)) {
|
||||||
|
String reason = goalService.exhaustionReason(refreshed);
|
||||||
|
GoalEntity exhausted = goalService.markExhausted(refreshed.getId(), reason);
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluationResult(result.toMap())
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
||||||
|
"goalId", String.valueOf(exhausted.getId()),
|
||||||
|
"turnsUsed", exhausted.getTurnsUsed(),
|
||||||
|
"agentLlmCallsUsed", exhausted.getAgentLlmCallsUsed(),
|
||||||
|
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
|
||||||
|
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
|
||||||
|
"reason", reason,
|
||||||
|
"goal", goalService.toResponse(exhausted)))))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
|
||||||
|
refreshed.getId(), t.toString());
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluationResult(result.toMap())
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
int followupCountThisRun = accessor.goalFollowupCount();
|
||||||
|
int hardContinuationCount = accessor.goalHardContinuationCount();
|
||||||
|
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
|
||||||
|
GoalProperties.MAX_HARD_CONTINUATIONS_CEILING);
|
||||||
|
Optional<String> followup;
|
||||||
|
try {
|
||||||
|
followup = followupService.maybeBuildFollowup(refreshed, result);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}",
|
||||||
|
refreshed.getId(), t.toString());
|
||||||
|
followup = Optional.empty();
|
||||||
|
}
|
||||||
|
// Per-run safety net: cap the autonomous self-continuation loop so a
|
||||||
|
// single user message can't drive an unbounded number of steps or
|
||||||
|
// approach the graph recursion limit. When the cap is hit we fall
|
||||||
|
// through to the terminal "continue, no followup" path — the goal stays
|
||||||
|
// active and the cross-message turn / LLM budget (or the user) carries
|
||||||
|
// it on.
|
||||||
|
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
|
||||||
|
// A max-iterations continuation re-runs a FULL fresh ReAct segment
|
||||||
|
// (iteration budget reset), so it carries a tighter, dedicated cap on
|
||||||
|
// 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 && !hardCapReached) {
|
||||||
|
try {
|
||||||
|
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
log.warn("[GoalEvaluationNode] recordFollowupInjected failed — emitting followup anyway: {}",
|
||||||
|
t.toString());
|
||||||
|
// Continue: the in-memory state-machine path still works
|
||||||
|
// even if the audit row could not be written.
|
||||||
|
}
|
||||||
|
MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
|
||||||
|
.goalEvaluationResult(result.toMap())
|
||||||
|
.goalFollowupInjected(true)
|
||||||
|
.goalFollowupPrompt(followup.get())
|
||||||
|
.goalFollowupCount(followupCountThisRun + 1)
|
||||||
|
// Advance the LLM-billing marker to the current cumulative
|
||||||
|
// count so the NEXT evaluation in this run charges only its
|
||||||
|
// own delta (see agentLlmDelta above).
|
||||||
|
.goalAccountedLlmCallCount(accessor.llmCallCount())
|
||||||
|
// Deliberately NOT setting goalEvaluatedThisRun(true): leaving
|
||||||
|
// it false lets the NEXT answer be re-evaluated, turning the
|
||||||
|
// old single-step behaviour into run-to-completion. The loop
|
||||||
|
// is bounded by the per-run cap above plus the turn / LLM
|
||||||
|
// budgets; the dispatcher treats any terminal pass
|
||||||
|
// (goalEvaluatedThisRun == true) as END even if this flag
|
||||||
|
// lingers true under the REPLACE key strategy.
|
||||||
|
.needsToolCall(false)
|
||||||
|
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||||
|
"goalId", String.valueOf(refreshed.getId()),
|
||||||
|
"prompt", followup.get(),
|
||||||
|
"goal", goalService.toResponse(refreshed)))));
|
||||||
|
|
||||||
|
if (flavor == GraphFlavor.REACT) {
|
||||||
|
// ReAct: append the followup as a fresh user message via the
|
||||||
|
// MESSAGES APPEND strategy. ReasoningNode picks it up on its
|
||||||
|
// next call without any followup-specific logic on its side.
|
||||||
|
out.clearFinalAnswer()
|
||||||
|
.clearFinishReason()
|
||||||
|
.messages(List.of((Message) new UserMessage(followup.get())));
|
||||||
|
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 {
|
||||||
|
// Plan-Execute: wipe the wider mid-pass + terminal state.
|
||||||
|
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
|
||||||
|
// preserved — the next PlanGeneration pass needs them.
|
||||||
|
out.clearFinalAnswer()
|
||||||
|
.clearFinishReason()
|
||||||
|
.clearPlanFinalSummary()
|
||||||
|
.clearPlanDirectAnswer()
|
||||||
|
.clearPlanId()
|
||||||
|
.clearPlanSteps()
|
||||||
|
.clearPlanValid()
|
||||||
|
.clearNeedsPlanning()
|
||||||
|
.clearCurrentStepIndex()
|
||||||
|
.clearCurrentStepTitle()
|
||||||
|
.clearCurrentStepResult()
|
||||||
|
.clearCompletedResults()
|
||||||
|
.clearFinalSummaryThinking()
|
||||||
|
.clearCurrentStepThinking();
|
||||||
|
}
|
||||||
|
return out.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue but no follow-up — just record the evaluation event.
|
||||||
|
// (helper below avoids needing a custom() factory on GraphEventPublisher.)
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.goalEvaluationResult(result.toMap())
|
||||||
|
.goalEvaluatedThisRun(true)
|
||||||
|
.events(List.of(goalEvent("goal_evaluated", Map.of(
|
||||||
|
"goalId", String.valueOf(refreshed.getId()),
|
||||||
|
"score", result.score(),
|
||||||
|
"gap", result.gap() == null ? "" : result.gap(),
|
||||||
|
"goal", goalService.toResponse(refreshed)))))
|
||||||
|
.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. */
|
||||||
|
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
|
||||||
|
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a goal_evaluated event for skip paths so the frontend can
|
||||||
|
* unconditionally flip its "evaluating" flag off after every turn that
|
||||||
|
* has an active goal — even when the evaluator never ran. The reason
|
||||||
|
* field lets us tell apart "normal continue" from "skipped because of
|
||||||
|
* max iterations" in logs / future telemetry without ambiguity.
|
||||||
|
*/
|
||||||
|
private static GraphEventPublisher.GraphEvent skippedEvent(Long goalId, String reason) {
|
||||||
|
return goalEvent("goal_evaluated", Map.of(
|
||||||
|
"goalId", goalId == null ? "" : String.valueOf(goalId),
|
||||||
|
"skipped", true,
|
||||||
|
"reason", reason == null ? "" : reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import vip.mate.agent.graph.observation.ObservationProcessor;
|
|||||||
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.prompt.PromptLoader;
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -44,20 +45,43 @@ public class LimitExceededNode implements NodeAction {
|
|||||||
private final ChatModel chatModel;
|
private final ChatModel chatModel;
|
||||||
private final ObservationProcessor observationProcessor;
|
private final ObservationProcessor observationProcessor;
|
||||||
private final NodeStreamingChatHelper streamingHelper;
|
private final NodeStreamingChatHelper streamingHelper;
|
||||||
|
/** Optional i18n service; nullable so legacy/tests without Spring context still work. */
|
||||||
|
private final I18nService i18n;
|
||||||
|
/**
|
||||||
|
* Optional ledger loader. When set, the conversation's progress snapshot
|
||||||
|
* (done / in-progress / pending) is appended to the LLM's context so the
|
||||||
|
* "graceful wrap-up" answer can be honest about which steps actually
|
||||||
|
* finished and which were still pending when the iteration cap hit.
|
||||||
|
* Null in legacy/test constructors — the wrap behaves as before.
|
||||||
|
*/
|
||||||
|
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
|
||||||
|
|
||||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||||
NodeStreamingChatHelper streamingHelper) {
|
NodeStreamingChatHelper streamingHelper) {
|
||||||
|
this(chatModel, observationProcessor, streamingHelper, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||||
|
NodeStreamingChatHelper streamingHelper, I18nService i18n) {
|
||||||
|
this(chatModel, observationProcessor, streamingHelper, i18n, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||||
|
NodeStreamingChatHelper streamingHelper, I18nService i18n,
|
||||||
|
vip.mate.agent.progress.ProgressLedgerService progressLedgerService) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.observationProcessor = observationProcessor;
|
this.observationProcessor = observationProcessor;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
|
this.i18n = i18n;
|
||||||
|
this.progressLedgerService = progressLedgerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use constructor with NodeStreamingChatHelper
|
* @deprecated use the constructor with {@link NodeStreamingChatHelper} (and optionally {@link I18nService})
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
|
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
|
||||||
this(chatModel, observationProcessor, null);
|
this(chatModel, observationProcessor, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -88,7 +112,26 @@ public class LimitExceededNode implements NodeAction {
|
|||||||
contextForLLM = observationProcessor.truncate(sb.toString(),
|
contextForLLM = observationProcessor.truncate(sb.toString(),
|
||||||
observationProcessor.getMaxTotalObservationChars());
|
observationProcessor.getMaxTotalObservationChars());
|
||||||
} else {
|
} else {
|
||||||
contextForLLM = "(尚未收集到工具调用结果)";
|
contextForLLM = i18n != null ? i18n.msg("agent.limit_exceeded.empty_context") : "(no tool results)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend the conversation's progress ledger snapshot when available
|
||||||
|
// so the wrap-up answer can be honest about partial completion ("4/10
|
||||||
|
// models researched, 6 still pending") rather than vaguely describing
|
||||||
|
// "what I tried". Without this, hitting the iteration cap on a
|
||||||
|
// 10-step task produces a useless catch-all message — observed in
|
||||||
|
// round-4 of the LLM-review smoke test.
|
||||||
|
String ledgerSnapshot = null;
|
||||||
|
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
|
||||||
|
try {
|
||||||
|
ledgerSnapshot = progressLedgerService.load(conversationId).renderSnapshot();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[LimitExceededNode] Failed to load progress ledger for {}: {}",
|
||||||
|
conversationId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ledgerSnapshot != null) {
|
||||||
|
contextForLLM = ledgerSnapshot + "\n\n---\n\n" + contextForLLM;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 prompt
|
// 构建 prompt
|
||||||
@ -110,8 +153,11 @@ public class LimitExceededNode implements NodeAction {
|
|||||||
log.info("[LimitExceededNode] Generated limit-exceeded final answer: {} chars",
|
log.info("[LimitExceededNode] Generated limit-exceeded final answer: {} chars",
|
||||||
finalDraft != null ? finalDraft.length() : 0);
|
finalDraft != null ? finalDraft.length() : 0);
|
||||||
|
|
||||||
|
String fallbackMsg = i18n != null
|
||||||
|
? i18n.msg("agent.limit_exceeded.fallback")
|
||||||
|
: "Sorry, the maximum reasoning steps were reached.";
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
.finalAnswerDraft(finalDraft != null ? finalDraft : "抱歉,已达到最大推理步数,未能获得完整结果。")
|
.finalAnswerDraft(finalDraft != null ? finalDraft : fallbackMsg)
|
||||||
.currentThinking(result.thinking())
|
.currentThinking(result.thinking())
|
||||||
.limitExceeded(true)
|
.limitExceeded(true)
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.alibaba.cloud.ai.graph.OverAllState;
|
|||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.graph.observation.ObservationProcessor;
|
import vip.mate.agent.graph.observation.ObservationProcessor;
|
||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
|
|
||||||
@ -31,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);
|
||||||
}
|
}
|
||||||
@ -55,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()))
|
||||||
@ -71,7 +99,7 @@ public class ObservationNode implements NodeAction {
|
|||||||
// 合并为单条观察记录
|
// 合并为单条观察记录
|
||||||
String combinedObservation = String.join("\n---\n", processedObservations);
|
String combinedObservation = String.join("\n---\n", processedObservations);
|
||||||
|
|
||||||
// Budget Pressure Warning(Hermes 风格):接近上限时注入警告到工具结果中
|
// Budget Pressure Warning:接近上限时注入警告到工具结果中
|
||||||
// LLM 下一轮 reasoning 时能看到,从而主动收束,而非被硬性截断
|
// LLM 下一轮 reasoning 时能看到,从而主动收束,而非被硬性截断
|
||||||
if (maxIterations > 0) {
|
if (maxIterations > 0) {
|
||||||
int progress = (int) ((double) nextIteration / maxIterations * 100);
|
int progress = (int) ((double) nextIteration / maxIterations * 100);
|
||||||
@ -120,6 +148,21 @@ 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
|
||||||
|
// (not nextIteration) so the index pairs with whatever
|
||||||
|
// iteration_start the ReasoningNode emitted at the top of this turn.
|
||||||
|
// Char totals are best-effort: ObservationNode doesn't see the LLM
|
||||||
|
// delta stream directly, so 0/0 is acceptable for now — consumers
|
||||||
|
// that care fall back to summing the deltas themselves.
|
||||||
|
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
|
||||||
|
builder.events(List.of(
|
||||||
|
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
// 重复观察时标记错误,让 ObservationDispatcher 路由到 limitExceededNode
|
// 重复观察时标记错误,让 ObservationDispatcher 路由到 limitExceededNode
|
||||||
if (duplicateObservation) {
|
if (duplicateObservation) {
|
||||||
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
|
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
|
||||||
|
|||||||
@ -3,13 +3,18 @@ package vip.mate.agent.graph.node;
|
|||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
import org.springframework.ai.chat.messages.SystemMessage;
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
import org.springframework.ai.chat.messages.UserMessage;
|
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.ChatOptions;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.StructuredTruncator;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
import vip.mate.agent.prompt.PromptLoader;
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
@ -101,16 +106,23 @@ public class SummarizingNode implements NodeAction {
|
|||||||
promptMessages.add(new SystemMessage(SYSTEM_PROMPT));
|
promptMessages.add(new SystemMessage(SYSTEM_PROMPT));
|
||||||
promptMessages.add(new UserMessage(userPrompt));
|
promptMessages.add(new UserMessage(userPrompt));
|
||||||
|
|
||||||
|
// Summarization is mechanical text compression — disable thinking/reasoning to avoid
|
||||||
|
// inheriting the user's thinkingLevel=high from the model's default options.
|
||||||
|
// Without this override, a plain Prompt would inherit extended thinking from chatModel
|
||||||
|
// defaults, causing 100+ second delays for a task that needs no deep reasoning.
|
||||||
|
Prompt summarizePrompt = buildNoThinkingPrompt(promptMessages);
|
||||||
|
|
||||||
// 流式调用 LLM,实时推送 content/thinking
|
// 流式调用 LLM,实时推送 content/thinking
|
||||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||||
chatModel, new Prompt(promptMessages), conversationId, "summarizing");
|
chatModel, summarizePrompt, conversationId, "summarizing");
|
||||||
|
|
||||||
// 错误处理:摘要失败时用原始观察的前 500 字符作为 fallback
|
// 错误处理:摘要失败时用原始观察的前 500 字符作为 fallback
|
||||||
if (result.hasFatalError()) {
|
if (result.hasFatalError()) {
|
||||||
log.warn("[SummarizingNode] Summarization LLM call failed: {}, using raw observations as fallback",
|
log.warn("[SummarizingNode] Summarization LLM call failed: {}, using raw observations as fallback",
|
||||||
result.errorMessage());
|
result.errorMessage());
|
||||||
String fallback = observationText.length() > 500
|
String fallback = observationText.length() > 500
|
||||||
? observationText.substring(0, 500) + "...[摘要生成失败,已截断]"
|
? StructuredTruncator.headSlice(observationText.toString(), 500)
|
||||||
|
+ "\n...[摘要生成失败,仅保留原始观察的前部片段;数据不完整,请勿编造、补全或重新编号缺失内容]"
|
||||||
: observationText.toString();
|
: observationText.toString();
|
||||||
AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback);
|
AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback);
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
@ -173,6 +185,9 @@ public class SummarizingNode implements NodeAction {
|
|||||||
// 摘要的 content 已流式推送,但它不是最终回答,标记防重即可
|
// 摘要的 content 已流式推送,但它不是最终回答,标记防重即可
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
|
// 把当轮 summary 文本写入 STREAMED_CONTENT,让 StateGraphReActAgent 用 persistOnly
|
||||||
|
// StreamDelta 推给 Accumulator 持久化(用户刷新页面后能看到摘要正文,否则只剩 tool_call 卡片)
|
||||||
|
.streamedContent(summaryContent)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
// 不设 finishReason — summarizing 不是终止,循环继续
|
// 不设 finishReason — summarizing 不是终止,循环继续
|
||||||
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
|
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
|
||||||
@ -181,6 +196,26 @@ public class SummarizingNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Prompt with thinking/reasoning explicitly disabled.
|
||||||
|
* Summarization is mechanical compression — it never needs extended reasoning,
|
||||||
|
* and inheriting the user's thinkingLevel=high from model defaults wastes 100+ seconds.
|
||||||
|
*/
|
||||||
|
private Prompt buildNoThinkingPrompt(List<Message> messages) {
|
||||||
|
ChatOptions opts;
|
||||||
|
if (chatModel instanceof AnthropicChatModel) {
|
||||||
|
opts = AnthropicChatOptions.builder()
|
||||||
|
.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.DISABLED, 0)
|
||||||
|
.build();
|
||||||
|
} else {
|
||||||
|
// OpenAI / DashScope / other: omit reasoningEffort to disable chain-of-thought
|
||||||
|
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder().build();
|
||||||
|
oaiOpts.setStreamUsage(true);
|
||||||
|
opts = oaiOpts;
|
||||||
|
}
|
||||||
|
return new Prompt(messages, opts);
|
||||||
|
}
|
||||||
|
|
||||||
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
|
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
|
||||||
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
|
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package vip.mate.agent.graph.observation;
|
package vip.mate.agent.graph.observation;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.agent.context.StructuredTruncator;
|
||||||
import vip.mate.config.GraphObservationProperties;
|
import vip.mate.config.GraphObservationProperties;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -94,12 +95,11 @@ public class ObservationProcessor {
|
|||||||
int headLen = (int) (available * effectiveHeadRatio);
|
int headLen = (int) (available * effectiveHeadRatio);
|
||||||
int tailLen = available - headLen;
|
int tailLen = available - headLen;
|
||||||
|
|
||||||
String head = text.substring(0, headLen);
|
String result = StructuredTruncator.truncate(text, headLen, tailLen, marker);
|
||||||
String tail = text.substring(originalLen - tailLen);
|
|
||||||
|
|
||||||
log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})",
|
log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})",
|
||||||
originalLen, head.length() + tail.length(), maxLen, effectiveHeadRatio);
|
originalLen, result.length(), maxLen, effectiveHeadRatio);
|
||||||
return head + marker + tail;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -45,16 +45,28 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
private final PlanningService planningService;
|
private final PlanningService planningService;
|
||||||
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
|
/** Held only so context-window budget includes the tools schema. Nullable for legacy constructor. */
|
||||||
|
private final vip.mate.agent.AgentToolSet toolSet;
|
||||||
|
|
||||||
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
|
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
|
||||||
CompiledGraph compiledGraph, PlanningService planningService,
|
CompiledGraph compiledGraph, PlanningService planningService,
|
||||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||||
ConversationWindowManager conversationWindowManager) {
|
ConversationWindowManager conversationWindowManager) {
|
||||||
|
this(chatClient, conversationService, compiledGraph, planningService,
|
||||||
|
chatModel, conversationWindowManager, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
|
||||||
|
CompiledGraph compiledGraph, PlanningService planningService,
|
||||||
|
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
vip.mate.agent.AgentToolSet toolSet) {
|
||||||
super(chatClient, conversationService);
|
super(chatClient, conversationService);
|
||||||
this.compiledGraph = compiledGraph;
|
this.compiledGraph = compiledGraph;
|
||||||
this.planningService = planningService;
|
this.planningService = planningService;
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.conversationWindowManager = conversationWindowManager;
|
this.conversationWindowManager = conversationWindowManager;
|
||||||
|
this.toolSet = toolSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -136,7 +148,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||||
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||||
// 1. 提取事件(只发送新增部分)
|
// 1. 提取事件(只发送新增部分)
|
||||||
@ -206,7 +218,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
|
||||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
.doOnComplete(() -> setState(AgentState.IDLE))
|
||||||
.doOnError(e -> {
|
.doOnError(e -> {
|
||||||
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
|
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
|
||||||
@ -254,11 +266,14 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
maxInputTokens,
|
maxInputTokens,
|
||||||
chatModel,
|
chatModel,
|
||||||
conversationId,
|
conversationId,
|
||||||
parsedAgentId);
|
parsedAgentId,
|
||||||
|
toolSet != null ? toolSet.callbacks() : null,
|
||||||
|
workspaceBasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Message> messages = new ArrayList<>(historyMessages);
|
List<Message> messages = new ArrayList<>(historyMessages);
|
||||||
messages.add(buildCurrentUserMessage(conversationId, userMessage));
|
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
|
||||||
|
messages.add(currentTurn.userMessage());
|
||||||
|
|
||||||
// 构建 working context:对历史消息做受控长度摘要
|
// 构建 working context:对历史消息做受控长度摘要
|
||||||
String workingContext = buildWorkingContext(historyMessages, List.of());
|
String workingContext = buildWorkingContext(historyMessages, List.of());
|
||||||
@ -285,6 +300,43 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
|
||||||
|
if (currentTurn.routingDecision() != null
|
||||||
|
&& (currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|
||||||
|
|| !currentTurn.routingDecision().skipped().isEmpty())) {
|
||||||
|
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC-063r §2.5: same as ReAct path — enrich and store the ChatOrigin
|
||||||
|
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
|
||||||
|
// can read it back from state.
|
||||||
|
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||||
|
Long parsedAgentIdForOrigin = null;
|
||||||
|
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||||
|
if (parsedAgentIdForOrigin != null) {
|
||||||
|
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||||
|
}
|
||||||
|
origin = origin.withConversationId(conversationId)
|
||||||
|
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||||
|
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||||
|
|
||||||
|
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
||||||
|
// Mirrors StateGraphReActAgent.buildInitialState exactly.
|
||||||
|
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
|
||||||
|
try {
|
||||||
|
vip.mate.goal.model.GoalEntity active =
|
||||||
|
goalService.findActiveByConversation(conversationId);
|
||||||
|
if (active != null) {
|
||||||
|
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
|
||||||
|
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
|
||||||
|
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
|
||||||
|
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,21 +5,24 @@ import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
|||||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计划生成后的路由分发器
|
* Routes the graph after the triage node.
|
||||||
* <p>
|
|
||||||
* 根据 needs_planning 判断:
|
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>false → 路由到 DIRECT_ANSWER_NODE(简单问答快速退出)</li>
|
* <li>{@code needs_planning=false} → {@code DIRECT_ANSWER_NODE} (direct answer, no tools)</li>
|
||||||
* <li>true → 路由到 STEP_EXECUTION_NODE(开始步骤执行)</li>
|
* <li>{@code needs_planning=true} → {@code STEP_EXECUTION_NODE} (single- or multi-step plan)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
* <p>
|
||||||
* @author MateClaw Team
|
* If the triage key is absent, we default to {@code direct_answer} — an unset
|
||||||
|
* {@code needs_planning} means triage did not run to completion, and Occam's
|
||||||
|
* razor says treat it as "no planning" rather than auto-splitting a task the
|
||||||
|
* system never classified. The previous default ({@code true}) biased every
|
||||||
|
* unresolved request into a multi-step plan, which was the main source of the
|
||||||
|
* "every request splits into subtasks" behavior (see RFC-008).
|
||||||
*/
|
*/
|
||||||
public class PlanGenerationDispatcher implements EdgeAction {
|
public class PlanGenerationDispatcher implements EdgeAction {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String apply(OverAllState state) {
|
public String apply(OverAllState state) {
|
||||||
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, true);
|
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false);
|
||||||
if (!needsPlanning) {
|
if (!needsPlanning) {
|
||||||
return PlanStateKeys.DIRECT_ANSWER_NODE;
|
return PlanStateKeys.DIRECT_ANSWER_NODE;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -3,19 +3,37 @@ package vip.mate.agent.graph.plan.node;
|
|||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接回答节点
|
* 直接回答节点
|
||||||
* <p>
|
* <p>
|
||||||
* 当 PlanGenerationNode 判定用户消息是简单问答时,
|
* When PlanGenerationNode classifies the user's message as a simple
|
||||||
* 将 direct_answer 透传为 final_summary,直接结束图执行。
|
* question, this node propagates {@code direct_answer} into
|
||||||
|
* {@code FINAL_SUMMARY} so the graph terminates with the answer in the
|
||||||
|
* canonical place every downstream consumer reads from.
|
||||||
* <p>
|
* <p>
|
||||||
* 如果 PlanGenerationNode 已通过 broadcastContent() 推送了内容
|
* Earlier versions skipped writing FINAL_SUMMARY when
|
||||||
* (contentStreamed=true),则不再复制到 FINAL_SUMMARY,
|
* {@code CONTENT_STREAMED=true}, on the theory that broadcastContent had
|
||||||
* 避免 StreamAccumulator 重复收集导致持久化内容翻倍。
|
* already pushed the text and a second copy in FINAL_SUMMARY would cause
|
||||||
|
* double persistence. That was wrong: broadcastContent goes directly to
|
||||||
|
* the SSE side-channel via {@code streamTracker.broadcastDelta} and does
|
||||||
|
* NOT participate in the DB segment that ChatController accumulates from
|
||||||
|
* the structured stream. Skipping FINAL_SUMMARY left
|
||||||
|
* {@code AgentService.chat()} (the sync entry used by every IM channel)
|
||||||
|
* with an empty reply, which silently dropped DingTalk / Slack /
|
||||||
|
* Telegram replies on the direct-answer path. It also left
|
||||||
|
* {@code mate_message.content} empty on the web channel — the SSE
|
||||||
|
* client saw the answer in real time but reopening the conversation
|
||||||
|
* showed a blank assistant turn.
|
||||||
|
* <p>
|
||||||
|
* Re-broadcast suppression is the responsibility of the stream layer,
|
||||||
|
* not this node:
|
||||||
|
* {@link vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent#chatStructuredStream}
|
||||||
|
* tags the FINAL_SUMMARY delta as {@code persistOnly} when
|
||||||
|
* CONTENT_STREAMED is true, and {@code ChatController} respects that flag
|
||||||
|
* to persist without re-pushing.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -23,11 +41,6 @@ public class DirectAnswerNode implements NodeAction {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> apply(OverAllState state) {
|
public Map<String, Object> apply(OverAllState state) {
|
||||||
boolean alreadyStreamed = state.value(MateClawStateKeys.CONTENT_STREAMED, false);
|
|
||||||
if (alreadyStreamed) {
|
|
||||||
// broadcastContent 已推送并被 accumulator 收集,不重复写入 FINAL_SUMMARY
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
String directAnswer = state.value(PlanStateKeys.DIRECT_ANSWER, "");
|
String directAnswer = state.value(PlanStateKeys.DIRECT_ANSWER, "");
|
||||||
return Map.of(PlanStateKeys.FINAL_SUMMARY, directAnswer);
|
return Map.of(PlanStateKeys.FINAL_SUMMARY, directAnswer);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,45 +2,57 @@ package vip.mate.agent.graph.plan.node;
|
|||||||
|
|
||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
import org.springframework.ai.chat.messages.SystemMessage;
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
import org.springframework.ai.chat.messages.UserMessage;
|
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.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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计划生成节点
|
* Task triage node for the Plan-Execute graph.
|
||||||
* <p>
|
* <p>
|
||||||
* 职责:
|
* Decides one of three routes for the user's goal and emits a JSON directive:
|
||||||
* <ol>
|
* <ul>
|
||||||
* <li>判断是否需要规划(简单问答快速退出)</li>
|
* <li>{@code direct_answer} — pure knowledge question, no tools, no planning</li>
|
||||||
* <li>如需规划:生成计划 JSON、解析、校验</li>
|
* <li>single-step plan — needs tools but a single coherent action (steps=1)</li>
|
||||||
* <li>调 PlanningService.createPlan() 持久化</li>
|
* <li>multi-step plan — genuinely independent subtasks (2–6 steps)</li>
|
||||||
* <li>发布 plan_created 事件</li>
|
* </ul>
|
||||||
* </ol>
|
* When {@code needs_planning} is false the node streams the direct answer
|
||||||
|
* through {@link NodeStreamingChatHelper} and the graph exits via
|
||||||
|
* {@code DirectAnswerNode}. Otherwise a plan is persisted via
|
||||||
|
* {@link PlanningService} and {@code step_execution} takes over.
|
||||||
* <p>
|
* <p>
|
||||||
* 使用 {@link NodeStreamingChatHelper} 进行流式调用。
|
* The previous version forced {@code needs_planning=true} whenever any tool
|
||||||
* 即便最终返回 JSON,也允许模型的 planning 输出以流式产生,最终再聚合解析。
|
* was required, producing multi-step plans for trivial single-hop tasks.
|
||||||
* 直接回答路径也通过流式 helper 实时输出给前端。
|
* The revised prompt collapses single-hop tool use into a 1-step plan so the
|
||||||
*
|
* executor can handle it with one ReAct-style iteration (see RFC-008).
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class PlanGenerationNode implements NodeAction {
|
public class PlanGenerationNode implements NodeAction {
|
||||||
@ -50,59 +62,310 @@ 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;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
/** 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
|
||||||
|
* snake_case keys the LLM is instructed to produce, so no prompt changes needed.
|
||||||
|
*/
|
||||||
|
record TriageResult(
|
||||||
|
@JsonProperty("needs_planning") boolean needsPlanning,
|
||||||
|
@JsonProperty("direct_answer") String directAnswer,
|
||||||
|
@JsonProperty("plan_type") String planType,
|
||||||
|
@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 = """
|
||||||
你是任务规划器,不是聊天助手。
|
你是任务分流器,不是聊天助手。根据用户目标把请求分到三类之一,并只输出一个 JSON 对象。
|
||||||
|
|
||||||
你的输出必须满足以下规则:
|
硬性规则:
|
||||||
1. 只能返回一个 JSON 对象。
|
1. 只返回一个 JSON 对象;不允许 markdown 代码块、不允许任何 JSON 以外的文字。
|
||||||
2. 不允许输出任何 JSON 之外的文字。
|
2. 不要解释,不要寒暄,不要说"我来...""我先..."。
|
||||||
3. 不允许使用 markdown 代码块。
|
3. 判断依据是"目标是否由多个明显独立的子任务/交付物组成",而不是难度高低:
|
||||||
4. 不要解释,不要寒暄,不要先说"我来...""我先..."。
|
单个连贯动作不要拆,但目标确实分成多个部分时也不要硬压成一步。
|
||||||
|
|
||||||
返回格式二选一:
|
三类分流:
|
||||||
|
|
||||||
不需要规划时:
|
(A) 直接回答 — 简单的纯知识问答:凭自身知识用一两段话即可答完,不需要任何工具、不需要读文件、
|
||||||
{"needs_planning": false, "direct_answer": "..."}
|
不需要查询当前状态,且目标本身不包含多个需要分别完成的子任务。
|
||||||
|
(注意:成段的分析、对比、方案、规划、教程等通常不属于此类,应走 B 或 C。)
|
||||||
|
输出:{"needs_planning": false, "direct_answer": "<你的回答>"}
|
||||||
|
|
||||||
需要规划时:
|
(B) 单步任务 — 本质是一个连贯动作(一次文件读取 / 一次搜索 / 一次命令 / 一次记忆读写 / 一次计算 /
|
||||||
{"needs_planning": true, "steps": ["步骤1", "步骤2", "步骤3"]}
|
一段集中产出)。执行器会在这一步内部迭代调用多次工具,你**不要**提前拆分。
|
||||||
|
输出:{"needs_planning": true, "steps": ["<将用户目标复述为一句清晰可执行的指令>"]}
|
||||||
|
|
||||||
要求:
|
(C) 多步任务 — 用户目标包含 2 个及以上明显独立、需要先后完成的子任务或交付物(例如"先调研 A 再调研 B
|
||||||
- steps 数量 2 到 6 个。
|
然后对比"、"读配置、迁移数据、验证结果"、"分阶段制定计划"、"产出由若干独立部分组成的方案")。
|
||||||
- 每个步骤必须是可执行动作,不要写空话。
|
这是规划型智能体的主路径——当目标确实由多个部分组成时就走这里。
|
||||||
- 默认不要把 MEMORY.md、PROFILE.md、记忆文件当成独立步骤;但如果用户目标明显依赖历史偏好、长期约束、过往决策或持续上下文,可以加入必要的记忆读取步骤。
|
输出:{"needs_planning": true, "steps": ["步骤1", "步骤2", ...]}(2 到 6 个步骤)
|
||||||
- 不要把技能文件当成独立步骤,除非用户任务明确要求。
|
|
||||||
- 如果用户目标需要调用任何工具才能完成(包括记忆读写、文件操作、搜索、命令执行等),必须返回 needs_planning: true。只有纯知识问答(不需要调用任何工具的简单问题)才返回 needs_planning: false。
|
关键原则:
|
||||||
- 如果无法确定,也必须返回合法 JSON,不能输出自然语言。
|
- 单工具调用绝对不拆成多步。例:"读 A 文件并总结" 是单步(B),不是两步。
|
||||||
|
- 默认不要把 MEMORY.md / PROFILE.md / 技能文件读取当成独立步骤;仅当用户明确询问偏好、历史决策或长期约束时才加入。
|
||||||
|
- 每个步骤必须是可执行动作,不写"思考一下""确认一下"之类的空话。
|
||||||
|
- 多部分、多阶段、需要逐步推进的目标走(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use constructor with full parameters
|
* @deprecated use the full-parameter constructor instead
|
||||||
*/
|
*/
|
||||||
@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
|
||||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||||
PlanStateAccessor accessor = new PlanStateAccessor(state);
|
PlanStateAccessor accessor = new PlanStateAccessor(state);
|
||||||
String goal = accessor.goal();
|
String goal = accessor.goal();
|
||||||
|
|
||||||
|
// Goal follow-up injection: GoalEvaluationNode requested a re-plan
|
||||||
|
// pass with extra guidance. The mid-pass plan state was wiped by
|
||||||
|
// the previous node, so we run the normal planning flow but
|
||||||
|
// append the follow-up prompt to the user goal so the planner
|
||||||
|
// sees "do these original objectives + this next step the
|
||||||
|
// evaluator just asked for".
|
||||||
|
String followupPrompt = state.value(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
|
||||||
|
if (!followupPrompt.isEmpty()) {
|
||||||
|
log.info("[PlanGeneration] Goal follow-up active, augmenting goal with {} chars of guidance",
|
||||||
|
followupPrompt.length());
|
||||||
|
goal = goal + "\n\n[Follow-up guidance]\n" + followupPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
String systemPrompt = accessor.systemPrompt();
|
String systemPrompt = accessor.systemPrompt();
|
||||||
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
|
// 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);
|
||||||
@ -110,7 +373,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||||
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
|
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
|
||||||
|
|
||||||
// Replay 模式:计划已在 state 中(由 chatWithReplayStream 注入),直接跳过 LLM
|
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
|
||||||
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
||||||
if (existingPlanId != null) {
|
if (existingPlanId != null) {
|
||||||
List<String> existingSteps = accessor.planSteps();
|
List<String> existingSteps = accessor.planSteps();
|
||||||
@ -128,42 +391,81 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 构建 prompt 消息列表:PLANNING_PROMPT 作为独立 system message,
|
// PLANNING_PROMPT is the sole system message; we deliberately do NOT
|
||||||
// 不拼接完整 systemPrompt(wiki/技能/记忆指南等与规划决策无关,
|
// concatenate the agent's full systemPrompt (wiki / skill / memory guidance),
|
||||||
// 拼接后会稀释 PLANNING_PROMPT 的指令优先级)
|
// which would dilute the triage instructions.
|
||||||
List<Message> promptMessages = new ArrayList<>();
|
List<Message> promptMessages = new ArrayList<>();
|
||||||
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
|
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
|
||||||
// 注入运行时上下文(当前时间 + 工作目录)
|
|
||||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||||
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||||
|
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||||
|
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
||||||
|
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
||||||
|
promptMessages.add(new UserMessage(
|
||||||
|
RuntimeContextInjector.buildContextMessage(
|
||||||
|
workspaceBasePath, null, chatOrigin, runtimeModelName, runtimeProviderId)));
|
||||||
|
|
||||||
// 注入可用工具名称,帮助 LLM 判断用户目标是否需要工具
|
// 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
|
||||||
|
// should resolve to a 1-step plan, not a multi-step decomposition.
|
||||||
if (toolSet != null && !toolSet.callbacks().isEmpty()) {
|
if (toolSet != null && !toolSet.callbacks().isEmpty()) {
|
||||||
String toolNames = toolSet.callbacks().stream()
|
String toolNames = toolSet.callbacks().stream()
|
||||||
.map(cb -> cb.getToolDefinition().name())
|
.map(cb -> cb.getToolDefinition().name())
|
||||||
.collect(Collectors.joining(", "));
|
.collect(Collectors.joining(", "));
|
||||||
promptMessages.add(new UserMessage(
|
promptMessages.add(new UserMessage(
|
||||||
"你可以使用以下工具:" + toolNames
|
"可用工具:" + toolNames
|
||||||
+ "\n如果用户目标需要调用任何工具才能完成,必须返回 needs_planning: true。"));
|
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注入 working context(对话历史摘要),让规划能感知之前对话的约束和补充条件
|
// 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
|
||||||
|
// prior constraints without re-reading full history.
|
||||||
String workingContext = accessor.workingContext();
|
String workingContext = accessor.workingContext();
|
||||||
if (!workingContext.isEmpty()) {
|
if (!workingContext.isEmpty()) {
|
||||||
promptMessages.add(new UserMessage(
|
promptMessages.add(new UserMessage(
|
||||||
"以下是此前对话中用户提出的约束、说明和上下文,请在规划时充分考虑:\n\n"
|
"以下是此前对话中用户提出的约束、说明和上下文,请在分流时参考:\n\n"
|
||||||
+ workingContext));
|
+ workingContext));
|
||||||
}
|
}
|
||||||
|
|
||||||
promptMessages.add(new UserMessage("用户目标:" + goal));
|
promptMessages.add(new UserMessage("用户目标:" + goal));
|
||||||
|
|
||||||
|
// Append JSON schema hint generated by BeanOutputConverter so the LLM
|
||||||
|
// knows the exact expected structure (replaces hand-written schema in PLANNING_PROMPT).
|
||||||
|
BeanOutputConverter<TriageResult> converter = new BeanOutputConverter<>(TriageResult.class);
|
||||||
|
promptMessages.add(new UserMessage(converter.getFormat()));
|
||||||
|
|
||||||
Prompt prompt = new Prompt(promptMessages);
|
Prompt prompt = new Prompt(promptMessages);
|
||||||
|
|
||||||
// 静默流式调用 LLM — 返回结构化 JSON,不直接推送给前端
|
// Broadcast a lightweight progress token so the frontend shows activity
|
||||||
|
// during the silent triage call (typically 1-3 s).
|
||||||
|
if (streamingHelper != null) {
|
||||||
|
streamingHelper.broadcastProgress(conversationId, "分析中...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silent streaming call — structured JSON is parsed below; tokens are not forwarded to the client.
|
||||||
|
long triageStartMs = System.currentTimeMillis();
|
||||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCallSilent(
|
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCallSilent(
|
||||||
chatModel, prompt, conversationId, "plan_generation");
|
chatModel, prompt, conversationId, "plan_generation");
|
||||||
|
|
||||||
// PTL 处理:压缩后重试
|
// Prompt-too-long handling: compact the conversation window and retry once.
|
||||||
if (result.isPromptTooLong() && conversationWindowManager != null) {
|
if (result.isPromptTooLong() && conversationWindowManager != null) {
|
||||||
log.warn("[PlanGeneration] Prompt too long, attempting compaction and retry");
|
log.warn("[PlanGeneration] Prompt too long, attempting compaction and retry");
|
||||||
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
|
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
|
||||||
@ -177,23 +479,53 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long triageMs = System.currentTimeMillis() - triageStartMs;
|
||||||
|
|
||||||
String llmResponse = result.text();
|
String llmResponse = result.text();
|
||||||
|
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
|
||||||
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
|
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
|
||||||
|
|
||||||
// 清理 markdown 代码块标记
|
// D-6: emit triage perf summary
|
||||||
String cleanedJson = cleanJsonResponse(llmResponse);
|
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
|
||||||
|
"triage_ms", triageMs,
|
||||||
|
"prompt_tokens", result.promptTokens(),
|
||||||
|
"completion_tokens", result.completionTokens()
|
||||||
|
)));
|
||||||
|
|
||||||
// 解析 JSON
|
TriageResult triage = converter.convert(llmResponse);
|
||||||
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
|
boolean needsPlanning = triage != null && triage.needsPlanning();
|
||||||
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
|
|
||||||
|
|
||||||
if (!needsPlanning) {
|
if (!needsPlanning) {
|
||||||
// 简单问答快速退出 — 解析出 direct_answer 后手动推送给前端
|
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
||||||
String directAnswer = parsed.get("direct_answer") != null
|
String directAnswer = triage != null && triage.directAnswer() != null
|
||||||
? parsed.get("direct_answer").toString() : llmResponse;
|
? triage.directAnswer() : llmResponse;
|
||||||
log.info("[PlanGeneration] Simple question detected, returning direct answer");
|
|
||||||
|
// 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)");
|
||||||
|
|
||||||
// 手动广播 direct_answer 文本(而不是原始 JSON)
|
|
||||||
streamingHelper.broadcastContent(conversationId, directAnswer);
|
streamingHelper.broadcastContent(conversationId, directAnswer);
|
||||||
|
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
@ -207,30 +539,45 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要规划:提取步骤
|
// Categories (B) single-step or (C) multi-step: extract steps.
|
||||||
@SuppressWarnings("unchecked")
|
List<String> steps = triage != null ? triage.steps() : null;
|
||||||
List<String> steps = (List<String>) parsed.get("steps");
|
|
||||||
if (steps == null || steps.isEmpty()) {
|
if (steps == null || steps.isEmpty()) {
|
||||||
log.warn("[PlanGeneration] LLM returned needs_planning=true but empty steps, falling back to direct answer");
|
// LLM asked for planning but produced no steps — fall back to a
|
||||||
return PlanStateAccessor.output()
|
// synthetic 1-step plan using the user's goal so the executor
|
||||||
.needsPlanning(false)
|
// can still reach the tools. (Previous behavior dropped back to
|
||||||
.directAnswer(llmResponse)
|
// direct_answer, which silently stripped tool capability.)
|
||||||
.currentPhase("direct_answer")
|
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
|
||||||
.contentStreamed(true)
|
steps = List.of(goal);
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.mergeUsage(state, result)
|
|
||||||
.events(events)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化计划
|
// Resolve any per-step agent delegation the planner asked for. Null
|
||||||
var plan = planningService.createPlan(agentId, goal, steps);
|
// when nothing is delegated, keeping createPlan on the legacy path.
|
||||||
log.info("[PlanGeneration] Plan created: id={}, steps={}", plan.getId(), steps.size());
|
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 : "");
|
||||||
|
|
||||||
// 发布 plan_created 事件
|
|
||||||
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)
|
||||||
@ -240,40 +587,40 @@ 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] Failed to generate plan: {}", e.getMessage(), e);
|
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);
|
||||||
// 降级:作为简单问答处理,不向前端暴露内部异常细节
|
// When the triage LLM fails or returns unparseable output we now fall back to
|
||||||
|
// a single-step plan (the user's goal verbatim) instead of a direct text
|
||||||
|
// answer. This preserves tool access on the failure path; the previous
|
||||||
|
// "direct answer" fallback silently degraded tool-requiring tasks.
|
||||||
|
try {
|
||||||
|
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
|
||||||
|
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.needsPlanning(true)
|
||||||
|
.planId(plan.getId())
|
||||||
|
.planSteps(List.of(goal))
|
||||||
|
.planValid(true)
|
||||||
|
.currentStepIndex(0)
|
||||||
|
.currentPhase("plan_generated")
|
||||||
|
.events(events)
|
||||||
|
.build();
|
||||||
|
} catch (Exception persistErr) {
|
||||||
|
log.error("[PlanGeneration] Single-step fallback persistence also failed: {}", persistErr.getMessage());
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.needsPlanning(false)
|
.needsPlanning(false)
|
||||||
.directAnswer("抱歉,我暂时无法完成规划,请重试或换一种方式描述任务。")
|
.directAnswer("抱歉,我暂时无法完成任务分流,请重试或换一种方式描述任务。")
|
||||||
.currentPhase("direct_answer")
|
.currentPhase("direct_answer")
|
||||||
.events(events)
|
.events(events)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理 LLM 返回的 JSON,移除可能的 markdown 代码块标记。
|
|
||||||
* 若响应中不包含合法的 JSON 对象,抛出异常让调用方走降级路径。
|
|
||||||
*/
|
|
||||||
private String cleanJsonResponse(String response) {
|
|
||||||
if (response == null) {
|
|
||||||
throw new IllegalArgumentException("LLM returned null response");
|
|
||||||
}
|
|
||||||
String cleaned = response.trim();
|
|
||||||
if (cleaned.startsWith("```")) {
|
|
||||||
cleaned = cleaned.replaceAll("```json?\\n?", "").replaceAll("```", "").trim();
|
|
||||||
}
|
|
||||||
// 找到第一个 { 和最后一个 }
|
|
||||||
int start = cleaned.indexOf('{');
|
|
||||||
int end = cleaned.lastIndexOf('}');
|
|
||||||
if (start < 0 || end <= start) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"LLM response does not contain a valid JSON object: " + cleaned.substring(0, Math.min(80, cleaned.length())));
|
|
||||||
}
|
|
||||||
return cleaned.substring(start, end + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ 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.ChatOptions;
|
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
|
||||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
@ -21,22 +20,34 @@ import vip.mate.agent.GraphEventPublisher;
|
|||||||
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.DirectToolOutput;
|
||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
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.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.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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 步骤执行节点
|
* 步骤执行节点
|
||||||
* <p>
|
* <p>
|
||||||
* 执行当前步骤,使用显式工具执行循环(internalToolExecutionEnabled=false)。
|
* 执行当前步骤,使用显式工具执行循环(internalToolExecutionEnabled=false)。
|
||||||
* 单步最大工具调用次数限制为 5 次,防止无限循环。
|
* 单步最大工具调用次数限制为 {@link #MAX_TOOL_CALLS_PER_STEP} 次,与
|
||||||
|
* {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING} 对齐——因此实际生效的上限
|
||||||
|
* 永远是 agent 的 {@code max_iterations}(DB 列),单步本身不会先于 agent
|
||||||
|
* 的整体预算被打掉。早期 5 次的硬限制对"查新闻 + 整理 Word"这种合理多
|
||||||
|
* 工具任务过紧,被 LimitExceededNode 提前拦截后用户看到的是冷冰冰的
|
||||||
|
* "工具调用次数超出最大限制"。
|
||||||
* <p>
|
* <p>
|
||||||
* 支持 NEEDS_APPROVAL 审批流程:对需要审批的工具调用创建 pending,
|
* 支持 NEEDS_APPROVAL 审批流程:对需要审批的工具调用创建 pending,
|
||||||
* 发出 SSE 事件后立即返回审批提示(非阻塞)。审批通过后通过 replay 重新执行。
|
* 发出 SSE 事件后立即返回审批提示(非阻塞)。审批通过后通过 replay 重新执行。
|
||||||
@ -54,8 +65,56 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
private final String reasoningEffort;
|
private final String reasoningEffort;
|
||||||
private final NodeStreamingChatHelper streamingHelper;
|
private final NodeStreamingChatHelper streamingHelper;
|
||||||
|
private final long stepWallClockTimeoutMs;
|
||||||
|
/**
|
||||||
|
* Renders the {@code ## Skills} catalog at runtime. Null in legacy / test
|
||||||
|
* constructors — when null, no catalog segment is appended (the Plan path's
|
||||||
|
* pre-disclosure behavior of baking it into the system prompt is gone).
|
||||||
|
*/
|
||||||
|
private final SkillCatalogRenderer skillCatalogRenderer;
|
||||||
|
|
||||||
private static final int MAX_TOOL_CALLS_PER_STEP = 5;
|
/**
|
||||||
|
* 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}.
|
||||||
|
* Matching the agent-level cap means this constant is never the bottleneck —
|
||||||
|
* the agent's own {@code max_iterations} (DB column) will fire first if a
|
||||||
|
* task is genuinely runaway, and a well-budgeted multi-tool step (e.g.
|
||||||
|
* web_search + browser_navigate + browser_read*N + file_write) is no longer
|
||||||
|
* cut short by an arbitrary 5-call ceiling.
|
||||||
|
*/
|
||||||
|
private static final int MAX_TOOL_CALLS_PER_STEP = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock budget per step, complementing {@link #MAX_TOOL_CALLS_PER_STEP}.
|
||||||
|
* The call-count cap doesn't help when a single LLM stream stalls or a
|
||||||
|
* concurrency-unsafe tool runs synchronously without a per-tool deadline
|
||||||
|
* (the parallel batch path enforces {@code ToolTimeoutProperties}, but the
|
||||||
|
* single-unsafe path in {@code ToolExecutionExecutor#executeSingleTool}
|
||||||
|
* currently does not). 10 minutes is generous for legitimate long steps
|
||||||
|
* (large file edits, multi-page browser flows) while still cutting off the
|
||||||
|
* pathological cases where the agent appears frozen to the user.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
@ -64,6 +123,45 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
ChatStreamTracker streamTracker,
|
ChatStreamTracker streamTracker,
|
||||||
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||||
ConversationWindowManager conversationWindowManager) {
|
ConversationWindowManager conversationWindowManager) {
|
||||||
|
this(chatModel, toolSet, executor, planningService, streamTracker,
|
||||||
|
reasoningEffort, streamingHelper, conversationWindowManager,
|
||||||
|
null, STEP_WALL_CLOCK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production constructor with the runtime skill-catalog renderer. */
|
||||||
|
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
||||||
|
ToolExecutionExecutor executor,
|
||||||
|
PlanningService planningService,
|
||||||
|
ChatStreamTracker streamTracker,
|
||||||
|
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
SkillCatalogRenderer skillCatalogRenderer) {
|
||||||
|
this(chatModel, toolSet, executor, planningService, streamTracker,
|
||||||
|
reasoningEffort, streamingHelper, conversationWindowManager,
|
||||||
|
skillCatalogRenderer, STEP_WALL_CLOCK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-friendly overload — production callers use the default timeout. */
|
||||||
|
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
||||||
|
ToolExecutionExecutor executor,
|
||||||
|
PlanningService planningService,
|
||||||
|
ChatStreamTracker streamTracker,
|
||||||
|
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
long stepWallClockTimeoutMs) {
|
||||||
|
this(chatModel, toolSet, executor, planningService, streamTracker,
|
||||||
|
reasoningEffort, streamingHelper, conversationWindowManager,
|
||||||
|
null, stepWallClockTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
||||||
|
ToolExecutionExecutor executor,
|
||||||
|
PlanningService planningService,
|
||||||
|
ChatStreamTracker streamTracker,
|
||||||
|
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
SkillCatalogRenderer skillCatalogRenderer,
|
||||||
|
long stepWallClockTimeoutMs) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.toolSet = toolSet;
|
this.toolSet = toolSet;
|
||||||
this.executor = executor;
|
this.executor = executor;
|
||||||
@ -72,6 +170,8 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
this.conversationWindowManager = conversationWindowManager;
|
this.conversationWindowManager = conversationWindowManager;
|
||||||
this.reasoningEffort = reasoningEffort;
|
this.reasoningEffort = reasoningEffort;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
|
this.skillCatalogRenderer = skillCatalogRenderer;
|
||||||
|
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -86,6 +186,14 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
|
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
|
||||||
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||||
|
// RFC-063r §2.5: read parent ChatOrigin from graph state so tools in
|
||||||
|
// this step (and any DelegateAgentTool sub-graphs) inherit channel /
|
||||||
|
// workspace / requester context.
|
||||||
|
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||||
|
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||||
|
.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());
|
||||||
@ -100,13 +208,36 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
|
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
|
||||||
|
|
||||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||||
|
// Iteration boundary for the plan-execute loop: each step is one
|
||||||
|
// iteration that may itself fan out to multiple LLM calls. Reason is
|
||||||
|
// "plan_step" so consumers can distinguish it from ReAct's
|
||||||
|
// "react_step" / "first_turn" markers when both stream into the
|
||||||
|
// same SSE feed.
|
||||||
|
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) {
|
||||||
|
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||||
|
}
|
||||||
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
|
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
|
||||||
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
|
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
|
||||||
|
|
||||||
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;
|
||||||
@ -117,21 +248,56 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
int stepPromptTokens = 0;
|
int stepPromptTokens = 0;
|
||||||
int stepCompletionTokens = 0;
|
int stepCompletionTokens = 0;
|
||||||
|
|
||||||
|
// RFC-052: any returnDirect tool that fires inside this step must
|
||||||
|
// short-circuit the entire plan (not just this step). We accumulate
|
||||||
|
// outputs across the inner loop and break out as soon as one appears.
|
||||||
|
List<DirectToolOutput> stepDirectOutputs = new ArrayList<>();
|
||||||
|
|
||||||
|
// Wall-clock budget guards against a single hung LLM stream or
|
||||||
|
// synchronous unsafe-tool call (the per-tool timeout is enforced only
|
||||||
|
// in the parallel batch path of ToolExecutionExecutor). Checked at the
|
||||||
|
// top of each iteration so we never issue another LLM call after the
|
||||||
|
// budget is gone.
|
||||||
|
long stepStartedAtMs = System.currentTimeMillis();
|
||||||
|
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) {
|
||||||
ChatOptions options;
|
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
|
||||||
if (StringUtils.hasText(reasoningEffort)) {
|
if (elapsedMs > stepWallClockTimeoutMs) {
|
||||||
|
log.warn("[StepExecution] Step {} exceeded wall-clock budget " +
|
||||||
|
"({} ms > {} ms) after {} tool round(s); aborting step",
|
||||||
|
stepIndex, elapsedMs, stepWallClockTimeoutMs, toolCallCount);
|
||||||
|
wallClockExceeded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
|
||||||
|
// producer in NodeStreamingChatHelper.doStreamCall can attach the
|
||||||
|
// user-token. Using ToolCallingChatOptions when reasoningEffort is
|
||||||
|
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
|
||||||
|
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
|
||||||
|
// would 400 again.
|
||||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
||||||
.toolCallbacks(toolSet.callbacks())
|
.toolCallbacks(toolSet.callbacks())
|
||||||
.reasoningEffort(reasoningEffort)
|
|
||||||
.build();
|
.build();
|
||||||
|
if (StringUtils.hasText(reasoningEffort)) {
|
||||||
|
oaiOpts.setReasoningEffort(reasoningEffort);
|
||||||
|
}
|
||||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||||
options = oaiOpts;
|
ChatOptions options = oaiOpts;
|
||||||
} else {
|
|
||||||
options = ToolCallingChatOptions.builder()
|
if (conversationWindowManager != null) {
|
||||||
.toolCallbacks(toolSet.callbacks())
|
// Pass conversationId + workspaceBasePath so oversized
|
||||||
.internalToolExecutionEnabled(false)
|
// older tool results can be spilled to disk instead of
|
||||||
.build();
|
// being rewritten into a lossy single-line summary.
|
||||||
|
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, conversationId, workspaceBasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||||
@ -180,16 +346,23 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
|
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
|
||||||
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
|
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
|
||||||
events.add(GraphEventPublisher.toolStart(toolCall.name(), toolCall.arguments()));
|
events.add(GraphEventPublisher.toolStart(toolCall.name(), toolCall.arguments()));
|
||||||
|
// RFC-052: pass the directOutputs collector so that an
|
||||||
|
// approved direct tool's full content is captured here
|
||||||
|
// (instead of leaking into the next LLM round).
|
||||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||||
toolCall, storedArguments, events);
|
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||||
|
stepDirectOutputs);
|
||||||
toolResponses.add(response);
|
toolResponses.add(response);
|
||||||
preApprovedPayload = ""; // 只消费一次
|
preApprovedPayload = ""; // 只消费一次
|
||||||
} else {
|
} else {
|
||||||
// 非预批准工具走正常执行器
|
// 非预批准工具走正常执行器
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
|
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
|
if (execResult.hasDirectOutputs()) {
|
||||||
|
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||||
|
}
|
||||||
if (execResult.awaitingApproval()) {
|
if (execResult.awaitingApproval()) {
|
||||||
approvalTriggered = true;
|
approvalTriggered = true;
|
||||||
approvalToolName = toolCall.name();
|
approvalToolName = toolCall.name();
|
||||||
@ -200,9 +373,12 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
} else {
|
} else {
|
||||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
|
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
|
if (execResult.hasDirectOutputs()) {
|
||||||
|
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||||
|
}
|
||||||
if (execResult.awaitingApproval()) {
|
if (execResult.awaitingApproval()) {
|
||||||
approvalTriggered = true;
|
approvalTriggered = true;
|
||||||
approvalToolName = execResult.barrierToolName() != null
|
approvalToolName = execResult.barrierToolName() != null
|
||||||
@ -221,6 +397,40 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
if (approvalTriggered) {
|
if (approvalTriggered) {
|
||||||
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
|
||||||
|
// step ends the plan immediately; the dispatcher routes via
|
||||||
|
// currentPhase=plan_aborted so no further LLM call happens.
|
||||||
|
if (!stepDirectOutputs.isEmpty()) {
|
||||||
|
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
|
||||||
|
"tool output(s); aborting plan execution",
|
||||||
|
stepIndex, stepDirectOutputs.size());
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理审批暂停
|
// 处理审批暂停
|
||||||
@ -239,10 +449,103 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC-052: direct tool short-circuit at the plan level. Treat the
|
||||||
|
// assembled direct text as the final summary and abort the plan;
|
||||||
|
// the dispatcher routes plan_aborted to END so no further LLM call
|
||||||
|
// is made. Persisting RETURN_DIRECT_TRIGGERED + DIRECT_TOOL_OUTPUTS
|
||||||
|
// lets the SSE accumulator pick up directToolNames metadata so
|
||||||
|
// history scrub (BaseAgent.isDirectToolMessage) kicks in next turn.
|
||||||
|
//
|
||||||
|
// Plan status is "completed" (not "failed"): the user got their
|
||||||
|
// answer correctly, the plan just terminated earlier than the
|
||||||
|
// model's planning stage anticipated. Marking as failed would skew
|
||||||
|
// operational dashboards and confuse plan-history readers.
|
||||||
|
if (!stepDirectOutputs.isEmpty()) {
|
||||||
|
String assembled = assembleDirectAnswerText(stepDirectOutputs);
|
||||||
|
planningService.updateSubPlanResult(planId, stepIndex, assembled);
|
||||||
|
planningService.completePlan(planId,
|
||||||
|
"Plan completed via returnDirect tool: " +
|
||||||
|
stepDirectOutputs.get(0).toolName());
|
||||||
|
events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled));
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||||
|
assembled != null ? assembled.length() : 0, 0));
|
||||||
|
}
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.currentStepResult(assembled)
|
||||||
|
.currentStepIndex(steps.size()) // 越界 → dispatcher 收束
|
||||||
|
.currentPhase("plan_aborted")
|
||||||
|
.finalSummary(assembled)
|
||||||
|
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
|
||||||
|
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
|
||||||
|
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
finalResult = "步骤执行超过最大耗时限制("
|
||||||
|
+ (stepWallClockTimeoutMs / 1000) + "秒),已中止本步骤";
|
||||||
|
} else {
|
||||||
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
|
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
|
||||||
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
|
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[StepExecution] Step {} execution failed: {}", stepIndex, e.getMessage(), e);
|
log.error("[StepExecution] Step {} execution failed: {}", stepIndex, e.getMessage(), e);
|
||||||
@ -250,6 +553,50 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
|
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
|
||||||
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
|
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
|
||||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
|
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||||
|
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")
|
||||||
@ -262,15 +609,32 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
|
|
||||||
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
|
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
|
||||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
|
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||||
|
finalResult != null ? finalResult.length() : 0,
|
||||||
|
stepThinking != null ? stepThinking.length() : 0));
|
||||||
|
}
|
||||||
|
|
||||||
log.info("[StepExecution] Step {}/{} completed: {}",
|
log.info("[StepExecution] Step {}/{} completed: {}",
|
||||||
stepIndex + 1, steps.size(),
|
stepIndex + 1, steps.size(),
|
||||||
finalResult.length() > 100 ? finalResult.substring(0, 100) + "..." : finalResult);
|
finalResult.length() > 100 ? finalResult.substring(0, 100) + "..." : finalResult);
|
||||||
|
|
||||||
// 更新 working context:将最新完成的步骤结果纳入摘要
|
// RFC-008 P4.2: incremental working-context update.
|
||||||
List<String> allCompleted = new ArrayList<>(accessor.completedResults());
|
// Previous behavior rebuilt the entire context from history + every
|
||||||
allCompleted.add(formatStepResult(stepIndex, finalResult));
|
// completed result on every step (O(N) per step). On long plans this
|
||||||
String updatedWorkingContext = rebuildWorkingContext(accessor, allCompleted);
|
// re-walks the same conversation history each iteration. Now we take
|
||||||
|
// the previous context as-is (which already encodes earlier history
|
||||||
|
// and earlier completed steps) and append just the freshly-completed
|
||||||
|
// step, then trim from the head if the running total exceeds the cap.
|
||||||
|
// For first-step calls where prior context is empty, fall through to
|
||||||
|
// the original rebuild path so the conversation history seed is still
|
||||||
|
// captured.
|
||||||
|
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()
|
return PlanStateAccessor.output()
|
||||||
.currentStepResult(finalResult)
|
.currentStepResult(finalResult)
|
||||||
@ -287,7 +651,120 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
|
/**
|
||||||
|
* 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
|
||||||
|
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
|
||||||
|
* sees the same shape regardless of which graph (ReAct / Plan-Execute)
|
||||||
|
* produced the answer.
|
||||||
|
*/
|
||||||
|
private static String assembleDirectAnswerText(List<DirectToolOutput> outputs) {
|
||||||
|
if (outputs.size() == 1) {
|
||||||
|
return outputs.get(0).fullResult();
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < outputs.size(); i++) {
|
||||||
|
DirectToolOutput out = outputs.get(i);
|
||||||
|
if (i > 0) sb.append("\n\n");
|
||||||
|
sb.append("### ").append(out.toolName()).append("\n");
|
||||||
|
sb.append(out.fullResult());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(增强指令)
|
||||||
@ -306,8 +783,19 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。
|
8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。
|
||||||
""";
|
""";
|
||||||
messages.add(new SystemMessage(enhancedSystemPrompt));
|
messages.add(new SystemMessage(enhancedSystemPrompt));
|
||||||
// 注入运行时上下文(当前时间 + 工作目录)
|
// Runtime skill catalog (rendered here instead of baked into the system
|
||||||
messages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
// prompt). The Plan path never pins per-run loads, so render with an
|
||||||
|
// empty loaded set — this reproduces the pre-disclosure DB ordering.
|
||||||
|
if (skillCatalogRenderer != null) {
|
||||||
|
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of());
|
||||||
|
if (skillCatalog != null && !skillCatalog.isBlank()) {
|
||||||
|
messages.add(new SystemMessage(skillCatalog));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文 + 模型身份)
|
||||||
|
messages.add(new UserMessage(
|
||||||
|
RuntimeContextInjector.buildContextMessage(
|
||||||
|
workspaceBasePath, null, accessor.chatOrigin(), runtimeModelName, runtimeProviderId)));
|
||||||
|
|
||||||
// Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要)
|
// Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要)
|
||||||
String workingContext = accessor.workingContext();
|
String workingContext = accessor.workingContext();
|
||||||
@ -375,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>
|
||||||
@ -414,9 +926,51 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append helper used by the incremental working-context fast path. */
|
||||||
|
private static List<String> appendOne(List<String> previous, String item) {
|
||||||
|
List<String> out = new ArrayList<>(previous);
|
||||||
|
out.add(item);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据当前 accessor 中的会话历史消息和更新后的已完成步骤结果,
|
* Incrementally extend the previous working context with one new step
|
||||||
* 重建 working context。复用与 StateGraphPlanExecuteAgent.buildWorkingContext 相同的逻辑。
|
* result. Cheap O(1) path used for steps 2..N: avoids walking the full
|
||||||
|
* conversation history again. The result is trimmed from the head if it
|
||||||
|
* exceeds the same overall cap that {@link #rebuildWorkingContext}
|
||||||
|
* enforces, so the budget invariant is preserved.
|
||||||
|
*
|
||||||
|
* <p>Per-step truncation: a single step result longer than 800 chars is
|
||||||
|
* abbreviated before append, mirroring the per-step caps in
|
||||||
|
* {@code rebuildWorkingContext}.</p>
|
||||||
|
*/
|
||||||
|
private static String appendStepIncremental(String previousContext, String formattedStepResult) {
|
||||||
|
final int OVERALL_CAP = 6000;
|
||||||
|
final int PER_STEP_CAP = 800;
|
||||||
|
String stepLine = formattedStepResult.length() > PER_STEP_CAP
|
||||||
|
? formattedStepResult.substring(0, PER_STEP_CAP) + "…"
|
||||||
|
: formattedStepResult;
|
||||||
|
String combined = previousContext + "\n" + stepLine + "\n";
|
||||||
|
if (combined.length() <= OVERALL_CAP) {
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
// Drop oldest content from the head until we fit. Cut on a newline
|
||||||
|
// boundary so we don't truncate mid-line.
|
||||||
|
int overshoot = combined.length() - OVERALL_CAP;
|
||||||
|
int cutFrom = combined.indexOf('\n', overshoot);
|
||||||
|
if (cutFrom < 0 || cutFrom >= combined.length() - 1) {
|
||||||
|
cutFrom = overshoot;
|
||||||
|
} else {
|
||||||
|
cutFrom += 1; // skip the newline itself
|
||||||
|
}
|
||||||
|
return "…(earlier context truncated)\n" + combined.substring(cutFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full rebuild of working context from conversation history plus all
|
||||||
|
* completed step results. Reused on the cold path (first step, or when
|
||||||
|
* the incremental path can't be applied). Mirrors
|
||||||
|
* {@code StateGraphPlanExecuteAgent.buildWorkingContext}.
|
||||||
*/
|
*/
|
||||||
private static String rebuildWorkingContext(PlanStateAccessor accessor, List<String> allCompletedResults) {
|
private static String rebuildWorkingContext(PlanStateAccessor accessor, List<String> allCompletedResults) {
|
||||||
List<Message> messages = accessor.messages();
|
List<Message> messages = accessor.messages();
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,7 +48,10 @@ public final class PlanStateAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean needsPlanning() {
|
public boolean needsPlanning() {
|
||||||
return state.value(NEEDS_PLANNING, true);
|
// Default to false: an unset triage flag means the request was not
|
||||||
|
// classified as requiring a plan. See PlanGenerationDispatcher for the
|
||||||
|
// rationale and RFC-008 for the full discussion.
|
||||||
|
return state.value(NEEDS_PLANNING, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 步骤控制 =====
|
// ===== 步骤控制 =====
|
||||||
@ -70,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() {
|
||||||
@ -104,6 +112,17 @@ public final class PlanStateAccessor {
|
|||||||
return state.value(MateClawStateKeys.TRACE_ID, "");
|
return state.value(MateClawStateKeys.TRACE_ID, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link vip.mate.agent.context.ChatOrigin} forwarded into graph
|
||||||
|
* state by {@code MateClawStateAccessor.OutputBuilder.chatOrigin}.
|
||||||
|
* Returns {@link vip.mate.agent.context.ChatOrigin#EMPTY} when nothing
|
||||||
|
* was injected (legacy callers / non-channel entry points).
|
||||||
|
*/
|
||||||
|
public vip.mate.agent.context.ChatOrigin chatOrigin() {
|
||||||
|
return state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||||
|
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 会话消息(复用 MateClawStateKeys.MESSAGES)=====
|
// ===== 会话消息(复用 MateClawStateKeys.MESSAGES)=====
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@ -173,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);
|
||||||
}
|
}
|
||||||
@ -232,8 +255,10 @@ public final class PlanStateAccessor {
|
|||||||
NodeStreamingChatHelper.StreamResult result) {
|
NodeStreamingChatHelper.StreamResult result) {
|
||||||
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
|
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
|
||||||
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
|
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
|
||||||
|
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
|
||||||
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
|
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
|
||||||
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
||||||
|
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"; // 简单问答的直接回答
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
package vip.mate.agent.graph.state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052: full-text result of a tool call that declared {@code returnDirect=true}.
|
||||||
|
*
|
||||||
|
* <p>The result is delivered to the user (and persisted to {@code mate_message})
|
||||||
|
* verbatim, but is intentionally <em>not</em> placed into any subsequent LLM
|
||||||
|
* prompt — see {@link MateClawStateKeys#DIRECT_TOOL_OUTPUTS} and
|
||||||
|
* {@code FinalAnswerNode}'s direct branch.
|
||||||
|
*
|
||||||
|
* @param toolCallId the tool call id from the originating LLM response
|
||||||
|
* @param toolName the resolved tool name
|
||||||
|
* @param fullResult the complete tool result, never truncated or spilled
|
||||||
|
* @param executedAtMs epoch milliseconds when the tool returned
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public record DirectToolOutput(
|
||||||
|
String toolCallId,
|
||||||
|
String toolName,
|
||||||
|
String fullResult,
|
||||||
|
long executedAtMs
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -19,8 +19,18 @@ public enum FinishReason {
|
|||||||
/** 发生错误后降级回答 */
|
/** 发生错误后降级回答 */
|
||||||
ERROR_FALLBACK("error_fallback"),
|
ERROR_FALLBACK("error_fallback"),
|
||||||
|
|
||||||
|
/** 响应未完整完成,需要继续生成或重试 */
|
||||||
|
INCOMPLETE("incomplete"),
|
||||||
|
|
||||||
|
/** 最终回答引用了未被工具结果验证的源码事实 */
|
||||||
|
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
|
||||||
|
|
||||||
/** 用户主动停止 */
|
/** 用户主动停止 */
|
||||||
STOPPED("stopped");
|
STOPPED("stopped"),
|
||||||
|
|
||||||
|
/** RFC-052: a tool with returnDirect=true short-circuited the loop;
|
||||||
|
* result was delivered to the user without re-entering the LLM. */
|
||||||
|
RETURN_DIRECT("return_direct");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
|
|||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@ -79,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")
|
||||||
@ -195,12 +201,58 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(AWAITING_APPROVAL, false);
|
return state.value(AWAITING_APPROVAL, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== RFC-052: returnDirect =====
|
||||||
|
|
||||||
|
public boolean returnDirectTriggered() {
|
||||||
|
return state.value(RETURN_DIRECT_TRIGGERED, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<DirectToolOutput> directToolOutputs() {
|
||||||
|
return state.<List<DirectToolOutput>>value(DIRECT_TOOL_OUTPUTS).orElse(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SourceEvidenceLedger sourceEvidenceLedger() {
|
||||||
|
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 审批重放 =====
|
// ===== 审批重放 =====
|
||||||
|
|
||||||
public String forcedToolCall() {
|
public String forcedToolCall() {
|
||||||
return state.value(FORCED_TOOL_CALL, "");
|
return state.value(FORCED_TOOL_CALL, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== RFC-063r: ChatOrigin =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: the {@link ChatOrigin} written into graph state by the
|
||||||
|
* top-level agent. Returns {@link ChatOrigin#EMPTY} when the entry path
|
||||||
|
* did not supply one (e.g., legacy callers using the bridge overloads).
|
||||||
|
*/
|
||||||
|
public ChatOrigin chatOrigin() {
|
||||||
|
return state.<ChatOrigin>value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Skill progressive disclosure =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skills loaded via {@code load_skill} so far this run. Empty when none
|
||||||
|
* have been loaded (the common first-iteration case).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Set<String> loadedSkills() {
|
||||||
|
return state.<Set<String>>value(LOADED_SKILLS).orElse(Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension tools activated via {@code enable_tool} so far this run. Empty
|
||||||
|
* when none have been enabled (the common case).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Set<String> enabledExtensionTools() {
|
||||||
|
return state.<Set<String>>value(ENABLED_EXTENSION_TOOLS).orElse(Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Token Usage =====
|
// ===== Token Usage =====
|
||||||
|
|
||||||
public int promptTokens() {
|
public int promptTokens() {
|
||||||
@ -219,6 +271,72 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(RUNTIME_PROVIDER_ID, "");
|
return state.value(RUNTIME_PROVIDER_ID, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Persistent goal accessors =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active goal snapshot or empty. The injected object is the
|
||||||
|
* {@code vip.mate.goal.model.GoalEntity}; we reference it by Object
|
||||||
|
* here to avoid pulling the goal package into core graph state.
|
||||||
|
*/
|
||||||
|
public Optional<Object> activeGoal() {
|
||||||
|
return state.<Object>value(ACTIVE_GOAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasActiveGoal() {
|
||||||
|
return state.<Object>value(ACTIVE_GOAL).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean goalEvaluatedThisRun() {
|
||||||
|
return state.value(GOAL_EVALUATED_THIS_RUN, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean goalFollowupInjected() {
|
||||||
|
return state.value(GOAL_FOLLOWUP_INJECTED, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String goalFollowupPrompt() {
|
||||||
|
return state.value(GOAL_FOLLOWUP_PROMPT, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-followups already injected in this graph run (0 at run start). */
|
||||||
|
public int goalFollowupCount() {
|
||||||
|
return state.value(GOAL_FOLLOWUP_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cumulative agent LLM calls already billed to the goal this run (0 at run start). */
|
||||||
|
public int goalAccountedLlmCallCount() {
|
||||||
|
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
|
||||||
|
* Plan-Execute writes to {@code PlanStateKeys.FINAL_SUMMARY} (long
|
||||||
|
* path) or {@code PlanStateKeys.DIRECT_ANSWER} (short path). The
|
||||||
|
* GoalEvaluationNode reads whichever is populated without having to
|
||||||
|
* know which graph it's inside.
|
||||||
|
*/
|
||||||
|
public String terminalAnswer() {
|
||||||
|
String fa = state.value(FINAL_ANSWER, "");
|
||||||
|
if (!fa.isEmpty()) {
|
||||||
|
return fa;
|
||||||
|
}
|
||||||
|
// Avoid a direct compile-time reference to PlanStateKeys (the plan
|
||||||
|
// sub-package depends on core graph state); use the string keys
|
||||||
|
// verbatim. Mismatches would surface as terminalAnswer() returning
|
||||||
|
// empty in tests — the v3 TerminalAnswerTest pins exactly that.
|
||||||
|
String summary = state.value("final_summary", "");
|
||||||
|
if (!summary.isEmpty()) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
return state.value("direct_answer", "");
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 输出构建器 =====
|
// ===== 输出构建器 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -260,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);
|
||||||
@ -373,11 +495,39 @@ public final class MateClawStateAccessor {
|
|||||||
return put(AWAITING_APPROVAL, awaiting);
|
return put(AWAITING_APPROVAL, awaiting);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RFC-052: returnDirect ----
|
||||||
|
public OutputBuilder returnDirectTriggered(boolean triggered) {
|
||||||
|
return put(RETURN_DIRECT_TRIGGERED, triggered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder directToolOutputs(List<DirectToolOutput> outputs) {
|
||||||
|
return put(DIRECT_TOOL_OUTPUTS, outputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder sourceEvidenceLedger(SourceEvidenceLedger ledger) {
|
||||||
|
return put(SOURCE_EVIDENCE_LEDGER, ledger);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 审批重放 ----
|
// ---- 审批重放 ----
|
||||||
public OutputBuilder forcedToolCall(String json) {
|
public OutputBuilder forcedToolCall(String json) {
|
||||||
return put(FORCED_TOOL_CALL, json);
|
return put(FORCED_TOOL_CALL, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RFC-063r: ChatOrigin ----
|
||||||
|
public OutputBuilder chatOrigin(ChatOrigin origin) {
|
||||||
|
return put(CHAT_ORIGIN, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Skill progressive disclosure ----
|
||||||
|
public OutputBuilder loadedSkills(Set<String> names) {
|
||||||
|
return put(LOADED_SKILLS, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tool progressive disclosure ----
|
||||||
|
public OutputBuilder enabledExtensionTools(Set<String> names) {
|
||||||
|
return put(ENABLED_EXTENSION_TOOLS, names);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Token Usage ----
|
// ---- Token Usage ----
|
||||||
|
|
||||||
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
||||||
@ -390,6 +540,113 @@ public final class MateClawStateAccessor {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Persistent goal ----
|
||||||
|
|
||||||
|
public OutputBuilder goalEvaluationResult(Map<String, Object> result) {
|
||||||
|
return put(GOAL_EVALUATION_RESULT, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder goalFollowupInjected(boolean injected) {
|
||||||
|
return put(GOAL_FOLLOWUP_INJECTED, injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder goalFollowupPrompt(String prompt) {
|
||||||
|
return put(GOAL_FOLLOWUP_PROMPT, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder goalEvaluatedThisRun(boolean v) {
|
||||||
|
return put(GOAL_EVALUATED_THIS_RUN, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder goalFollowupCount(int n) {
|
||||||
|
return put(GOAL_FOLLOWUP_COUNT, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder goalAccountedLlmCallCount(int n) {
|
||||||
|
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* immediately re-terminate via the existing final text. */
|
||||||
|
public OutputBuilder clearFinalAnswer() {
|
||||||
|
return put(FINAL_ANSWER, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wipe FINISH_REASON for the same reason as clearFinalAnswer(). */
|
||||||
|
public OutputBuilder clearFinishReason() {
|
||||||
|
return put(FINISH_REASON, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the next PlanGeneration pass starts clean. Identifier is the
|
||||||
|
* string literal "final_summary" to avoid a compile-time link to
|
||||||
|
* the plan sub-package from core graph state. */
|
||||||
|
public OutputBuilder clearPlanFinalSummary() {
|
||||||
|
return put("final_summary", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearPlanDirectAnswer() {
|
||||||
|
return put("direct_answer", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plan-Execute follow-up: wipe the mid-pass plan state so the next
|
||||||
|
* PlanGenerationNode pass re-derives everything from scratch. */
|
||||||
|
public OutputBuilder clearPlanId() {
|
||||||
|
return put("plan_id", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearPlanSteps() {
|
||||||
|
return put("plan_steps", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearPlanValid() {
|
||||||
|
return put("plan_valid", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearNeedsPlanning() {
|
||||||
|
return put("needs_planning", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearCurrentStepIndex() {
|
||||||
|
return put("current_step_index", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearCurrentStepTitle() {
|
||||||
|
return put("current_step_title", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearCurrentStepResult() {
|
||||||
|
return put("current_step_result", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearCompletedResults() {
|
||||||
|
return put("completed_results", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearFinalSummaryThinking() {
|
||||||
|
return put("final_summary_thinking", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputBuilder clearCurrentStepThinking() {
|
||||||
|
return put("current_step_thinking", "");
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> build() {
|
public Map<String, Object> build() {
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
@ -83,6 +93,15 @@ public final class MateClawStateKeys {
|
|||||||
// ===== 事件流(APPEND 策略)=====
|
// ===== 事件流(APPEND 策略)=====
|
||||||
public static final String PENDING_EVENTS = "pending_events";
|
public static final String PENDING_EVENTS = "pending_events";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multimodal routing decision for the current turn (REPLACE strategy).
|
||||||
|
* Stored as a Map ready for JSON serialization. Set by BaseAgent before
|
||||||
|
* the reasoning node runs; read back by FinalAnswerNode and (separately)
|
||||||
|
* emitted as a graph event for the SSE accumulator to write into the
|
||||||
|
* persisted message metadata under {@code metadata.routing}.
|
||||||
|
*/
|
||||||
|
public static final String ROUTING_DECISION = "routing_decision";
|
||||||
|
|
||||||
// ===== 阶段标记(REPLACE 策略)=====
|
// ===== 阶段标记(REPLACE 策略)=====
|
||||||
public static final String CURRENT_PHASE = "current_phase";
|
public static final String CURRENT_PHASE = "current_phase";
|
||||||
|
|
||||||
@ -140,4 +159,141 @@ public final class MateClawStateKeys {
|
|||||||
// ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)=====
|
// ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)=====
|
||||||
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
|
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
|
||||||
public static final String RUNTIME_PROVIDER_ID = "runtime_provider_id";
|
public static final String RUNTIME_PROVIDER_ID = "runtime_provider_id";
|
||||||
|
|
||||||
|
// ===== RFC-052: Tool returnDirect 与数据隔离 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052: when true the latest tool batch contained at least one tool
|
||||||
|
* declared as returnDirect, so the graph must short-circuit to
|
||||||
|
* {@link #FINAL_ANSWER_NODE} without re-entering the LLM.
|
||||||
|
*/
|
||||||
|
public static final String RETURN_DIRECT_TRIGGERED = "return_direct_triggered";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052: list of {@code DirectToolOutput} accumulated from the most recent
|
||||||
|
* tool batch, used by FinalAnswerNode to assemble the final answer.
|
||||||
|
*/
|
||||||
|
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
|
||||||
|
|
||||||
|
/** Source references observed from successful tool results during this run. */
|
||||||
|
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
|
||||||
|
|
||||||
|
// ===== Persistent goal — cross-turn objective lock-in =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active goal snapshot bound to the conversation; null when no goal.
|
||||||
|
* Injected by {@code buildInitialState} from {@code GoalService.findActiveByConversation}.
|
||||||
|
* Read by GoalEvaluationNode + its dispatcher.
|
||||||
|
*/
|
||||||
|
public static final String ACTIVE_GOAL = "active_goal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map snapshot of the latest evaluation pass (score/gap/decision/...).
|
||||||
|
* Written by GoalEvaluationNode; consumed by the SSE accumulator for
|
||||||
|
* the {@code goal_evaluated} event payload.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_EVALUATION_RESULT = "goal_evaluation_result";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when GoalEvaluationNode injected a follow-up prompt and the
|
||||||
|
* dispatcher should re-enter the reasoning loop (or PlanGeneration in
|
||||||
|
* the Plan-Execute graph) instead of terminating to END.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_FOLLOWUP_INJECTED = "goal_followup_injected";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow-up user-message text to append to MESSAGES on graph re-entry.
|
||||||
|
* ReasoningNode (or PlanGenerationNode) reads this on its way in,
|
||||||
|
* appends to MESSAGES, then clears the value so the second pass
|
||||||
|
* cannot double-inject.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets
|
||||||
|
* this true only when it ENDS the run (completed / exhausted / skip /
|
||||||
|
* continue-without-followup). The FinalAnswerNode→GoalEvaluation edge skips
|
||||||
|
* re-entering once it's true. The followup branch deliberately leaves it
|
||||||
|
* false so the self-continuation loop can re-evaluate the next answer; that
|
||||||
|
* loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus
|
||||||
|
* the goal's turn / LLM-call budgets.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of auto-followups already injected in THIS graph run (one user
|
||||||
|
* turn). Bounds the self-continuation loop per single message — independent
|
||||||
|
* of the goal's cross-turn turn_budget — so one message can't drive an
|
||||||
|
* unbounded number of autonomous steps or exhaust the graph recursion
|
||||||
|
* limit. Implicitly 0 at the start of each graph invocation.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cumulative agent LLM-call count already billed to the goal in THIS graph
|
||||||
|
* run. The run-to-completion loop evaluates multiple times per run while
|
||||||
|
* {@link #LLM_CALL_COUNT} keeps growing; recording only
|
||||||
|
* (current − accounted) on each pass avoids re-billing earlier calls and
|
||||||
|
* exhausting the goal's LLM budget prematurely. Implicitly 0 at run start.
|
||||||
|
*/
|
||||||
|
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
|
||||||
|
|
||||||
|
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: top-level agent writes the {@code ChatOrigin} value object
|
||||||
|
* into graph state once at {@code buildInitialState}; nodes (especially
|
||||||
|
* {@code StepExecutionNode} in the Plan-Execute sub-graph) read it
|
||||||
|
* read-only when invoking {@link vip.mate.agent.graph.executor.ToolExecutionExecutor}
|
||||||
|
* so child graphs and delegated agents inherit the originating channel /
|
||||||
|
* workspace context.
|
||||||
|
*/
|
||||||
|
public static final String CHAT_ORIGIN = "chat_origin";
|
||||||
|
|
||||||
|
// ===== Skill progressive disclosure (REPLACE strategy) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names of skills explicitly loaded via the {@code load_skill} tool during
|
||||||
|
* this graph run. Stored as a {@code Set<String>} and used to pin recently
|
||||||
|
* loaded skills to the top of the runtime skill catalog so a multi-iteration
|
||||||
|
* loop stops re-loading the same skill it already pulled into message
|
||||||
|
* history. ActionNode reads the prior value and writes back the merged set
|
||||||
|
* (read-merge-write under the REPLACE strategy).
|
||||||
|
* <p>
|
||||||
|
* MUST be registered in both the ReAct and Plan-Execute KeyStrategyFactory
|
||||||
|
* blocks or the framework will drop it on multi-node merges, leaving the
|
||||||
|
* catalog ranker blind to in-run loads.
|
||||||
|
*/
|
||||||
|
public static final String LOADED_SKILLS = "loaded_skills";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function names of extension-tier tools activated via {@code enable_tool}
|
||||||
|
* during this run. Stored as a {@code Set<String>}; ReasoningNode adds these
|
||||||
|
* back to the active tool callbacks on its next turn so an enabled extension
|
||||||
|
* tool becomes callable within the same ReAct loop. ActionNode reads the
|
||||||
|
* prior value and writes back the merged set (read-merge-write under REPLACE).
|
||||||
|
* <p>
|
||||||
|
* MUST be registered in both KeyStrategyFactory blocks (see
|
||||||
|
* {@link #LOADED_SKILLS}).
|
||||||
|
*/
|
||||||
|
public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools";
|
||||||
}
|
}
|
||||||
|
|||||||