Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/**
|
||||||
38
.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,29 @@ 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# ── 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
|
||||||
4
.gitignore
vendored
@ -92,5 +92,9 @@ 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/
|
||||||
|
|||||||
247
README.md
@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
# 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>
|
||||||
|
|
||||||
[](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 +28,101 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
**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 — agents, models, tools, skills, knowledge, security, cron |
|
||||||
|
| **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
|
||||||
|
|
||||||
|
### Agent runtime
|
||||||
|
**ReAct** for iterative reasoning. **Plan-and-Execute** for complex multi-step work. 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
|
||||||
|
- **Workspace memory** — `AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
|
||||||
|
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, dreaming workflows
|
||||||
|
|
||||||
|
### Tools, skills, MCP
|
||||||
|
Built-in tools for web search, files, memory, date/time. **MCP** over stdio / SSE / Streamable HTTP. **SKILL.md** packages from the ClawHub marketplace. A **Tool Guard** layer with RBAC, approval flows, and path protection — capability needs boundaries.
|
||||||
|
|
||||||
|
### Multimodal creation
|
||||||
|
Text-to-speech · Speech-to-text · Image · Music · Video. First-class, not add-ons.
|
||||||
|
|
||||||
|
### Enterprise-ready
|
||||||
|
RBAC + JWT. 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 flow + audit** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
|
||||||
|
| **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 agent, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. A web admin dashboard where one operator manages 50 agents across 14 vendors. 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 +143,63 @@ 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 |
|
||||||
|
| Agent | StateGraph runtime · ReAct + Plan-Execute |
|
||||||
|
| 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
|
Sharper multi-agent collaboration · Smarter model routing · Deeper multimodal understanding · Longer-lived memory · A richer ClawHub.
|
||||||
- Smarter model routing
|
|
||||||
- Deeper multimodal understanding
|
|
||||||
- Stronger long-term memory
|
|
||||||
- Richer ClawHub ecosystem
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@ -217,14 +212,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.
|
||||||
|
|||||||
241
README_zh.md
@ -4,15 +4,15 @@
|
|||||||
<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>
|
||||||
|
|
||||||
[](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 +28,102 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
|
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
||||||
|
>
|
||||||
|
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
|
||||||
|
|
||||||
**一个产品。完整交付。**
|
大多数 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>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 盒子里有什么
|
||||||
|
|
||||||
|
### 智能体引擎
|
||||||
|
**ReAct** 做迭代推理。**Plan-and-Execute** 做复杂多步任务。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
|
||||||
|
|
||||||
|
### 知识与记忆
|
||||||
|
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面
|
||||||
|
- **工作区记忆** — `AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
|
||||||
|
- **记忆生命周期** — 对话后自动提取 · 定时整理 · 记忆涌现工作流
|
||||||
|
|
||||||
|
### 工具、技能、MCP
|
||||||
|
内置工具覆盖搜索、文件、记忆、日期。**MCP** 支持 stdio / SSE / Streamable HTTP 三种传输。**SKILL.md** 包可从 ClawHub 市场安装。**工具防护**层提供 RBAC、审批流、文件路径保护——能力必须有边界。
|
||||||
|
|
||||||
|
### 多模态创作
|
||||||
|
语音合成 · 语音识别 · 图片 · 音乐 · 视频。一等公民,不是附加插件。
|
||||||
|
|
||||||
|
### 企业就绪
|
||||||
|
RBAC + JWT。完整审计事件流。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 | 企业版 | 团队版 |
|
||||||
|
| **用户触点** | 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 是那个给团队用的版本。** 每个 agent、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。一个 Web 管理台里,一个运维能同时管 50 个 agent 跑在 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 +145,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 +168,38 @@ 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 |
|
||||||
|
| 数据库 | 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 参考。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 路线图
|
## 路线图
|
||||||
|
|
||||||
- 更丰富的多智能体协作
|
更强的多智能体协作 · 更智能的模型路由 · 更深度的多模态理解 · 更长久的记忆 · 更繁荣的 ClawHub。
|
||||||
- 更智能的模型路由
|
|
||||||
- 更深度的多模态理解
|
|
||||||
- 更强的长期记忆
|
|
||||||
- 更丰富的 ClawHub 生态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参与贡献
|
## 参与贡献
|
||||||
|
|
||||||
@ -221,10 +216,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
|
|||||||
|
|
||||||
**Mate** 是陪伴。**Claw** 是能力。
|
**Mate** 是陪伴。**Claw** 是能力。
|
||||||
|
|
||||||
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。
|
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE)
|
[Apache License 2.0](LICENSE)。没有星号。
|
||||||
|
|||||||
@ -36,14 +36,15 @@
|
|||||||
<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">Reason · Plan · Execute</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">Lazy ingest · On-demand</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">Tools & Skills</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">Built-in + MCP + Skills</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">ClawHub Marketplace</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,10 +98,11 @@
|
|||||||
<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 ===== -->
|
<!-- ===== 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>
|
||||||
|
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
|
||||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.2 KiB |
@ -37,14 +37,15 @@
|
|||||||
<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">lazy 入库 · 按需出页</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">工具与技能</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">内置 + MCP + 技能包</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">ClawHub 市场扩展</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,11 +105,12 @@
|
|||||||
<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 ===== -->
|
<!-- ===== 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>
|
||||||
|
<!-- Arrow up to agent -->
|
||||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 8.5 KiB |
@ -56,13 +56,15 @@
|
|||||||
</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 ===== -->
|
||||||
@ -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 → Channel</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">Ambient AI</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Proactive Delivery</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,13 +59,15 @@
|
|||||||
</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 ===== -->
|
||||||
@ -93,9 +95,9 @@
|
|||||||
</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)"/>
|
||||||
@ -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">Ambient AI</text>
|
||||||
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付</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 |
@ -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,27 @@ 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}
|
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-}
|
||||||
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:-}
|
||||||
|
# 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
|
||||||
volumes:
|
volumes:
|
||||||
- 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
|
||||||
@ -1,13 +1,99 @@
|
|||||||
# 多阶段构建
|
# 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
|
||||||
|
RUN npm install -g pnpm --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.
|
||||||
|
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
|
||||||
|
# the production Docker image build.
|
||||||
|
RUN 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 — speeds up builds dramatically inside mainland China.
|
||||||
|
ARG MAVEN_FLAGS=""
|
||||||
|
|
||||||
|
# Inject mirror settings to avoid Maven Central timeouts in restricted networks
|
||||||
|
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
|
||||||
|
|
||||||
|
# Build and install plugin-api into the local Maven cache first
|
||||||
|
WORKDIR /plugin-api
|
||||||
|
COPY mateclaw-plugin-api/pom.xml ./pom.xml
|
||||||
|
COPY mateclaw-plugin-api/src ./src
|
||||||
|
RUN mvn install -DskipTests -q ${MAVEN_FLAGS}
|
||||||
|
|
||||||
|
# Pre-fetch mateclaw-server dependencies (uses mirror, so this won't hang)
|
||||||
|
WORKDIR /build
|
||||||
|
COPY mateclaw-server/pom.xml .
|
||||||
|
RUN mvn dependency:go-offline -q ${MAVEN_FLAGS}
|
||||||
|
|
||||||
|
# Copy backend source and inject pre-built frontend into the right classpath location
|
||||||
|
COPY mateclaw-server/src ./src
|
||||||
|
COPY --from=frontend-builder /static ./src/main/resources/static
|
||||||
|
|
||||||
|
RUN mvn package -DskipTests -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 pom.xml (1.52.0). If you
|
||||||
|
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
||||||
|
# tag with the matching driver, so mismatched versions cause the java driver to
|
||||||
|
# re-download browsers at runtime (defeating the whole point of this image).
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 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 \
|
||||||
|
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
|
||||||
|
|
||||||
COPY --from=builder /build/target/*.jar app.jar
|
COPY --from=builder /build/target/*.jar app.jar
|
||||||
EXPOSE 18088
|
EXPOSE 18088
|
||||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
<artifactId>mateclaw-server</artifactId>
|
<artifactId>mateclaw-server</artifactId>
|
||||||
<version>1.1.0</version>
|
<version>1.1.137</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>MateClaw Server</name>
|
<name>MateClaw Server</name>
|
||||||
@ -67,6 +67,12 @@
|
|||||||
<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 中
|
1.1.2.2 需单独指定版本,不在 BOM 中
|
||||||
@ -189,14 +195,14 @@
|
|||||||
<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>
|
<version>1.3.12</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||||
<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>
|
<version>2.6.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
||||||
@ -274,6 +280,77 @@
|
|||||||
<version>2.2.0</version>
|
<version>2.2.0</version>
|
||||||
</dependency>
|
</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>
|
||||||
|
<version>5.4.1</version>
|
||||||
|
</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>
|
||||||
|
<version>1.18</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.xmlgraphics</groupId>
|
||||||
|
<artifactId>batik-codec</artifactId>
|
||||||
|
<version>1.18</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== jsoup (HTML cleanup for Wiki ingest, RFC-051 PR-1c) ===== -->
|
||||||
|
<!--
|
||||||
|
Used by WikiContentNormalizer to strip nav/footer/script/style/aside
|
||||||
|
and ad-class nodes from URL/HTML uploads before chunking. Small
|
||||||
|
(~430KB), no transitive deps, JVM-only — safe for the desktop bundle.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.18.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Apache Tika (RFC-051 PR-?: Java-side last-resort extractor) ===== -->
|
||||||
|
<!--
|
||||||
|
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
|
||||||
|
chains, after every system command + Python + POI-based path has failed.
|
||||||
|
Used in production primarily by Windows users without Python or poppler
|
||||||
|
installed; otherwise idle.
|
||||||
|
|
||||||
|
Pinned to the precise format modules called out in RFC-051 §5.2 — we
|
||||||
|
deliberately avoid `tika-parsers-standard-package`, which pulls in mail,
|
||||||
|
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
|
||||||
|
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
|
||||||
|
tika-parser-microsoft-module (POI-scratchpad ~10MB) ≈ 16MB.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.tika</groupId>
|
||||||
|
<artifactId>tika-core</artifactId>
|
||||||
|
<version>3.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.tika</groupId>
|
||||||
|
<artifactId>tika-parser-pdf-module</artifactId>
|
||||||
|
<version>3.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.tika</groupId>
|
||||||
|
<artifactId>tika-parser-microsoft-module</artifactId>
|
||||||
|
<version>3.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Database Migration (Flyway) ===== -->
|
<!-- ===== Database Migration (Flyway) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
@ -290,6 +367,20 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 architecture invariants) =====
|
||||||
|
test-scope only — guards:
|
||||||
|
- every ToolCallback implementation overrides call(String, ToolContext)
|
||||||
|
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
|
||||||
|
- CronJobRunner (introduced in PR-3) must not carry @Transactional
|
||||||
|
(would silently fail under self-invocation; see RFC §5.2)
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.tngtech.archunit</groupId>
|
||||||
|
<artifactId>archunit-junit5</artifactId>
|
||||||
|
<version>1.3.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@ -306,6 +397,143 @@
|
|||||||
</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>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Dependency repositories, with US + CN mirrors listed side by side so builds
|
||||||
|
are reasonable on either continent. Maven tries repositories in the order
|
||||||
|
they are declared — the first one that resolves an artifact wins.
|
||||||
|
|
||||||
|
IDs are deliberately distinct from the super-POM's `central` id so that
|
||||||
|
mirror rules in settings.xml (if any) don't silently redirect them. Keep
|
||||||
|
the fastest-by-default first; switch order via a local ~/.m2/settings.xml
|
||||||
|
or pass `-Paliyun-first` when building from inside China.
|
||||||
|
-->
|
||||||
|
<repositories>
|
||||||
|
<!-- Primary: Maven Central direct — fast from US/EU backbones. -->
|
||||||
|
<repository>
|
||||||
|
<id>maven-central</id>
|
||||||
|
<name>Maven Central</name>
|
||||||
|
<url>https://repo.maven.apache.org/maven2</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<!-- Fallback 1: Google Cloud's Maven Central mirror (global CDN edge). -->
|
||||||
|
<repository>
|
||||||
|
<id>google-maven-central</id>
|
||||||
|
<name>Google Maven Central Mirror</name>
|
||||||
|
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<!-- Fallback 2: Aliyun public — fast from China, full Central mirror. -->
|
||||||
|
<repository>
|
||||||
|
<id>aliyun-public</id>
|
||||||
|
<name>Aliyun Public</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<!-- Spring milestones / snapshots — direct from Spring (US). -->
|
||||||
|
<repository>
|
||||||
|
<id>spring-milestones</id>
|
||||||
|
<name>Spring Milestones</name>
|
||||||
|
<url>https://repo.spring.io/milestone</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<!-- Aliyun Spring mirror — fallback for CN builds. -->
|
||||||
|
<repository>
|
||||||
|
<id>aliyun-spring</id>
|
||||||
|
<name>Aliyun Spring Mirror</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/spring</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<!-- Plugin lookups follow the same multi-region fallback. -->
|
||||||
|
<pluginRepositories>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>maven-central</id>
|
||||||
|
<name>Maven Central</name>
|
||||||
|
<url>https://repo.maven.apache.org/maven2</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</pluginRepository>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>google-maven-central</id>
|
||||||
|
<name>Google Maven Central Mirror</name>
|
||||||
|
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</pluginRepository>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>aliyun-public</id>
|
||||||
|
<name>Aliyun Public</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</pluginRepository>
|
||||||
|
</pluginRepositories>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Profile: swap the primary repo order when building from China so Aliyun
|
||||||
|
is tried first. Activate with `mvn -Paliyun-first ...`.
|
||||||
|
-->
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>aliyun-first</id>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>aliyun-public-first</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>aliyun-spring-first</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/spring</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
<pluginRepositories>
|
||||||
|
<pluginRepository>
|
||||||
|
<id>aliyun-public-first</id>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</pluginRepository>
|
||||||
|
</pluginRepositories>
|
||||||
|
</profile>
|
||||||
|
</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>
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package vip.mate;
|
package vip.mate;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.DbType;
|
|
||||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
@ -33,12 +32,17 @@ public class MateClawApplication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MyBatis Plus 分页插件
|
* MyBatis Plus pagination plugin.
|
||||||
|
*
|
||||||
|
* <p>DbType is auto-detected from the JDBC connection at runtime rather
|
||||||
|
* than hardcoded. Hardcoding H2 here meant the MySQL deployment used
|
||||||
|
* the H2 dialect for the count query, which silently returned 0 —
|
||||||
|
* frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||||
return interceptor;
|
return interceptor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,15 +7,22 @@ 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.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.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 java.util.List;
|
import java.util.List;
|
||||||
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,6 +40,8 @@ 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;
|
||||||
|
|
||||||
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
||||||
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
||||||
@ -91,28 +100,67 @@ public class AgentService {
|
|||||||
// ==================== 运行时入口 ====================
|
// ==================== 运行时入口 ====================
|
||||||
|
|
||||||
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 = getOrBuildAgent(agentId);
|
||||||
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = getOrBuildAgent(agentId);
|
||||||
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 = getOrBuildAgent(agentId);
|
||||||
|
|
||||||
@ -129,22 +177,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 = getOrBuildAgent(agentId);
|
||||||
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 +229,20 @@ 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 = getOrBuildAgent(agentId);
|
||||||
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -168,15 +250,29 @@ 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 = getOrBuildAgent(agentId);
|
||||||
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) {
|
||||||
@ -208,6 +304,66 @@ 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)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 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);
|
||||||
|
}
|
||||||
|
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 内部方法 ====================
|
// ==================== 内部方法 ====================
|
||||||
|
|
||||||
private BaseAgent getOrBuildAgent(Long agentId) {
|
private BaseAgent getOrBuildAgent(Long agentId) {
|
||||||
|
|||||||
@ -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,107 @@ public class AgentToolSet {
|
|||||||
public int size() {
|
public int size() {
|
||||||
return callbacks.size();
|
return callbacks.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
package vip.mate.agent;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays per-request assistant {@code reasoning_content} from the producer
|
||||||
|
* ({@code NodeStreamingChatHelper}, which sees {@code AssistantMessage.metadata})
|
||||||
|
* to the consumer ({@code AgentGraphBuilder.patchReasoningContent}, which rebuilds
|
||||||
|
* the outbound {@code ChatCompletionRequest}).
|
||||||
|
*
|
||||||
|
* <p>Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to
|
||||||
|
* {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the
|
||||||
|
* caller does not propagate across the producer/consumer boundary. The relay
|
||||||
|
* token travels inside the request object itself
|
||||||
|
* ({@code OpenAiApi.ChatCompletionRequest.user}), which survives scheduler hops
|
||||||
|
* without needing Reactor context propagation config.
|
||||||
|
*
|
||||||
|
* <p>The {@link RelayEntry} carries both the per-assistant thinking list and the
|
||||||
|
* caller's <em>original</em> {@code user} field — the producer overwrites
|
||||||
|
* {@code OpenAiChatOptions.user} with the relay token before handing the
|
||||||
|
* {@code Prompt} to Spring AI, so by the time the consumer runs,
|
||||||
|
* {@code request.user()} only contains the token. The consumer restores the
|
||||||
|
* caller's original value from the entry when rebuilding the outbound request.
|
||||||
|
* The internal token is never sent to the provider.
|
||||||
|
*
|
||||||
|
* <p>Ownership: the producer is responsible for calling {@link #discard(String)}
|
||||||
|
* in a {@code finally} block as a belt-and-suspenders cleanup. The consumer's
|
||||||
|
* {@link #take(String)} already removes the entry on the happy path, so
|
||||||
|
* {@code discard} is a no-op in that case; it becomes the only cleanup when the
|
||||||
|
* consumer never runs (e.g., a Reactor error before the request is dispatched).
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public final class AssistantThinkingRelay {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request relay payload.
|
||||||
|
*
|
||||||
|
* @param thinkings per-assistant {@code reasoning_content} in message order;
|
||||||
|
* empty string means "this assistant had no thinking"
|
||||||
|
* @param originalUser the caller's original {@code OpenAiChatOptions.user} value
|
||||||
|
* before the producer overwrote it with the relay token;
|
||||||
|
* may be {@code null}
|
||||||
|
*/
|
||||||
|
public record RelayEntry(List<String> thinkings, String originalUser) {
|
||||||
|
public RelayEntry {
|
||||||
|
thinkings = List.copyOf(thinkings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ConcurrentHashMap<String, RelayEntry> MAP = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Prefix must be distinctive enough that a caller-provided {@code user} value
|
||||||
|
* can never collide with a relay token. */
|
||||||
|
public static final String TOKEN_PREFIX = "__mc_thinking_";
|
||||||
|
|
||||||
|
private AssistantThinkingRelay() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash per-assistant thinking (in message order) plus the caller's original
|
||||||
|
* {@code user} field. Returns the token to embed in
|
||||||
|
* {@code OpenAiChatOptions.user}.
|
||||||
|
*/
|
||||||
|
public static String stash(List<String> thinkingsInOrder, String originalUser) {
|
||||||
|
String token = TOKEN_PREFIX + UUID.randomUUID();
|
||||||
|
MAP.put(token, new RelayEntry(thinkingsInOrder, originalUser));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume and remove entry. Returns {@code null} if {@code user} is not a
|
||||||
|
* relay token or the entry was already taken. */
|
||||||
|
public static RelayEntry take(String user) {
|
||||||
|
if (!isToken(user)) return null;
|
||||||
|
return MAP.remove(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the given {@code user} field value is a relay token produced by
|
||||||
|
* {@link #stash(List, String)}. */
|
||||||
|
public static boolean isToken(String user) {
|
||||||
|
return user != null && user.startsWith(TOKEN_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defensive cleanup; idempotent — safe to call even after {@link #take}. */
|
||||||
|
public static void discard(String token) {
|
||||||
|
if (token != null) MAP.remove(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- test hooks ----------
|
||||||
|
|
||||||
|
/** Visible for tests: current map size. Production code must not use. */
|
||||||
|
static int size() {
|
||||||
|
return MAP.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests: clear all entries. Production code must not use. */
|
||||||
|
static void clearAll() {
|
||||||
|
MAP.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,8 +43,13 @@ public abstract class BaseAgent {
|
|||||||
/** 系统提示词 */
|
/** 系统提示词 */
|
||||||
protected String systemPrompt;
|
protected String systemPrompt;
|
||||||
|
|
||||||
/** 最大工具调用迭代次数 */
|
/**
|
||||||
protected int maxIterations = 25;
|
* Max ReAct iterations (one reasoning + action + observation step counts as one).
|
||||||
|
* Default 100, hard ceiling 100 (enforced in AgentGraphBuilder so per-agent DB
|
||||||
|
* overrides cannot exceed it). Aligned with QwenPaw's _MAX_MAX_ITERATIONS.
|
||||||
|
*/
|
||||||
|
public static final int MAX_ITERATIONS_HARD_CEILING = 100;
|
||||||
|
protected int maxIterations = 100;
|
||||||
|
|
||||||
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */
|
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */
|
||||||
protected String workspaceBasePath;
|
protected String workspaceBasePath;
|
||||||
@ -217,20 +222,92 @@ public abstract class BaseAgent {
|
|||||||
|
|
||||||
List<Message> messages = new ArrayList<>(limit);
|
List<Message> messages = new ArrayList<>(limit);
|
||||||
for (int i = 0; i < limit; i += 1) {
|
for (int i = 0; i < limit; i += 1) {
|
||||||
MessageEntity entity = history.get(i);
|
Message springMessage = sanitizeForLlm(history.get(i));
|
||||||
// 过滤审批占位消息,确保 LLM 上下文不包含审批残留
|
|
||||||
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
|
|
||||||
log.debug("[{}] Filtering approval placeholder from history: msgId={}", agentName, entity.getId());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Message springMessage = toSpringMessage(entity);
|
|
||||||
if (springMessage != null) {
|
if (springMessage != null) {
|
||||||
messages.add(springMessage);
|
messages.add(springMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tail guard: a few providers reject prompts whose history ends with an
|
||||||
|
// assistant message. Anthropic Claude returns 400 "does not support
|
||||||
|
// assistant message prefill"; DeepSeek thinking mode requires the
|
||||||
|
// last assistant turn's reasoning_content (which we may not have).
|
||||||
|
// The trailing-user-dedup above can already produce an assistant tail
|
||||||
|
// when the immediately-prior turn was an error / placeholder that got
|
||||||
|
// dropped by stage 1 / 1.5 of sanitizeForLlm. Strip remaining assistant
|
||||||
|
// tails defensively — the current user message is fed in separately as
|
||||||
|
// the final prompt by the caller, so dropping these assistant entries
|
||||||
|
// never loses information the LLM needs.
|
||||||
|
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof AssistantMessage) {
|
||||||
|
messages.remove(messages.size() - 1);
|
||||||
|
}
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History sanitization entry point. Encapsulates *all* steps applied to a
|
||||||
|
* persisted message before it reaches an LLM prompt. Returns {@code null}
|
||||||
|
* to drop the message, or a Spring AI {@link Message} (possibly with
|
||||||
|
* rewritten content) to keep it.
|
||||||
|
*
|
||||||
|
* <p>Design philosophy (OpenClaw-inspired): keep the conversion + every
|
||||||
|
* sanitization stage centralized here so future steps (RFC-052 §9 PII
|
||||||
|
* field-level redaction, RFC-049 thinking-block replay strategy, image
|
||||||
|
* compression for vision models, etc.) plug in as additional inline
|
||||||
|
* stages with clear ordering rather than scattering across the loop.
|
||||||
|
*
|
||||||
|
* <p>Current stages (in order):
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Drop approval placeholders</b> — assistant messages whose
|
||||||
|
* content is a "[等待审批]" stub from the approval flow are removed
|
||||||
|
* entirely so they don't pollute the LLM context.</li>
|
||||||
|
* <li><b>Render content</b> — convert {@code MessageEntity} to a string
|
||||||
|
* via {@link ConversationService#renderMessageContent}.</li>
|
||||||
|
* <li><b>Direct-tool scrub (RFC-052)</b> — assistant messages produced
|
||||||
|
* by a returnDirect tool path get their content replaced with a
|
||||||
|
* tool-named placeholder; the original DB content is unchanged.</li>
|
||||||
|
* <li><b>Type dispatch</b> — wrap into {@code AssistantMessage},
|
||||||
|
* {@code SystemMessage}, or {@code UserMessage} (with multimodal
|
||||||
|
* Media for image/video parts).</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private Message sanitizeForLlm(MessageEntity entity) {
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 1: drop approval-placeholder assistant messages
|
||||||
|
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
|
||||||
|
log.debug("[{}] Filtering approval placeholder from history: msgId={}",
|
||||||
|
agentName, entity.getId());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 1.5: drop typed-error assistant messages. These are persisted
|
||||||
|
// by ChatController.doOnComplete with status='error' (or carry the
|
||||||
|
// "[错误] " prefix injected by NodeStreamingChatHelper for legacy
|
||||||
|
// rows). Re-sending them as multi-turn context drives a self-replicating
|
||||||
|
// failure loop:
|
||||||
|
// - DeepSeek thinking mode → 400 "reasoning_content must be passed back"
|
||||||
|
// (we never captured a real reasoning_content for the failed turn)
|
||||||
|
// - Anthropic Claude → 400 "does not support assistant message prefill"
|
||||||
|
// (the trailing-user-dedup at the call site can leave an assistant
|
||||||
|
// tail when the prior turn errored)
|
||||||
|
// Both providers' 400 then re-persist a fresh "[错误] " row, repeat.
|
||||||
|
if ("assistant".equals(entity.getRole())
|
||||||
|
&& ("error".equals(entity.getStatus())
|
||||||
|
|| (entity.getContent() != null && entity.getContent().startsWith("[错误] ")))) {
|
||||||
|
log.debug("[{}] Filtering error assistant message from history: msgId={} status={}",
|
||||||
|
agentName, entity.getId(), entity.getStatus());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate stages 2-4 to toSpringMessage; the stage 3 scrub is applied
|
||||||
|
// there so the rendered content is replaced before the typed Message
|
||||||
|
// wrapper is constructed.
|
||||||
|
return toSpringMessage(entity);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断消息是否为持久化的压缩摘要。
|
* 判断消息是否为持久化的压缩摘要。
|
||||||
*/
|
*/
|
||||||
@ -256,6 +333,86 @@ public abstract class BaseAgent {
|
|||||||
return ApprovalPlaceholderUtil.isApprovalPlaceholder(content);
|
return ApprovalPlaceholderUtil.isApprovalPlaceholder(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052: regex matching {@code "directToolNames":["a","b",...]} in the
|
||||||
|
* metadata JSON and capturing every tool name in group(1) iterations. The
|
||||||
|
* {@code \\s*} guards keep us robust to pretty-printed JSON.
|
||||||
|
*
|
||||||
|
* <p>Design note (OpenClaw-inspired): rather than a one-shot "is this a
|
||||||
|
* direct turn?" boolean we extract the actual tool names and weave them
|
||||||
|
* into the placeholder, so the next LLM turn can reason about *which* tool
|
||||||
|
* answered (e.g. "the user just asked their salary; you used
|
||||||
|
* query_employee_salary; if they ask follow-up questions, call it again").
|
||||||
|
* This preserves conversational continuity that a generic placeholder
|
||||||
|
* destroys.
|
||||||
|
*/
|
||||||
|
private static final java.util.regex.Pattern DIRECT_TOOL_NAMES_ARRAY =
|
||||||
|
java.util.regex.Pattern.compile(
|
||||||
|
"\"directToolNames\"\\s*:\\s*\\[(\\s*\"[^\"]*\"\\s*(?:,\\s*\"[^\"]*\"\\s*)*)\\]");
|
||||||
|
private static final java.util.regex.Pattern DIRECT_TOOL_NAMES_INNER =
|
||||||
|
java.util.regex.Pattern.compile("\"([^\"]+)\"");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052: returns the list of returnDirect tool names recorded in the
|
||||||
|
* persisted assistant message's metadata. Empty list means this is NOT a
|
||||||
|
* direct-tool message and the content is safe for the LLM.
|
||||||
|
*
|
||||||
|
* <p>Allocates only when a non-empty {@code directToolNames} array is
|
||||||
|
* actually present (the common case — normal assistant turns — exits at
|
||||||
|
* the first {@code contains} check with zero allocations).
|
||||||
|
*/
|
||||||
|
static List<String> directToolNamesIn(MessageEntity msg) {
|
||||||
|
if (msg == null) return List.of();
|
||||||
|
String metadata = msg.getMetadata();
|
||||||
|
if (metadata == null || metadata.isEmpty()) return List.of();
|
||||||
|
if (!metadata.contains("\"directToolNames\"")) return List.of();
|
||||||
|
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
|
||||||
|
if (!arrayMatcher.find()) return List.of();
|
||||||
|
String inner = arrayMatcher.group(1);
|
||||||
|
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
||||||
|
List<String> names = new ArrayList<>(2);
|
||||||
|
while (nameMatcher.find()) {
|
||||||
|
names.add(nameMatcher.group(1));
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper preserved for callers that only need the boolean.
|
||||||
|
* Keeps the original test surface stable.
|
||||||
|
*/
|
||||||
|
static boolean isDirectToolMessage(MessageEntity msg) {
|
||||||
|
return !directToolNamesIn(msg).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052: build the placeholder text used to replace a direct-tool
|
||||||
|
* assistant message in next-turn prompts. Includes the originating tool
|
||||||
|
* names so the model retains conversational structure (it knows *why*
|
||||||
|
* the content is redacted and *which* tool would re-fetch it). The
|
||||||
|
* original message stays unchanged in {@code mate_message.content}.
|
||||||
|
*
|
||||||
|
* <p>Worded as a neutral status line, not as a faux assistant utterance —
|
||||||
|
* the model treats it as a system-level note, not as previous output to
|
||||||
|
* be continued.
|
||||||
|
*/
|
||||||
|
static String directToolHistoryPlaceholder(List<String> toolNames) {
|
||||||
|
if (toolNames == null || toolNames.isEmpty()) {
|
||||||
|
return "[Previous answer was tool data returned directly to the user. " +
|
||||||
|
"Content withheld from model context per tool policy.]";
|
||||||
|
}
|
||||||
|
String joined = toolNames.size() == 1
|
||||||
|
? "'" + toolNames.get(0) + "'"
|
||||||
|
: toolNames.stream()
|
||||||
|
.map(n -> "'" + n + "'")
|
||||||
|
.reduce((a, b) -> a + ", " + b)
|
||||||
|
.orElse("");
|
||||||
|
return "[Previous turn used direct-return tool(s) " + joined + " to deliver " +
|
||||||
|
"data straight to the user. Content withheld from model context per tool " +
|
||||||
|
"policy. If the user asks a follow-up that requires that data, call the " +
|
||||||
|
"tool again.]";
|
||||||
|
}
|
||||||
|
|
||||||
private Message toSpringMessage(MessageEntity message) {
|
private Message toSpringMessage(MessageEntity message) {
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
return null;
|
return null;
|
||||||
@ -264,6 +421,24 @@ public abstract class BaseAgent {
|
|||||||
if (renderedContent == null || renderedContent.isBlank()) {
|
if (renderedContent == null || renderedContent.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// RFC-052: scrub direct-tool content from any subsequent LLM prompt.
|
||||||
|
// The DB content stays unchanged; only the in-memory Message handed to
|
||||||
|
// the model gets replaced. This is MateClaw's persistence-aware analog
|
||||||
|
// of joyagent-jdgenie's Memory.clearToolContext (purely in-memory) and
|
||||||
|
// OpenClaw's stripToolResultDetails (structural strip per replay).
|
||||||
|
//
|
||||||
|
// Unlike a generic "withheld" placeholder, we name the originating
|
||||||
|
// tool(s) so the model retains the dialog structure: it knows what
|
||||||
|
// kind of data was withheld and which tool would fetch it again. This
|
||||||
|
// preserves multi-turn coherence without leaking the payload itself.
|
||||||
|
if ("assistant".equals(message.getRole())) {
|
||||||
|
List<String> directNames = directToolNamesIn(message);
|
||||||
|
if (!directNames.isEmpty()) {
|
||||||
|
log.debug("[{}] Scrubbing direct-tool content from history msgId={} tools={} (RFC-052)",
|
||||||
|
agentName, message.getId(), directNames);
|
||||||
|
renderedContent = directToolHistoryPlaceholder(directNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
return switch (message.getRole()) {
|
return switch (message.getRole()) {
|
||||||
case "assistant" -> new AssistantMessage(renderedContent);
|
case "assistant" -> new AssistantMessage(renderedContent);
|
||||||
case "system" -> new SystemMessage(renderedContent);
|
case "system" -> new SystemMessage(renderedContent);
|
||||||
|
|||||||
@ -27,6 +27,14 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件记录
|
* 事件记录
|
||||||
@ -44,8 +52,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,8 +76,13 @@ 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();
|
||||||
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 ? truncateResult(result) : "",
|
||||||
"success", success,
|
"success", success,
|
||||||
@ -121,6 +149,39 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 提取方法 =====
|
// ===== 提取方法 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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.service.AgentBindingService;
|
import vip.mate.agent.binding.service.AgentBindingService;
|
||||||
@ -102,6 +103,33 @@ public class AgentBindingController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Workspace Verification ====================
|
// ==================== Workspace Verification ====================
|
||||||
|
|
||||||
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
|
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
|
||||||
|
|||||||
@ -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,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> {
|
||||||
|
}
|
||||||
@ -4,8 +4,10 @@ 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.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
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.repository.AgentProviderPreferenceMapper;
|
||||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||||
|
|
||||||
@ -30,6 +32,7 @@ public class AgentBindingService {
|
|||||||
|
|
||||||
private final AgentSkillBindingMapper skillBindingMapper;
|
private final AgentSkillBindingMapper skillBindingMapper;
|
||||||
private final AgentToolBindingMapper toolBindingMapper;
|
private final AgentToolBindingMapper toolBindingMapper;
|
||||||
|
private final AgentProviderPreferenceMapper providerPreferenceMapper;
|
||||||
|
|
||||||
// ==================== Skill Bindings ====================
|
// ==================== Skill Bindings ====================
|
||||||
|
|
||||||
@ -167,4 +170,52 @@ public class AgentBindingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
||||||
|
|
||||||
|
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */
|
||||||
|
public List<AgentProviderPreference> listProviderPreferences(Long agentId) {
|
||||||
|
return providerPreferenceMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<AgentProviderPreference>()
|
||||||
|
.eq(AgentProviderPreference::getAgentId, agentId)
|
||||||
|
.orderByAsc(AgentProviderPreference::getSortOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered list of provider ids the agent prefers, lowest sort_order
|
||||||
|
* first. Disabled rows are filtered out. Empty list means "no
|
||||||
|
* preference — fall back to the global chain order".
|
||||||
|
*
|
||||||
|
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
|
||||||
|
* fallback chain order per agent.</p>
|
||||||
|
*/
|
||||||
|
public List<String> getPreferredProviderIds(Long agentId) {
|
||||||
|
if (agentId == null) return Collections.emptyList();
|
||||||
|
return listProviderPreferences(agentId).stream()
|
||||||
|
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
|
||||||
|
.map(AgentProviderPreference::getProviderId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the full preference list for an agent. {@code providerIds}
|
||||||
|
* is the new ordered preference (index 0 = highest preference).
|
||||||
|
* Empty / null list clears all preferences for the agent.
|
||||||
|
*/
|
||||||
|
public void setProviderPreferences(Long agentId, List<String> providerIds) {
|
||||||
|
providerPreferenceMapper.delete(
|
||||||
|
new LambdaQueryWrapper<AgentProviderPreference>()
|
||||||
|
.eq(AgentProviderPreference::getAgentId, agentId));
|
||||||
|
if (providerIds == null) return;
|
||||||
|
int order = 0;
|
||||||
|
for (String providerId : providerIds) {
|
||||||
|
if (providerId == null || providerId.isBlank()) continue;
|
||||||
|
AgentProviderPreference row = new AgentProviderPreference();
|
||||||
|
row.setAgentId(agentId);
|
||||||
|
row.setProviderId(providerId.trim());
|
||||||
|
row.setSortOrder(order++);
|
||||||
|
row.setEnabled(true);
|
||||||
|
providerPreferenceMapper.insert(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,203 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import io.micrometer.observation.ObservationRegistry;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||||
|
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import vip.mate.agent.ThinkingLevelHolder;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.llm.cache.AnthropicCacheOptionsFactory;
|
||||||
|
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.model.ModelProtocol;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_MESSAGES}.
|
||||||
|
*
|
||||||
|
* <p>Owns the full Anthropic construction logic — API client + chat options
|
||||||
|
* including the extended-thinking budget mapping (low/medium/high/max →
|
||||||
|
* 4k/8k/16k/32k thinking tokens) and prompt-cache options. PR-0b moved this
|
||||||
|
* out of {@code AgentGraphBuilder}.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||||
|
|
||||||
|
private final ModelProviderService modelProviderService;
|
||||||
|
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||||
|
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||||
|
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||||
|
private final AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
|
||||||
|
|
||||||
|
public AgentAnthropicChatModelBuilder(
|
||||||
|
ModelProviderService modelProviderService,
|
||||||
|
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||||
|
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||||
|
ObjectProvider<ObservationRegistry> observationRegistryProvider,
|
||||||
|
AnthropicCacheOptionsFactory anthropicCacheOptionsFactory) {
|
||||||
|
this.modelProviderService = modelProviderService;
|
||||||
|
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||||
|
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||||
|
this.observationRegistryProvider = observationRegistryProvider;
|
||||||
|
this.anthropicCacheOptionsFactory = anthropicCacheOptionsFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModelProtocol supportedProtocol() {
|
||||||
|
return ModelProtocol.ANTHROPIC_MESSAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||||
|
AnthropicApi api = buildAnthropicApi(provider);
|
||||||
|
AnthropicChatOptions options = buildAnthropicOptions(model);
|
||||||
|
return AnthropicChatModel.builder()
|
||||||
|
.anthropicApi(api)
|
||||||
|
.defaultOptions(options)
|
||||||
|
.retryTemplate(retry)
|
||||||
|
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
|
||||||
|
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||||||
|
throw new MateClawException("err.agent.anthropic_not_configured",
|
||||||
|
"Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||||||
|
}
|
||||||
|
String apiKey = provider.getApiKey();
|
||||||
|
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||||
|
throw new MateClawException("err.agent.anthropic_key_invalid",
|
||||||
|
"Anthropic API Key 未配置或无效: " + provider.getProviderId());
|
||||||
|
}
|
||||||
|
String baseUrl = provider.getBaseUrl();
|
||||||
|
RestClient.Builder restClientBuilder = applyHttpTimeouts(
|
||||||
|
restClientBuilderProvider.getIfAvailable(RestClient::builder));
|
||||||
|
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
|
||||||
|
|
||||||
|
AnthropicApi.Builder builder = AnthropicApi.builder()
|
||||||
|
.apiKey(apiKey.trim())
|
||||||
|
.restClientBuilder(restClientBuilder)
|
||||||
|
.webClientBuilder(webClientBuilder);
|
||||||
|
if (StringUtils.hasText(baseUrl)) {
|
||||||
|
builder.baseUrl(baseUrl.trim());
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substrings used to detect Claude 4.7 model variants. Reference:
|
||||||
|
* hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
|
||||||
|
* Claude 4.7 returns HTTP 400 if any of {@code temperature}, {@code top_p},
|
||||||
|
* or {@code top_k} are set to non-default values, AND introduces an
|
||||||
|
* "xhigh" thinking effort level between high and max.
|
||||||
|
*/
|
||||||
|
static boolean isClaude47(String modelName) {
|
||||||
|
if (modelName == null) return false;
|
||||||
|
String lower = modelName.toLowerCase();
|
||||||
|
// Require the "claude" token to avoid false positives like "gpt-4-7"
|
||||||
|
// matching. Match both hyphenated (claude-opus-4-7, anthropic/claude-opus-4-7)
|
||||||
|
// and dotted (claude-opus-4.7, anthropic/claude-opus-4.7 via OpenRouter)
|
||||||
|
// forms. Also tolerates date-stamped variants (claude-opus-4-7-20260415).
|
||||||
|
if (!lower.contains("claude")) return false;
|
||||||
|
return lower.contains("4-7") || lower.contains("4.7");
|
||||||
|
}
|
||||||
|
|
||||||
|
AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||||||
|
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
|
||||||
|
String modelName = runtimeModel.getModelName();
|
||||||
|
if (StringUtils.hasText(modelName)) {
|
||||||
|
builder.model(modelName);
|
||||||
|
}
|
||||||
|
boolean isClaude47 = isClaude47(modelName);
|
||||||
|
|
||||||
|
// Extended thinking — request-level depth from ThinkingLevelHolder
|
||||||
|
String thinkingLevel = ThinkingLevelHolder.get();
|
||||||
|
boolean thinkingEnabled = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||||
|
|
||||||
|
if (thinkingEnabled) {
|
||||||
|
// Anthropic thinking-mode constraints (pre-4.7): temperature MUST be 1,
|
||||||
|
// top_p forbidden, max_tokens must accommodate budget_tokens + buffer.
|
||||||
|
// Claude 4.7 forbids temperature/top_p/top_k entirely (any non-null value
|
||||||
|
// → HTTP 400) and adds an "xhigh" budget tier between high and max.
|
||||||
|
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
|
||||||
|
case "low" -> 4096;
|
||||||
|
case "medium" -> 8192;
|
||||||
|
case "high" -> 16384;
|
||||||
|
case "xhigh" -> 24576; // 4.7 only — between high (16k) and max (32k)
|
||||||
|
case "max" -> 32768;
|
||||||
|
default -> 16384;
|
||||||
|
};
|
||||||
|
builder.thinking(AnthropicApi.ThinkingType.ENABLED, budgetTokens);
|
||||||
|
builder.maxTokens(Math.max(budgetTokens + 4096,
|
||||||
|
runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192));
|
||||||
|
// Claude 4.7: omit temperature entirely. Pre-4.7 thinking mode requires
|
||||||
|
// temperature=1 (Anthropic-mandated default for thinking).
|
||||||
|
if (!isClaude47) {
|
||||||
|
builder.temperature(1.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-thinking path.
|
||||||
|
// - Pre-4.7: Anthropic accepts EITHER temperature OR top_p (not both).
|
||||||
|
// - 4.7+: rejects all of temperature/top_p/top_k unless null/default. We
|
||||||
|
// omit them entirely so operators with legacy configs don't 400.
|
||||||
|
if (!isClaude47) {
|
||||||
|
if (runtimeModel.getTemperature() != null) {
|
||||||
|
builder.temperature(runtimeModel.getTemperature());
|
||||||
|
} else if (runtimeModel.getTopP() != null) {
|
||||||
|
builder.topP(runtimeModel.getTopP());
|
||||||
|
}
|
||||||
|
} else if (runtimeModel.getTemperature() != null || runtimeModel.getTopP() != null) {
|
||||||
|
log.debug("Ignoring temperature/top_p for Claude 4.7 model {} (API rejects sampling params)",
|
||||||
|
modelName);
|
||||||
|
}
|
||||||
|
// RFC-025: Anthropic rejects non-positive maxTokens — clamp here so a bad config
|
||||||
|
// surfaces as a logged warning instead of an opaque API 400 mid-conversation.
|
||||||
|
Integer configuredMax = runtimeModel.getMaxTokens();
|
||||||
|
if (configuredMax != null && configuredMax > 0) {
|
||||||
|
builder.maxTokens(configuredMax);
|
||||||
|
} else {
|
||||||
|
if (configuredMax != null) {
|
||||||
|
log.warn("Ignoring non-positive Anthropic maxTokens={} for model {}; falling back to 4096",
|
||||||
|
configuredMax, modelName);
|
||||||
|
}
|
||||||
|
builder.maxTokens(4096);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RFC-014: prompt cache (system / tools / conversation history) — Spring AI 1.1.4+ first-class.
|
||||||
|
builder.cacheOptions(anthropicCacheOptionsFactory.build());
|
||||||
|
|
||||||
|
return builder.internalToolExecutionEnabled(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply 10s connect / 180s read timeouts. The 180s read covers the case
|
||||||
|
* where nginx caps the gateway at 60s but a real long thinking response
|
||||||
|
* needs more — the upper retry layer takes over once we time out.
|
||||||
|
*
|
||||||
|
* <p>Package-private + static so {@code AgentClaudeCodeChatModelBuilder}
|
||||||
|
* (RFC-062) can apply the same timeouts to its OAuth RestClient without
|
||||||
|
* duplicating the snippet.</p>
|
||||||
|
*/
|
||||||
|
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
|
||||||
|
HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
|
||||||
|
rf.setReadTimeout(Duration.ofSeconds(180));
|
||||||
|
return builder.requestFactory(rf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.micrometer.observation.ObservationRegistry;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||||
|
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.model.NoopApiKey;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
|
||||||
|
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||||
|
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.model.ModelProtocol;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-062: Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
|
||||||
|
*
|
||||||
|
* <p>Sends Anthropic Messages API requests authenticated with the user's
|
||||||
|
* Claude Code OAuth subscription token instead of an API key — letting users
|
||||||
|
* with a Claude Pro/Max plan run MateClaw against their existing entitlement.
|
||||||
|
*
|
||||||
|
* <h2>How OAuth changes the wire format</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code Authorization: Bearer <oauth-token>} replaces {@code x-api-key}.
|
||||||
|
* Spring AI's {@link AnthropicApi} only sets {@code x-api-key} when the
|
||||||
|
* supplied {@code ApiKey.getValue()} returns a non-blank string, so we
|
||||||
|
* pass a {@link NoopApiKey} to satisfy the non-null assertion without
|
||||||
|
* leaking a key header.</li>
|
||||||
|
* <li>{@code anthropic-beta} must include {@code claude-code-20250219} and
|
||||||
|
* {@code oauth-2025-04-20} or Anthropic's edge intermittently 500s.
|
||||||
|
* We push these via {@link AnthropicApi.Builder#anthropicBetaFeatures}
|
||||||
|
* so Spring AI's existing header-merging logic still applies.</li>
|
||||||
|
* <li>{@code User-Agent: claude-cli/<ver>} (bare — no suffix) and
|
||||||
|
* {@code x-app: cli} masquerade as the Claude Code CLI. Suffix variants
|
||||||
|
* like {@code (external, cli)} are anti-abuse fingerprints; see
|
||||||
|
* {@link ClaudeCodeApiHeaders#userAgent()}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h2>Token lifecycle</h2>
|
||||||
|
* <p>Each {@link #build} call asks {@link ClaudeCodeOAuthService} for a valid
|
||||||
|
* access token. The service auto-refreshes when within 60s of expiry and
|
||||||
|
* persists the fresh credential back to whichever source (Keychain / JSON
|
||||||
|
* file) it originally read from. The constructed {@link AnthropicApi} pins
|
||||||
|
* the token at build time — for a multi-hour session this is fine because
|
||||||
|
* tokens last hours and Spring AI's call-site retry covers the rare case
|
||||||
|
* where a token rolls mid-call (next request rebuilds with a fresh token).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||||
|
|
||||||
|
private final AgentAnthropicChatModelBuilder anthropicBuilder;
|
||||||
|
private final ClaudeCodeOAuthService oauthService;
|
||||||
|
private final ClaudeCodeApiHeaders apiHeaders;
|
||||||
|
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||||
|
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||||
|
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public AgentClaudeCodeChatModelBuilder(
|
||||||
|
AgentAnthropicChatModelBuilder anthropicBuilder,
|
||||||
|
ClaudeCodeOAuthService oauthService,
|
||||||
|
ClaudeCodeApiHeaders apiHeaders,
|
||||||
|
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||||
|
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||||
|
ObjectProvider<ObservationRegistry> observationRegistryProvider,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.anthropicBuilder = anthropicBuilder;
|
||||||
|
this.oauthService = oauthService;
|
||||||
|
this.apiHeaders = apiHeaders;
|
||||||
|
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||||
|
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||||
|
this.observationRegistryProvider = observationRegistryProvider;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModelProtocol supportedProtocol() {
|
||||||
|
return ModelProtocol.ANTHROPIC_CLAUDE_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||||
|
// 1) Pull a fresh access token (auto-refreshes when near expiry; throws
|
||||||
|
// err.anthropic.no_claude_code or err.anthropic.token_expired_no_refresh
|
||||||
|
// so the UI / global handler can present an actionable message).
|
||||||
|
String accessToken = oauthService.getValidToken();
|
||||||
|
|
||||||
|
// 2) Build the Anthropic API client wired with OAuth headers.
|
||||||
|
AnthropicApi api = buildOauthAnthropicApi(accessToken);
|
||||||
|
|
||||||
|
// 3) Reuse the canonical Anthropic options builder — same Claude 4.7
|
||||||
|
// sampling-params handling, thinking-budget mapping, prompt cache.
|
||||||
|
AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model);
|
||||||
|
|
||||||
|
AnthropicChatModel raw = AnthropicChatModel.builder()
|
||||||
|
.anthropicApi(api)
|
||||||
|
.defaultOptions(options)
|
||||||
|
.retryTemplate(retry)
|
||||||
|
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 4) Wrap with the OAuth identity decorator. Anthropic's edge rate-limits /
|
||||||
|
// 5xxs requests that don't claim Claude Code identity in the system
|
||||||
|
// prompt — symptom: 429 rate_limit_error with body "Error" on quiet
|
||||||
|
// accounts. See ClaudeCodeIdentityChatModelDecorator javadoc.
|
||||||
|
return new ClaudeCodeIdentityChatModelDecorator(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct an {@link AnthropicApi} whose underlying RestClient + WebClient
|
||||||
|
* are pre-stamped with OAuth-mode headers. Package-private so unit tests
|
||||||
|
* can verify header composition without spinning up a chat model.
|
||||||
|
*/
|
||||||
|
AnthropicApi buildOauthAnthropicApi(String accessToken) {
|
||||||
|
String authHeader = apiHeaders.bearerAuth(accessToken);
|
||||||
|
String userAgent = apiHeaders.userAgent();
|
||||||
|
String xApp = apiHeaders.xApp();
|
||||||
|
String betas = apiHeaders.allBetas();
|
||||||
|
|
||||||
|
// Real Claude Code is an Electron + Node app that uses the official
|
||||||
|
// Anthropic JS SDK. The SDK auto-sets `accept: application/json` and
|
||||||
|
// `anthropic-dangerous-direct-browser-access: true` on every request.
|
||||||
|
// Spring AI's Java client doesn't, so Anthropic's edge fingerprint
|
||||||
|
// sees the missing headers and treats the traffic as suspicious —
|
||||||
|
// rate-limited harder than spec'd. Reference: openclaw
|
||||||
|
// anthropic-transport-stream.ts:567-574.
|
||||||
|
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
|
||||||
|
restClientBuilderProvider.getIfAvailable(RestClient::builder))
|
||||||
|
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
|
||||||
|
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
|
||||||
|
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
|
||||||
|
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
|
||||||
|
.defaultHeader("x-app", xApp)
|
||||||
|
// Rewrite system string → array before the request hits the wire.
|
||||||
|
// Anthropic's OAuth anti-abuse gate requires system to be an array;
|
||||||
|
// see ClaudeCodeSystemArrayInterceptor for the full explanation.
|
||||||
|
.requestInterceptor(new ClaudeCodeSystemArrayInterceptor(objectMapper))
|
||||||
|
// Diagnostic: log Anthropic's rate-limit headers on 429 so we
|
||||||
|
// can tell apart "5h Pro quota exhausted" (tokens-remaining=0,
|
||||||
|
// retry-after huge) from "anti-abuse gate" (tokens-remaining
|
||||||
|
// large, retry-after small) from "burst limit hit" without
|
||||||
|
// staring at SDK internals.
|
||||||
|
.requestInterceptor(new RateLimitDiagnosticInterceptor());
|
||||||
|
|
||||||
|
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
|
||||||
|
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
|
||||||
|
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
|
||||||
|
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
|
||||||
|
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
|
||||||
|
.defaultHeader("x-app", xApp)
|
||||||
|
// Rewrite system string → array (streaming path counterpart).
|
||||||
|
.filter(new ClaudeCodeSystemArrayExchangeFilter(objectMapper))
|
||||||
|
.filter(new RateLimitDiagnosticExchangeFilter());
|
||||||
|
|
||||||
|
// NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing
|
||||||
|
// skips x-api-key. The Builder.build() Assert.notNull on apiKey still
|
||||||
|
// passes because the object is non-null.
|
||||||
|
return AnthropicApi.builder()
|
||||||
|
.apiKey(new NoopApiKey())
|
||||||
|
.anthropicBetaFeatures(betas)
|
||||||
|
.restClientBuilder(restClientBuilder)
|
||||||
|
.webClientBuilder(webClientBuilder)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||||
|
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||||||
|
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||||
|
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||||
|
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.model.ModelProtocol;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy implementation for {@link ModelProtocol#DASHSCOPE_NATIVE}.
|
||||||
|
*
|
||||||
|
* <p>Owns all DashScope-specific construction logic (api + options) plus the
|
||||||
|
* fallback-chain helpers for resolving API key / Base URL when the provider
|
||||||
|
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
|
||||||
|
* so the agent package no longer carries any DashScope schema knowledge.</p>
|
||||||
|
*
|
||||||
|
* <p>DashScopeChatModel is injected via ObjectProvider so that the builder
|
||||||
|
* degrades gracefully when DashScope auto-configuration is disabled or the
|
||||||
|
* dependency is absent, rather than failing the entire application context.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
||||||
|
|
||||||
|
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
|
||||||
|
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||||
|
private final ModelProviderService modelProviderService;
|
||||||
|
|
||||||
|
public AgentDashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
|
||||||
|
DashScopeConnectionProperties dashScopeConnectionProperties,
|
||||||
|
ModelProviderService modelProviderService) {
|
||||||
|
this.dashScopeChatModelProvider = dashScopeChatModelProvider;
|
||||||
|
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
|
||||||
|
this.modelProviderService = modelProviderService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModelProtocol supportedProtocol() {
|
||||||
|
return ModelProtocol.DASHSCOPE_NATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||||
|
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
|
||||||
|
if (defaultModel == null) {
|
||||||
|
throw new MateClawException("err.agent.dashscope_unavailable",
|
||||||
|
"DashScope 自动配置未激活(可能缺少依赖或被排除),无法构建 DashScope 模型");
|
||||||
|
}
|
||||||
|
DashScopeApi api = buildDashScopeApi(provider);
|
||||||
|
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
|
||||||
|
return defaultModel.mutate()
|
||||||
|
.dashScopeApi(api)
|
||||||
|
.defaultOptions(options)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashScope's built-in web search is on by default; only an explicit
|
||||||
|
* {@code enableSearch=false} in provider kwargs disables it. Public so
|
||||||
|
* {@code AgentGraphBuilder.build()} can surface the "built-in search
|
||||||
|
* active" log once per agent.
|
||||||
|
*/
|
||||||
|
public boolean isBuiltinSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||||
|
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||||
|
Object kwargsSearch = kwargs.get("enableSearch");
|
||||||
|
if (kwargsSearch != null) {
|
||||||
|
return Boolean.TRUE.equals(kwargsSearch);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||||
|
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
|
||||||
|
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||||||
|
builder.withModel(runtimeModel.getModelName());
|
||||||
|
}
|
||||||
|
if (runtimeModel.getTemperature() != null) {
|
||||||
|
builder.withTemperature(runtimeModel.getTemperature());
|
||||||
|
}
|
||||||
|
if (runtimeModel.getMaxTokens() != null) {
|
||||||
|
builder.withMaxToken(runtimeModel.getMaxTokens());
|
||||||
|
}
|
||||||
|
if (runtimeModel.getTopP() != null) {
|
||||||
|
builder.withTopP(runtimeModel.getTopP());
|
||||||
|
}
|
||||||
|
if (isBuiltinSearchEnabled(runtimeModel, provider)) {
|
||||||
|
builder.withEnableSearch(true);
|
||||||
|
String strategy = runtimeModel.getSearchStrategy();
|
||||||
|
if (!StringUtils.hasText(strategy)) {
|
||||||
|
strategy = (String) kwargs.get("searchStrategy");
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(strategy)) {
|
||||||
|
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
|
||||||
|
.searchStrategy(strategy)
|
||||||
|
.enableSource(true)
|
||||||
|
.enableCitation(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
|
||||||
|
DashScopeApi.Builder builder = DashScopeApi.builder();
|
||||||
|
|
||||||
|
// API Key fallback chain: provider UI config → env / application.yml → default bean reflection
|
||||||
|
String apiKey = provider != null ? provider.getApiKey() : null;
|
||||||
|
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||||
|
apiKey = dashScopeConnectionProperties.getApiKey();
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||||
|
apiKey = readApiKeyFromDefaultChatModel();
|
||||||
|
}
|
||||||
|
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||||
|
throw new MateClawException("err.agent.dashscope_key_missing",
|
||||||
|
"DashScope API Key 未配置,请在模型设置中填写 dashscope 的 API Key,或设置 DASHSCOPE_API_KEY 环境变量");
|
||||||
|
}
|
||||||
|
builder.apiKey(apiKey.trim());
|
||||||
|
|
||||||
|
// Base URL fallback chain — same priority as API Key
|
||||||
|
String baseUrl = provider != null ? provider.getBaseUrl() : null;
|
||||||
|
if (!StringUtils.hasText(baseUrl)) {
|
||||||
|
baseUrl = dashScopeConnectionProperties.getBaseUrl();
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(baseUrl)) {
|
||||||
|
baseUrl = readBaseUrlFromDefaultChatModel();
|
||||||
|
}
|
||||||
|
String normalizedBaseUrl = normalizeDashScopeBaseUrl(baseUrl);
|
||||||
|
if (StringUtils.hasText(normalizedBaseUrl)) {
|
||||||
|
builder.baseUrl(normalizedBaseUrl);
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the OpenAI compatible-mode path off any user-supplied URL
|
||||||
|
* (common when migrating from compat-mode), trim trailing slash, and
|
||||||
|
* return null when the result is the SDK default — letting Spring AI's
|
||||||
|
* built-in default win avoids path-concat surprises.
|
||||||
|
*/
|
||||||
|
private String normalizeDashScopeBaseUrl(String baseUrl) {
|
||||||
|
if (baseUrl == null || baseUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = baseUrl.trim();
|
||||||
|
int compatibleIndex = normalized.indexOf("/compatible-mode/");
|
||||||
|
if (compatibleIndex >= 0) {
|
||||||
|
normalized = normalized.substring(0, compatibleIndex);
|
||||||
|
}
|
||||||
|
if (normalized.endsWith("/")) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1);
|
||||||
|
}
|
||||||
|
if ("https://dashscope.aliyuncs.com".equals(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Reflection helpers — read whatever the auto-configured default
|
||||||
|
// DashScopeChatModel was built with, as a final fallback when no
|
||||||
|
// explicit credentials reach us.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private String readApiKeyFromDefaultChatModel() {
|
||||||
|
try {
|
||||||
|
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||||
|
if (api == null) return null;
|
||||||
|
Field apiKeyField = DashScopeApi.class.getDeclaredField("apiKey");
|
||||||
|
apiKeyField.setAccessible(true);
|
||||||
|
Object apiKey = apiKeyField.get(api);
|
||||||
|
if (apiKey instanceof org.springframework.ai.model.ApiKey key) {
|
||||||
|
return key.getValue();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to read API key from default DashScopeChatModel: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readBaseUrlFromDefaultChatModel() {
|
||||||
|
try {
|
||||||
|
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||||
|
if (api == null) return null;
|
||||||
|
Field baseUrlField = DashScopeApi.class.getDeclaredField("baseUrl");
|
||||||
|
baseUrlField.setAccessible(true);
|
||||||
|
Object baseUrl = baseUrlField.get(api);
|
||||||
|
return baseUrl instanceof String value ? value : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to read baseUrl from default DashScopeChatModel: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
||||||
|
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
|
||||||
|
if (defaultModel == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
||||||
|
apiField.setAccessible(true);
|
||||||
|
Object api = apiField.get(defaultModel);
|
||||||
|
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import io.micrometer.observation.ObservationRegistry;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatModel;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
|
import org.springframework.ai.openai.api.OpenAiApi;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.AgentGraphBuilder;
|
||||||
|
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.model.ModelFamily;
|
||||||
|
import vip.mate.llm.model.ModelProtocol;
|
||||||
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
|
||||||
|
* Delegates to {@link AgentGraphBuilder}'s helpers; see
|
||||||
|
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
|
||||||
|
* pattern and the {@code @Lazy} cycle break.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||||
|
|
||||||
|
private final AgentGraphBuilder agentGraphBuilder;
|
||||||
|
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||||
|
|
||||||
|
public AgentOpenAiCompatibleChatModelBuilder(
|
||||||
|
@Lazy AgentGraphBuilder agentGraphBuilder,
|
||||||
|
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||||
|
this.agentGraphBuilder = agentGraphBuilder;
|
||||||
|
this.observationRegistryProvider = observationRegistryProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModelProtocol supportedProtocol() {
|
||||||
|
return ModelProtocol.OPENAI_COMPATIBLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||||
|
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider);
|
||||||
|
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
|
||||||
|
ChatModel raw = OpenAiChatModel.builder()
|
||||||
|
.openAiApi(api)
|
||||||
|
.defaultOptions(options)
|
||||||
|
.retryTemplate(retry)
|
||||||
|
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
|
||||||
|
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
|
||||||
|
// those directly — wrap with a per-request payload patcher. See
|
||||||
|
// DeepSeekV4ThinkingDecorator javadoc.
|
||||||
|
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
|
||||||
|
return new DeepSeekV4ThinkingDecorator(raw);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,324 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.model.Generation;
|
||||||
|
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.definition.DefaultToolDefinition;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-062: Claude Code OAuth identity transform applied to every Anthropic
|
||||||
|
* request when the underlying auth is a Claude Code OAuth token.
|
||||||
|
*
|
||||||
|
* <p>Anthropic's OAuth edge enforces an anti-abuse path that rate-limits
|
||||||
|
* (and intermittently 5xxs) requests claiming Claude Code identity but
|
||||||
|
* shaped differently from real Claude Code traffic. Symptoms:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>HTTP 429 with {@code rate_limit_error} on quiet accounts that haven't
|
||||||
|
* come close to their token budget — give-away is a body of just
|
||||||
|
* {@code {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}}
|
||||||
|
* (genuine quota exhaustion carries a descriptive message).</li>
|
||||||
|
* <li>Sporadic 500s on the first call after a long idle period.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request}
|
||||||
|
* lines 1571-1607 — same transforms applied unconditionally on
|
||||||
|
* {@code is_oauth=True} requests.
|
||||||
|
*
|
||||||
|
* <h2>Transforms applied per call</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li><b>System prompt prefix</b>: prepend
|
||||||
|
* {@code "You are Claude Code, Anthropic's official CLI for Claude."}.
|
||||||
|
* Insert a new SystemMessage if none exists.</li>
|
||||||
|
* <li><b>Brand scrub</b>: replace {@code "MateClaw"}/{@code "mateclaw"}
|
||||||
|
* in system text with their Claude Code equivalents — Anthropic's
|
||||||
|
* content filter flags identity contradictions.</li>
|
||||||
|
* <li><b>Tool {@code mcp_} prefix (outgoing)</b>: every tool definition
|
||||||
|
* sent to Anthropic is renamed {@code mcp_<orig>} — Claude Code
|
||||||
|
* runs all tools through MCP servers, so real Claude Code traffic
|
||||||
|
* always has the prefix. Mismatch trips anti-abuse.</li>
|
||||||
|
* <li><b>History tool_use prefix</b>: previously-issued tool calls in
|
||||||
|
* AssistantMessage history get the prefix re-applied (we strip on
|
||||||
|
* response, so they're stored unprefixed).</li>
|
||||||
|
* <li><b>Tool {@code mcp_} prefix (incoming)</b>: ChatResponse tool_use
|
||||||
|
* names are stripped of the {@code mcp_} prefix so MateClaw's tool
|
||||||
|
* registry can resolve them.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
|
||||||
|
|
||||||
|
/** Magic identity prefix Anthropic's OAuth edge requires in the system prompt. */
|
||||||
|
static final String CLAUDE_CODE_SYSTEM_PREFIX =
|
||||||
|
"You are Claude Code, Anthropic's official CLI for Claude.";
|
||||||
|
|
||||||
|
/** Tool-name prefix Claude Code uses for all MCP-routed tools. */
|
||||||
|
static final String MCP_TOOL_PREFIX = "mcp_";
|
||||||
|
|
||||||
|
private final ChatModel delegate;
|
||||||
|
|
||||||
|
public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatResponse call(Prompt prompt) {
|
||||||
|
return stripToolPrefixes(delegate.call(transform(prompt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||||
|
return delegate.stream(transform(prompt)).map(this::stripToolPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatOptions getDefaultOptions() {
|
||||||
|
return delegate.getDefaultOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================================================================== */
|
||||||
|
/* Outbound transform: Prompt → Prompt with identity + tool prefix */
|
||||||
|
/* ====================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a new {@link Prompt} with the OAuth identity transforms applied.
|
||||||
|
* Package-private for unit tests.
|
||||||
|
*/
|
||||||
|
Prompt transform(Prompt original) {
|
||||||
|
if (original == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Message> source = original.getInstructions();
|
||||||
|
List<Message> rewritten = new ArrayList<>(source.size() + 1);
|
||||||
|
|
||||||
|
boolean systemSeen = false;
|
||||||
|
for (Message msg : source) {
|
||||||
|
if (msg instanceof SystemMessage sm && !systemSeen) {
|
||||||
|
// Emit identity as its own block so Spring AI serialises system as an
|
||||||
|
// array. Anthropic's OAuth anti-abuse gate 429s when the identity prefix
|
||||||
|
// and additional content are merged into a single string, but accepts
|
||||||
|
// them as separate array elements (verified 2026-04-25).
|
||||||
|
rewritten.add(new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
|
||||||
|
String sanitized = sanitizeBranding(sm.getText());
|
||||||
|
if (sanitized != null && !sanitized.isBlank()) {
|
||||||
|
rewritten.add(new SystemMessage(sanitized));
|
||||||
|
}
|
||||||
|
systemSeen = true;
|
||||||
|
} else if (msg instanceof AssistantMessage am && am.hasToolCalls()) {
|
||||||
|
// Re-prefix tool_use names in history. We strip on response, so
|
||||||
|
// by the time MateClaw stores the AssistantMessage the names
|
||||||
|
// are unprefixed — must put the prefix back when echoing the
|
||||||
|
// history to Anthropic for it to match its own prior turn.
|
||||||
|
rewritten.add(rebuildAssistantMessage(am, true));
|
||||||
|
} else {
|
||||||
|
rewritten.add(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!systemSeen) {
|
||||||
|
rewritten.add(0, new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatOptions transformedOptions = transformOptions(original.getOptions());
|
||||||
|
return new Prompt(rewritten, transformedOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap each tool callback in the options so its {@code getToolDefinition().name()}
|
||||||
|
* returns {@code mcp_<orig>}. Spring AI sends those names verbatim to Anthropic.
|
||||||
|
* Other tool fields (description, schema) untouched. Returns {@code null} for
|
||||||
|
* non-Anthropic options so we don't accidentally drop them on a custom subclass.
|
||||||
|
*/
|
||||||
|
private ChatOptions transformOptions(ChatOptions options) {
|
||||||
|
if (!(options instanceof AnthropicChatOptions anthropicOpts)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
List<ToolCallback> originalCallbacks = anthropicOpts.getToolCallbacks();
|
||||||
|
Set<String> originalToolNames = anthropicOpts.getToolNames();
|
||||||
|
|
||||||
|
boolean hasCallbacks = originalCallbacks != null && !originalCallbacks.isEmpty();
|
||||||
|
boolean hasToolNames = originalToolNames != null && !originalToolNames.isEmpty();
|
||||||
|
if (!hasCallbacks && !hasToolNames) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(anthropicOpts);
|
||||||
|
if (hasCallbacks) {
|
||||||
|
List<ToolCallback> wrapped = new ArrayList<>(originalCallbacks.size());
|
||||||
|
for (ToolCallback cb : originalCallbacks) {
|
||||||
|
wrapped.add(cb instanceof PrefixedToolCallback ? cb : new PrefixedToolCallback(cb));
|
||||||
|
}
|
||||||
|
copy.setToolCallbacks(wrapped);
|
||||||
|
}
|
||||||
|
if (hasToolNames) {
|
||||||
|
// toolNames is a set used by Spring AI's tool resolver to filter from
|
||||||
|
// a wider registry. If MateClaw populates it (most paths use callbacks
|
||||||
|
// directly so this is rare), prefix the names so they line up with
|
||||||
|
// the wrapped callbacks above.
|
||||||
|
Set<String> prefixed = new LinkedHashSet<>(originalToolNames.size());
|
||||||
|
for (String n : originalToolNames) {
|
||||||
|
prefixed.add(n.startsWith(MCP_TOOL_PREFIX) ? n : MCP_TOOL_PREFIX + n);
|
||||||
|
}
|
||||||
|
copy.setToolNames(prefixed);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================================================================== */
|
||||||
|
/* Inbound transform: ChatResponse → strip tool prefix */
|
||||||
|
/* ====================================================================== */
|
||||||
|
|
||||||
|
ChatResponse stripToolPrefixes(ChatResponse response) {
|
||||||
|
if (response == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Generation> origGens = response.getResults();
|
||||||
|
if (origGens == null || origGens.isEmpty()) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
List<Generation> rewritten = null;
|
||||||
|
for (int i = 0; i < origGens.size(); i++) {
|
||||||
|
Generation g = origGens.get(i);
|
||||||
|
AssistantMessage am = g.getOutput();
|
||||||
|
if (am == null || !am.hasToolCalls()) continue;
|
||||||
|
boolean changed = false;
|
||||||
|
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||||
|
if (tc.name() != null && tc.name().startsWith(MCP_TOOL_PREFIX)) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) continue;
|
||||||
|
|
||||||
|
if (rewritten == null) {
|
||||||
|
rewritten = new ArrayList<>(origGens);
|
||||||
|
}
|
||||||
|
AssistantMessage stripped = rebuildAssistantMessage(am, false);
|
||||||
|
ChatGenerationMetadata meta = g.getMetadata();
|
||||||
|
rewritten.set(i, new Generation(stripped, meta));
|
||||||
|
}
|
||||||
|
if (rewritten == null) {
|
||||||
|
return response; // no tool_use blocks needed rewriting
|
||||||
|
}
|
||||||
|
return new ChatResponse(rewritten, response.getMetadata());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild an AssistantMessage with tool_call names prefixed (when
|
||||||
|
* {@code prefix=true}) or stripped (when {@code prefix=false}).
|
||||||
|
*/
|
||||||
|
private AssistantMessage rebuildAssistantMessage(AssistantMessage original, boolean prefix) {
|
||||||
|
List<AssistantMessage.ToolCall> rebuilt = new ArrayList<>(original.getToolCalls().size());
|
||||||
|
for (AssistantMessage.ToolCall tc : original.getToolCalls()) {
|
||||||
|
String name = tc.name();
|
||||||
|
String newName;
|
||||||
|
if (prefix) {
|
||||||
|
newName = (name == null || name.startsWith(MCP_TOOL_PREFIX)) ? name : MCP_TOOL_PREFIX + name;
|
||||||
|
} else {
|
||||||
|
newName = (name != null && name.startsWith(MCP_TOOL_PREFIX))
|
||||||
|
? name.substring(MCP_TOOL_PREFIX.length()) : name;
|
||||||
|
}
|
||||||
|
rebuilt.add(new AssistantMessage.ToolCall(tc.id(), tc.type(), newName, tc.arguments()));
|
||||||
|
}
|
||||||
|
return AssistantMessage.builder()
|
||||||
|
.content(original.getText())
|
||||||
|
.properties(original.getMetadata())
|
||||||
|
.toolCalls(rebuilt)
|
||||||
|
.media(original.getMedia())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================================================================== */
|
||||||
|
/* String helpers (system prompt + branding) */
|
||||||
|
/* ====================================================================== */
|
||||||
|
|
||||||
|
private static String prependIdentity(String existingSystem) {
|
||||||
|
if (existingSystem == null || existingSystem.isBlank()) {
|
||||||
|
return CLAUDE_CODE_SYSTEM_PREFIX;
|
||||||
|
}
|
||||||
|
if (existingSystem.startsWith(CLAUDE_CODE_SYSTEM_PREFIX)) {
|
||||||
|
return existingSystem;
|
||||||
|
}
|
||||||
|
return CLAUDE_CODE_SYSTEM_PREFIX + "\n\n" + existingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String sanitizeBranding(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
.replace("MateClaw", "Claude Code")
|
||||||
|
.replace("mateclaw", "claude-code")
|
||||||
|
.replace("Mate Claw", "Claude Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================================================================== */
|
||||||
|
/* PrefixedToolCallback — wraps a ToolCallback to expose the mcp_ name */
|
||||||
|
/* ====================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a {@link ToolCallback} so its {@code getToolDefinition().name()}
|
||||||
|
* returns {@code mcp_<orig>}, while {@code call(...)} forwards verbatim
|
||||||
|
* to the underlying tool. Anthropic sees the prefixed name on the wire;
|
||||||
|
* MateClaw's tool implementation never sees the prefix.
|
||||||
|
*/
|
||||||
|
static final class PrefixedToolCallback implements ToolCallback {
|
||||||
|
|
||||||
|
private final ToolCallback delegate;
|
||||||
|
private final ToolDefinition prefixedDefinition;
|
||||||
|
|
||||||
|
PrefixedToolCallback(ToolCallback delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
ToolDefinition orig = delegate.getToolDefinition();
|
||||||
|
String origName = orig.name();
|
||||||
|
String prefixed = (origName != null && origName.startsWith(MCP_TOOL_PREFIX))
|
||||||
|
? origName : MCP_TOOL_PREFIX + origName;
|
||||||
|
this.prefixedDefinition = DefaultToolDefinition.builder()
|
||||||
|
.name(prefixed)
|
||||||
|
.description(orig.description())
|
||||||
|
.inputSchema(orig.inputSchema())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolDefinition getToolDefinition() {
|
||||||
|
return prefixedDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolMetadata getToolMetadata() {
|
||||||
|
return delegate.getToolMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String call(String input) {
|
||||||
|
return delegate.call(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String call(String input, ToolContext context) {
|
||||||
|
return delegate.call(input, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolCallback unwrap() {
|
||||||
|
return delegate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
|
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
|
||||||
|
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||||
|
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||||
|
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||||
|
import org.springframework.web.reactive.function.client.ExchangeFunction;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient (streaming) counterpart of {@link ClaudeCodeSystemArrayInterceptor}.
|
||||||
|
*
|
||||||
|
* <p>Collects the full request body via {@code DataBufferUtils.join}, delegates
|
||||||
|
* the rewrite to {@link ClaudeCodeSystemArrayInterceptor#rewriteSystemField}, and
|
||||||
|
* emits the modified bytes as a single new {@link DataBuffer}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class ClaudeCodeSystemArrayExchangeFilter implements ExchangeFilterFunction {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
|
||||||
|
ClientRequest intercepted = ClientRequest.from(request)
|
||||||
|
.body((outputMessage, context) -> request.body().insert(
|
||||||
|
new ClientHttpRequestDecorator(outputMessage) {
|
||||||
|
@Override
|
||||||
|
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
|
||||||
|
return DataBufferUtils.join(Flux.from(body))
|
||||||
|
.flatMap(joined -> {
|
||||||
|
byte[] original = new byte[joined.readableByteCount()];
|
||||||
|
joined.read(original);
|
||||||
|
DataBufferUtils.release(joined);
|
||||||
|
|
||||||
|
byte[] rewritten = ClaudeCodeSystemArrayInterceptor
|
||||||
|
.rewriteSystemField(original, objectMapper);
|
||||||
|
|
||||||
|
long declared = getHeaders().getContentLength();
|
||||||
|
if (declared > 0 && declared != rewritten.length) {
|
||||||
|
getHeaders().setContentLength(rewritten.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.writeWith(Mono.just(
|
||||||
|
outputMessage.bufferFactory().wrap(rewritten)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, context))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return next.exchange(intercepted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpRequest;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RestClient interceptor that rewrites the Anthropic {@code system} field from
|
||||||
|
* a plain string to a two-element content-block array before the request hits
|
||||||
|
* the wire.
|
||||||
|
*
|
||||||
|
* <p>Anthropic's OAuth anti-abuse gate accepts the Claude Code identity prefix
|
||||||
|
* as a string ONLY when it is the sole content. Appending any additional text
|
||||||
|
* triggers a 429; two separate array elements always pass (verified 2026-04-25).
|
||||||
|
*
|
||||||
|
* <p>Spring AI's native array path is guarded by {@code @JsonIgnore cacheOptions}
|
||||||
|
* which {@code ModelOptionsUtils.copyToTarget} drops before our settings can
|
||||||
|
* reach {@code buildSystemContent}. This interceptor bypasses that by rewriting
|
||||||
|
* at the HTTP transport layer.
|
||||||
|
*
|
||||||
|
* <p>Sync (RestClient) variant; the WebFlux equivalent is
|
||||||
|
* {@link ClaudeCodeSystemArrayExchangeFilter}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class ClaudeCodeSystemArrayInterceptor implements ClientHttpRequestInterceptor {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||||
|
ClientHttpRequestExecution execution) throws IOException {
|
||||||
|
return execution.execute(request, rewriteSystemField(body, objectMapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If {@code body} is a JSON object whose {@code system} field is a string,
|
||||||
|
* replace it with a two-element content-block array:
|
||||||
|
* <pre>
|
||||||
|
* [ {"type":"text","text":"You are Claude Code..."}, {"type":"text","text":"<rest>"} ]
|
||||||
|
* </pre>
|
||||||
|
* Returns {@code body} unchanged on any error or if rewrite is not needed.
|
||||||
|
* Package-private static so {@link ClaudeCodeSystemArrayExchangeFilter} can reuse.
|
||||||
|
*/
|
||||||
|
static byte[] rewriteSystemField(byte[] body, ObjectMapper mapper) {
|
||||||
|
if (body == null || body.length == 0) return body;
|
||||||
|
try {
|
||||||
|
JsonNode root = mapper.readTree(body);
|
||||||
|
if (!root.isObject()) return body;
|
||||||
|
JsonNode systemNode = root.get("system");
|
||||||
|
if (systemNode == null || !systemNode.isTextual()) return body;
|
||||||
|
byte[] rewritten = mapper.writeValueAsBytes(buildRewritten((ObjectNode) root, systemNode.asText()));
|
||||||
|
log.debug("[ClaudeCodeSystem] rewrote system field to array ({} → {} bytes)",
|
||||||
|
body.length, rewritten.length);
|
||||||
|
return rewritten;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ClaudeCodeSystem] body rewrite failed, sending original: {}", e.getMessage());
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ObjectNode buildRewritten(ObjectNode root, String systemText) {
|
||||||
|
String identity = ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX;
|
||||||
|
ArrayNode arr = root.arrayNode();
|
||||||
|
|
||||||
|
ObjectNode identityBlock = arr.objectNode();
|
||||||
|
identityBlock.put("type", "text");
|
||||||
|
identityBlock.put("text", identity);
|
||||||
|
arr.add(identityBlock);
|
||||||
|
|
||||||
|
if (!systemText.equals(identity) && systemText.startsWith(identity)) {
|
||||||
|
String rest = systemText.substring(identity.length()).replaceFirst("^\n+", "");
|
||||||
|
if (!rest.isBlank()) {
|
||||||
|
ObjectNode contentBlock = arr.objectNode();
|
||||||
|
contentBlock.put("type", "text");
|
||||||
|
contentBlock.put("text", rest);
|
||||||
|
arr.add(contentBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode copy = root.deepCopy();
|
||||||
|
copy.set("system", arr);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.MessageType;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import vip.mate.agent.ThinkingLevelHolder;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC: DeepSeek V4 thinking-mode payload patcher applied to every
|
||||||
|
* {@code deepseek-v4-flash} / {@code deepseek-v4-pro} request.
|
||||||
|
*
|
||||||
|
* <p>DeepSeek V4 extends OpenAI's chat-completions wire format with two
|
||||||
|
* non-standard request fields that the base Spring AI {@link OpenAiChatOptions}
|
||||||
|
* has no first-class support for:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code thinking: {"type": "enabled" | "disabled"}} — toggles V4's
|
||||||
|
* step-by-step reasoning channel.</li>
|
||||||
|
* <li>{@code reasoning_effort: "low" | "medium" | "high"} — only meaningful
|
||||||
|
* when {@code thinking.type == "enabled"}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>It also has a strict replay contract: when thinking is enabled and the
|
||||||
|
* conversation contains prior assistant tool-calls, every such tool-call
|
||||||
|
* message must carry a {@code reasoning_content} string (empty allowed) or
|
||||||
|
* the API rejects with an obscure 400. When thinking is disabled, any prior
|
||||||
|
* {@code reasoning_content} must be stripped or DeepSeek echoes the old
|
||||||
|
* thinking back into the response.
|
||||||
|
*
|
||||||
|
* <p>Reference: openclaw {@code plugin-sdk/provider-stream-shared.ts}
|
||||||
|
* lines 185-213 ({@code createDeepSeekV4OpenAICompatibleThinkingWrapper}).
|
||||||
|
*
|
||||||
|
* <h2>Pipeline (per request)</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>Read {@link ThinkingLevelHolder} for the current request's thinking
|
||||||
|
* level (set by AgentService before the call).</li>
|
||||||
|
* <li>Clone {@link OpenAiChatOptions} and patch its {@code extraBody} +
|
||||||
|
* {@code reasoningEffort} fields. Spring AI sends {@code extraBody}
|
||||||
|
* verbatim in the JSON body, so the {@code thinking} key lands where
|
||||||
|
* DeepSeek expects it.</li>
|
||||||
|
* <li>Walk message history: when disabled, strip {@code reasoning_content}
|
||||||
|
* from {@link AssistantMessage} metadata; when enabled, ensure each
|
||||||
|
* tool-call message has a (possibly empty) {@code reasoning_content}
|
||||||
|
* entry to satisfy V4's replay contract.</li>
|
||||||
|
* <li>Delegate to the wrapped {@link ChatModel}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Spring AI 1.1.4's {@link OpenAiChatOptions} exposes a public
|
||||||
|
* {@code extraBody: Map<String, Object>} (verified via {@code javap}). No
|
||||||
|
* byte-level body patching needed — the simple path works.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class DeepSeekV4ThinkingDecorator implements ChatModel {
|
||||||
|
|
||||||
|
/** Metadata key under which we stash {@code reasoning_content} on AssistantMessage. */
|
||||||
|
static final String REASONING_CONTENT_KEY = "reasoning_content";
|
||||||
|
|
||||||
|
/** Request-body field DeepSeek V4 reads to toggle thinking mode. */
|
||||||
|
static final String THINKING_FIELD = "thinking";
|
||||||
|
|
||||||
|
private final ChatModel delegate;
|
||||||
|
|
||||||
|
public DeepSeekV4ThinkingDecorator(ChatModel delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatResponse call(Prompt prompt) {
|
||||||
|
return delegate.call(transform(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||||
|
return delegate.stream(transform(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatOptions getDefaultOptions() {
|
||||||
|
return delegate.getDefaultOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Outbound transform */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Build a new Prompt with thinking + reasoning_content patched. Package-private for tests. */
|
||||||
|
Prompt transform(Prompt original) {
|
||||||
|
if (original == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean thinkingEnabled = isThinkingEnabled();
|
||||||
|
ChatOptions patchedOptions = patchOptions(original.getOptions(), thinkingEnabled);
|
||||||
|
List<Message> patchedMessages = patchMessages(original.getInstructions(), thinkingEnabled);
|
||||||
|
return new Prompt(patchedMessages, patchedOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isThinkingEnabled() {
|
||||||
|
String level = ThinkingLevelHolder.get();
|
||||||
|
// null/empty → fall back to enabled (V4's default behavior is reasoning-on);
|
||||||
|
// explicit "off" → disabled.
|
||||||
|
return level == null || level.isBlank() || !"off".equalsIgnoreCase(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map MateClaw's thinking levels (off/low/medium/high/max) to DeepSeek's
|
||||||
|
* accepted reasoning_effort values. Aligns with openclaw
|
||||||
|
* {@code resolveDeepSeekV4ReasoningEffort}: max collapses into high since
|
||||||
|
* DeepSeek doesn't expose a "max" tier on V4.
|
||||||
|
*/
|
||||||
|
static String mapEffort(String level) {
|
||||||
|
if (level == null || level.isBlank()) return "medium";
|
||||||
|
return switch (level.toLowerCase()) {
|
||||||
|
case "low" -> "low";
|
||||||
|
case "medium" -> "medium";
|
||||||
|
case "high", "max" -> "high";
|
||||||
|
default -> "medium";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone {@link OpenAiChatOptions} and inject extraBody.thinking + reasoning_effort.
|
||||||
|
* Returns the input unchanged for non-OpenAI options (defensive — should
|
||||||
|
* never happen for V4, but skips ahead-of-binding work in tests that pass
|
||||||
|
* vanilla {@link ChatOptions}).
|
||||||
|
*/
|
||||||
|
private static ChatOptions patchOptions(ChatOptions original, boolean enabled) {
|
||||||
|
if (!(original instanceof OpenAiChatOptions oai)) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
OpenAiChatOptions copy = OpenAiChatOptions.fromOptions(oai);
|
||||||
|
Map<String, Object> extra = copy.getExtraBody();
|
||||||
|
Map<String, Object> patched = (extra == null) ? new LinkedHashMap<>() : new LinkedHashMap<>(extra);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
patched.put(THINKING_FIELD, Map.of("type", "enabled"));
|
||||||
|
// reasoning_effort is a first-class OpenAiChatOptions field → set via setter.
|
||||||
|
String level = ThinkingLevelHolder.get();
|
||||||
|
copy.setReasoningEffort(mapEffort(level));
|
||||||
|
} else {
|
||||||
|
patched.put(THINKING_FIELD, Map.of("type", "disabled"));
|
||||||
|
// Drop reasoning_effort — DeepSeek 400s if both are present with thinking disabled.
|
||||||
|
copy.setReasoningEffort(null);
|
||||||
|
}
|
||||||
|
copy.setExtraBody(patched);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk message history and patch reasoning_content per V4's contract:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>enabled</b>: every assistant tool-call message must carry a
|
||||||
|
* (possibly empty) {@code reasoning_content} entry in its metadata.</li>
|
||||||
|
* <li><b>disabled</b>: strip any {@code reasoning_content} from prior
|
||||||
|
* messages so DeepSeek doesn't echo stale reasoning back.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
static List<Message> patchMessages(List<Message> source, boolean enabled) {
|
||||||
|
if (source == null || source.isEmpty()) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
List<Message> out = new ArrayList<>(source.size());
|
||||||
|
for (Message msg : source) {
|
||||||
|
if (msg.getMessageType() == MessageType.ASSISTANT && msg instanceof AssistantMessage am) {
|
||||||
|
out.add(rewriteAssistant(am, enabled));
|
||||||
|
} else {
|
||||||
|
out.add(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssistantMessage rewriteAssistant(AssistantMessage am, boolean enabled) {
|
||||||
|
Map<String, Object> meta = am.getMetadata();
|
||||||
|
boolean hasTools = am.hasToolCalls();
|
||||||
|
boolean hasReasoning = meta != null && meta.containsKey(REASONING_CONTENT_KEY);
|
||||||
|
|
||||||
|
// Fast path: no rewrite needed.
|
||||||
|
if (enabled && (!hasTools || hasReasoning)) {
|
||||||
|
return am;
|
||||||
|
}
|
||||||
|
if (!enabled && !hasReasoning) {
|
||||||
|
return am;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> newMeta = (meta == null) ? new HashMap<>() : new HashMap<>(meta);
|
||||||
|
if (enabled) {
|
||||||
|
// Tool-call messages need reasoning_content present (empty OK) for replay.
|
||||||
|
newMeta.putIfAbsent(REASONING_CONTENT_KEY, "");
|
||||||
|
} else {
|
||||||
|
// Drop reasoning_content entirely — DeepSeek mirrors back stale thinking otherwise.
|
||||||
|
newMeta.remove(REASONING_CONTENT_KEY);
|
||||||
|
}
|
||||||
|
return AssistantMessage.builder()
|
||||||
|
.content(am.getText())
|
||||||
|
.properties(newMeta)
|
||||||
|
.toolCalls(am.getToolCalls())
|
||||||
|
.media(am.getMedia())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
|
||||||
|
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||||
|
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||||
|
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||||
|
import org.springframework.web.reactive.function.client.ExchangeFunction;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient (streaming) counterpart of {@link RateLimitDiagnosticInterceptor}.
|
||||||
|
*
|
||||||
|
* <p>On 429, logs outgoing request headers (sanitized), a body preview captured
|
||||||
|
* non-destructively via {@link DataBuffer#toByteBuffer(int, int)}, and the
|
||||||
|
* {@code anthropic-ratelimit-*} response headers. Delegates constant and
|
||||||
|
* formatting logic to the shared statics on {@link RateLimitDiagnosticInterceptor}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
class RateLimitDiagnosticExchangeFilter implements ExchangeFilterFunction {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
|
||||||
|
AtomicReference<String> capturedBody = new AtomicReference<>();
|
||||||
|
|
||||||
|
ClientRequest intercepted = ClientRequest.from(request)
|
||||||
|
.body((outputMessage, context) -> request.body().insert(
|
||||||
|
new ClientHttpRequestDecorator(outputMessage) {
|
||||||
|
@Override
|
||||||
|
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
|
||||||
|
return super.writeWith(
|
||||||
|
Flux.from(body).doOnNext(buf -> {
|
||||||
|
if (capturedBody.get() == null) {
|
||||||
|
int len = Math.min(buf.readableByteCount(),
|
||||||
|
RateLimitDiagnosticInterceptor.BODY_LOG_LIMIT);
|
||||||
|
ByteBuffer view = buf.toByteBuffer(buf.readPosition(), len);
|
||||||
|
byte[] bytes = new byte[len];
|
||||||
|
view.get(bytes);
|
||||||
|
capturedBody.compareAndSet(null,
|
||||||
|
new String(bytes, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, context))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return next.exchange(intercepted).doOnNext(response -> {
|
||||||
|
if (response.statusCode().value() == 429) {
|
||||||
|
RateLimitDiagnosticInterceptor.logRequestHeaders(request.headers());
|
||||||
|
String preview = capturedBody.get();
|
||||||
|
log.warn("[Anthropic 429] request body preview: {}",
|
||||||
|
preview != null ? preview : "(not captured)");
|
||||||
|
RateLimitDiagnosticInterceptor.logResponseHeaders(
|
||||||
|
response.headers().asHttpHeaders());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
package vip.mate.agent.chatmodel;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpRequest;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On 429, logs the outgoing request headers (sanitized), the request body
|
||||||
|
* (first {@link #BODY_LOG_LIMIT} bytes), and the {@code anthropic-ratelimit-*}
|
||||||
|
* response headers to distinguish three failure modes:
|
||||||
|
*
|
||||||
|
* <table>
|
||||||
|
* <caption>How to read the response headers</caption>
|
||||||
|
* <tr><th>Failure mode</th><th>tokens-remaining</th><th>retry-after</th></tr>
|
||||||
|
* <tr><td>5h Pro/Max quota exhausted</td><td>0</td><td>thousands of seconds</td></tr>
|
||||||
|
* <tr><td>Anti-abuse fingerprint gate</td><td>(absent)</td><td>(absent)</td></tr>
|
||||||
|
* <tr><td>Per-minute burst limit</td><td>large</td><td>single-digit seconds</td></tr>
|
||||||
|
* </table>
|
||||||
|
*
|
||||||
|
* <p>Sync (RestClient) variant; the WebFlux equivalent is
|
||||||
|
* {@link RateLimitDiagnosticExchangeFilter}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor {
|
||||||
|
|
||||||
|
static final int BODY_LOG_LIMIT = 16384;
|
||||||
|
|
||||||
|
static final List<String> RATE_LIMIT_HEADERS = List.of(
|
||||||
|
"anthropic-ratelimit-requests-limit",
|
||||||
|
"anthropic-ratelimit-requests-remaining",
|
||||||
|
"anthropic-ratelimit-requests-reset",
|
||||||
|
"anthropic-ratelimit-tokens-limit",
|
||||||
|
"anthropic-ratelimit-tokens-remaining",
|
||||||
|
"anthropic-ratelimit-tokens-reset",
|
||||||
|
"anthropic-ratelimit-input-tokens-limit",
|
||||||
|
"anthropic-ratelimit-input-tokens-remaining",
|
||||||
|
"anthropic-ratelimit-input-tokens-reset",
|
||||||
|
"anthropic-ratelimit-output-tokens-limit",
|
||||||
|
"anthropic-ratelimit-output-tokens-remaining",
|
||||||
|
"anthropic-ratelimit-output-tokens-reset",
|
||||||
|
"retry-after");
|
||||||
|
|
||||||
|
static final List<String> REQUEST_HEADERS_TO_LOG = List.of(
|
||||||
|
"authorization",
|
||||||
|
"user-agent",
|
||||||
|
"accept",
|
||||||
|
"x-app",
|
||||||
|
"anthropic-beta",
|
||||||
|
"anthropic-version",
|
||||||
|
"anthropic-dangerous-direct-browser-access");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||||
|
ClientHttpRequestExecution execution) throws IOException {
|
||||||
|
ClientHttpResponse response = execution.execute(request, body);
|
||||||
|
if (response.getStatusCode().value() == 429) {
|
||||||
|
logRequestHeaders(request.getHeaders());
|
||||||
|
logRequestBody(body);
|
||||||
|
logResponseHeaders(response.getHeaders());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void logRequestHeaders(HttpHeaders headers) {
|
||||||
|
StringBuilder sb = new StringBuilder("[Anthropic 429] outgoing request headers (sanitized): ");
|
||||||
|
boolean first = true;
|
||||||
|
for (String name : REQUEST_HEADERS_TO_LOG) {
|
||||||
|
String value = headers.getFirst(name);
|
||||||
|
if (value == null) continue;
|
||||||
|
if (!first) sb.append(", ");
|
||||||
|
first = false;
|
||||||
|
if ("authorization".equalsIgnoreCase(name) && value.startsWith("Bearer ")) {
|
||||||
|
sb.append(name).append("=Bearer <redacted>");
|
||||||
|
} else {
|
||||||
|
sb.append(name).append('=').append(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void logRequestBody(byte[] body) {
|
||||||
|
if (body == null || body.length == 0) {
|
||||||
|
log.warn("[Anthropic 429] request body: (empty)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int len = Math.min(body.length, BODY_LOG_LIMIT);
|
||||||
|
log.warn("[Anthropic 429] request body (first {} of {} bytes): {}",
|
||||||
|
len, body.length, new String(body, 0, len, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void logResponseHeaders(HttpHeaders headers) {
|
||||||
|
StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit response headers: ");
|
||||||
|
boolean any = false;
|
||||||
|
for (String name : RATE_LIMIT_HEADERS) {
|
||||||
|
String value = headers.getFirst(name);
|
||||||
|
if (value != null) {
|
||||||
|
if (any) sb.append(", ");
|
||||||
|
sb.append(name).append('=').append(value);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) {
|
||||||
|
log.warn("[Anthropic 429] no rate-limit response headers — likely anti-abuse gate, not real quota");
|
||||||
|
} else {
|
||||||
|
log.warn(sb.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,102 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
|
||||||
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath) {
|
||||||
|
return new ChatOrigin(null, conversationId,
|
||||||
|
requesterId != null ? requesterId : "",
|
||||||
|
workspaceId, workspaceBasePath, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Wither-style updates ----------------
|
||||||
|
|
||||||
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
|
@Nullable String newWorkspaceBasePath) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 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.agent.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -435,19 +435,20 @@ public class ConversationWindowManager {
|
|||||||
String systemPrompt;
|
String systemPrompt;
|
||||||
String userPrompt;
|
String userPrompt;
|
||||||
|
|
||||||
|
// System prompt always carries the budget directive; both branches
|
||||||
|
// must replace the placeholder. The previous code applied the
|
||||||
|
// replace only on the first-compression branch, so iterative-mode
|
||||||
|
// calls leaked the literal "{summary_budget}" string to the LLM.
|
||||||
|
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
|
||||||
|
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
||||||
if (previousSummary != null) {
|
if (previousSummary != null) {
|
||||||
// 迭代更新模式:旧摘要 + 新轮次
|
// Iterative update: previous summary + new turns.
|
||||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM;
|
|
||||||
userPrompt = STRUCTURED_SUMMARY_UPDATE
|
userPrompt = STRUCTURED_SUMMARY_UPDATE
|
||||||
.replace("{previous_summary}", previousSummary)
|
.replace("{previous_summary}", previousSummary)
|
||||||
.replace("{conversation}", conversationText)
|
.replace("{conversation}", conversationText);
|
||||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
|
||||||
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
|
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
|
||||||
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
|
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
|
||||||
} else {
|
} else {
|
||||||
// 首次压缩
|
|
||||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
|
|
||||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
|
||||||
userPrompt = STRUCTURED_SUMMARY_USER
|
userPrompt = STRUCTURED_SUMMARY_USER
|
||||||
.replace("{conversation}", conversationText);
|
.replace("{conversation}", conversationText);
|
||||||
log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId);
|
log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId);
|
||||||
|
|||||||
@ -4,15 +4,20 @@ 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.audit.service.AuditEventService;
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
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;
|
||||||
@ -33,6 +38,8 @@ 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 ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
@Operation(summary = "获取Agent列表")
|
@Operation(summary = "获取Agent列表")
|
||||||
@ -60,9 +67,12 @@ public class AgentController {
|
|||||||
@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);
|
||||||
@ -84,11 +94,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();
|
||||||
@ -105,7 +128,8 @@ public class AgentController {
|
|||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
|
|
||||||
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)
|
||||||
@ -183,4 +207,23 @@ public class AgentController {
|
|||||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,24 @@ 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,
|
||||||
|
Authentication auth) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
Long userId = resolveUserId(auth);
|
||||||
|
return R.ok(templateService.applyTemplate(id, wsId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,12 @@ package vip.mate.agent.graph;
|
|||||||
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;
|
||||||
|
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.model.ChatResponse;
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
import vip.mate.agent.AssistantThinkingRelay;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
|
||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
@ -44,17 +46,176 @@ public class NodeStreamingChatHelper {
|
|||||||
|
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
/** 备选模型(主模型连续失败后使用) */
|
/**
|
||||||
private final ChatModel fallbackModel;
|
* Ordered fallback chain tried after the primary model exhausts retries.
|
||||||
|
* Each entry is attempted once (no retry); the first successful response
|
||||||
|
* wins. Empty list disables fallover entirely. See RFC-009.
|
||||||
|
*
|
||||||
|
* <p>Stored as {@link vip.mate.llm.failover.FallbackEntry} (providerId +
|
||||||
|
* ChatModel) so the chain walker can consult {@link vip.mate.llm.failover.ProviderHealthTracker}
|
||||||
|
* — cooldown state is keyed by providerId, not by ChatModel instance.</p>
|
||||||
|
*/
|
||||||
|
private final List<vip.mate.llm.failover.FallbackEntry> fallbackChain;
|
||||||
|
|
||||||
|
/** Optional cache-metrics aggregator; {@code null} in tests or when the bean is absent. */
|
||||||
|
private final vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics;
|
||||||
|
|
||||||
|
/** Optional per-provider health tracker; {@code null} in tests or when bean absent. */
|
||||||
|
private final vip.mate.llm.failover.ProviderHealthTracker healthTracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider id of the primary {@link ChatModel} this helper drives. Used
|
||||||
|
* by {@link #streamCallInternal} to consult / update {@link #healthTracker}
|
||||||
|
* for the primary too — if a provider's API key is revoked, primary
|
||||||
|
* cooldown lets us bypass the 5-retry stall on subsequent calls within
|
||||||
|
* the same conversation. Falls back to {@code null} when unknown
|
||||||
|
* (legacy callers, tests).
|
||||||
|
*/
|
||||||
|
private final String primaryProviderId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-009 Phase 4: membership gate for usable providers. A provider is
|
||||||
|
* removed from the pool on HARD errors (AUTH_ERROR / BILLING /
|
||||||
|
* MODEL_NOT_FOUND) so subsequent requests skip it entirely without
|
||||||
|
* burning a round-trip. {@code null} disables the gate (legacy callers,
|
||||||
|
* tests) — every provider then counts as in-pool (fail-open).
|
||||||
|
*/
|
||||||
|
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
|
||||||
|
|
||||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
||||||
this.streamTracker = streamTracker;
|
this(streamTracker, List.of(), null, null, null, null);
|
||||||
this.fallbackModel = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use the full constructor — a single fallback cannot
|
||||||
|
* express the ordered multi-provider chain from RFC-009.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
|
||||||
|
this(streamTracker, wrap(fallbackModel), null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use the full constructor.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
|
||||||
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
||||||
|
this(streamTracker, wrap(fallbackModel), cacheMetrics, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chain constructor without health tracker — primarily for tests and
|
||||||
|
* legacy wiring. Production callers should use the full constructor.
|
||||||
|
*/
|
||||||
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
|
||||||
|
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
|
||||||
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
||||||
|
this(streamTracker, fallbackChain, cacheMetrics, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with health tracker but unknown primary provider — used by
|
||||||
|
* tests where the helper isn't tied to a specific primary. Primary
|
||||||
|
* health tracking is disabled for instances built this way.
|
||||||
|
*/
|
||||||
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
|
||||||
|
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
|
||||||
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
|
||||||
|
vip.mate.llm.failover.ProviderHealthTracker healthTracker) {
|
||||||
|
this(streamTracker, fallbackChain, cacheMetrics, healthTracker, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor that wires health tracker + primary provider id but leaves
|
||||||
|
* the {@link vip.mate.llm.failover.AvailableProviderPool} disabled. Kept
|
||||||
|
* so existing tests (e.g. {@code NodeStreamingChatHelperFailoverTest})
|
||||||
|
* compile unchanged — they don't exercise the pool gate.
|
||||||
|
*/
|
||||||
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
|
||||||
|
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
|
||||||
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
|
||||||
|
vip.mate.llm.failover.ProviderHealthTracker healthTracker,
|
||||||
|
String primaryProviderId) {
|
||||||
|
this(streamTracker, fallbackChain, cacheMetrics, healthTracker, primaryProviderId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full constructor — preferred for production wiring. The
|
||||||
|
* {@link vip.mate.llm.failover.AvailableProviderPool} hookup gates both
|
||||||
|
* the primary short-circuit and the fallback walker; passing {@code null}
|
||||||
|
* runs in fail-open mode (every provider counted as in-pool).
|
||||||
|
*/
|
||||||
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
|
||||||
|
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
|
||||||
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
|
||||||
|
vip.mate.llm.failover.ProviderHealthTracker healthTracker,
|
||||||
|
String primaryProviderId,
|
||||||
|
vip.mate.llm.failover.AvailableProviderPool providerPool) {
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
this.fallbackModel = fallbackModel;
|
this.fallbackChain = fallbackChain == null ? List.of() : List.copyOf(fallbackChain);
|
||||||
|
this.cacheMetrics = cacheMetrics;
|
||||||
|
this.healthTracker = healthTracker;
|
||||||
|
this.primaryProviderId = primaryProviderId;
|
||||||
|
this.providerPool = providerPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<vip.mate.llm.failover.FallbackEntry> wrap(ChatModel m) {
|
||||||
|
// Legacy single-fallback path: providerId is unknown so health tracking
|
||||||
|
// is silently disabled for that one entry (it gets a synthetic id).
|
||||||
|
return m == null ? List.of() : List.of(new vip.mate.llm.failover.FallbackEntry("__legacy__", m));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a single primary-model outcome to the health tracker. No-op when
|
||||||
|
* either the tracker bean isn't wired or the primary's providerId is
|
||||||
|
* unknown (e.g., tests, legacy callers built without the full constructor).
|
||||||
|
*/
|
||||||
|
private void recordPrimary(boolean success) {
|
||||||
|
if (healthTracker == null || primaryProviderId == null) return;
|
||||||
|
if (success) healthTracker.recordSuccess(primaryProviderId);
|
||||||
|
else healthTracker.recordFailure(primaryProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-009 Phase 4 — map an {@link ErrorType} to the matching pool
|
||||||
|
* {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for
|
||||||
|
* HARD failures (AUTH / BILLING / MODEL_NOT_FOUND). Returns {@code null}
|
||||||
|
* for SOFT errors and benign types — those keep the provider in-pool and
|
||||||
|
* are handled by {@link vip.mate.llm.failover.ProviderHealthTracker}'s
|
||||||
|
* cooldown instead.
|
||||||
|
*/
|
||||||
|
private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) {
|
||||||
|
if (type == null) return null;
|
||||||
|
return switch (type) {
|
||||||
|
case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR;
|
||||||
|
case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING;
|
||||||
|
case MODEL_NOT_FOUND -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.MODEL_NOT_FOUND;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */
|
||||||
|
private boolean inPool(String providerId) {
|
||||||
|
return providerPool == null || providerId == null || providerPool.contains(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the given provider from the pool if {@code errorType} is HARD
|
||||||
|
* (AUTH_ERROR / BILLING / MODEL_NOT_FOUND). No-op when the pool is
|
||||||
|
* disabled, the provider id is unknown, or the error is SOFT.
|
||||||
|
*/
|
||||||
|
private void removeFromPool(String providerId, ErrorType errorType, String message) {
|
||||||
|
if (providerPool == null || providerId == null) return;
|
||||||
|
var source = hardRemovalSource(errorType);
|
||||||
|
if (source == null) return;
|
||||||
|
providerPool.remove(providerId, source, message != null ? message : errorType.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defensively re-affirm pool membership after a successful call. Idempotent + cheap. */
|
||||||
|
private void addToPool(String providerId) {
|
||||||
|
if (providerPool == null || providerId == null) return;
|
||||||
|
providerPool.add(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,9 +258,25 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a lightweight progress event so the frontend shows activity
|
||||||
|
* during silent LLM calls (e.g. triage). Sent as a "progress" SSE event.
|
||||||
|
*/
|
||||||
|
public void broadcastProgress(String conversationId, String message) {
|
||||||
|
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamTracker.broadcastObject(conversationId, "progress",
|
||||||
|
Map.of("message", message != null ? message : ""));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 重试配置 ====================
|
// ==================== 重试配置 ====================
|
||||||
|
|
||||||
private static final int MAX_RETRIES = 5;
|
private static final int MAX_RETRIES = 5;
|
||||||
|
// RATE_LIMIT: fail fast to failover chain — staying on the same
|
||||||
|
// provider during a rate-limit window wastes time without recovery.
|
||||||
|
// SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal).
|
||||||
|
private static final int MAX_RETRIES_RATE_LIMIT = 2;
|
||||||
private static final long BACKOFF_BASE_MS = 3000;
|
private static final long BACKOFF_BASE_MS = 3000;
|
||||||
private static final long BACKOFF_CAP_MS = 60_000;
|
private static final long BACKOFF_CAP_MS = 60_000;
|
||||||
|
|
||||||
@ -148,24 +325,43 @@ public class NodeStreamingChatHelper {
|
|||||||
|| msg.contains("thinking block")) {
|
|| msg.contains("thinking block")) {
|
||||||
return ErrorType.THINKING_BLOCK_ERROR;
|
return ErrorType.THINKING_BLOCK_ERROR;
|
||||||
}
|
}
|
||||||
|
// RFC-009 P3.2: BILLING — payment / quota exhausted. Distinct from AUTH because
|
||||||
|
// a different provider may have credits, so we should fall back instead of
|
||||||
|
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
|
||||||
|
// ("credit balance is too low") use these phrases in 402-class responses.
|
||||||
|
if (msg.contains("402") || msg.contains("insufficient_quota")
|
||||||
|
|| msg.contains("credit balance is too low")
|
||||||
|
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|
||||||
|
|| msg.contains("You exceeded your current quota")
|
||||||
|
|| msg.contains("quota exceeded") || msg.contains("Quota exceeded")) {
|
||||||
|
return ErrorType.BILLING;
|
||||||
|
}
|
||||||
|
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
||||||
|
// Includes DashScope's "[InvalidParameter] url error, please check url"
|
||||||
|
// (https://help.aliyun.com/zh/model-studio/error-code#error-url) which despite
|
||||||
|
// the wording is the provider rejecting an unknown/unsupported model id on
|
||||||
|
// the native protocol. Splitting this out from CLIENT_ERROR lets us hand off
|
||||||
|
// to the fallback chain instead of terminating — a different provider may
|
||||||
|
// recognize the model name (or have an equivalent default).
|
||||||
|
if (msg.contains("Model not exist")
|
||||||
|
|| msg.contains("model_not_found")
|
||||||
|
|| msg.contains("Model not found")
|
||||||
|
|| msg.contains("does not exist")
|
||||||
|
|| msg.contains("[InvalidParameter]")
|
||||||
|
|| msg.contains("InvalidParameter")
|
||||||
|
|| msg.contains("url error")
|
||||||
|
// Volcano Ark: model exists but the user's account hasn't opened it,
|
||||||
|
// or the id isn't valid for this region. Both are hard failures —
|
||||||
|
// retrying won't help, and a different provider may serve the model.
|
||||||
|
|| msg.contains("ModelNotOpen")
|
||||||
|
|| msg.contains("InvalidEndpointOrModel")) {
|
||||||
|
return ErrorType.MODEL_NOT_FOUND;
|
||||||
|
}
|
||||||
// Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable
|
// Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable
|
||||||
if (msg.contains("400") || msg.contains("Bad Request")
|
if (msg.contains("400") || msg.contains("Bad Request")
|
||||||
|| msg.contains("invalid_request_error") || msg.contains("unsupported")) {
|
|| msg.contains("invalid_request_error") || msg.contains("unsupported")) {
|
||||||
return ErrorType.CLIENT_ERROR;
|
return ErrorType.CLIENT_ERROR;
|
||||||
}
|
}
|
||||||
// DashScope-specific "model name does not map to a valid endpoint" — reported as
|
|
||||||
// "[InvalidParameter] url error, please check url" (see
|
|
||||||
// https://help.aliyun.com/zh/model-studio/error-code#error-url). Despite the wording
|
|
||||||
// it's not a URL issue — it's the provider rejecting an unknown/unsupported model id
|
|
||||||
// on the native protocol. Treat as client error so we do NOT retry.
|
|
||||||
if (msg.contains("[InvalidParameter]")
|
|
||||||
|| msg.contains("InvalidParameter")
|
|
||||||
|| msg.contains("url error")
|
|
||||||
|| msg.contains("Model not exist")
|
|
||||||
|| msg.contains("model_not_found")
|
|
||||||
|| msg.contains("Model not found")) {
|
|
||||||
return ErrorType.CLIENT_ERROR;
|
|
||||||
}
|
|
||||||
// Server errors
|
// Server errors
|
||||||
if (msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|
if (msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|
||||||
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
|
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
|
||||||
@ -185,6 +381,20 @@ public class NodeStreamingChatHelper {
|
|||||||
sb.append(cur.getMessage()).append(" | ");
|
sb.append(cur.getMessage()).append(" | ");
|
||||||
}
|
}
|
||||||
sb.append(cur.getClass().getSimpleName()).append(" | ");
|
sb.append(cur.getClass().getSimpleName()).append(" | ");
|
||||||
|
// Include the HTTP response body for WebClient errors. Many providers
|
||||||
|
// (Volcano Ark, Ollama, …) put the actionable error code only in the
|
||||||
|
// body, while the surface message is just "404 Not Found from POST X".
|
||||||
|
// Without this, classifyError() can never see codes like ModelNotOpen.
|
||||||
|
if (cur instanceof WebClientResponseException wre) {
|
||||||
|
try {
|
||||||
|
String body = wre.getResponseBodyAsString();
|
||||||
|
if (body != null && !body.isEmpty()) {
|
||||||
|
sb.append(body.length() > 1024 ? body.substring(0, 1024) : body)
|
||||||
|
.append(" | ");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
cur = cur.getCause();
|
cur = cur.getCause();
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
@ -199,18 +409,65 @@ public class NodeStreamingChatHelper {
|
|||||||
throw new CancellationException("Stream stopped by user");
|
throw new CancellationException("Stream stopped by user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC-009 P3.1 + Phase 4: short-circuit the primary retry loop in two cases.
|
||||||
|
// (a) primary is in cooldown (P3.3) — soft, transient
|
||||||
|
// (b) primary was HARD-removed from the pool (Phase 4) — auth/billing/missing model
|
||||||
|
// Either way, retrying the same model wastes seconds; head straight to fallback.
|
||||||
|
boolean primaryInCooldown = primaryProviderId != null
|
||||||
|
&& healthTracker != null
|
||||||
|
&& healthTracker.isInCooldown(primaryProviderId);
|
||||||
|
boolean primaryOutOfPool = primaryProviderId != null && !inPool(primaryProviderId);
|
||||||
|
boolean primarySkipped = primaryInCooldown || primaryOutOfPool;
|
||||||
|
if (primarySkipped) {
|
||||||
|
String reason = primaryOutOfPool ? "removed from pool" : "in cooldown";
|
||||||
|
log.warn("[{}] Primary provider={} {} — skipping straight to fallback chain",
|
||||||
|
phase, primaryProviderId, reason);
|
||||||
|
if (broadcast) {
|
||||||
|
broadcastDelta(conversationId, "warning",
|
||||||
|
buildDeltaJson("主模型暂时不可用(" + (primaryOutOfPool ? "已下线" : "冷却中")
|
||||||
|
+ "),直接尝试备选模型..."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-6: performance counters
|
||||||
|
int retryCount = 0;
|
||||||
|
long totalBackoffMs = 0;
|
||||||
|
int failoverCount = 0;
|
||||||
|
int llmCallCount = 0;
|
||||||
|
long callStartMs = System.currentTimeMillis();
|
||||||
|
|
||||||
// 主模型重试循环
|
// 主模型重试循环
|
||||||
StreamResult lastResult = null;
|
StreamResult lastResult = null;
|
||||||
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
if (!primarySkipped) for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
llmCallCount++;
|
||||||
|
if (attempt > 0) retryCount++;
|
||||||
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
|
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
|
||||||
if (lastResult != null) {
|
if (lastResult != null) {
|
||||||
// PTL: 不重试,直接返回给上层 Node 处理
|
// PTL: 不重试,直接返回给上层 Node 处理
|
||||||
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
|
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
|
||||||
return lastResult;
|
return lastResult;
|
||||||
}
|
}
|
||||||
// AUTH: 不重试
|
// AUTH: primary key 失效不会自愈,跳过同模型重试,交给 fallback chain
|
||||||
|
// — 其它 provider 的 key 可能仍然可用(与 BILLING / MODEL_NOT_FOUND 同策略)。
|
||||||
|
// recordPrimary(false) 仍记一次失败用于 healthTracker 冷却累计。
|
||||||
|
// 若 fallback chain 全部 401,walker 末尾会把最后一次 AUTH_ERROR 透出,
|
||||||
|
// 不会静默吞错。
|
||||||
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
|
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
|
||||||
return lastResult;
|
log.warn("[{}] Primary auth failed — skipping same-model retries, handing off to fallback chain", phase);
|
||||||
|
recordPrimary(false);
|
||||||
|
removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// RFC-009 P3.2: BILLING / MODEL_NOT_FOUND — provider-side hard failures
|
||||||
|
// that won't change on retry. Skip to fallback chain (a different
|
||||||
|
// provider may have credits, or the model name may be valid there).
|
||||||
|
if (lastResult.errorType() == ErrorType.BILLING
|
||||||
|
|| lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) {
|
||||||
|
log.warn("[{}] Primary error={} — skipping same-model retries, handing off to fallback chain",
|
||||||
|
phase, lastResult.errorType());
|
||||||
|
recordPrimary(false);
|
||||||
|
removeFromPool(primaryProviderId, lastResult.errorType(), lastResult.errorMessage());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// CLIENT_ERROR (400 Bad Request): 不重试(参数/格式错误重试也不会变)
|
// CLIENT_ERROR (400 Bad Request): 不重试(参数/格式错误重试也不会变)
|
||||||
if (lastResult.errorType() == ErrorType.CLIENT_ERROR) {
|
if (lastResult.errorType() == ErrorType.CLIENT_ERROR) {
|
||||||
@ -225,38 +482,105 @@ public class NodeStreamingChatHelper {
|
|||||||
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
||||||
return lastResult; // 已经重试过了
|
return lastResult; // 已经重试过了
|
||||||
}
|
}
|
||||||
|
// RFC-009: EMPTY_RESPONSE — break the primary-retry loop and fall through to
|
||||||
|
// the fallback chain. Retrying the same model that returned nothing is rarely
|
||||||
|
// productive; a different provider has a better chance of succeeding.
|
||||||
|
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
|
||||||
|
log.warn("[{}] Primary returned empty response — skipping same-model retries, handing off to fallback chain", phase);
|
||||||
|
recordPrimary(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
// 成功
|
// 成功
|
||||||
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
|
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
|
||||||
|
recordPrimary(true);
|
||||||
|
addToPool(primaryProviderId);
|
||||||
|
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||||
return lastResult;
|
return lastResult;
|
||||||
}
|
}
|
||||||
// Any other non-null errored result with a classified type that doStreamCall
|
// Any other non-null errored result with a classified type that doStreamCall
|
||||||
// chose NOT to retry (i.e. UNKNOWN, or RATE_LIMIT/SERVER_ERROR past MAX_RETRIES)
|
// chose NOT to retry (i.e. UNKNOWN, or RATE_LIMIT/SERVER_ERROR past MAX_RETRIES)
|
||||||
// must exit — otherwise we silently spin through attempts and waste seconds
|
// must exit — otherwise we silently spin through attempts and waste seconds
|
||||||
// per turn on unrecoverable errors like DashScope's "url error" / unknown model.
|
// per turn on unrecoverable errors like DashScope's "url error" / unknown model.
|
||||||
|
recordPrimary(false);
|
||||||
|
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||||
return lastResult;
|
return lastResult;
|
||||||
}
|
}
|
||||||
// lastResult == null 表示需要重试
|
// lastResult == null 表示需要重试
|
||||||
}
|
}
|
||||||
|
// If we exhausted the retry loop without a verdict, primary effectively failed.
|
||||||
|
if (!primarySkipped && lastResult != null && lastResult.errorType() != ErrorType.NONE) {
|
||||||
|
recordPrimary(false);
|
||||||
|
}
|
||||||
|
|
||||||
// 主模型耗尽重试 — 尝试 fallback model
|
// Primary exhausted retries — walk the fallback chain in priority order.
|
||||||
if (fallbackModel != null && fallbackModel != chatModel) {
|
// Each fallback gets a single shot (no retry); first successful result wins.
|
||||||
log.warn("[{}] Primary model exhausted retries, switching to fallback model for conversation {}",
|
// Same-instance entries (e.g., primary accidentally included in the chain)
|
||||||
phase, conversationId);
|
// are skipped so we don't re-try the exact model that just failed.
|
||||||
|
// Providers in cooldown (RFC-009 P3.3) are also skipped so a known-bad
|
||||||
|
// provider doesn't add latency to every conversation turn.
|
||||||
|
for (int i = 0; i < fallbackChain.size(); i++) {
|
||||||
|
vip.mate.llm.failover.FallbackEntry entry = fallbackChain.get(i);
|
||||||
|
ChatModel fallback = entry.chatModel();
|
||||||
|
if (fallback == chatModel) continue;
|
||||||
|
// RFC-009 Phase 4 — pool gate (the real runtime fence). A provider
|
||||||
|
// HARD-removed earlier (or by another conversation) must not even
|
||||||
|
// be attempted here. Build-time filtering is best-effort; this is
|
||||||
|
// the one that matters when pool state changes mid-conversation.
|
||||||
|
if (!inPool(entry.providerId())) {
|
||||||
|
log.info("[{}] Skipping fallback {}/{} provider={} — not in pool",
|
||||||
|
phase, i + 1, fallbackChain.size(), entry.providerId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (healthTracker != null && healthTracker.isInCooldown(entry.providerId())) {
|
||||||
|
log.info("[{}] Skipping fallback {}/{} provider={} — in cooldown",
|
||||||
|
phase, i + 1, fallbackChain.size(), entry.providerId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log.warn("[{}] Primary exhausted, trying fallback {}/{} provider={} ({}) for conversation {}",
|
||||||
|
phase, i + 1, fallbackChain.size(), entry.providerId(),
|
||||||
|
fallback.getClass().getSimpleName(), conversationId);
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
broadcastDelta(conversationId, "warning",
|
broadcastDelta(conversationId, "warning",
|
||||||
buildDeltaJson("主模型不可用,正在切换到备选模型..."));
|
buildDeltaJson("主模型不可用,正在切换到备选模型 (" + (i + 1) + "/" + fallbackChain.size() + ")..."));
|
||||||
}
|
}
|
||||||
StreamResult fallbackResult = doStreamCall(fallbackModel, prompt, conversationId,
|
failoverCount++;
|
||||||
phase + "_fallback", broadcast, 0);
|
llmCallCount++;
|
||||||
if (fallbackResult != null) {
|
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
|
||||||
|
phase + "_fallback_" + (i + 1), broadcast, 0);
|
||||||
|
// Accept only fully successful fallbacks. Non-successful results (auth
|
||||||
|
// error, client error, still-rate-limited) propagate to the next
|
||||||
|
// fallback instead of being surfaced as the final result.
|
||||||
|
if (fallbackResult != null
|
||||||
|
&& fallbackResult.errorType() == ErrorType.NONE
|
||||||
|
&& fallbackResult.errorMessage() == null) {
|
||||||
|
if (healthTracker != null) healthTracker.recordSuccess(entry.providerId());
|
||||||
|
addToPool(entry.providerId());
|
||||||
|
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||||
return fallbackResult;
|
return fallbackResult;
|
||||||
}
|
}
|
||||||
|
if (healthTracker != null) healthTracker.recordFailure(entry.providerId());
|
||||||
|
if (fallbackResult != null) {
|
||||||
|
// RFC-009 Phase 4: HARD errors evict from the pool so later
|
||||||
|
// walks skip this provider outright. SOFT errors keep it
|
||||||
|
// in-pool and let the tracker's cooldown absorb the blip.
|
||||||
|
removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage());
|
||||||
|
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
|
||||||
return lastResult != null ? lastResult
|
return lastResult != null ? lastResult
|
||||||
: buildErrorResult("LLM 调用失败,已达最大重试次数", conversationId, phase);
|
: buildErrorResult("LLM 调用失败,已达最大重试次数", conversationId, phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** D-6: log a structured performance summary for the LLM call phase. */
|
||||||
|
private void logPerfSummary(String phase, String conversationId, long startMs,
|
||||||
|
int llmCallCount, int retryCount, int failoverCount) {
|
||||||
|
long totalMs = System.currentTimeMillis() - startMs;
|
||||||
|
log.info("[{}] perf_summary: conversationId={} total_ms={} llm_call_count={} retry_count={} failover_count={}",
|
||||||
|
phase, conversationId, totalMs, llmCallCount, retryCount, failoverCount);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单次流式调用尝试。
|
* 单次流式调用尝试。
|
||||||
* @return StreamResult 如果成功/降级/不可重试;null 如果应该重试
|
* @return StreamResult 如果成功/降级/不可重试;null 如果应该重试
|
||||||
@ -264,6 +588,77 @@ public class NodeStreamingChatHelper {
|
|||||||
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
|
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
|
||||||
String conversationId, String phase,
|
String conversationId, String phase,
|
||||||
boolean broadcast, int attempt) {
|
boolean broadcast, int attempt) {
|
||||||
|
// PR-2 L4 (RFC-049 §2.4.2): normalize as a pre-egress step (not only on retry).
|
||||||
|
// Strip reasoning_content from prior-turn AssistantMessages (i <= lastUserIdx),
|
||||||
|
// preserving in-turn thinking (i > lastUserIdx) so DeepSeek's contract holds.
|
||||||
|
// The returned Prompt shares `options` by reference with the input prompt.
|
||||||
|
Prompt outbound = stripThinkingFromPrompt(prompt);
|
||||||
|
|
||||||
|
// RFC-049 follow-up (2026-04-27): trim trailing AssistantMessage from the
|
||||||
|
// outbound prompt. Triggered in practice by the summarizing→reasoning
|
||||||
|
// graph transition: the summarizer emits an in-turn AssistantMessage,
|
||||||
|
// graph state ends with it, reasoning's next LLM call sends history
|
||||||
|
// ending with assistant. Anthropic Claude returns 400 "does not
|
||||||
|
// support assistant message prefill"; some DeepSeek model variants 400
|
||||||
|
// similarly. The dropped assistant is summarizer scaffolding, not
|
||||||
|
// user-relevant content, so removing it before egress is safe.
|
||||||
|
outbound = dropTrailingAssistant(outbound);
|
||||||
|
|
||||||
|
// PR-2 L3 (RFC-049 §2.3.2): producer-side relay stash. Extract per-assistant
|
||||||
|
// thinking from the normalized prompt (cross-turn positions are already "" due
|
||||||
|
// to strip), stash with the caller's original `user` field, and overwrite
|
||||||
|
// `options.user` with the relay token. The consumer in
|
||||||
|
// AgentGraphBuilder.patchReasoningContent restores the original user when
|
||||||
|
// rebuilding the outbound ChatCompletionRequest; the token never reaches the
|
||||||
|
// provider. We only activate relay on OpenAiChatOptions paths — Anthropic has
|
||||||
|
// its own thinking mechanism (extended thinking via AnthropicChatOptions.thinking).
|
||||||
|
String relayToken = null;
|
||||||
|
String originalUser = null;
|
||||||
|
org.springframework.ai.openai.OpenAiChatOptions oaiOptsForRelay = null;
|
||||||
|
if (outbound.getOptions() instanceof org.springframework.ai.openai.OpenAiChatOptions oaiOpts) {
|
||||||
|
List<String> thinkings = extractAssistantThinkings(outbound);
|
||||||
|
if (thinkings.stream().anyMatch(s -> !s.isEmpty())) {
|
||||||
|
originalUser = oaiOpts.getUser();
|
||||||
|
relayToken = AssistantThinkingRelay.stash(thinkings, originalUser);
|
||||||
|
oaiOpts.setUser(relayToken);
|
||||||
|
oaiOptsForRelay = oaiOpts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt);
|
||||||
|
} finally {
|
||||||
|
// Idempotent: if consumer already took the entry, discard is a no-op.
|
||||||
|
if (relayToken != null) {
|
||||||
|
AssistantThinkingRelay.discard(relayToken);
|
||||||
|
if (oaiOptsForRelay != null) {
|
||||||
|
oaiOptsForRelay.setUser(originalUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR-2: Extract per-assistant {@code reasoningContent} from a Prompt's messages in
|
||||||
|
* order. Non-assistant messages are skipped; assistants with no metadata or no
|
||||||
|
* reasoningContent yield {@code ""} so the returned list's positional index aligns
|
||||||
|
* with the assistant-message index as seen by the consumer.
|
||||||
|
*/
|
||||||
|
private static List<String> extractAssistantThinkings(Prompt prompt) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (Message m : prompt.getInstructions()) {
|
||||||
|
if (m instanceof AssistantMessage am) {
|
||||||
|
Map<String, Object> meta = am.getMetadata();
|
||||||
|
Object rc = meta != null ? meta.get("reasoningContent") : null;
|
||||||
|
out.add(rc instanceof String s ? s : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
|
||||||
|
String conversationId, String phase,
|
||||||
|
boolean broadcast, int attempt) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
||||||
// 加入 jitter 防止雷群效应(Hermes 风格)
|
// 加入 jitter 防止雷群效应(Hermes 风格)
|
||||||
@ -276,12 +671,23 @@ public class NodeStreamingChatHelper {
|
|||||||
broadcastDelta(conversationId, "warning",
|
broadcastDelta(conversationId, "warning",
|
||||||
buildDeltaJson("⏱️ 请求频率受限,等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
|
buildDeltaJson("⏱️ 请求频率受限,等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
|
||||||
}
|
}
|
||||||
|
// Poll stop flag every 100ms so user Stop is honored mid-backoff.
|
||||||
|
long remaining = delay;
|
||||||
|
while (remaining > 0) {
|
||||||
|
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
||||||
|
log.info("[{}] Stop requested during backoff — aborting retry: conversationId={}",
|
||||||
|
phase, conversationId);
|
||||||
|
throw new CancellationException("Stream stopped by user");
|
||||||
|
}
|
||||||
|
long slice = Math.min(100, remaining);
|
||||||
try {
|
try {
|
||||||
Thread.sleep(delay);
|
Thread.sleep(slice);
|
||||||
} catch (InterruptedException ie) {
|
} catch (InterruptedException ie) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
||||||
}
|
}
|
||||||
|
remaining -= slice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder contentAccum = new StringBuilder();
|
StringBuilder contentAccum = new StringBuilder();
|
||||||
@ -467,12 +873,18 @@ public class NodeStreamingChatHelper {
|
|||||||
conversationId, phase, errorType);
|
conversationId, phase, errorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit / Server error: 重试
|
// Rate limit / Server error: retryable, but with different budgets.
|
||||||
if (attempt < MAX_RETRIES && (errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.SERVER_ERROR)) {
|
// RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2).
|
||||||
|
// SERVER_ERROR: keep full MAX_RETRIES — upstream flaps often self-heal.
|
||||||
|
if (errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.SERVER_ERROR) {
|
||||||
|
int effectiveMaxRetries = (errorType == ErrorType.RATE_LIMIT)
|
||||||
|
? MAX_RETRIES_RATE_LIMIT : MAX_RETRIES;
|
||||||
|
if (attempt < effectiveMaxRetries) {
|
||||||
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
|
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
|
||||||
phase, attempt, MAX_RETRIES, errorType, error.getMessage());
|
phase, attempt, effectiveMaxRetries, errorType, error.getMessage());
|
||||||
return null; // 返回 null 触发重试
|
return null; // 返回 null 触发重试
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 不可重试或已耗尽重试
|
// 不可重试或已耗尽重试
|
||||||
log.error("[{}] LLM call failed after {} attempts for conversation {}: {}",
|
log.error("[{}] LLM call failed after {} attempts for conversation {}: {}",
|
||||||
@ -488,6 +900,22 @@ public class NodeStreamingChatHelper {
|
|||||||
phase, conversationId);
|
phase, conversationId);
|
||||||
// warning 已在 dispose 时广播,无需重复
|
// warning 已在 dispose 时广播,无需重复
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC-009: guard against silent empty responses. Some providers return
|
||||||
|
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
|
||||||
|
// capacity, context filter, upstream overload). Treat this as a failure
|
||||||
|
// signal so streamCallInternal can hand off to the fallback chain.
|
||||||
|
// Only fire when the primary wasn't truncated by our own repetition
|
||||||
|
// detector (which deliberately produces short content) and when there
|
||||||
|
// are no tool calls (tool-only responses are legitimately empty-text).
|
||||||
|
if (!truncatedByRepetition
|
||||||
|
&& contentAccum.length() == 0
|
||||||
|
&& thinkingAccum.length() == 0
|
||||||
|
&& toolCallAccumulators.isEmpty()) {
|
||||||
|
log.warn("[{}] LLM returned empty response (no content, no thinking, no tool calls) — marking as EMPTY_RESPONSE for fallback", phase);
|
||||||
|
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
|
||||||
|
}
|
||||||
|
|
||||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||||
promptTokens.get(), completionTokens.get(),
|
promptTokens.get(), completionTokens.get(),
|
||||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
||||||
@ -512,10 +940,9 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AssistantMessage assembledMessage = !finalToolCalls.isEmpty()
|
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
|
||||||
? AssistantMessage.builder().content(fullContent).toolCalls(finalToolCalls).build()
|
|
||||||
: new AssistantMessage(fullContent);
|
|
||||||
|
|
||||||
|
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||||
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
|
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
|
||||||
@ -542,43 +969,100 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AssistantMessage assembledMessage;
|
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
|
||||||
if (!finalToolCalls.isEmpty()) {
|
|
||||||
assembledMessage = AssistantMessage.builder()
|
|
||||||
.content(fullContent)
|
|
||||||
.toolCalls(finalToolCalls)
|
|
||||||
.build();
|
|
||||||
} else {
|
|
||||||
assembledMessage = new AssistantMessage(fullContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
|
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR-2 L2 (RFC-049): Build an {@link AssistantMessage} that persists the per-turn
|
||||||
|
* {@code fullThinking} into the message's properties under key {@code "reasoningContent"}.
|
||||||
|
*
|
||||||
|
* <p>This is the linchpin of the structural fix: without writing thinking back into
|
||||||
|
* the AssistantMessage that enters the next ReAct round's state, the outbound
|
||||||
|
* request's {@code reasoning_content} is lost (Spring AI 1.1.4's
|
||||||
|
* {@code OpenAiChatModel.lambda$createRequest$20} hardcodes {@code null} on the
|
||||||
|
* outbound conversion, so the relay in {@code AssistantThinkingRelay} is the only
|
||||||
|
* way back — see RFC-049 §2.3 L3).
|
||||||
|
*
|
||||||
|
* <p>Note the Spring AI naming asymmetry: the builder method is
|
||||||
|
* {@code .properties(Map)} but the reader is {@code getMetadata()} (see
|
||||||
|
* {@link #stripThinkingFromPrompt} L937).
|
||||||
|
*/
|
||||||
|
private static AssistantMessage buildAssistantMessageWithThinking(
|
||||||
|
String fullContent, String fullThinking, List<AssistantMessage.ToolCall> finalToolCalls) {
|
||||||
|
AssistantMessage.Builder builder = AssistantMessage.builder().content(fullContent);
|
||||||
|
if (finalToolCalls != null && !finalToolCalls.isEmpty()) {
|
||||||
|
builder.toolCalls(finalToolCalls);
|
||||||
|
}
|
||||||
|
if (fullThinking != null && !fullThinking.isEmpty()) {
|
||||||
|
builder.properties(Map.of("reasoningContent", fullThinking));
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record token / cache usage to the optional metrics aggregator.
|
||||||
|
* Called only from successful assembly paths ({@link #assembleResult}
|
||||||
|
* and {@link #assembleStoppedResult}) — error paths are excluded because
|
||||||
|
* their token counts are typically zero and would skew the ratio.
|
||||||
|
*/
|
||||||
|
private void recordCacheMetrics(String phase, int promptTok, int completionTok,
|
||||||
|
int cacheReadTok, int cacheWriteTok) {
|
||||||
|
if (cacheMetrics == null) return;
|
||||||
|
// Skip empty-usage records (pure error responses or broken chunks).
|
||||||
|
if (promptTok == 0 && completionTok == 0 && cacheReadTok == 0 && cacheWriteTok == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cacheMetrics.record(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||||
|
}
|
||||||
|
|
||||||
/** 构建纯错误 StreamResult(无任何内容) */
|
/** 构建纯错误 StreamResult(无任何内容) */
|
||||||
/**
|
/**
|
||||||
* 从 Prompt 中剥离旧 AssistantMessage 的 thinking/reasoningContent metadata。
|
* Strip {@code reasoningContent} from AssistantMessages that belong to <em>prior</em>
|
||||||
* 保留最新一条 AssistantMessage 的 thinking(可能是模型需要的签名)。
|
* user turns, keeping thinking for messages within the <strong>current</strong> user
|
||||||
|
* turn intact.
|
||||||
|
*
|
||||||
|
* <p>PR-2 L4 (RFC-049 §2.4.1): The old semantics "keep only the last AssistantMessage's
|
||||||
|
* thinking" broke DeepSeek's contract for multi-round tool-calls within a single user
|
||||||
|
* turn (DeepSeek requires all in-turn assistant thinking to be passed back on subsequent
|
||||||
|
* rounds). Now the boundary is the most recent {@link UserMessage}: AssistantMessages at
|
||||||
|
* index {@code <= lastUserIdx} are prior-turn history (their thinking must be stripped
|
||||||
|
* per DeepSeek's "reset across user turns" rule); AssistantMessages at {@code > lastUserIdx}
|
||||||
|
* are in-turn (their thinking must be preserved).
|
||||||
|
*
|
||||||
|
* <p>PR-2 L4 (RFC-049 §2.4.2): This method is called as a normal pre-egress step from
|
||||||
|
* {@link #doStreamCall}, not only from the {@code THINKING_BLOCK_ERROR} retry path. The
|
||||||
|
* retry path still calls it too (idempotent), serving as defensive re-application.
|
||||||
|
*
|
||||||
|
* <p>Note: {@code Prompt.getOptions()} is preserved by reference into the returned
|
||||||
|
* {@code Prompt} (this is existing behavior). Callers rely on that — mutations to
|
||||||
|
* {@code options.user} via {@link AssistantThinkingRelay} must stay visible after
|
||||||
|
* normalize.
|
||||||
*/
|
*/
|
||||||
private Prompt stripThinkingFromPrompt(Prompt prompt) {
|
static Prompt stripThinkingFromPrompt(Prompt prompt) {
|
||||||
List<Message> messages = prompt.getInstructions();
|
List<Message> messages = prompt.getInstructions();
|
||||||
// 找最后一个 AssistantMessage
|
|
||||||
int lastAssistantIdx = -1;
|
// Find most recent UserMessage — boundary of the current user turn
|
||||||
|
int lastUserIdx = -1;
|
||||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||||
if (messages.get(i) instanceof AssistantMessage) {
|
if (messages.get(i) instanceof UserMessage) {
|
||||||
lastAssistantIdx = i;
|
lastUserIdx = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<Message> cleaned = new ArrayList<>();
|
|
||||||
|
int strippedCount = 0;
|
||||||
|
List<Message> cleaned = new ArrayList<>(messages.size());
|
||||||
for (int i = 0; i < messages.size(); i++) {
|
for (int i = 0; i < messages.size(); i++) {
|
||||||
Message msg = messages.get(i);
|
Message msg = messages.get(i);
|
||||||
if (msg instanceof AssistantMessage am && i != lastAssistantIdx) {
|
// Only strip prior-turn assistant thinking (i <= lastUserIdx); in-turn (i > lastUserIdx) stays
|
||||||
|
if (msg instanceof AssistantMessage am && i <= lastUserIdx) {
|
||||||
Map<String, Object> meta = am.getMetadata();
|
Map<String, Object> meta = am.getMetadata();
|
||||||
if (meta != null && meta.containsKey("reasoningContent")) {
|
if (meta != null && meta.containsKey("reasoningContent")) {
|
||||||
// 用 builder 重建 AssistantMessage,去掉 reasoningContent
|
|
||||||
Map<String, Object> cleanMeta = new java.util.HashMap<>(meta);
|
Map<String, Object> cleanMeta = new java.util.HashMap<>(meta);
|
||||||
cleanMeta.remove("reasoningContent");
|
cleanMeta.remove("reasoningContent");
|
||||||
AssistantMessage.Builder builder = AssistantMessage.builder()
|
AssistantMessage.Builder builder = AssistantMessage.builder()
|
||||||
@ -591,16 +1075,51 @@ public class NodeStreamingChatHelper {
|
|||||||
builder.media(am.getMedia());
|
builder.media(am.getMedia());
|
||||||
}
|
}
|
||||||
cleaned.add(builder.build());
|
cleaned.add(builder.build());
|
||||||
|
strippedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleaned.add(msg);
|
cleaned.add(msg);
|
||||||
}
|
}
|
||||||
log.info("[ThinkingRecovery] Stripped thinking blocks from {} messages, last assistant at index {}",
|
if (strippedCount > 0) {
|
||||||
messages.size(), lastAssistantIdx);
|
log.debug("[ThinkingRecovery] Stripped reasoningContent from {} prior-turn assistant messages "
|
||||||
|
+ "(lastUserIdx={}, total={})",
|
||||||
|
strippedCount, lastUserIdx, messages.size());
|
||||||
|
}
|
||||||
return new Prompt(cleaned, prompt.getOptions());
|
return new Prompt(cleaned, prompt.getOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop trailing {@link AssistantMessage} entries from a Prompt's instructions.
|
||||||
|
* Most LLM providers reject prompts whose history ends with an assistant turn —
|
||||||
|
* Anthropic Claude with a 400 "does not support assistant message prefill",
|
||||||
|
* DeepSeek thinking-mode variants with reasoning_content errors. The
|
||||||
|
* trailing assistant is typically a summarizer-emitted scaffold message that
|
||||||
|
* shouldn't be sent as the final user-facing prompt anyway. Returns the
|
||||||
|
* input unchanged if there's nothing to drop.
|
||||||
|
*/
|
||||||
|
static Prompt dropTrailingAssistant(Prompt prompt) {
|
||||||
|
List<Message> messages = prompt.getInstructions();
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
int end = messages.size();
|
||||||
|
while (end > 0 && messages.get(end - 1) instanceof AssistantMessage) {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
if (end == messages.size()) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
if (end == 0) {
|
||||||
|
// Refuse to produce an empty prompt — caller's bug; let provider error surface.
|
||||||
|
log.warn("[dropTrailingAssistant] all messages were AssistantMessage; skipping trim to avoid empty prompt");
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
log.debug("[dropTrailingAssistant] trimmed {} trailing AssistantMessage(s) from prompt (size {} -> {})",
|
||||||
|
messages.size() - end, messages.size(), end);
|
||||||
|
return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions());
|
||||||
|
}
|
||||||
|
|
||||||
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
|
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
|
||||||
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
|
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
|
||||||
if (streamTracker != null && conversationId != null) {
|
if (streamTracker != null && conversationId != null) {
|
||||||
@ -655,6 +1174,19 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pulls the offending model id out of a Volcano Ark error body. Both
|
||||||
|
// ModelNotOpen and InvalidEndpointOrModel.NotFound mention it after a
|
||||||
|
// recognizable phrase ("activated the model X" / "model or endpoint X").
|
||||||
|
private static final java.util.regex.Pattern ARK_MODEL_NAME_PATTERN =
|
||||||
|
java.util.regex.Pattern.compile(
|
||||||
|
"(?:activated the model|model or endpoint)\\s+([A-Za-z0-9._-]+)");
|
||||||
|
|
||||||
|
private static String extractArkModelName(String body) {
|
||||||
|
if (body == null) return null;
|
||||||
|
java.util.regex.Matcher m = ARK_MODEL_NAME_PATTERN.matcher(body);
|
||||||
|
return m.find() ? m.group(1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 从异常链提取用户友好的错误信息 */
|
/** 从异常链提取用户友好的错误信息 */
|
||||||
private static String extractUserFriendlyError(Throwable error) {
|
private static String extractUserFriendlyError(Throwable error) {
|
||||||
String msg = error.getMessage();
|
String msg = error.getMessage();
|
||||||
@ -688,6 +1220,26 @@ public class NodeStreamingChatHelper {
|
|||||||
+ "例如 qwen3、qwen2.5:7b+、llama3.1:8b+、mistral-nemo、command-r 等。";
|
+ "例如 qwen3、qwen2.5:7b+、llama3.1:8b+、mistral-nemo、command-r 等。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volcano Engine Ark — model exists but the user's account hasn't activated it.
|
||||||
|
// Body shape: {"error":{"code":"ModelNotOpen","message":"Your account ... has not activated the model X. Please activate the model service in the Ark Console..."}}
|
||||||
|
if (combined.contains("ModelNotOpen")) {
|
||||||
|
String modelId = extractArkModelName(combined);
|
||||||
|
String suffix = modelId != null ? "「" + modelId + "」" : "";
|
||||||
|
return "火山方舟(Volcano Ark)尚未为该账号开通模型" + suffix
|
||||||
|
+ "。请前往 Ark 控制台 → 模型广场,对该模型点击「开通服务」后重试。"
|
||||||
|
+ "(控制台:https://console.volcengine.com/ark)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volcano Engine Ark — model id doesn't exist for the user's region/key.
|
||||||
|
// Body shape: {"error":{"code":"InvalidEndpointOrModel.NotFound","message":"The model or endpoint X does not exist or you do not have access to it..."}}
|
||||||
|
if (combined.contains("InvalidEndpointOrModel")) {
|
||||||
|
String modelId = extractArkModelName(combined);
|
||||||
|
String suffix = modelId != null ? "「" + modelId + "」" : "";
|
||||||
|
return "火山方舟(Volcano Ark)找不到模型" + suffix
|
||||||
|
+ "。原因可能是模型 ID 不在当前区域,或你的账号没有访问权限。"
|
||||||
|
+ "建议在 设置 → 模型 里点「刷新模型」重新发现,或在 Ark 控制台创建「推理接入点」(ep-XXX) 后使用该 ID。";
|
||||||
|
}
|
||||||
|
|
||||||
// DashScope "url error" is really "model name not mapped to any valid endpoint".
|
// DashScope "url error" is really "model name not mapped to any valid endpoint".
|
||||||
if (msg.contains("url error") || msg.contains("[InvalidParameter]")
|
if (msg.contains("url error") || msg.contains("[InvalidParameter]")
|
||||||
|| msg.contains("Model not exist") || msg.contains("model_not_found")
|
|| msg.contains("Model not exist") || msg.contains("model_not_found")
|
||||||
@ -724,6 +1276,29 @@ public class NodeStreamingChatHelper {
|
|||||||
CLIENT_ERROR,
|
CLIENT_ERROR,
|
||||||
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
|
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
|
||||||
THINKING_BLOCK_ERROR,
|
THINKING_BLOCK_ERROR,
|
||||||
|
/**
|
||||||
|
* RFC-009: LLM returned no content, no thinking, and no tool calls.
|
||||||
|
* Treated as a soft failure — skip same-model retries and hand off to
|
||||||
|
* the fallback chain directly. Typical cause: upstream rate-limit
|
||||||
|
* rejection that comes back as HTTP 200 with empty body.
|
||||||
|
*/
|
||||||
|
EMPTY_RESPONSE,
|
||||||
|
/**
|
||||||
|
* RFC-009 P3.2: payment / billing failure (HTTP 402, "insufficient_quota",
|
||||||
|
* "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR}
|
||||||
|
* because the right response is to <i>switch provider</i> (a different
|
||||||
|
* provider may have credits) rather than just terminate. Skips same-model
|
||||||
|
* retries and falls through to the fallback chain.
|
||||||
|
*/
|
||||||
|
BILLING,
|
||||||
|
/**
|
||||||
|
* RFC-009 P3.2: requested model id not recognized by the provider
|
||||||
|
* (HTTP 404, "Model not exist", "model_not_found", DashScope's
|
||||||
|
* "url error"). Same handling as {@link #BILLING} — heads straight
|
||||||
|
* to the fallback chain instead of looping retries against a model
|
||||||
|
* that does not exist.
|
||||||
|
*/
|
||||||
|
MODEL_NOT_FOUND,
|
||||||
/** 其他未知错误 */
|
/** 其他未知错误 */
|
||||||
UNKNOWN
|
UNKNOWN
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,6 +175,7 @@ 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<>("");
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
@ -192,6 +193,14 @@ 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);
|
||||||
|
|
||||||
|
// 与 chatStructuredStream 一致:把每轮 STREAMED_CONTENT 用 persistOnly 推给 Accumulator,
|
||||||
|
// 否则中间叙述(reasoning narrative + summarize)只在 SSE 上出现一次,刷新后丢失。
|
||||||
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
|
lastEmittedStreamedContent.set(streamed);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
@ -265,6 +274,9 @@ 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<>("");
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
@ -287,6 +299,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(THINKING_STREAMED, false);
|
.value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
|
// 2a. 中间叙述内容持久化:每轮 ReasoningNode(带 tool_calls)和 SummarizingNode
|
||||||
|
// 都把当轮 LLM 输出写入 STREAMED_CONTENT。NodeStreamingChatHelper 已实时广播
|
||||||
|
// 给前端,但 Accumulator 不在 SSE 订阅链路上,必须用 persistOnly StreamDelta
|
||||||
|
// 补一刀,否则刷新后正文文字全部丢失(只剩 final_answer + tool_call 卡片)。
|
||||||
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
|
lastEmittedStreamedContent.set(streamed);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
@ -388,6 +410,19 @@ 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));
|
||||||
|
|
||||||
|
// 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);
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {}, " +
|
||||||
|
|||||||
@ -4,10 +4,13 @@ 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.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
||||||
@ -38,26 +41,54 @@ import java.util.concurrent.*;
|
|||||||
public class ToolExecutionExecutor {
|
public class ToolExecutionExecutor {
|
||||||
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
private static final ExecutorService TOOL_EXECUTOR = Executors.newFixedThreadPool(
|
// JDK 21 virtual threads: no blocking stall for I/O-bound tools.
|
||||||
Math.max(4, Runtime.getRuntime().availableProcessors()),
|
// Each tool invocation gets its own lightweight carrier thread.
|
||||||
r -> {
|
// Named threads (matching HookDispatcher convention) for log traceability.
|
||||||
Thread t = new Thread(r, "tool-executor");
|
private static final ExecutorService TOOL_EXECUTOR =
|
||||||
t.setDaemon(true);
|
Executors.newThreadPerTaskExecutor(
|
||||||
return t;
|
Thread.ofVirtual().name("tool-executor-", 0).factory());
|
||||||
});
|
|
||||||
|
|
||||||
/** 默认不安全工具列表(写操作、浏览器交互等) */
|
/**
|
||||||
|
* Legacy hardcoded unsafe set, kept as a fallback when no
|
||||||
|
* {@link vip.mate.tool.ToolConcurrencyRegistry} is wired in (legacy tests,
|
||||||
|
* backwards-compatible constructors). New code should annotate the tool
|
||||||
|
* method with {@link vip.mate.tool.ConcurrencyUnsafe} instead of editing
|
||||||
|
* this list.
|
||||||
|
*/
|
||||||
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
||||||
"browser_use", "BrowserUseTool", "write_file", "edit_file"
|
"browser_use", "BrowserUseTool", "write_file", "edit_file"
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 工具结果最大字符数(防止超长结果膨胀 ToolResponseMessage → 撑爆 LLM 上下文) */
|
/**
|
||||||
|
* Layer 1 — hard truncation cap applied to every tool result before it
|
||||||
|
* reaches ToolResultStorage (Layer 2 spill) or the LLM prompt.
|
||||||
|
*
|
||||||
|
* <p>Two-level budget chain (RFC-008 / RFC-06 D-5):
|
||||||
|
* <pre>
|
||||||
|
* raw tool result
|
||||||
|
* → truncateToolResult(..., MAX_TOOL_RESULT_CHARS=8000) // Layer 1: hard cap
|
||||||
|
* → persistIfOversized(..., perResultThresholdChars=16000) // Layer 2: spill to disk
|
||||||
|
* → enforceTurnBudget(..., perTurnBudgetChars=32000) // Layer 3: per-turn aggregate
|
||||||
|
* </pre>
|
||||||
|
* Layer 1 runs first and is intentionally kept at 8000 to prevent oversized
|
||||||
|
* results from inflating the prompt. Layers 2/3 thresholds are configured in
|
||||||
|
* {@link ToolResultProperties} and application.yml.
|
||||||
|
*/
|
||||||
private static final int MAX_TOOL_RESULT_CHARS = 8000;
|
private static final int MAX_TOOL_RESULT_CHARS = 8000;
|
||||||
|
|
||||||
/** 尾部错误模式检测 */
|
/** 尾部错误模式检测 */
|
||||||
private static final java.util.regex.Pattern ERROR_TAIL_PATTERN = java.util.regex.Pattern.compile(
|
private static final java.util.regex.Pattern ERROR_TAIL_PATTERN = java.util.regex.Pattern.compile(
|
||||||
"(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b");
|
"(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052 §2.4: placeholder written into {@code ToolResponseMessage.content}
|
||||||
|
* for returnDirect tools. Intentionally English, short, and free of any
|
||||||
|
* tool-specific data so it is safe to enter prompt cache and gives the LLM
|
||||||
|
* a clear signal that the tool ran (vs failed).
|
||||||
|
*/
|
||||||
|
static final String DIRECT_TOOL_PLACEHOLDER =
|
||||||
|
"[Tool result returned directly to user. Content withheld from model context per tool policy.]";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 智能截断工具结果:检测尾部是否含错误信息,动态调整 head/tail 比例。
|
* 智能截断工具结果:检测尾部是否含错误信息,动态调整 head/tail 比例。
|
||||||
* 错误信息在尾部时保留 80% tail,确保 agent 能看到错误原因。
|
* 错误信息在尾部时保留 80% tail,确保 agent 能看到错误原因。
|
||||||
@ -88,16 +119,35 @@ public class ToolExecutionExecutor {
|
|||||||
private final ApprovalWorkflowService approvalService;
|
private final ApprovalWorkflowService approvalService;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties;
|
private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties;
|
||||||
|
/** RFC-008 Phase 3 spill store; nullable so legacy constructors keep working. */
|
||||||
|
private final ToolResultStorage resultStorage;
|
||||||
|
/** RFC-008 Phase 4 metadata-driven concurrency classifier; nullable for legacy constructors. */
|
||||||
|
private final vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry;
|
||||||
|
|
||||||
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||||
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
|
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
|
||||||
this(toolSet, toolGuardService, null, approvalService, streamTracker, null);
|
this(toolSet, toolGuardService, null, approvalService, streamTracker, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||||
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker,
|
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker,
|
||||||
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties) {
|
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties) {
|
||||||
this(toolSet, toolGuardService, null, approvalService, streamTracker, toolTimeoutProperties);
|
this(toolSet, toolGuardService, null, approvalService, streamTracker, toolTimeoutProperties, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||||
|
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker,
|
||||||
|
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties,
|
||||||
|
ToolResultStorage resultStorage) {
|
||||||
|
this(toolSet, toolGuardService, null, approvalService, streamTracker, toolTimeoutProperties, resultStorage, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||||
|
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker,
|
||||||
|
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties,
|
||||||
|
ToolResultStorage resultStorage,
|
||||||
|
vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry) {
|
||||||
|
this(toolSet, toolGuardService, null, approvalService, streamTracker, toolTimeoutProperties, resultStorage, concurrencyRegistry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuard toolGuard,
|
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuard toolGuard,
|
||||||
@ -108,18 +158,24 @@ public class ToolExecutionExecutor {
|
|||||||
this.approvalService = approvalService;
|
this.approvalService = approvalService;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
this.toolTimeoutProperties = null;
|
this.toolTimeoutProperties = null;
|
||||||
|
this.resultStorage = null;
|
||||||
|
this.concurrencyRegistry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
private ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||||
ToolGuard toolGuard, ApprovalWorkflowService approvalService,
|
ToolGuard toolGuard, ApprovalWorkflowService approvalService,
|
||||||
ChatStreamTracker streamTracker,
|
ChatStreamTracker streamTracker,
|
||||||
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties) {
|
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties,
|
||||||
|
ToolResultStorage resultStorage,
|
||||||
|
vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry) {
|
||||||
this.toolCallbackMap = toolSet.callbackByName();
|
this.toolCallbackMap = toolSet.callbackByName();
|
||||||
this.toolGuardService = toolGuardService;
|
this.toolGuardService = toolGuardService;
|
||||||
this.toolGuard = toolGuard;
|
this.toolGuard = toolGuard;
|
||||||
this.approvalService = approvalService;
|
this.approvalService = approvalService;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
this.toolTimeoutProperties = toolTimeoutProperties;
|
this.toolTimeoutProperties = toolTimeoutProperties;
|
||||||
|
this.resultStorage = resultStorage;
|
||||||
|
this.concurrencyRegistry = concurrencyRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
private long getToolTimeoutMs(String toolName) {
|
private long getToolTimeoutMs(String toolName) {
|
||||||
@ -144,11 +200,6 @@ public class ToolExecutionExecutor {
|
|||||||
return execute(toolCalls, conversationId, agentId, isReplay, "");
|
return execute(toolCalls, conversationId, agentId, isReplay, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前执行的 requesterId,传递给 ToolExecutionContext */
|
|
||||||
private volatile String currentRequesterId;
|
|
||||||
/** 当前工作区活动目录(为空不限制),传递给 ToolExecutionContext */
|
|
||||||
private volatile String currentWorkspaceBasePath;
|
|
||||||
|
|
||||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
String conversationId, String agentId,
|
String conversationId, String agentId,
|
||||||
boolean isReplay, String requesterId) {
|
boolean isReplay, String requesterId) {
|
||||||
@ -159,10 +210,43 @@ public class ToolExecutionExecutor {
|
|||||||
String conversationId, String agentId,
|
String conversationId, String agentId,
|
||||||
boolean isReplay, String requesterId,
|
boolean isReplay, String requesterId,
|
||||||
String workspaceBasePath) {
|
String workspaceBasePath) {
|
||||||
this.currentRequesterId = requesterId;
|
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
|
||||||
this.currentWorkspaceBasePath = workspaceBasePath;
|
workspaceBasePath, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: preferred overload — accepts a {@link ChatOrigin} that the
|
||||||
|
* top-level agent has enriched with agentId/workspace/channel context.
|
||||||
|
* Builds a Spring AI {@link ToolContext} per tool invocation so
|
||||||
|
* {@code @Tool} methods can read the origin via
|
||||||
|
* {@code ChatOrigin.from(toolContext)}.
|
||||||
|
*
|
||||||
|
* <p>During the PR-1 transition the legacy {@link ToolExecutionContext}
|
||||||
|
* ThreadLocal is also populated, so existing tools that read from it keep
|
||||||
|
* working unchanged. After all 8 callsites migrate, the ThreadLocal can be
|
||||||
|
* removed.
|
||||||
|
*
|
||||||
|
* <p><b>Thread safety</b>: this executor instance is shared across all
|
||||||
|
* concurrent invocations of a single agent (one executor per agent, per
|
||||||
|
* {@code AgentGraphBuilder.build}). Origin / requester / workspace are
|
||||||
|
* therefore <em>method-local</em> — they live as parameters all the way
|
||||||
|
* down into {@link PreparedToolCall} and never touch instance state. An
|
||||||
|
* earlier draft used {@code volatile} fields here; concurrent users hitting
|
||||||
|
* the same agent (Web + IM at once) raced on those fields and the channel
|
||||||
|
* binding was occasionally cross-contaminated. Do not reintroduce the
|
||||||
|
* fields — pass via parameters.
|
||||||
|
*/
|
||||||
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
|
String conversationId, String agentId,
|
||||||
|
boolean isReplay, String requesterId,
|
||||||
|
String workspaceBasePath,
|
||||||
|
ChatOrigin origin) {
|
||||||
|
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
||||||
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
||||||
|
// graph can route to FinalAnswerNode without re-entering the LLM.
|
||||||
|
List<DirectToolOutput> directOutputs = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
|
||||||
events.add(GraphEventPublisher.phase("action", Map.of("toolCount", toolCalls.size())));
|
events.add(GraphEventPublisher.phase("action", Map.of("toolCount", toolCalls.size())));
|
||||||
|
|
||||||
@ -175,7 +259,7 @@ public class ToolExecutionExecutor {
|
|||||||
String toolName = toolCall.name();
|
String toolName = toolCall.name();
|
||||||
String arguments = toolCall.arguments();
|
String arguments = toolCall.arguments();
|
||||||
|
|
||||||
events.add(GraphEventPublisher.toolStart(toolName, arguments));
|
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments));
|
||||||
|
|
||||||
// 0. 子会话工具拦截:委派上下文中的子 Agent 禁止调用特定工具
|
// 0. 子会话工具拦截:委派上下文中的子 Agent 禁止调用特定工具
|
||||||
if (vip.mate.tool.builtin.DelegationContext.currentDepth() > 0) {
|
if (vip.mate.tool.builtin.DelegationContext.currentDepth() > 0) {
|
||||||
@ -183,7 +267,7 @@ public class ToolExecutionExecutor {
|
|||||||
if (denied.contains(toolName)) {
|
if (denied.contains(toolName)) {
|
||||||
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
|
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
|
||||||
log.info("[ToolExecutor] Child agent blocked from using tool: {}", toolName);
|
log.info("[ToolExecutor] Child agent blocked from using tool: {}", toolName);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, msg, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||||
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, msg));
|
toolCall.id(), toolName, msg));
|
||||||
continue;
|
continue;
|
||||||
@ -198,7 +282,7 @@ public class ToolExecutionExecutor {
|
|||||||
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
||||||
toolName, arguments.length(), jsonEx.getMessage());
|
toolName, arguments.length(), jsonEx.getMessage());
|
||||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
String truncationError = normalizeToolExecutionError(jsonEx);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, truncationError, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, truncationError));
|
toolCall.id(), toolName, truncationError));
|
||||||
continue;
|
continue;
|
||||||
@ -243,7 +327,7 @@ public class ToolExecutionExecutor {
|
|||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
if (callback == null) {
|
if (callback == null) {
|
||||||
log.warn("[ToolExecutor] Tool not found: {}", toolName);
|
log.warn("[ToolExecutor] Tool not found: {}", toolName);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, "Tool not found: " + toolName, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, "Tool not found: " + toolName));
|
toolCall.id(), toolName, "Tool not found: " + toolName));
|
||||||
continue;
|
continue;
|
||||||
@ -252,64 +336,157 @@ public class ToolExecutionExecutor {
|
|||||||
// 4. 分类: concurrencySafe
|
// 4. 分类: concurrencySafe
|
||||||
boolean safe = isConcurrencySafe(toolName);
|
boolean safe = isConcurrencySafe(toolName);
|
||||||
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||||
conversationId, currentRequesterId, currentWorkspaceBasePath));
|
conversationId, requesterId, workspaceBasePath, safeOrigin));
|
||||||
// 占位,Phase 2 填充
|
// 占位,Phase 2 填充
|
||||||
allResponses.add(null);
|
allResponses.add(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Phase 2: 分段并发执行 ═══
|
// ═══ Phase 2: 分段并发执行 ═══
|
||||||
if (!preparedCalls.isEmpty()) {
|
if (!preparedCalls.isEmpty()) {
|
||||||
executePreparedCalls(preparedCalls, allResponses, events);
|
executePreparedCalls(preparedCalls, allResponses, events, directOutputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除 null 占位(不应该有,但防御性处理)
|
// Defensive: drop null placeholders (should never appear in practice).
|
||||||
allResponses.removeIf(Objects::isNull);
|
allResponses.removeIf(Objects::isNull);
|
||||||
|
|
||||||
|
// RFC-008 Phase 3 Layer 3: enforce per-turn aggregate budget across all
|
||||||
|
// tool responses for this assistant turn. Spills the largest non-spilled
|
||||||
|
// response in turn until the cumulative size fits the budget.
|
||||||
|
if (resultStorage != null && !allResponses.isEmpty()) {
|
||||||
|
allResponses = new ArrayList<>(resultStorage.enforceTurnBudget(
|
||||||
|
allResponses, conversationId, workspaceBasePath));
|
||||||
|
}
|
||||||
|
|
||||||
boolean hasApprovalPending = barrier != null;
|
boolean hasApprovalPending = barrier != null;
|
||||||
return new ToolExecutionResult(allResponses, events, hasApprovalPending,
|
return new ToolExecutionResult(allResponses, events, hasApprovalPending,
|
||||||
barrier != null ? barrier.pendingId : null,
|
barrier != null ? barrier.pendingId : null,
|
||||||
barrier != null ? barrier.toolName : null);
|
barrier != null ? barrier.toolName : null,
|
||||||
|
List.copyOf(directOutputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行预批准的工具调用(用于 StepExecutionNode 的 replay 路径)
|
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
|
||||||
|
* after a user approves a previously-blocked invocation).
|
||||||
|
*
|
||||||
|
* @param conversationId required for per-conversation spill scoping; when
|
||||||
|
* blank, spill files would land in a shared {@code unknown/} directory
|
||||||
|
* and break per-conversation cleanup.
|
||||||
|
* @param workspaceBasePath optional; when blank, spill falls back to tmp.
|
||||||
*/
|
*/
|
||||||
public ToolResponseMessage.ToolResponse executePreApproved(
|
public ToolResponseMessage.ToolResponse executePreApproved(
|
||||||
AssistantMessage.ToolCall toolCall, String storedArguments,
|
AssistantMessage.ToolCall toolCall, String storedArguments,
|
||||||
List<GraphEventPublisher.GraphEvent> events) {
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
String conversationId, String workspaceBasePath) {
|
||||||
|
return executePreApproved(toolCall, storedArguments, events, conversationId,
|
||||||
|
workspaceBasePath, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-052 PR-2: returnDirect-aware variant. When the pre-approved tool
|
||||||
|
* declares {@code returnDirect=true}, the result is captured into
|
||||||
|
* {@code directOutputs} (verbatim), the SSE consumer gets a
|
||||||
|
* {@code tool_direct_result} event, and the {@link ToolResponseMessage}
|
||||||
|
* carries the placeholder so any subsequent LLM call can never see the
|
||||||
|
* full payload.
|
||||||
|
*
|
||||||
|
* @param directOutputs nullable; pass-through for callers that don't track
|
||||||
|
* direct outputs (kept for legacy compatibility).
|
||||||
|
*/
|
||||||
|
public ToolResponseMessage.ToolResponse executePreApproved(
|
||||||
|
AssistantMessage.ToolCall toolCall, String storedArguments,
|
||||||
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
String conversationId, String workspaceBasePath,
|
||||||
|
List<DirectToolOutput> directOutputs) {
|
||||||
String toolName = toolCall.name();
|
String toolName = toolCall.name();
|
||||||
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
||||||
|
|
||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
if (callback == null) {
|
if (callback == null) {
|
||||||
log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName);
|
log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, "Tool not found: " + toolName, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false));
|
||||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "Tool not found: " + toolName);
|
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "Tool not found: " + toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
||||||
String result = callback.call(callArguments);
|
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
||||||
|
// still observe the originating ChatOrigin (channel/workspace).
|
||||||
|
// Origin is method-local (see thread-safety note on execute());
|
||||||
|
// the legacy ThreadLocal that used to carry it across executePreApproved
|
||||||
|
// calls was a cross-conversation footgun and has been removed.
|
||||||
|
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||||
|
.withConversationId(conversationId)
|
||||||
|
.withWorkspace(null, workspaceBasePath);
|
||||||
|
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
|
||||||
|
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||||
|
// case its result must take the direct path (no spill, no LLM).
|
||||||
|
// Without this branch, an approved direct tool would leak its full
|
||||||
|
// content into the next LLM round-trip via the ToolResponseMessage.
|
||||||
|
if (isReturnDirect(callback)) {
|
||||||
|
String fullResult = result != null ? result : "";
|
||||||
|
log.info("[ToolExecutor] Pre-approved tool {} is returnDirect; bypassing " +
|
||||||
|
"spill/truncate, broadcasting tool_direct_result ({} chars)", toolName, rawLen);
|
||||||
|
if (directOutputs != null) {
|
||||||
|
directOutputs.add(new DirectToolOutput(
|
||||||
|
toolCall.id(), toolName, fullResult, System.currentTimeMillis()));
|
||||||
|
}
|
||||||
|
events.add(GraphEventPublisher.toolDirectResult(
|
||||||
|
toolCall.id(), toolName, fullResult));
|
||||||
|
return new ToolResponseMessage.ToolResponse(
|
||||||
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3 Layer 2: spill before truncation when storage is wired.
|
||||||
|
// Use the caller-supplied conversationId so spill files inherit the
|
||||||
|
// same per-conversation directory layout as the non-replay path.
|
||||||
|
if (resultStorage != null && result != null) {
|
||||||
|
String spillConv = conversationId != null && !conversationId.isEmpty() ? conversationId : "unknown";
|
||||||
|
result = resultStorage.persistIfOversized(
|
||||||
|
result, toolName, toolCall.id(), spillConv, workspaceBasePath);
|
||||||
|
}
|
||||||
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
||||||
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (truncated to " + result.length() + ")" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, result, true));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, result != null ? result : "");
|
toolCall.id(), toolName, result != null ? result : "");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, e.getMessage(), false));
|
String safeError = isReturnDirect(callback)
|
||||||
return new ToolResponseMessage.ToolResponse(
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
toolCall.id(), toolName, "Tool execution failed: " + e.getMessage());
|
: "Tool execution failed: " + e.getMessage();
|
||||||
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
||||||
|
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backwards-compatible overload — replay spills land in a synthetic
|
||||||
|
* {@code unknown/} conversation bucket. New callers must use the
|
||||||
|
* {@link #executePreApproved(AssistantMessage.ToolCall, String, List, String, String)}
|
||||||
|
* variant so spill files are correctly scoped per conversation.
|
||||||
|
*
|
||||||
|
* @deprecated use the 5-arg overload with explicit {@code conversationId}
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public ToolResponseMessage.ToolResponse executePreApproved(
|
||||||
|
AssistantMessage.ToolCall toolCall, String storedArguments,
|
||||||
|
List<GraphEventPublisher.GraphEvent> events) {
|
||||||
|
// Workspace base path is no longer carried as instance state — legacy
|
||||||
|
// callers that don't supply one get unrestricted file access (matches
|
||||||
|
// pre-RFC behavior when WorkspacePathGuard.basePath was null).
|
||||||
|
return executePreApproved(toolCall, storedArguments, events, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Phase 2: 并发执行 ====================
|
// ==================== Phase 2: 并发执行 ====================
|
||||||
|
|
||||||
private void executePreparedCalls(List<PreparedToolCall> preparedCalls,
|
private void executePreparedCalls(List<PreparedToolCall> preparedCalls,
|
||||||
List<ToolResponseMessage.ToolResponse> allResponses,
|
List<ToolResponseMessage.ToolResponse> allResponses,
|
||||||
List<GraphEventPublisher.GraphEvent> events) {
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
List<DirectToolOutput> directOutputs) {
|
||||||
|
long execStartMs = System.currentTimeMillis();
|
||||||
if (!preparedCalls.isEmpty() && streamTracker != null) {
|
if (!preparedCalls.isEmpty() && streamTracker != null) {
|
||||||
String conversationId = preparedCalls.get(0).conversationId;
|
String conversationId = preparedCalls.get(0).conversationId;
|
||||||
String phase = classifyBatchPhase(preparedCalls);
|
String phase = classifyBatchPhase(preparedCalls);
|
||||||
@ -325,13 +502,21 @@ public class ToolExecutionExecutor {
|
|||||||
if (batch.size() == 1) {
|
if (batch.size() == 1) {
|
||||||
// 单个工具(safe 或 unsafe),直接执行
|
// 单个工具(safe 或 unsafe),直接执行
|
||||||
PreparedToolCall pc = batch.get(0);
|
PreparedToolCall pc = batch.get(0);
|
||||||
ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events);
|
ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events, directOutputs);
|
||||||
allResponses.set(pc.resultIndex, response);
|
allResponses.set(pc.resultIndex, response);
|
||||||
} else {
|
} else {
|
||||||
// 多个 safe 工具,并行执行
|
// 多个 safe 工具,并行执行
|
||||||
executeParallelBatch(batch, allResponses, events);
|
executeParallelBatch(batch, allResponses, events, directOutputs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-6: emit tool execution perf summary
|
||||||
|
long toolExecMs = System.currentTimeMillis() - execStartMs;
|
||||||
|
events.add(GraphEventPublisher.perfSummary("tool_execution", Map.of(
|
||||||
|
"tool_exec_ms", toolExecMs,
|
||||||
|
"tool_count", preparedCalls.size(),
|
||||||
|
"batch_count", batches.size()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -365,7 +550,8 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
private void executeParallelBatch(List<PreparedToolCall> batch,
|
private void executeParallelBatch(List<PreparedToolCall> batch,
|
||||||
List<ToolResponseMessage.ToolResponse> allResponses,
|
List<ToolResponseMessage.ToolResponse> allResponses,
|
||||||
List<GraphEventPublisher.GraphEvent> events) {
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
List<DirectToolOutput> directOutputs) {
|
||||||
log.info("[ToolExecutor] Executing {} safe tools in parallel: {}",
|
log.info("[ToolExecutor] Executing {} safe tools in parallel: {}",
|
||||||
batch.size(), batch.stream().map(pc -> pc.toolCall.name()).toList());
|
batch.size(), batch.stream().map(pc -> pc.toolCall.name()).toList());
|
||||||
long batchStartMs = System.currentTimeMillis();
|
long batchStartMs = System.currentTimeMillis();
|
||||||
@ -373,7 +559,7 @@ public class ToolExecutionExecutor {
|
|||||||
Map<Integer, CompletableFuture<ToolResponseMessage.ToolResponse>> futures = new LinkedHashMap<>();
|
Map<Integer, CompletableFuture<ToolResponseMessage.ToolResponse>> futures = new LinkedHashMap<>();
|
||||||
for (PreparedToolCall pc : batch) {
|
for (PreparedToolCall pc : batch) {
|
||||||
CompletableFuture<ToolResponseMessage.ToolResponse> future =
|
CompletableFuture<ToolResponseMessage.ToolResponse> future =
|
||||||
CompletableFuture.supplyAsync(() -> executeSingleTool(pc, events), TOOL_EXECUTOR);
|
CompletableFuture.supplyAsync(() -> executeSingleTool(pc, events, directOutputs), TOOL_EXECUTOR);
|
||||||
futures.put(pc.resultIndex, future);
|
futures.put(pc.resultIndex, future);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,50 +590,99 @@ public class ToolExecutionExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ToolResponseMessage.ToolResponse executeSingleTool(PreparedToolCall pc,
|
private ToolResponseMessage.ToolResponse executeSingleTool(PreparedToolCall pc,
|
||||||
List<GraphEventPublisher.GraphEvent> events) {
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
List<DirectToolOutput> directOutputs) {
|
||||||
String toolName = pc.toolCall.name();
|
String toolName = pc.toolCall.name();
|
||||||
try {
|
try {
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
|
||||||
GraphEventPublisher.toolStart(toolName, pc.arguments).data());
|
GraphEventPublisher.toolStart(pc.toolCall.id(), toolName, pc.arguments).data());
|
||||||
}
|
}
|
||||||
log.info("[ToolExecutor] Executing tool: {} with args: {}",
|
log.info("[ToolExecutor] Executing tool: {} with args: {}",
|
||||||
toolName, pc.arguments != null && pc.arguments.length() > 200
|
toolName, pc.arguments != null && pc.arguments.length() > 200
|
||||||
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
|
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
|
||||||
|
|
||||||
// 注入工具执行上下文(供 VideoGenerateTool 等获取 conversationId / username / workspaceBasePath)
|
// RFC-063r §2.5 / PR-1 transition window: populate BOTH the explicit
|
||||||
|
// Spring AI ToolContext (preferred — read via ChatOrigin.from(ctx))
|
||||||
|
// AND the legacy ToolExecutionContext ThreadLocal so tools that have
|
||||||
|
// not yet migrated to ToolContext keep working unchanged.
|
||||||
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
||||||
String result;
|
String result;
|
||||||
try {
|
try {
|
||||||
result = pc.callback.call(pc.arguments);
|
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
|
||||||
|
runtimeOrigin = runtimeOrigin
|
||||||
|
.withConversationId(pc.conversationId)
|
||||||
|
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||||
|
ToolContext toolContext = runtimeOrigin.toToolContext();
|
||||||
|
result = pc.callback.call(pc.arguments, toolContext);
|
||||||
} finally {
|
} finally {
|
||||||
ToolExecutionContext.clear();
|
ToolExecutionContext.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
// RFC-052: returnDirect tools bypass spill / truncation / LLM context.
|
||||||
|
// Their full text goes to the user verbatim and is never persisted to
|
||||||
|
// a workspace cache file (spill could leak sensitive data).
|
||||||
|
if (isReturnDirect(pc.callback)) {
|
||||||
|
String fullResult = result != null ? result : "";
|
||||||
|
log.info("[ToolExecutor] Tool {} is returnDirect; bypassing spill/truncate, " +
|
||||||
|
"broadcasting tool_direct_result ({} chars)", toolName, rawLen);
|
||||||
|
directOutputs.add(new DirectToolOutput(
|
||||||
|
pc.toolCall.id(), toolName, fullResult, System.currentTimeMillis()));
|
||||||
|
GraphEventPublisher.GraphEvent directEvent =
|
||||||
|
GraphEventPublisher.toolDirectResult(pc.toolCall.id(), toolName, fullResult);
|
||||||
|
events.add(directEvent);
|
||||||
|
if (streamTracker != null) {
|
||||||
|
streamTracker.broadcastObject(pc.conversationId,
|
||||||
|
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
|
||||||
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
|
}
|
||||||
|
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
||||||
|
// for OpenAI-compatible providers, while withholding the data from
|
||||||
|
// any subsequent LLM round (the graph won't take a next round —
|
||||||
|
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
||||||
|
return new ToolResponseMessage.ToolResponse(
|
||||||
|
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC-008 Phase 3 Layer 2: spill oversized results to disk and replace
|
||||||
|
// with preview + path. Falls back to truncation when spilling is
|
||||||
|
// disabled or fails. Spill preserves the full output (read_file can
|
||||||
|
// retrieve it); truncation discards the tail.
|
||||||
|
if (resultStorage != null && result != null) {
|
||||||
|
result = resultStorage.persistIfOversized(
|
||||||
|
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
|
||||||
|
}
|
||||||
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
||||||
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (truncated to " + result.length() + ")" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, result, true));
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
GraphEventPublisher.toolComplete(toolName, result, true).data());
|
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), toolName, result != null ? result : "");
|
pc.toolCall.id(), toolName, result != null ? result : "");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
||||||
String normalizedError = normalizeToolExecutionError(e);
|
// RFC-052: for returnDirect tools, even the error message is
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, normalizedError, false));
|
// suspect — exception text may carry stack traces, SQL fragments,
|
||||||
|
// or other sensitive substrings that should not enter LLM context.
|
||||||
|
// Emit a generic placeholder instead. Full error still goes to logs
|
||||||
|
// for operator diagnosis.
|
||||||
|
String reportedError = isReturnDirect(pc.callback)
|
||||||
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
|
: normalizeToolExecutionError(e);
|
||||||
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
GraphEventPublisher.toolComplete(toolName, normalizedError, false).data());
|
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false).data());
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), toolName, normalizedError);
|
pc.toolCall.id(), toolName, reportedError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -464,7 +699,7 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
if (evaluation.shouldBlock()) {
|
if (evaluation.shouldBlock()) {
|
||||||
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
|
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, evaluation.summary(), false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, evaluation.summary(), false));
|
||||||
return GuardDecision.blocked(
|
return GuardDecision.blocked(
|
||||||
"[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。");
|
"[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。");
|
||||||
}
|
}
|
||||||
@ -483,7 +718,7 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
if (guardResult.isBlocked()) {
|
if (guardResult.isBlocked()) {
|
||||||
log.warn("[ToolExecutor] Tool call BLOCKED by ToolGuard: tool={}, reason={}", toolName, guardResult.reason());
|
log.warn("[ToolExecutor] Tool call BLOCKED by ToolGuard: tool={}, reason={}", toolName, guardResult.reason());
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, guardResult.reason(), false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, guardResult.reason(), false));
|
||||||
return GuardDecision.blocked(
|
return GuardDecision.blocked(
|
||||||
"[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。");
|
"[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。");
|
||||||
}
|
}
|
||||||
@ -504,9 +739,33 @@ public class ToolExecutionExecutor {
|
|||||||
// ==================== 辅助方法 ====================
|
// ==================== 辅助方法 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断工具是否并发安全
|
* RFC-052: a tool is "direct" when its {@link ToolCallback#getToolMetadata()}
|
||||||
|
* reports {@code returnDirect=true}. {@code @Tool(returnDirect=true)} maps
|
||||||
|
* here automatically; MCP tools rely on the
|
||||||
|
* {@code ReturnDirectMcpToolCallback} decorator to override the metadata
|
||||||
|
* (the upstream {@code SyncMcpToolCallback} returns the framework default
|
||||||
|
* of {@code false}).
|
||||||
|
*/
|
||||||
|
private static boolean isReturnDirect(ToolCallback callback) {
|
||||||
|
try {
|
||||||
|
return callback.getToolMetadata() != null
|
||||||
|
&& callback.getToolMetadata().returnDirect();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[ToolExecutor] Failed to read returnDirect metadata: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the tool can run in parallel with other safe tools.
|
||||||
|
* Consults the registry first (annotation-driven, populated at startup);
|
||||||
|
* falls back to the legacy hardcoded set for callers built without a
|
||||||
|
* registry (legacy constructor / unit tests).
|
||||||
*/
|
*/
|
||||||
private boolean isConcurrencySafe(String toolName) {
|
private boolean isConcurrencySafe(String toolName) {
|
||||||
|
if (concurrencyRegistry != null && concurrencyRegistry.isUnsafe(toolName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return !DEFAULT_UNSAFE_TOOLS.contains(toolName);
|
return !DEFAULT_UNSAFE_TOOLS.contains(toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -526,8 +785,20 @@ public class ToolExecutionExecutor {
|
|||||||
|| lower.contains("unexpected character escape sequence")
|
|| lower.contains("unexpected character escape sequence")
|
||||||
|| lower.contains("json parse error")
|
|| lower.contains("json parse error")
|
||||||
|| lower.contains("malformed json")) {
|
|| lower.contains("malformed json")) {
|
||||||
return "Tool execution failed: model generated invalid JSON for tool arguments. "
|
// Truncated tool_call args — typically from max_tokens being hit while
|
||||||
+ "或在字符串转义位置被截断。请改为分步骤写入,拆成多个文件,或缩小单次 write_file/edit_file 的内容后重试。";
|
// streaming a large `content` field (e.g. renderDocx with 7000+ char
|
||||||
|
// markdown body). The fix MUST come from the model: re-emit the same
|
||||||
|
// tool call with smaller content per call, OR split the work across
|
||||||
|
// multiple sequential tool calls. We tell the LLM directly so the
|
||||||
|
// next reasoning iteration knows what to do — without this, models
|
||||||
|
// tend to fall back to narrating the result as final_answer text.
|
||||||
|
return "Tool execution failed: your tool_call arguments JSON was truncated mid-stream "
|
||||||
|
+ "(very likely you hit max_tokens while emitting a long content field). "
|
||||||
|
+ "Action required: re-call the SAME tool now in your next response, but "
|
||||||
|
+ "(1) make the content field shorter, OR (2) split the work into multiple "
|
||||||
|
+ "sequential tool calls (e.g. write the doc in 2-3 chunks via separate calls). "
|
||||||
|
+ "Do NOT describe the result as text — you must call the tool again to actually "
|
||||||
|
+ "produce the output.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lower.contains("access denied") && lower.contains("path outside allowed directories")) {
|
if (lower.contains("access denied") && lower.contains("path outside allowed directories")) {
|
||||||
@ -556,7 +827,8 @@ public class ToolExecutionExecutor {
|
|||||||
int resultIndex,
|
int resultIndex,
|
||||||
String conversationId,
|
String conversationId,
|
||||||
String requesterId,
|
String requesterId,
|
||||||
String workspaceBasePath
|
String workspaceBasePath,
|
||||||
|
ChatOrigin origin
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private record ApprovalBarrier(String pendingId, String toolName) {}
|
private record ApprovalBarrier(String pendingId, String toolName) {}
|
||||||
@ -594,6 +866,25 @@ public class ToolExecutionExecutor {
|
|||||||
/** 审批 pending ID(如果 awaitingApproval=true) */
|
/** 审批 pending ID(如果 awaitingApproval=true) */
|
||||||
String pendingId,
|
String pendingId,
|
||||||
/** 触发审批 barrier 的工具名(如果 awaitingApproval=true) */
|
/** 触发审批 barrier 的工具名(如果 awaitingApproval=true) */
|
||||||
String barrierToolName
|
String barrierToolName,
|
||||||
) {}
|
/**
|
||||||
|
* RFC-052: full-text outputs from any returnDirect tools that ran
|
||||||
|
* in this batch. Non-empty list ⇒ graph must short-circuit to
|
||||||
|
* FinalAnswerNode without re-entering the LLM.
|
||||||
|
*/
|
||||||
|
List<DirectToolOutput> directOutputs
|
||||||
|
) {
|
||||||
|
/** Backwards-compatible constructor for callers that don't track direct outputs. */
|
||||||
|
public ToolExecutionResult(List<ToolResponseMessage.ToolResponse> responses,
|
||||||
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
boolean awaitingApproval,
|
||||||
|
String pendingId,
|
||||||
|
String barrierToolName) {
|
||||||
|
this(responses, events, awaitingApproval, pendingId, barrierToolName, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasDirectOutputs() {
|
||||||
|
return directOutputs != null && !directOutputs.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
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
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 2 — a single tool result larger than this is spilled to disk.
|
||||||
|
* Note: Layer 1 hard truncation ({@code MAX_TOOL_RESULT_CHARS=8000} in
|
||||||
|
* {@link ToolExecutionExecutor}) runs before this threshold is evaluated,
|
||||||
|
* so only results that survive Layer 1 can trigger a spill.
|
||||||
|
*/
|
||||||
|
private int perResultThresholdChars = 16000; // was 4000 — prevents WebSearch spill-to-disk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O(1) membership test for the exclusion list, used on every tool result. */
|
||||||
|
public Set<String> excludedToolsSet() {
|
||||||
|
return Set.copyOf(excludedTools);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,254 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
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();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Nothing left to spill; remaining oversize is from excluded tools or
|
||||||
|
// already-spilled responses. Accept the over-budget state — better than
|
||||||
|
// breaking the agent's retrieval path.
|
||||||
|
log.warn("[ToolResultStorage] aggregate still {} chars after spilling everything eligible (excluded tools may push past budget)",
|
||||||
|
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 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) {
|
||||||
|
int previewLen = Math.min(props.getPreviewHeadChars(), fullResult.length());
|
||||||
|
String head = fullResult.substring(0, previewLen);
|
||||||
|
return SPILL_MARKER_PREFIX
|
||||||
|
+ " tool=" + toolName
|
||||||
|
+ " full_chars=" + fullResult.length()
|
||||||
|
+ " path=" + spillFile.toAbsolutePath()
|
||||||
|
+ "\n[Preview — first " + previewLen + " of " + fullResult.length()
|
||||||
|
+ " chars. Use read_file with the path above to retrieve the rest.]\n"
|
||||||
|
+ 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) {
|
||||||
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
|
return Paths.get(props.getStorageBaseDir());
|
||||||
|
}
|
||||||
|
if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||||
|
return Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||||
|
}
|
||||||
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
|
if (tmp == null || tmp.isEmpty()) return null;
|
||||||
|
return Paths.get(tmp, "mateclaw", "tool-results");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -65,9 +65,13 @@ 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())
|
||||||
@ -84,6 +88,33 @@ public class ActionNode implements NodeAction {
|
|||||||
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("");
|
||||||
|
|||||||
@ -3,9 +3,11 @@ 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.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 java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,6 +36,34 @@ 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 = 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);
|
||||||
|
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 = accessor.streamedContent();
|
||||||
@ -115,6 +145,27 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@ -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,28 @@ 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;
|
||||||
|
|
||||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||||
NodeStreamingChatHelper streamingHelper) {
|
NodeStreamingChatHelper streamingHelper) {
|
||||||
|
this(chatModel, observationProcessor, streamingHelper, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||||
|
NodeStreamingChatHelper streamingHelper, I18nService i18n) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.observationProcessor = observationProcessor;
|
this.observationProcessor = observationProcessor;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
|
this.i18n = i18n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -88,7 +97,7 @@ 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)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 prompt
|
// 构建 prompt
|
||||||
@ -110,8 +119,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)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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;
|
||||||
import org.springframework.ai.chat.messages.SystemMessage;
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
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.ChatOptions;
|
||||||
@ -51,12 +52,48 @@ public class ReasoningNode implements NodeAction {
|
|||||||
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
/** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */
|
/**
|
||||||
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
* 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成。
|
||||||
|
* <p>
|
||||||
|
* RFC-049 follow-up (2026-04-27): bumped 4096 → 16384. 4096 was hitting
|
||||||
|
* the cap when models emit large generative tool_call args (e.g. renderDocx
|
||||||
|
* with a multi-thousand-character markdown body) on top of thinking
|
||||||
|
* content for reasoning_effort=high — the JSON args got truncated mid-
|
||||||
|
* stream, the tool failed to parse, the docx was never generated. 16k is
|
||||||
|
* the conservative ceiling that covers typical "write a long document"
|
||||||
|
* tool calls without enabling true runaway loops (those are bounded by
|
||||||
|
* iteration count, not per-call tokens).
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hermes-agent style enforcement clause appended to every ReasoningNode
|
||||||
|
* system prompt. Treats narration ("I will now …") as a protocol violation
|
||||||
|
* to prevent the recurring failure mode where a model says it will call a
|
||||||
|
* tool but emits the description as final_answer text instead.
|
||||||
|
*/
|
||||||
|
private static final String TOOL_USE_ENFORCEMENT = "\n\n"
|
||||||
|
+ "## 工具调用纪律(必读)\n\n"
|
||||||
|
+ "- 你**必须**直接调用工具来产生结果,不允许只用文字描述\"接下来要做什么\"。\n"
|
||||||
|
+ "- 当你说要执行某个动作(如生成文件、发送消息、调用接口、生成 docx),\n"
|
||||||
|
+ " 你**必须**在同一条回复里**立即发出对应的 tool_call**,不允许只写文字承诺。\n"
|
||||||
|
+ "- 禁止以\"现在 / 接下来 / 我将 / 直接生成 / 我直接\"+动作描述结束本轮回复——\n"
|
||||||
|
+ " 这种叙述会让系统误判任务已完成,**实际上工具没被调用**,结果文件不会产生。\n"
|
||||||
|
+ "- 如果上一次工具调用因 args JSON 截断(max_tokens 超限)失败,\n"
|
||||||
|
+ " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n"
|
||||||
|
+ "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n"
|
||||||
|
+ " 才输出无 tool_call 的纯文字回答。\n";
|
||||||
|
|
||||||
private final ChatModel chatModel;
|
private final ChatModel chatModel;
|
||||||
private final List<ToolCallback> toolCallbacks;
|
private final List<ToolCallback> toolCallbacks;
|
||||||
private final String reasoningEffort;
|
private final String reasoningEffort;
|
||||||
|
/**
|
||||||
|
* PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts
|
||||||
|
* {@code reasoning_effort}. Drives the capability gate in
|
||||||
|
* {@link #resolveEffectiveReasoningEffort()} so that a front-end {@code ThinkingLevelHolder}
|
||||||
|
* override is dropped on chat-type models that cannot honor it.
|
||||||
|
*/
|
||||||
|
private final boolean supportsReasoningEffort;
|
||||||
private final NodeStreamingChatHelper streamingHelper;
|
private final NodeStreamingChatHelper streamingHelper;
|
||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
@ -85,9 +122,31 @@ public class ReasoningNode implements NodeAction {
|
|||||||
ConversationWindowManager conversationWindowManager,
|
ConversationWindowManager conversationWindowManager,
|
||||||
ChatStreamTracker streamTracker, int maxOutputTokens,
|
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||||
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
||||||
|
// Backward-compatible delegate. Callers that have not migrated to the explicit
|
||||||
|
// supportsReasoningEffort parameter inherit the pre-PR-1 behavior: treat the bound
|
||||||
|
// model as supporting reasoning_effort iff reasoningEffort was resolved to a non-null
|
||||||
|
// value at construction time. New callers (AgentGraphBuilder) should use the
|
||||||
|
// 9-arg constructor below.
|
||||||
|
this(chatModel, toolSet, reasoningEffort, reasoningEffort != null,
|
||||||
|
streamingHelper, conversationWindowManager, streamTracker,
|
||||||
|
maxOutputTokens, wikiContextService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR-1.2 (RFC-049): Primary constructor with explicit {@code supportsReasoningEffort}
|
||||||
|
* capability flag — avoids inferring capability from {@code reasoningEffort == null},
|
||||||
|
* which fails for a future "supports but not auto-enabled" scenario.
|
||||||
|
*/
|
||||||
|
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||||
|
boolean supportsReasoningEffort,
|
||||||
|
NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||||
|
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.toolCallbacks = toolSet.callbacks();
|
this.toolCallbacks = toolSet.callbacks();
|
||||||
this.reasoningEffort = reasoningEffort;
|
this.reasoningEffort = reasoningEffort;
|
||||||
|
this.supportsReasoningEffort = supportsReasoningEffort;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
this.conversationWindowManager = conversationWindowManager;
|
this.conversationWindowManager = conversationWindowManager;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
@ -119,6 +178,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.toolCallbacks = toolCallbacks;
|
this.toolCallbacks = toolCallbacks;
|
||||||
this.reasoningEffort = null;
|
this.reasoningEffort = null;
|
||||||
|
this.supportsReasoningEffort = false;
|
||||||
this.streamingHelper = null;
|
this.streamingHelper = null;
|
||||||
this.conversationWindowManager = null;
|
this.conversationWindowManager = null;
|
||||||
this.streamTracker = null;
|
this.streamTracker = null;
|
||||||
@ -172,16 +232,93 @@ public class ReasoningNode implements NodeAction {
|
|||||||
|
|
||||||
// ======= 构建 Prompt =======
|
// ======= 构建 Prompt =======
|
||||||
String systemPrompt = accessor.systemPrompt();
|
String systemPrompt = accessor.systemPrompt();
|
||||||
|
// RFC-049 follow-up: append a tool-use enforcement clause to every
|
||||||
|
// ReasoningNode call. Without this, models (especially DeepSeek thinking
|
||||||
|
// and Claude Opus) tend to "narrate" — emit a final_answer like "现在
|
||||||
|
// 直接生成立项材料 docx" instead of actually calling renderDocx, which
|
||||||
|
// makes the graph silently terminate at final_answer_node with the
|
||||||
|
// narration as the user-facing reply.
|
||||||
|
//
|
||||||
|
// Pattern adopted from hermes-agent's TOOL_USE_ENFORCEMENT_GUIDANCE
|
||||||
|
// (`/agent/prompt_builder.py:179-191`). Appended to systemPrompt rather
|
||||||
|
// than woven into the AgentEntity-stored prompt so it stays out of the
|
||||||
|
// user-editable agent UI but is still always-on at runtime.
|
||||||
|
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
|
||||||
List<Message> messages = accessor.messages();
|
List<Message> messages = accessor.messages();
|
||||||
|
|
||||||
// 消息列表膨胀防护
|
// Guard against runaway message list growth.
|
||||||
|
//
|
||||||
|
// CRITICAL: a naive head+tail cut can break the OpenAI-compatible protocol invariant
|
||||||
|
// that requires tool_call / tool_response pairs to be complete:
|
||||||
|
//
|
||||||
|
// P0 (originally observed): AssistantMessage(tool_calls) falls into the dropped gap,
|
||||||
|
// its ToolResponseMessage lands in the kept tail → provider sees an orphaned
|
||||||
|
// ToolResponseMessage → kimi-code 400 "tool_call_id is not found".
|
||||||
|
//
|
||||||
|
// P1 (symmetric): AssistantMessage(tool_calls) is kept in the head at the boundary,
|
||||||
|
// its ToolResponseMessage falls into the dropped gap → provider sees an assistant
|
||||||
|
// tool_call with no matching response → also a 400 on strict providers.
|
||||||
|
//
|
||||||
|
// Fix: perform the normal cut, then run an iterative bidirectional integrity pass until
|
||||||
|
// the list is stable:
|
||||||
|
// • Remove any ToolResponseMessage whose parent AssistantMessage.tool_calls id was
|
||||||
|
// dropped (P0).
|
||||||
|
// • Remove any AssistantMessage whose tool_calls have no matching ToolResponseMessage
|
||||||
|
// (P1).
|
||||||
|
// Iterate because a P1 removal could expose a new P0 orphan (and vice versa, though that
|
||||||
|
// is pathological in practice). With ≤40 messages convergence is always fast.
|
||||||
|
// Dropping incomplete pairs is safe — prior iterations already processed those
|
||||||
|
// observations; the LLM needs the summary context, not the raw tool I/O.
|
||||||
final int MAX_LOOP_MESSAGES = 40;
|
final int MAX_LOOP_MESSAGES = 40;
|
||||||
if (messages.size() > MAX_LOOP_MESSAGES) {
|
if (messages.size() > MAX_LOOP_MESSAGES) {
|
||||||
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
|
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
|
||||||
messages.size(), MAX_LOOP_MESSAGES, conversationId);
|
messages.size(), MAX_LOOP_MESSAGES, conversationId);
|
||||||
|
int headKeep = Math.min(4, messages.size());
|
||||||
|
int tailKeep = MAX_LOOP_MESSAGES - headKeep;
|
||||||
|
int tailStart = messages.size() - tailKeep;
|
||||||
|
|
||||||
List<Message> trimmed = new ArrayList<>(MAX_LOOP_MESSAGES);
|
List<Message> trimmed = new ArrayList<>(MAX_LOOP_MESSAGES);
|
||||||
trimmed.addAll(messages.subList(0, Math.min(4, messages.size())));
|
trimmed.addAll(messages.subList(0, headKeep));
|
||||||
trimmed.addAll(messages.subList(messages.size() - (MAX_LOOP_MESSAGES - 4), messages.size()));
|
trimmed.addAll(messages.subList(tailStart, messages.size()));
|
||||||
|
|
||||||
|
// Iterative bidirectional integrity pass.
|
||||||
|
int totalRemoved = 0;
|
||||||
|
boolean changed;
|
||||||
|
do {
|
||||||
|
// Snapshot current tool_call ids and response ids.
|
||||||
|
Set<String> callIds = new java.util.HashSet<>();
|
||||||
|
Set<String> respIds = new java.util.HashSet<>();
|
||||||
|
for (Message m : trimmed) {
|
||||||
|
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||||
|
for (AssistantMessage.ToolCall tc : am.getToolCalls()) callIds.add(tc.id());
|
||||||
|
}
|
||||||
|
if (m instanceof ToolResponseMessage trm) {
|
||||||
|
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) respIds.add(r.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int before = trimmed.size();
|
||||||
|
trimmed.removeIf(m -> {
|
||||||
|
// P0: ToolResponseMessage whose parent tool_call was dropped
|
||||||
|
if (m instanceof ToolResponseMessage trm) {
|
||||||
|
return trm.getResponses().stream().anyMatch(r -> !callIds.contains(r.id()));
|
||||||
|
}
|
||||||
|
// P1: AssistantMessage whose tool_call has no ToolResponseMessage
|
||||||
|
if (m instanceof AssistantMessage am && am.getToolCalls() != null
|
||||||
|
&& !am.getToolCalls().isEmpty()) {
|
||||||
|
return am.getToolCalls().stream().anyMatch(tc -> !respIds.contains(tc.id()));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
int removed = before - trimmed.size();
|
||||||
|
totalRemoved += removed;
|
||||||
|
changed = removed > 0;
|
||||||
|
} while (changed);
|
||||||
|
|
||||||
|
if (totalRemoved > 0) {
|
||||||
|
log.warn("[ReasoningNode] Removed {} message(s) with broken tool_call/response pairs "
|
||||||
|
+ "after trim (bidirectional integrity guard), conv={}", totalRemoved, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
messages = trimmed;
|
messages = trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,6 +591,13 @@ public class ReasoningNode implements NodeAction {
|
|||||||
* 解析有效的 reasoningEffort。
|
* 解析有效的 reasoningEffort。
|
||||||
* 优先级:ThinkingLevelHolder(请求级) > 构造时的 reasoningEffort(Agent/模型默认)。
|
* 优先级:ThinkingLevelHolder(请求级) > 构造时的 reasoningEffort(Agent/模型默认)。
|
||||||
* "off" 会清除 reasoningEffort(返回 null)。
|
* "off" 会清除 reasoningEffort(返回 null)。
|
||||||
|
*
|
||||||
|
* <p>PR-1.2 (RFC-049 L1-B): If the bound model's family does not support
|
||||||
|
* {@code reasoning_effort} (as declared via {@link #supportsReasoningEffort} at
|
||||||
|
* construction time), the front-end thinking-level override is ignored.
|
||||||
|
* Chat-type models like {@code deepseek-chat} must not be forced into thinking mode
|
||||||
|
* just because the user ticked "deep thinking" in the UI — this is a product
|
||||||
|
* contract, not a runtime option.
|
||||||
*/
|
*/
|
||||||
private String resolveEffectiveReasoningEffort() {
|
private String resolveEffectiveReasoningEffort() {
|
||||||
String requestLevel = ThinkingLevelHolder.get();
|
String requestLevel = ThinkingLevelHolder.get();
|
||||||
@ -461,6 +605,11 @@ public class ReasoningNode implements NodeAction {
|
|||||||
if ("off".equalsIgnoreCase(requestLevel)) {
|
if ("off".equalsIgnoreCase(requestLevel)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!this.supportsReasoningEffort) {
|
||||||
|
log.debug("[ReasoningNode] Ignoring thinkingLevel='{}' — bound model family does not support reasoning_effort",
|
||||||
|
requestLevel);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// thinkingLevel → reasoningEffort 映射
|
// thinkingLevel → reasoningEffort 映射
|
||||||
return switch (requestLevel.toLowerCase()) {
|
return switch (requestLevel.toLowerCase()) {
|
||||||
case "low" -> "low";
|
case "low" -> "low";
|
||||||
|
|||||||
@ -3,12 +3,16 @@ 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.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
@ -101,9 +105,15 @@ 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()) {
|
||||||
@ -173,6 +183,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 +194,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;
|
||||||
|
|||||||
@ -285,6 +285,19 @@ 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));
|
||||||
|
|
||||||
|
// 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);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,14 @@ 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 vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
@ -26,21 +26,23 @@ 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,32 +52,44 @@ 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();
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
) {}
|
||||||
|
|
||||||
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": "..."}
|
输出:{"needs_planning": false, "direct_answer": "<你的回答>"}
|
||||||
|
|
||||||
需要规划时:
|
(B) 单步任务 — 需要工具,但本质是一个连贯动作(一次文件读取 / 一次搜索 / 一次命令 / 一次记忆读写 / 一次计算)。
|
||||||
{"needs_planning": true, "steps": ["步骤1", "步骤2", "步骤3"]}
|
执行器会在这一步内部迭代调用多次工具,你**不要**提前拆分。
|
||||||
|
输出:{"needs_planning": true, "steps": ["<将用户目标复述为一句清晰可执行的指令>"]}
|
||||||
|
|
||||||
要求:
|
(C) 多步任务 — 用户目标包含 2 个及以上明显独立、必须先后完成的子任务(例如"先调研 A 再调研 B 然后对比"、
|
||||||
- steps 数量 2 到 6 个。
|
"读配置、迁移数据、验证结果")。子任务之间如果可以合并,应当合并。
|
||||||
- 每个步骤必须是可执行动作,不要写空话。
|
输出:{"needs_planning": true, "steps": ["步骤1", "步骤2", ...]}(2 到 6 个步骤)
|
||||||
- 默认不要把 MEMORY.md、PROFILE.md、记忆文件当成独立步骤;但如果用户目标明显依赖历史偏好、长期约束、过往决策或持续上下文,可以加入必要的记忆读取步骤。
|
|
||||||
- 不要把技能文件当成独立步骤,除非用户任务明确要求。
|
关键原则:
|
||||||
- 如果用户目标需要调用任何工具才能完成(包括记忆读写、文件操作、搜索、命令执行等),必须返回 needs_planning: true。只有纯知识问答(不需要调用任何工具的简单问题)才返回 needs_planning: false。
|
- 单工具调用绝对不拆成多步。例:"读 A 文件并总结" 是单步(B),不是两步。
|
||||||
- 如果无法确定,也必须返回合法 JSON,不能输出自然语言。
|
- 默认不要把 MEMORY.md / PROFILE.md / 技能文件读取当成独立步骤;仅当用户明确询问偏好、历史决策或长期约束时才加入。
|
||||||
|
- 每个步骤必须是可执行动作,不写"思考一下""确认一下"之类的空话。
|
||||||
|
- 解析不出来时,视作(B) 单步;宁愿单步也不要无脑拆分。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
@ -90,7 +104,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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) {
|
||||||
@ -110,7 +124,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 +142,56 @@ 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)));
|
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
||||||
|
|
||||||
// 注入可用工具名称,帮助 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(对话历史摘要),让规划能感知之前对话的约束和补充条件
|
// 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 +205,28 @@ 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");
|
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,27 +240,21 @@ 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化计划
|
|
||||||
var plan = planningService.createPlan(agentId, goal, steps);
|
var plan = planningService.createPlan(agentId, goal, steps);
|
||||||
log.info("[PlanGeneration] Plan created: id={}, steps={}", plan.getId(), steps.size());
|
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
|
||||||
|
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
|
||||||
|
|
||||||
// 发布 plan_created 事件
|
|
||||||
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
||||||
|
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
@ -244,36 +271,33 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.build();
|
.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, 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,6 +20,7 @@ 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;
|
||||||
@ -86,6 +86,12 @@ 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);
|
||||||
|
|
||||||
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());
|
||||||
@ -117,22 +123,27 @@ 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<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
||||||
ChatOptions options;
|
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
|
||||||
if (StringUtils.hasText(reasoningEffort)) {
|
// 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();
|
|
||||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
|
||||||
options = oaiOpts;
|
|
||||||
} else {
|
|
||||||
options = ToolCallingChatOptions.builder()
|
|
||||||
.toolCallbacks(toolSet.callbacks())
|
|
||||||
.internalToolExecutionEnabled(false)
|
|
||||||
.build();
|
.build();
|
||||||
|
if (StringUtils.hasText(reasoningEffort)) {
|
||||||
|
oaiOpts.setReasoningEffort(reasoningEffort);
|
||||||
}
|
}
|
||||||
|
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||||
|
ChatOptions options = oaiOpts;
|
||||||
|
|
||||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||||
chatModel, new Prompt(messages, options), conversationId,
|
chatModel, new Prompt(messages, options), conversationId,
|
||||||
@ -180,16 +191,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 +218,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 +242,16 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
if (approvalTriggered) {
|
if (approvalTriggered) {
|
||||||
break;
|
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,6 +270,40 @@ 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));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
if (finalResult == null) {
|
if (finalResult == null) {
|
||||||
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);
|
||||||
@ -267,10 +332,22 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
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,6 +364,26 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
|
||||||
List<Message> messages = new ArrayList<>();
|
List<Message> messages = new ArrayList<>();
|
||||||
|
|
||||||
@ -414,9 +511,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();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 步骤控制 =====
|
// ===== 步骤控制 =====
|
||||||
|
|||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -20,7 +20,11 @@ public enum FinishReason {
|
|||||||
ERROR_FALLBACK("error_fallback"),
|
ERROR_FALLBACK("error_fallback"),
|
||||||
|
|
||||||
/** 用户主动停止 */
|
/** 用户主动停止 */
|
||||||
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.*;
|
||||||
@ -195,12 +196,34 @@ 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Token Usage =====
|
// ===== Token Usage =====
|
||||||
|
|
||||||
public int promptTokens() {
|
public int promptTokens() {
|
||||||
@ -373,11 +396,25 @@ 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Token Usage ----
|
// ---- Token Usage ----
|
||||||
|
|
||||||
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
||||||
|
|||||||
@ -140,4 +140,31 @@ 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";
|
||||||
|
|
||||||
|
// ===== 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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,9 @@ public class AgentEntity {
|
|||||||
/** 所属工作区 ID(默认 1 = default) */
|
/** 所属工作区 ID(默认 1 = default) */
|
||||||
private Long workspaceId;
|
private Long workspaceId;
|
||||||
|
|
||||||
|
/** Creator user ID — backfilled on create; lets members delete their own Agents without admin role */
|
||||||
|
private Long creatorUserId;
|
||||||
|
|
||||||
/** 默认思考深度:off / low / medium / high / max,null 表示跟随模型默认 */
|
/** 默认思考深度:off / low / medium / high / max,null 表示跟随模型默认 */
|
||||||
private String defaultThinkingLevel;
|
private String defaultThinkingLevel;
|
||||||
|
|
||||||
@ -61,6 +64,5 @@ public class AgentEntity {
|
|||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,14 +9,14 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt 文件加载器
|
* Loads prompt text files from {@code classpath:/prompts/} with a thread-safe
|
||||||
* <p>
|
* lazy cache.
|
||||||
* 从 classpath:/prompts/ 目录加载 .txt 文件,使用 ConcurrentHashMap 做线程安全的懒加载缓存。
|
|
||||||
* <p>
|
|
||||||
* 未来扩展点:可在 loadPrompt() 中增加"先查数据库覆盖 → 再读 resource → 最后代码兜底"的优先级链,
|
|
||||||
* 但本次只实现 resource 读取。
|
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* <p>Single-language by design: prompts are written in the system's default
|
||||||
|
* language and the LLM is trusted to follow the user's input language for
|
||||||
|
* its output. The previous {@code loadPrompt(name, locale)} overload and
|
||||||
|
* {@code prompts/{locale}/...} fallback chain were never wired up by any
|
||||||
|
* caller and have been removed.</p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public final class PromptLoader {
|
public final class PromptLoader {
|
||||||
@ -28,44 +28,19 @@ public final class PromptLoader {
|
|||||||
private PromptLoader() {}
|
private PromptLoader() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载 prompt 文件内容(默认语言)
|
* Load a prompt file's contents.
|
||||||
*
|
*
|
||||||
* @param promptName 文件名(不含路径前缀和 .txt 后缀),例如 "graph/summarize-system"
|
* @param promptName file name without the {@code prompts/} prefix or {@code .txt} suffix
|
||||||
* @return 文件文本内容
|
* (e.g. {@code "graph/summarize-system"})
|
||||||
* @throws RuntimeException 文件不存在或读取失败时抛出,不会静默返回空字符串
|
* @return file text content
|
||||||
|
* @throws RuntimeException when the file is missing or unreadable; the loader never
|
||||||
|
* silently returns an empty string
|
||||||
*/
|
*/
|
||||||
public static String loadPrompt(String promptName) {
|
public static String loadPrompt(String promptName) {
|
||||||
return promptCache.computeIfAbsent(promptName, name -> readPromptFile(name, null));
|
return promptCache.computeIfAbsent(promptName, PromptLoader::readPromptFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static String readPromptFile(String name) {
|
||||||
* 加载指定语言的 prompt 文件内容
|
|
||||||
* <p>
|
|
||||||
* 查找顺序:{@code prompts/{locale}/{name}.txt} → {@code prompts/{name}.txt}
|
|
||||||
*
|
|
||||||
* @param promptName 文件名(不含路径前缀和 .txt 后缀)
|
|
||||||
* @param locale 语言标识(如 "en"、"zh"),为 null 或 "zh" 时使用默认文件
|
|
||||||
* @return 文件文本内容
|
|
||||||
*/
|
|
||||||
public static String loadPrompt(String promptName, String locale) {
|
|
||||||
if (locale == null || locale.isBlank() || "zh".equals(locale)) {
|
|
||||||
return loadPrompt(promptName);
|
|
||||||
}
|
|
||||||
String cacheKey = locale + ":" + promptName;
|
|
||||||
return promptCache.computeIfAbsent(cacheKey, key -> readPromptFile(promptName, locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String readPromptFile(String name, String locale) {
|
|
||||||
// 优先尝试 locale 目录
|
|
||||||
if (locale != null && !locale.isBlank()) {
|
|
||||||
String localeFileName = PROMPT_PATH_PREFIX + locale + "/" + name + ".txt";
|
|
||||||
try (InputStream is = PromptLoader.class.getClassLoader().getResourceAsStream(localeFileName)) {
|
|
||||||
if (is != null) {
|
|
||||||
return StreamUtils.copyToString(is, StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
} catch (IOException ignored) {}
|
|
||||||
}
|
|
||||||
// 回退到默认目录
|
|
||||||
String fileName = PROMPT_PATH_PREFIX + name + ".txt";
|
String fileName = PROMPT_PATH_PREFIX + name + ".txt";
|
||||||
try (InputStream inputStream = PromptLoader.class.getClassLoader().getResourceAsStream(fileName)) {
|
try (InputStream inputStream = PromptLoader.class.getClassLoader().getResourceAsStream(fileName)) {
|
||||||
if (inputStream == null) {
|
if (inputStream == null) {
|
||||||
@ -78,18 +53,12 @@ public final class PromptLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Drop the entire cache. Useful for tests and hot-reload tooling. */
|
||||||
* 清空缓存
|
|
||||||
*/
|
|
||||||
public static void clearCache() {
|
public static void clearCache() {
|
||||||
promptCache.clear();
|
promptCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Number of prompts currently cached. */
|
||||||
* 获取缓存大小
|
|
||||||
*
|
|
||||||
* @return 已缓存的 prompt 数量
|
|
||||||
*/
|
|
||||||
public static int getCacheSize() {
|
public static int getCacheSize() {
|
||||||
return promptCache.size();
|
return promptCache.size();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,16 +66,18 @@ public class TemplateService {
|
|||||||
* 应用模板创建 Agent 及其工作区文件
|
* 应用模板创建 Agent 及其工作区文件
|
||||||
*
|
*
|
||||||
* @param templateId 模板 ID
|
* @param templateId 模板 ID
|
||||||
|
* @param workspaceId 目标工作区 ID(来自 X-Workspace-Id header)
|
||||||
|
* @param creatorUserId 当前用户 ID(用于 RFC-077 创建者归属)
|
||||||
* @return 创建的 AgentEntity
|
* @return 创建的 AgentEntity
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public AgentEntity applyTemplate(String templateId) {
|
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId) {
|
||||||
TemplateDTO template = listTemplates().stream()
|
TemplateDTO template = listTemplates().stream()
|
||||||
.filter(t -> t.getId().equals(templateId))
|
.filter(t -> t.getId().equals(templateId))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow(() -> new MateClawException("err.agent.template_not_found", "模板不存在: " + templateId));
|
.orElseThrow(() -> new MateClawException("err.agent.template_not_found", "模板不存在: " + templateId));
|
||||||
|
|
||||||
// 1. 创建 Agent
|
// 1. 创建 Agent — RFC-077: 显式注入 workspaceId/creatorUserId,避免 DB 默认值兜底成 1(issue #26 Bug A)
|
||||||
AgentEntity agent = new AgentEntity();
|
AgentEntity agent = new AgentEntity();
|
||||||
agent.setName(template.getName());
|
agent.setName(template.getName());
|
||||||
agent.setDescription(template.getDescription());
|
agent.setDescription(template.getDescription());
|
||||||
@ -83,6 +85,8 @@ public class TemplateService {
|
|||||||
agent.setIcon(template.getIcon());
|
agent.setIcon(template.getIcon());
|
||||||
agent.setTags(template.getTags());
|
agent.setTags(template.getTags());
|
||||||
agent.setMaxIterations(template.getMaxIterations());
|
agent.setMaxIterations(template.getMaxIterations());
|
||||||
|
agent.setWorkspaceId(workspaceId);
|
||||||
|
agent.setCreatorUserId(creatorUserId);
|
||||||
AgentEntity created = agentService.createAgent(agent);
|
AgentEntity created = agentService.createAgent(agent);
|
||||||
|
|
||||||
// 2. 创建工作区文件
|
// 2. 创建工作区文件
|
||||||
|
|||||||
@ -2,12 +2,10 @@ package vip.mate.approval;
|
|||||||
|
|
||||||
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.Data;
|
|
||||||
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.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
@ -15,10 +13,16 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具执行审批接口
|
* Approval read-only endpoints.
|
||||||
* <p>
|
* <p>
|
||||||
* 提供 approve / deny 端点,供前端在收到 tool_approval_requested SSE 事件后调用。
|
* Web approve / deny actions ride the SSE {@code POST /chat/stream} path with
|
||||||
* 批准后自动触发工具重放,结果通过 SSE 流推送给前端。
|
* {@code /approve} or {@code /deny} text commands ({@link vip.mate.channel.web.ChatController}
|
||||||
|
* intercepts), so a write-style {@code POST /approve} REST endpoint was deleted
|
||||||
|
* in RFC-067 PR 6 — it bypassed the unified workflow lifecycle and let any
|
||||||
|
* future caller silently regress to the pre-RFC ghost-approval state.
|
||||||
|
* <p>
|
||||||
|
* Only {@link #getPendingApprovals} remains, used by the frontend for hydration
|
||||||
|
* after page refresh.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -29,72 +33,13 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ApprovalController {
|
public class ApprovalController {
|
||||||
|
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalWorkflowService approvalService;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final ChatStreamTracker streamTracker;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批准或拒绝工具执行
|
* Hydration query for page refresh: returns every pending approval still
|
||||||
* <p>
|
* waiting in the conversation. The frontend uses this to rebuild the
|
||||||
* 批准后自动触发工具重放(异步执行),结果通过已有的 SSE 连接推送给前端。
|
* approval banner after a reload.
|
||||||
*/
|
|
||||||
@Operation(summary = "审批工具执行")
|
|
||||||
@PostMapping("/{conversationId}/approve")
|
|
||||||
public R<String> approve(
|
|
||||||
@PathVariable String conversationId,
|
|
||||||
@RequestBody ApprovalRequest request,
|
|
||||||
Authentication auth) {
|
|
||||||
|
|
||||||
if (auth == null) {
|
|
||||||
return R.fail(401, "未登录,请先登录");
|
|
||||||
}
|
|
||||||
String username = auth.getName();
|
|
||||||
|
|
||||||
// 校验会话归属
|
|
||||||
if (!conversationService.isConversationOwner(conversationId, username)) {
|
|
||||||
log.warn("[Approval] Unauthorized: user={} is not owner of conversation={}", username, conversationId);
|
|
||||||
return R.fail(403, "无权操作该会话");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验 pendingId
|
|
||||||
if (request.getPendingId() == null || request.getPendingId().isBlank()) {
|
|
||||||
return R.fail("pendingId 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验 decision
|
|
||||||
String decision = request.getDecision();
|
|
||||||
if (decision == null || (!decision.equalsIgnoreCase("approved") && !decision.equalsIgnoreCase("denied"))) {
|
|
||||||
return R.fail("decision 必须为 approved 或 denied");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
approvalService.resolve(request.getPendingId(), username, decision);
|
|
||||||
log.info("[Approval] User {} {} pending {} for conversation {}",
|
|
||||||
username, decision, request.getPendingId(), conversationId);
|
|
||||||
|
|
||||||
// Web 端的 replay 由前端发送 /approve 消息到 POST /stream 触发(ChatController 拦截)
|
|
||||||
// 此端点只更新审批状态,保留给 IM 渠道(DingTalk/Feishu 等通过 ChannelMessageRouter 调用)
|
|
||||||
|
|
||||||
// 拒绝时通过 SSE 通知前端(如果流还活着)
|
|
||||||
if ("denied".equalsIgnoreCase(decision) && streamTracker.isRunning(conversationId)) {
|
|
||||||
streamTracker.broadcastObject(conversationId, "tool_approval_resolved", Map.of(
|
|
||||||
"pendingId", request.getPendingId(),
|
|
||||||
"decision", "denied",
|
|
||||||
"timestamp", System.currentTimeMillis()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return R.ok("操作成功");
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("[Approval] Resolve failed: {}", e.getMessage());
|
|
||||||
return R.fail(e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询指定会话下的待审批记录
|
|
||||||
* <p>
|
|
||||||
* 用于页面刷新后恢复审批卡片(hydration)。
|
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "查询待审批记录")
|
@Operation(summary = "查询待审批记录")
|
||||||
@GetMapping("/{conversationId}/pending-approvals")
|
@GetMapping("/{conversationId}/pending-approvals")
|
||||||
@ -114,11 +59,4 @@ public class ApprovalController {
|
|||||||
List<Map<String, Object>> pending = approvalService.getPendingByConversation(conversationId);
|
List<Map<String, Object>> pending = approvalService.getPendingByConversation(conversationId);
|
||||||
return R.ok(pending);
|
return R.ok(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
|
||||||
public static class ApprovalRequest {
|
|
||||||
private String pendingId;
|
|
||||||
/** "approved" 或 "denied" */
|
|
||||||
private String decision;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package vip.mate.approval;
|
package vip.mate.approval;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -9,21 +7,26 @@ import java.time.Duration;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具执行审批服务(消息驱动版 — 非阻塞)
|
* In-memory approval store (RFC-067).
|
||||||
* <p>
|
* <p>
|
||||||
* 核心变化:不再阻塞线程等待审批。
|
* INTERNAL — do not call mutating methods directly. Business code must go through
|
||||||
|
* {@link ApprovalWorkflowService}, which owns the DB / message-metadata / memory
|
||||||
|
* three-way state machine. This class only exposes:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link #createPending} 创建待审批记录后立即返回</li>
|
* <li>{@link #createPending} — used by tool guard to register a new approval;
|
||||||
* <li>{@link #resolve} 更新状态为 approved/denied</li>
|
* paired with {@link ApprovalWorkflowService#createPending} for DB persistence</li>
|
||||||
* <li>{@link #findPendingByConversation} 查找会话最早的 pending(FIFO)</li>
|
* <li>read-only queries: {@link #getPending}, {@link #findPendingByConversation},
|
||||||
* <li>{@link #consumeApproved} 一次性消费已批准记录供重放</li>
|
* {@link #getPendingByConversation}</li>
|
||||||
* <li>{@link #garbageCollect} 定时清理过期记录</li>
|
* <li>package-private snapshot / mutate helpers consumed by {@link ApprovalWorkflowService}
|
||||||
|
* (recovery, GC, two-phase resolve)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
|
* Public mutating methods (resolve / resolveAndConsume / consumeApproved /
|
||||||
|
* cancelStalePending / denyAllByConversation) were removed in PR-4 once all
|
||||||
|
* callers migrated to the workflow service. Reintroducing them is a regression —
|
||||||
|
* they bypass DB and message-metadata writes, which is the original ghost-approval
|
||||||
|
* source.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -33,31 +36,13 @@ public class ApprovalService {
|
|||||||
|
|
||||||
private final ConcurrentHashMap<String, PendingApproval> pendingMap = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, PendingApproval> pendingMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** GC 常量 */
|
/** GC constants. Package-visible so {@link ApprovalWorkflowService}'s GC loop
|
||||||
private static final Duration PENDING_TTL = Duration.ofMinutes(30);
|
* (RFC-067 §4.4) can apply the same TTL / cap thresholds while owning the
|
||||||
private static final Duration RESOLVED_TTL = Duration.ofHours(1);
|
* scheduler clock + DB+metadata sync. */
|
||||||
private static final int MAX_PENDING = 200;
|
static final Duration PENDING_TTL = Duration.ofMinutes(30);
|
||||||
private static final int MAX_RESOLVED = 500;
|
static final Duration RESOLVED_TTL = Duration.ofHours(1);
|
||||||
|
static final int MAX_PENDING = 200;
|
||||||
private ScheduledExecutorService gcScheduler;
|
static final int MAX_RESOLVED = 500;
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void initGc() {
|
|
||||||
gcScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
|
||||||
Thread t = new Thread(r, "approval-gc");
|
|
||||||
t.setDaemon(true);
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
gcScheduler.scheduleAtFixedRate(this::garbageCollect, 5, 5, TimeUnit.MINUTES);
|
|
||||||
log.info("[Approval] GC scheduler started (interval=5min)");
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
void shutdownGc() {
|
|
||||||
if (gcScheduler != null) {
|
|
||||||
gcScheduler.shutdownNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 创建 ====================
|
// ==================== 创建 ====================
|
||||||
|
|
||||||
@ -93,31 +78,41 @@ public class ApprovalService {
|
|||||||
return pendingId;
|
return pendingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 解决 ====================
|
/**
|
||||||
|
* INTERNAL — drop a pending entry from the map without changing its status.
|
||||||
|
* Used by {@link ApprovalWorkflowService} as the final memory-mutation step
|
||||||
|
* after DB + metadata writes commit. Status is mutated separately by the
|
||||||
|
* caller so consume / resolve flows can keep the {@code consumed} /
|
||||||
|
* {@code resolved} terminal state visible on the snapshot they return.
|
||||||
|
* <p>
|
||||||
|
* Only {@code ApprovalWorkflowService} should call this.
|
||||||
|
*/
|
||||||
|
void removeFromMap(String pendingId) {
|
||||||
|
if (pendingId == null) return;
|
||||||
|
pendingMap.remove(pendingId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解决审批(批准或拒绝)
|
* INTERNAL — register a {@link PendingApproval} reconstructed from DB during JVM startup.
|
||||||
*
|
* Bypasses id generation and pre-existing-entry checks; the snapshot's {@code pendingId}
|
||||||
* @param pendingId 待审批 ID
|
* must already match the DB row. Idempotent: if the same id already lives in the map
|
||||||
* @param userId 操作用户
|
* (concurrent recovery path), the second call is logged and dropped.
|
||||||
* @param decision "approved" 或 "denied"
|
* <p>
|
||||||
* @throws IllegalArgumentException 如果 pending 不存在
|
* Only {@code ApprovalWorkflowService.recoverFromDb} should call this.
|
||||||
*/
|
*/
|
||||||
public void resolve(String pendingId, String userId, String decision) {
|
void registerRecovered(PendingApproval snapshot) {
|
||||||
PendingApproval pending = pendingMap.get(pendingId);
|
if (snapshot == null || snapshot.getPendingId() == null) {
|
||||||
if (pending == null) {
|
log.warn("[Approval] registerRecovered: ignoring null snapshot");
|
||||||
throw new IllegalArgumentException("审批记录不存在或已过期: " + pendingId);
|
return;
|
||||||
}
|
}
|
||||||
|
PendingApproval existing = pendingMap.putIfAbsent(snapshot.getPendingId(), snapshot);
|
||||||
if ("approved".equalsIgnoreCase(decision)) {
|
if (existing != null) {
|
||||||
pending.setStatus("approved");
|
log.warn("[Approval] registerRecovered: pending id {} already in map, skipping",
|
||||||
} else {
|
snapshot.getPendingId());
|
||||||
pending.setStatus("denied");
|
return;
|
||||||
}
|
}
|
||||||
pending.setResolvedAt(Instant.now());
|
log.info("[Approval] Recovered pending from DB: id={}, tool={}, conversation={}",
|
||||||
pending.setResolvedBy(userId);
|
snapshot.getPendingId(), snapshot.getToolName(), snapshot.getConversationId());
|
||||||
|
|
||||||
log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 查询 ====================
|
// ==================== 查询 ====================
|
||||||
@ -172,154 +167,110 @@ public class ApprovalService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 原子解决+消费(IM 渠道 /approve 命令) ====================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 原子地 resolve 并 consume 审批记录(用于 IM 渠道 /approve 命令)
|
* INTERNAL — return the earliest {@code approved} pending matching the conversation +
|
||||||
* <p>
|
* tool, WITHOUT removing it. Workflow uses this to take a snapshot before the
|
||||||
* 合并 resolve() + consumeApproved() 为单一操作,消除 race condition。
|
* two-phase DB / metadata write; the actual map removal happens via
|
||||||
*
|
* {@link #removeFromMap(String)} after commit.
|
||||||
* @param pendingId 待审批 ID
|
|
||||||
* @param userId 操作用户
|
|
||||||
* @return 已消费的 PendingApproval(含 toolCallPayload),不存在或已处理返回 null
|
|
||||||
*/
|
*/
|
||||||
public synchronized PendingApproval resolveAndConsume(String pendingId, String userId) {
|
PendingApproval findApprovedForConsume(String conversationId, String toolName) {
|
||||||
PendingApproval pending = pendingMap.get(pendingId);
|
return pendingMap.values().stream()
|
||||||
if (pending == null || !"pending".equals(pending.getStatus())) {
|
|
||||||
log.warn("[Approval] resolveAndConsume: not found or not pending: id={}", pendingId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
pending.setStatus("consumed");
|
|
||||||
pending.setResolvedAt(Instant.now());
|
|
||||||
pending.setResolvedBy(userId);
|
|
||||||
pendingMap.remove(pendingId);
|
|
||||||
log.info("[Approval] Resolved and consumed atomically: id={}, tool={}", pendingId, pending.getToolName());
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 消费(重放时调用) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消费已批准的审批记录(一次性消费)
|
|
||||||
* <p>
|
|
||||||
* 验证 toolName 匹配(如果指定),防止参数替换攻击。
|
|
||||||
* 移除记录并返回 PendingApproval 供重放。
|
|
||||||
*
|
|
||||||
* @param conversationId 会话 ID
|
|
||||||
* @param toolName 要验证的工具名(null 跳过验证)
|
|
||||||
* @return 已消费的 PendingApproval,或 null 如果无匹配
|
|
||||||
*/
|
|
||||||
public PendingApproval consumeApproved(String conversationId, String toolName) {
|
|
||||||
return consumeApproved(conversationId, toolName, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消费一条已审批的记录(带参数匹配校验,防止审批后参数替换攻击)
|
|
||||||
*/
|
|
||||||
public PendingApproval consumeApproved(String conversationId, String toolName, String toolArguments) {
|
|
||||||
PendingApproval target = pendingMap.values().stream()
|
|
||||||
.filter(p -> conversationId.equals(p.getConversationId()))
|
.filter(p -> conversationId.equals(p.getConversationId()))
|
||||||
.filter(p -> "approved".equals(p.getStatus()))
|
.filter(p -> "approved".equals(p.getStatus()))
|
||||||
.filter(p -> toolName == null || toolName.equals(p.getToolName()))
|
.filter(p -> toolName == null || toolName.equals(p.getToolName()))
|
||||||
.filter(p -> toolArguments == null || toolArguments.equals(p.getToolArguments()))
|
|
||||||
.min(Comparator.comparing(PendingApproval::getCreatedAt))
|
.min(Comparator.comparing(PendingApproval::getCreatedAt))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (target == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.setStatus("consumed");
|
|
||||||
pendingMap.remove(target.getPendingId());
|
|
||||||
log.info("[Approval] Consumed approved: id={}, tool={}, conversation={}",
|
|
||||||
target.getPendingId(), target.getToolName(), conversationId);
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 取消与清理 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消指定会话的所有 pending(用户发新消息时旧 pending 自动取消)
|
|
||||||
*
|
|
||||||
* @param conversationId 会话 ID
|
|
||||||
* @param excludePendingId 排除的 pendingId(当前正在创建的,可为 null)
|
|
||||||
*/
|
|
||||||
public void cancelStalePending(String conversationId, String excludePendingId) {
|
|
||||||
pendingMap.values().stream()
|
|
||||||
.filter(p -> conversationId.equals(p.getConversationId()))
|
|
||||||
.filter(p -> "pending".equals(p.getStatus()))
|
|
||||||
.filter(p -> !p.getPendingId().equals(excludePendingId))
|
|
||||||
.forEach(p -> {
|
|
||||||
p.setStatus("superseded");
|
|
||||||
p.setResolvedAt(Instant.now());
|
|
||||||
pendingMap.remove(p.getPendingId());
|
|
||||||
log.info("[Approval] Cancelled stale pending: id={}", p.getPendingId());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定时清理过期记录
|
* INTERNAL — return a list snapshot of every {@code pending} record in the
|
||||||
* <ul>
|
* conversation, optionally excluding one id. Read-only; no map mutation.
|
||||||
* <li>pending 超过 30 分钟 → 标记 TIMEOUT 并清除</li>
|
* Workflow iterates this list and runs the two-phase resolve on each.
|
||||||
* <li>resolved(非 pending)超过 1 小时 → 清除</li>
|
|
||||||
* <li>上限:pending 200 条,resolved 500 条</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
public void garbageCollect() {
|
List<PendingApproval> snapshotPendingByConversation(String conversationId,
|
||||||
Instant now = Instant.now();
|
String excludePendingId) {
|
||||||
int expiredPending = 0;
|
List<PendingApproval> out = new ArrayList<>();
|
||||||
int expiredResolved = 0;
|
|
||||||
|
|
||||||
List<String> toRemove = new ArrayList<>();
|
|
||||||
|
|
||||||
for (PendingApproval p : pendingMap.values()) {
|
for (PendingApproval p : pendingMap.values()) {
|
||||||
if ("pending".equals(p.getStatus())) {
|
if (!conversationId.equals(p.getConversationId())) continue;
|
||||||
|
if (!"pending".equals(p.getStatus())) continue;
|
||||||
|
if (excludePendingId != null && excludePendingId.equals(p.getPendingId())) continue;
|
||||||
|
out.add(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GC snapshot helpers (used by ApprovalWorkflowService) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INTERNAL — return a list snapshot of {@code pending} records whose age
|
||||||
|
* exceeds {@link #PENDING_TTL}. Read-only; the workflow GC loop iterates this
|
||||||
|
* list and runs the two-phase {@code markTimeout} on each.
|
||||||
|
*/
|
||||||
|
List<PendingApproval> snapshotExpiredPending(Instant now) {
|
||||||
|
List<PendingApproval> out = new ArrayList<>();
|
||||||
|
for (PendingApproval p : pendingMap.values()) {
|
||||||
|
if (!"pending".equals(p.getStatus())) continue;
|
||||||
if (Duration.between(p.getCreatedAt(), now).compareTo(PENDING_TTL) > 0) {
|
if (Duration.between(p.getCreatedAt(), now).compareTo(PENDING_TTL) > 0) {
|
||||||
p.setStatus("timeout");
|
out.add(p);
|
||||||
p.setResolvedAt(now);
|
|
||||||
toRemove.add(p.getPendingId());
|
|
||||||
expiredPending++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 已解决的记录
|
|
||||||
Instant resolvedAt = p.getResolvedAt() != null ? p.getResolvedAt() : p.getCreatedAt();
|
|
||||||
if (Duration.between(resolvedAt, now).compareTo(RESOLVED_TTL) > 0) {
|
|
||||||
toRemove.add(p.getPendingId());
|
|
||||||
expiredResolved++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
toRemove.forEach(pendingMap::remove);
|
/**
|
||||||
|
* INTERNAL — when total pending count is over {@code maxPending}, return the
|
||||||
// 上限检查
|
* oldest excess entries so the workflow GC loop can {@code markTimeout} each.
|
||||||
enforceLimit("pending", MAX_PENDING);
|
* Read-only; sorts by createdAt ascending.
|
||||||
enforceLimit("resolved", MAX_RESOLVED);
|
*/
|
||||||
|
List<PendingApproval> snapshotExcessPending(int maxPending) {
|
||||||
if (expiredPending > 0 || expiredResolved > 0) {
|
List<PendingApproval> pending = pendingMap.values().stream()
|
||||||
log.info("[Approval] GC: expired {} pending, {} resolved, remaining={}",
|
.filter(p -> "pending".equals(p.getStatus()))
|
||||||
expiredPending, expiredResolved, pendingMap.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void enforceLimit(String statusType, int maxCount) {
|
|
||||||
boolean isPending = "pending".equals(statusType);
|
|
||||||
List<PendingApproval> matching = pendingMap.values().stream()
|
|
||||||
.filter(p -> isPending ? "pending".equals(p.getStatus()) : !"pending".equals(p.getStatus()))
|
|
||||||
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
|
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
|
||||||
.toList();
|
.toList();
|
||||||
|
if (pending.size() <= maxPending) return List.of();
|
||||||
|
return new ArrayList<>(pending.subList(0, pending.size() - maxPending));
|
||||||
|
}
|
||||||
|
|
||||||
if (matching.size() > maxCount) {
|
/**
|
||||||
int toEvict = matching.size() - maxCount;
|
* INTERNAL — drop already-resolved (non-{@code pending}) entries that exceed
|
||||||
|
* either the resolved-TTL or the resolved-cap. Memory-only: these rows are
|
||||||
|
* already terminal in DB, so no DB / metadata sync is required.
|
||||||
|
*
|
||||||
|
* @return number of map entries dropped
|
||||||
|
*/
|
||||||
|
int dropResolvedExceedingLimits(Instant now) {
|
||||||
|
int dropped = 0;
|
||||||
|
// TTL-based drops first
|
||||||
|
List<String> ttlExpired = new ArrayList<>();
|
||||||
|
for (PendingApproval p : pendingMap.values()) {
|
||||||
|
if ("pending".equals(p.getStatus())) continue;
|
||||||
|
Instant resolvedAt = p.getResolvedAt() != null ? p.getResolvedAt() : p.getCreatedAt();
|
||||||
|
if (Duration.between(resolvedAt, now).compareTo(RESOLVED_TTL) > 0) {
|
||||||
|
ttlExpired.add(p.getPendingId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ttlExpired.forEach(pendingMap::remove);
|
||||||
|
dropped += ttlExpired.size();
|
||||||
|
|
||||||
|
// Cap-based drops second
|
||||||
|
List<PendingApproval> resolved = pendingMap.values().stream()
|
||||||
|
.filter(p -> !"pending".equals(p.getStatus()))
|
||||||
|
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
|
||||||
|
.toList();
|
||||||
|
if (resolved.size() > MAX_RESOLVED) {
|
||||||
|
int toEvict = resolved.size() - MAX_RESOLVED;
|
||||||
for (int i = 0; i < toEvict; i++) {
|
for (int i = 0; i < toEvict; i++) {
|
||||||
PendingApproval oldest = matching.get(i);
|
pendingMap.remove(resolved.get(i).getPendingId());
|
||||||
if (isPending) {
|
|
||||||
oldest.setStatus("timeout");
|
|
||||||
oldest.setResolvedAt(Instant.now());
|
|
||||||
}
|
}
|
||||||
pendingMap.remove(oldest.getPendingId());
|
dropped += toEvict;
|
||||||
}
|
|
||||||
log.info("[Approval] Evicted {} {} records (exceeded limit {})", toEvict, statusType, maxCount);
|
|
||||||
}
|
}
|
||||||
|
return dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INTERNAL — current pending-map size, used by GC summary logs.
|
||||||
|
*/
|
||||||
|
int size() {
|
||||||
|
return pendingMap.size();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,22 +4,34 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ChatOriginHolder;
|
||||||
import vip.mate.approval.model.ToolApprovalEntity;
|
import vip.mate.approval.model.ToolApprovalEntity;
|
||||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||||
import vip.mate.tool.guard.model.GuardFinding;
|
import vip.mate.tool.guard.model.GuardFinding;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批工作流服务(write-through: 内存 + DB 双写)
|
* 审批工作流服务(write-through: 内存 + DB 双写)
|
||||||
@ -37,14 +49,48 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
private final ToolApprovalMapper approvalMapper;
|
private final ToolApprovalMapper approvalMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ConversationService conversationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC scheduler — owns the 5-minute clock for the entire approval state machine
|
||||||
|
* (RFC-067 §4.4). Lives on the workflow rather than {@link ApprovalService} so
|
||||||
|
* timeout / overflow eviction goes through the same DB+metadata+memory two-phase
|
||||||
|
* path as approve / deny — the in-memory map can no longer drift ahead of DB.
|
||||||
|
*/
|
||||||
|
private ScheduledExecutorService gcScheduler;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
recoverFromDb();
|
recoverFromDb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initGc() {
|
||||||
|
gcScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "approval-gc");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
gcScheduler.scheduleAtFixedRate(this::garbageCollect, 5, 5, TimeUnit.MINUTES);
|
||||||
|
log.info("[ApprovalWorkflow] GC scheduler started (interval=5min)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
void shutdownGc() {
|
||||||
|
if (gcScheduler != null) {
|
||||||
|
gcScheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动时从 DB 恢复 PENDING 审批到内存
|
* Reconstruct in-memory pending approvals from DB at startup, preserving the
|
||||||
|
* original {@code pendingId} and {@code createdAt} so subsequent resolve / GC
|
||||||
|
* paths stay consistent with the persisted row.
|
||||||
|
* <p>
|
||||||
|
* Effective expiration follows {@code expireAt != null ? expireAt : createdAt + PENDING_TTL},
|
||||||
|
* so legacy / test rows whose {@code expireAt} column is NULL still time out. Expired
|
||||||
|
* rows are reconciled (DB → TIMEOUT, message metadata → DENIED) and skipped from
|
||||||
|
* the in-memory map. See RFC-067 §4.1.
|
||||||
*/
|
*/
|
||||||
void recoverFromDb() {
|
void recoverFromDb() {
|
||||||
try {
|
try {
|
||||||
@ -55,56 +101,96 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
);
|
);
|
||||||
|
|
||||||
int recovered = 0;
|
int recovered = 0;
|
||||||
|
int expired = 0;
|
||||||
|
Instant now = Instant.now();
|
||||||
for (ToolApprovalEntity entity : pendingRecords) {
|
for (ToolApprovalEntity entity : pendingRecords) {
|
||||||
// 检查是否已过期(30 分钟)
|
// Defensive null handling: a row with neither createdAt nor expireAt is
|
||||||
if (entity.getCreatedAt() != null) {
|
// treated as freshly created so the next GC tick can revisit it instead
|
||||||
Instant createdAt = entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant();
|
// of being silently lost.
|
||||||
if (Instant.now().minusSeconds(1800).isAfter(createdAt)) {
|
Instant createdAt = entity.getCreatedAt() != null
|
||||||
// 已过期,更新 DB 状态
|
? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
|
||||||
entity.setStatus("TIMEOUT");
|
: now;
|
||||||
entity.setResolvedAt(LocalDateTime.now());
|
Instant effectiveExpireAt = entity.getExpireAt() != null
|
||||||
approvalMapper.updateById(entity);
|
? entity.getExpireAt().atZone(ZoneId.systemDefault()).toInstant()
|
||||||
|
: createdAt.plus(ApprovalService.PENDING_TTL);
|
||||||
|
|
||||||
|
if (now.isAfter(effectiveExpireAt)) {
|
||||||
|
expireRecoveredRow(entity);
|
||||||
|
expired++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复到内存
|
PendingApproval snapshot = new PendingApproval(
|
||||||
String pendingId = approvalService.createPending(
|
entity.getPendingId(),
|
||||||
entity.getConversationId(),
|
entity.getConversationId(),
|
||||||
entity.getUserId(),
|
entity.getUserId(),
|
||||||
entity.getToolName(),
|
entity.getToolName(),
|
||||||
entity.getToolArguments(),
|
entity.getToolArguments(),
|
||||||
entity.getSummary(),
|
entity.getSummary(),
|
||||||
entity.getToolCallPayload(),
|
createdAt,
|
||||||
entity.getSiblingToolCalls(),
|
"pending"
|
||||||
entity.getAgentId()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 修正内存中的 pendingId 以匹配 DB
|
|
||||||
// 由于 ApprovalService.createPending 会生成新 ID,我们需要取消它并使用原始 ID
|
|
||||||
approvalService.cancelStalePending(entity.getConversationId(), null);
|
|
||||||
pendingId = approvalService.createPending(
|
|
||||||
entity.getConversationId(),
|
|
||||||
entity.getUserId(),
|
|
||||||
entity.getToolName(),
|
|
||||||
entity.getToolArguments(),
|
|
||||||
entity.getSummary(),
|
|
||||||
entity.getToolCallPayload(),
|
|
||||||
entity.getSiblingToolCalls(),
|
|
||||||
entity.getAgentId()
|
|
||||||
);
|
);
|
||||||
|
snapshot.setToolCallPayload(entity.getToolCallPayload());
|
||||||
|
snapshot.setSiblingToolCalls(entity.getSiblingToolCalls());
|
||||||
|
snapshot.setAgentId(entity.getAgentId());
|
||||||
|
snapshot.setChannelType(entity.getChannelType());
|
||||||
|
snapshot.setRequesterName(entity.getRequesterName());
|
||||||
|
snapshot.setReplyTarget(entity.getReplyTarget());
|
||||||
|
snapshot.setFindingsJson(entity.getFindingsJson());
|
||||||
|
snapshot.setMaxSeverity(entity.getMaxSeverity());
|
||||||
|
snapshot.setSummary(entity.getSummary());
|
||||||
|
snapshot.setChatOrigin(entity.getChatOrigin());
|
||||||
|
|
||||||
|
approvalService.registerRecovered(snapshot);
|
||||||
recovered++;
|
recovered++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recovered > 0) {
|
if (recovered > 0 || expired > 0) {
|
||||||
log.info("[ApprovalWorkflow] Recovered {} pending approvals from DB", recovered);
|
log.info("[ApprovalWorkflow] DB recovery: recovered={}, expired={}", recovered, expired);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ApprovalWorkflow] Failed to recover from DB (table may not exist yet): {}", e.getMessage());
|
log.warn("[ApprovalWorkflow] Failed to recover from DB (table may not exist yet): {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an expired DB row to TIMEOUT and reconcile message metadata so the UI does
|
||||||
|
* not hydrate a ghost approval after restart.
|
||||||
|
* <p>
|
||||||
|
* Order matters: metadata writes are gated on DB success. If {@code updateById}
|
||||||
|
* throws or affects zero rows, we skip the metadata flip so the three persistence
|
||||||
|
* loci (DB / message metadata / in-memory map) cannot drift apart — DB stuck on
|
||||||
|
* PENDING + metadata flipped to DENIED is the worst-case ghost state because the
|
||||||
|
* next recoverFromDb would re-revive the approval while the UI insists it was
|
||||||
|
* already settled.
|
||||||
|
*/
|
||||||
|
private void expireRecoveredRow(ToolApprovalEntity entity) {
|
||||||
|
int rowsUpdated;
|
||||||
|
try {
|
||||||
|
entity.setStatus("TIMEOUT");
|
||||||
|
entity.setResolvedAt(LocalDateTime.now());
|
||||||
|
rowsUpdated = approvalMapper.updateById(entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] Failed to mark expired row {} as TIMEOUT: {}",
|
||||||
|
entity.getPendingId(), e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rowsUpdated == 0) {
|
||||||
|
log.warn("[ApprovalWorkflow] Expire skipped: DB row for pending {} affected 0 rows " +
|
||||||
|
"(concurrent resolve?); leaving metadata untouched", entity.getPendingId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
conversationService.markPendingApprovalsResolved(
|
||||||
|
entity.getConversationId(),
|
||||||
|
Set.of(entity.getPendingId()),
|
||||||
|
MetadataDecision.DENIED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] Failed to reconcile metadata for expired pending {}: {}",
|
||||||
|
entity.getPendingId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建待审批记录(增强版,含 GuardEvaluation)
|
* 创建待审批记录(增强版,含 GuardEvaluation)
|
||||||
*/
|
*/
|
||||||
@ -117,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
conversationId, userId, toolName, toolArguments, reason,
|
conversationId, userId, toolName, toolArguments, reason,
|
||||||
toolCallPayload, siblingToolCalls, agentId);
|
toolCallPayload, siblingToolCalls, agentId);
|
||||||
|
|
||||||
|
// RFC-063r §2.12: capture the originating ChatOrigin from the holder.
|
||||||
|
// The holder was set by AgentService.{chat,chatStream,...} for the
|
||||||
|
// duration of the agent invocation that produced this approval — so
|
||||||
|
// it is non-null for IM / web triggered tool calls. Snapshot is
|
||||||
|
// serialized once here and persisted on the DB row so cross-restart
|
||||||
|
// replays keep the channel binding.
|
||||||
|
String chatOriginJson = serializeChatOrigin(ChatOriginHolder.get());
|
||||||
|
|
||||||
// 2. 增强内存记录
|
// 2. 增强内存记录
|
||||||
approvalService.getPending(pendingId).ifPresent(pending -> {
|
approvalService.getPending(pendingId).ifPresent(pending -> {
|
||||||
if (evaluation != null) {
|
if (evaluation != null) {
|
||||||
@ -124,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
|
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
|
||||||
pending.setSummary(evaluation.summary());
|
pending.setSummary(evaluation.summary());
|
||||||
}
|
}
|
||||||
|
pending.setChatOrigin(chatOriginJson);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. DB 层
|
// 3. DB 层
|
||||||
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
|
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
|
||||||
toolCallPayload, siblingToolCalls, agentId, evaluation);
|
toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
|
||||||
|
|
||||||
return pendingId;
|
return pendingId;
|
||||||
}
|
}
|
||||||
@ -144,50 +239,277 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解决审批
|
* Resolve a pending approval (approve / deny) following the RFC-067 §4.2 two-phase
|
||||||
|
* contract: snapshot → DB UPDATE conditional on {@code status='PENDING'} →
|
||||||
|
* metadata reconciliation → memory mutation queued for after-commit.
|
||||||
|
* <p>
|
||||||
|
* Idempotent under concurrent resolve: when the DB UPDATE affects 0 rows (because
|
||||||
|
* another caller — IM channel, GC, recoverFromDb — already moved the row off
|
||||||
|
* PENDING), this method returns {@link ResolveOutcome#alreadyResolved(String)}
|
||||||
|
* without touching metadata or in-memory state. Callers should treat this as a
|
||||||
|
* silent no-op; do not surface a user-facing error.
|
||||||
|
* <p>
|
||||||
|
* On DB / metadata write failure the transaction rolls back and in-memory state
|
||||||
|
* stays untouched, so a retry from the next GC tick can recover. Memory mutation
|
||||||
|
* is registered as an {@code afterCommit} synchronization, never inline, so a
|
||||||
|
* post-update commit failure cannot leave memory ahead of DB.
|
||||||
|
*
|
||||||
|
* @param pendingId target approval id
|
||||||
|
* @param userId actor performing the resolution (for audit)
|
||||||
|
* @param decision case-insensitive {@code "approved"} or {@code "denied"}
|
||||||
|
* @return {@link ResolveOutcome} carrying the resolved snapshot + DB / metadata
|
||||||
|
* counters; idempotent return on no-op
|
||||||
*/
|
*/
|
||||||
public void resolve(String pendingId, String userId, String decision) {
|
@Transactional
|
||||||
approvalService.resolve(pendingId, userId, decision);
|
public ResolveOutcome resolve(String pendingId, String userId, String decision) {
|
||||||
updateDbStatus(pendingId, decision.toUpperCase(), userId);
|
boolean approved = "approved".equalsIgnoreCase(decision);
|
||||||
|
String dbStatus = approved ? "APPROVED" : "DENIED";
|
||||||
|
MetadataDecision metaDecision = approved ? MetadataDecision.APPROVED : MetadataDecision.DENIED;
|
||||||
|
String snapshotStatus = approved ? "approved" : "denied";
|
||||||
|
|
||||||
|
return performResolve(pendingId, userId, dbStatus, metaDecision, snapshotStatus,
|
||||||
|
/* removeFromMap */ false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 原子解决+消费
|
* Atomically resolve {@code approved} and consume the snapshot for replay.
|
||||||
|
* Same two-phase contract as {@link #resolve}, additionally removing the
|
||||||
|
* pending entry from the in-memory map after commit so a subsequent
|
||||||
|
* {@link #findPendingByConversation(String)} returns null and consume is
|
||||||
|
* single-shot. The returned {@link ResolveOutcome#consumedSnapshot()} carries
|
||||||
|
* {@code toolCallPayload} for replay.
|
||||||
*/
|
*/
|
||||||
public PendingApproval resolveAndConsume(String pendingId, String userId) {
|
@Transactional
|
||||||
PendingApproval consumed = approvalService.resolveAndConsume(pendingId, userId);
|
public ResolveOutcome resolveAndConsume(String pendingId, String userId) {
|
||||||
if (consumed != null) {
|
return performResolve(pendingId, userId, "CONSUMED", MetadataDecision.APPROVED,
|
||||||
updateDbStatus(pendingId, "CONSUMED", userId);
|
"consumed", /* removeFromMap */ true);
|
||||||
}
|
|
||||||
return consumed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消费已批准记录
|
* Consume the earliest already-{@code approved} record for the conversation +
|
||||||
|
* tool — used when an out-of-band approval (e.g. /approve text command flow that
|
||||||
|
* resolved the record) needs to be redeemed for replay.
|
||||||
*/
|
*/
|
||||||
public PendingApproval consumeApproved(String conversationId, String toolName) {
|
@Transactional
|
||||||
PendingApproval consumed = approvalService.consumeApproved(conversationId, toolName);
|
public ResolveOutcome consumeApproved(String conversationId, String toolName) {
|
||||||
if (consumed != null) {
|
PendingApproval target = approvalService.findApprovedForConsume(conversationId, toolName);
|
||||||
updateDbStatus(consumed.getPendingId(), "CONSUMED", null);
|
if (target == null) {
|
||||||
|
return ResolveOutcome.alreadyResolved(null);
|
||||||
}
|
}
|
||||||
return consumed;
|
return performResolveOnSnapshot(target, null, "CONSUMED", MetadataDecision.APPROVED,
|
||||||
|
"consumed", /* removeFromMap */ true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消过期 pending
|
* Bulk-deny every pending approval in the conversation (RFC-067 §4.4.1). Used by
|
||||||
|
* the Web Stop endpoint to clear orphaned approvals when the user halts a turn
|
||||||
|
* mid-stream; without this sweep, in-flight pendings linger in the map and
|
||||||
|
* resurrect via metadata after refresh / restart.
|
||||||
|
* <p>
|
||||||
|
* Two-phase per row: DB → {@code DENIED}, message metadata → {@code DENIED},
|
||||||
|
* map removed. Per-row failures are logged and the sweep continues; the returned
|
||||||
|
* list contains only the outcomes that successfully advanced through DB.
|
||||||
|
*
|
||||||
|
* @return outcome per pending that successfully transitioned to {@code DENIED}
|
||||||
*/
|
*/
|
||||||
public void cancelStalePending(String conversationId, String excludePendingId) {
|
@Transactional
|
||||||
approvalService.cancelStalePending(conversationId, excludePendingId);
|
public List<ResolveOutcome> denyAllByConversation(String conversationId, String userId) {
|
||||||
|
List<PendingApproval> targets = approvalService.snapshotPendingByConversation(
|
||||||
|
conversationId, /* excludePendingId */ null);
|
||||||
|
if (targets.isEmpty()) return List.of();
|
||||||
|
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
|
||||||
|
for (PendingApproval target : targets) {
|
||||||
try {
|
try {
|
||||||
approvalMapper.update(null, new LambdaUpdateWrapper<ToolApprovalEntity>()
|
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "DENIED",
|
||||||
.eq(ToolApprovalEntity::getConversationId, conversationId)
|
MetadataDecision.DENIED, "denied", /* removeFromMap */ true);
|
||||||
.eq(ToolApprovalEntity::getStatus, "PENDING")
|
if (outcome.dbSynced()) outcomes.add(outcome);
|
||||||
.ne(excludePendingId != null, ToolApprovalEntity::getPendingId, excludePendingId)
|
|
||||||
.set(ToolApprovalEntity::getStatus, "SUPERSEDED")
|
|
||||||
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now()));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ApprovalWorkflow] Failed to cancel stale in DB: {}", e.getMessage());
|
log.warn("[ApprovalWorkflow] denyAll: failed to deny {}: {}",
|
||||||
|
target.getPendingId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcomes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel every other pending approval in the conversation (excluding optional
|
||||||
|
* {@code excludePendingId}) — used when a user submits a fresh message and the
|
||||||
|
* old approval is implicitly abandoned. Each cancelled record goes through the
|
||||||
|
* same two-phase contract; metadata flips to {@code DENIED} (per RFC-067 §4.4.1
|
||||||
|
* state mapping for {@code superseded}).
|
||||||
|
*
|
||||||
|
* @return one outcome per pending that was actually moved off PENDING (empty list
|
||||||
|
* if there was nothing to cancel)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public List<ResolveOutcome> cancelStalePending(String conversationId, String excludePendingId) {
|
||||||
|
List<PendingApproval> targets = approvalService.snapshotPendingByConversation(
|
||||||
|
conversationId, excludePendingId);
|
||||||
|
if (targets.isEmpty()) return List.of();
|
||||||
|
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
|
||||||
|
for (PendingApproval target : targets) {
|
||||||
|
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "SUPERSEDED",
|
||||||
|
MetadataDecision.DENIED, "superseded", /* removeFromMap */ true);
|
||||||
|
if (outcome.dbSynced()) outcomes.add(outcome);
|
||||||
|
}
|
||||||
|
return outcomes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time out a single pending approval (RFC-067 §4.4): same two-phase contract as
|
||||||
|
* {@link #resolve} but with DB → {@code TIMEOUT} and metadata → {@code DENIED}
|
||||||
|
* (per RFC-067 §4.4.1 state mapping). Called by the GC scheduler for entries
|
||||||
|
* past {@link ApprovalService#PENDING_TTL} or beyond {@link ApprovalService#MAX_PENDING}.
|
||||||
|
* Package-private — not part of the external resolve API.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
ResolveOutcome markTimeout(String pendingId) {
|
||||||
|
return performResolve(pendingId, null, "TIMEOUT", MetadataDecision.DENIED, "timeout",
|
||||||
|
/* removeFromMap */ true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC tick (5-minute cadence; runs in {@code approval-gc} daemon thread).
|
||||||
|
* <ol>
|
||||||
|
* <li>Phase A — pending older than {@link ApprovalService#PENDING_TTL} time out
|
||||||
|
* through the full DB+metadata+memory contract.</li>
|
||||||
|
* <li>Phase B — when total pending count exceeds {@link ApprovalService#MAX_PENDING},
|
||||||
|
* evict the oldest excess via the same {@code markTimeout} path.</li>
|
||||||
|
* <li>Phase C — already-resolved entries (DB row already terminal) past
|
||||||
|
* {@link ApprovalService#RESOLVED_TTL} or beyond
|
||||||
|
* {@link ApprovalService#MAX_RESOLVED} are dropped from the map only —
|
||||||
|
* the DB does not need touching, nor does message metadata.</li>
|
||||||
|
* </ol>
|
||||||
|
* Each pending entry's transition runs in its own transaction (markTimeout is
|
||||||
|
* @Transactional) so a single bad row doesn't block the rest of the sweep.
|
||||||
|
*/
|
||||||
|
public void garbageCollect() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
|
||||||
|
// Phase A — TTL-expired pending. Snapshot first so we don't mutate a map
|
||||||
|
// we're iterating; markTimeout handles its own DB+metadata+memory contract.
|
||||||
|
int timedOut = 0;
|
||||||
|
for (PendingApproval expired : approvalService.snapshotExpiredPending(now)) {
|
||||||
|
try {
|
||||||
|
ResolveOutcome outcome = markTimeout(expired.getPendingId());
|
||||||
|
if (outcome.dbSynced()) timedOut++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] GC: markTimeout failed for {}: {}",
|
||||||
|
expired.getPendingId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase B — pending overflow eviction. Same path; just driven by a count cap.
|
||||||
|
int evictedPending = 0;
|
||||||
|
for (PendingApproval excess : approvalService.snapshotExcessPending(ApprovalService.MAX_PENDING)) {
|
||||||
|
try {
|
||||||
|
ResolveOutcome outcome = markTimeout(excess.getPendingId());
|
||||||
|
if (outcome.dbSynced()) evictedPending++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] GC: overflow markTimeout failed for {}: {}",
|
||||||
|
excess.getPendingId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase C — resolved cleanup. Memory-only; DB rows for these entries are
|
||||||
|
// already terminal (CONSUMED / DENIED / TIMEOUT / SUPERSEDED) so nothing
|
||||||
|
// would change in DB or metadata.
|
||||||
|
int droppedResolved = approvalService.dropResolvedExceedingLimits(now);
|
||||||
|
|
||||||
|
if (timedOut > 0 || evictedPending > 0 || droppedResolved > 0) {
|
||||||
|
log.info("[ApprovalWorkflow] GC: timed-out {}, evicted-pending {}, dropped-resolved {}, remaining={}",
|
||||||
|
timedOut, evictedPending, droppedResolved, approvalService.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- shared two-phase machinery ----------
|
||||||
|
|
||||||
|
private ResolveOutcome performResolve(String pendingId, String userId,
|
||||||
|
String dbStatus, MetadataDecision metaDecision,
|
||||||
|
String snapshotStatus, boolean removeFromMap) {
|
||||||
|
PendingApproval snapshot = approvalService.getPending(pendingId).orElse(null);
|
||||||
|
if (snapshot == null || !"pending".equals(snapshot.getStatus())) {
|
||||||
|
log.debug("[ApprovalWorkflow] resolve {}: not pending (snapshot={}, status={})",
|
||||||
|
pendingId, snapshot != null, snapshot != null ? snapshot.getStatus() : "n/a");
|
||||||
|
return ResolveOutcome.alreadyResolved(pendingId);
|
||||||
|
}
|
||||||
|
return performResolveOnSnapshot(snapshot, userId, dbStatus, metaDecision,
|
||||||
|
snapshotStatus, removeFromMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolveOutcome performResolveOnSnapshot(PendingApproval snapshot, String userId,
|
||||||
|
String dbStatus, MetadataDecision metaDecision,
|
||||||
|
String snapshotStatus, boolean removeFromMap) {
|
||||||
|
// Phase 1 — DB UPDATE (conditional). The eq("PENDING") guard makes the call
|
||||||
|
// idempotent: if another path already won, we get rows=0 and bail without
|
||||||
|
// touching metadata or memory.
|
||||||
|
int rows;
|
||||||
|
try {
|
||||||
|
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||||
|
.eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId())
|
||||||
|
.eq(ToolApprovalEntity::getStatus, "PENDING")
|
||||||
|
.set(ToolApprovalEntity::getStatus, dbStatus)
|
||||||
|
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now());
|
||||||
|
if (userId != null) {
|
||||||
|
wrapper.set(ToolApprovalEntity::getResolvedBy, userId);
|
||||||
|
}
|
||||||
|
rows = approvalMapper.update(null, wrapper);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] DB UPDATE failed for {} -> {}: {}",
|
||||||
|
snapshot.getPendingId(), dbStatus, e.getMessage());
|
||||||
|
// Re-throw so @Transactional rolls back any partial state and the caller sees the failure.
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (rows == 0) {
|
||||||
|
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in PENDING (concurrent resolve)",
|
||||||
|
snapshot.getPendingId());
|
||||||
|
return ResolveOutcome.alreadyResolved(snapshot.getPendingId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — metadata. Same transaction. If this throws, @Transactional rolls back DB.
|
||||||
|
int rewritten = conversationService.markPendingApprovalsResolved(
|
||||||
|
snapshot.getConversationId(),
|
||||||
|
Set.of(snapshot.getPendingId()),
|
||||||
|
metaDecision);
|
||||||
|
|
||||||
|
// Phase 3 — memory mutation, deferred until after commit. Registering inside
|
||||||
|
// a @Transactional method binds the hook to the active tx; if the tx rolls
|
||||||
|
// back (post-method but pre-commit failure, e.g. constraint violation at
|
||||||
|
// flush), the hook never fires and memory stays consistent with DB.
|
||||||
|
Instant resolvedAt = Instant.now();
|
||||||
|
afterCommit(() -> {
|
||||||
|
snapshot.setStatus(snapshotStatus);
|
||||||
|
snapshot.setResolvedAt(resolvedAt);
|
||||||
|
if (userId != null) snapshot.setResolvedBy(userId);
|
||||||
|
if (removeFromMap) approvalService.removeFromMap(snapshot.getPendingId());
|
||||||
|
});
|
||||||
|
|
||||||
|
boolean consumed = "consumed".equals(snapshotStatus);
|
||||||
|
ResolveOutcome outcome = consumed
|
||||||
|
? ResolveOutcome.consumed(snapshot, true, rewritten)
|
||||||
|
: ResolveOutcome.resolved(snapshot,
|
||||||
|
"superseded".equals(snapshotStatus) ? "superseded" : snapshotStatus,
|
||||||
|
true, rewritten);
|
||||||
|
log.info("[ApprovalWorkflow] resolved id={}, decision={}, dbStatus={}, messagesRewritten={}",
|
||||||
|
snapshot.getPendingId(), outcome.decision(), dbStatus, rewritten);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a memory mutation only after the surrounding {@code @Transactional} method's
|
||||||
|
* tx commits. When called outside a transaction (e.g. unit tests that bypass the
|
||||||
|
* proxy), executes immediately to keep test ergonomics simple.
|
||||||
|
*/
|
||||||
|
private void afterCommit(Runnable hook) {
|
||||||
|
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
hook.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
hook.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,7 +529,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
private void persistToDb(String pendingId, String conversationId, String userId,
|
private void persistToDb(String pendingId, String conversationId, String userId,
|
||||||
String toolName, String toolArguments,
|
String toolName, String toolArguments,
|
||||||
String toolCallPayload, String siblingToolCalls, String agentId,
|
String toolCallPayload, String siblingToolCalls, String agentId,
|
||||||
GuardEvaluation evaluation) {
|
GuardEvaluation evaluation, String chatOriginJson) {
|
||||||
try {
|
try {
|
||||||
ToolApprovalEntity entity = new ToolApprovalEntity();
|
ToolApprovalEntity entity = new ToolApprovalEntity();
|
||||||
entity.setPendingId(pendingId);
|
entity.setPendingId(pendingId);
|
||||||
@ -221,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
entity.setStatus("PENDING");
|
entity.setStatus("PENDING");
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
|
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
|
||||||
|
// RFC-063r §2.12: persist Memento snapshot. Null when the entry
|
||||||
|
// path didn't supply an origin — replay falls back to ChatOrigin.EMPTY.
|
||||||
|
entity.setChatOrigin(chatOriginJson);
|
||||||
|
|
||||||
if (evaluation != null) {
|
if (evaluation != null) {
|
||||||
entity.setFindingsJson(serializeFindings(evaluation.findings()));
|
entity.setFindingsJson(serializeFindings(evaluation.findings()));
|
||||||
@ -238,6 +563,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: serialize a {@link ChatOrigin} for persistence on
|
||||||
|
* {@code mate_tool_approval.chat_origin}. Returns null for
|
||||||
|
* {@code ChatOrigin.EMPTY} so legacy approvals that never captured an
|
||||||
|
* origin do not store a meaningless empty record.
|
||||||
|
*/
|
||||||
|
private String serializeChatOrigin(ChatOrigin origin) {
|
||||||
|
if (origin == null || origin == ChatOrigin.EMPTY) return null;
|
||||||
|
if (origin.agentId() == null && origin.channelId() == null
|
||||||
|
&& origin.conversationId() == null && origin.workspaceId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(origin);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("[ApprovalWorkflow] Failed to serialize ChatOrigin: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: deserialize a persisted Memento back into a
|
||||||
|
* {@link ChatOrigin}. Returns {@link ChatOrigin#EMPTY} when the column
|
||||||
|
* is null or the payload is corrupt — the caller treats that as
|
||||||
|
* "no channel binding" and replay proceeds with a web-style flow.
|
||||||
|
*/
|
||||||
|
public ChatOrigin restoreChatOrigin(String json) {
|
||||||
|
if (json == null || json.isBlank()) return ChatOrigin.EMPTY;
|
||||||
|
try {
|
||||||
|
ChatOrigin restored = objectMapper.readValue(json, ChatOrigin.class);
|
||||||
|
return restored != null ? restored : ChatOrigin.EMPTY;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] Failed to restore ChatOrigin: {} (payload-len={})",
|
||||||
|
e.getMessage(), json.length());
|
||||||
|
return ChatOrigin.EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
|
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
|
||||||
try {
|
try {
|
||||||
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
package vip.mate.approval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-valued decision used when reconciling persisted approval state.
|
||||||
|
* <p>
|
||||||
|
* The frontend's {@code Message.status} union (mateclaw-ui/src/types/index.ts) only supports
|
||||||
|
* {@code generating | completed | stopped | failed | awaiting_approval | interrupted}, so
|
||||||
|
* approval decisions never appear at the message-status layer. {@code PendingApprovalMeta.status}
|
||||||
|
* holds the decision ({@code approved} / {@code denied}); when the message itself was
|
||||||
|
* persisted as {@code awaiting_approval} we collapse it back to one of the existing terminal
|
||||||
|
* message states ({@code completed} for approved, {@code stopped} for denied) so downstream
|
||||||
|
* consumers (history sanitizer, list ordering, stuck counters) keep working unchanged.
|
||||||
|
* <p>
|
||||||
|
* Timeout / superseded both map to {@link #DENIED} at the metadata layer (DB layer keeps
|
||||||
|
* the more specific {@code TIMEOUT} / {@code SUPERSEDED} status for audit purposes).
|
||||||
|
*/
|
||||||
|
public enum MetadataDecision {
|
||||||
|
|
||||||
|
APPROVED("approved", "completed"),
|
||||||
|
DENIED("denied", "stopped");
|
||||||
|
|
||||||
|
/** Target value for {@code metadata.pendingApproval.status}. */
|
||||||
|
public final String pendingApprovalStatus;
|
||||||
|
|
||||||
|
/** Target value for {@code MessageEntity.status} when source was {@code awaiting_approval}. */
|
||||||
|
public final String messageStatus;
|
||||||
|
|
||||||
|
MetadataDecision(String pendingApprovalStatus, String messageStatus) {
|
||||||
|
this.pendingApprovalStatus = pendingApprovalStatus;
|
||||||
|
this.messageStatus = messageStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,6 +59,16 @@ public class PendingApproval {
|
|||||||
/** 风险摘要 */
|
/** 风险摘要 */
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: serialized {@code ChatOrigin} snapshot captured when
|
||||||
|
* this approval was created. Lets cross-process / cross-restart replays
|
||||||
|
* (the user approves hours later from a different node) restore the
|
||||||
|
* original channel binding so the replayed tool call still delivers
|
||||||
|
* back to the correct channel. Persisted into
|
||||||
|
* {@code mate_tool_approval.chat_origin}.
|
||||||
|
*/
|
||||||
|
private String chatOrigin;
|
||||||
|
|
||||||
public PendingApproval(String pendingId, String conversationId, String userId,
|
public PendingApproval(String pendingId, String conversationId, String userId,
|
||||||
String toolName, String toolArguments, String reason) {
|
String toolName, String toolArguments, String reason) {
|
||||||
this.pendingId = pendingId;
|
this.pendingId = pendingId;
|
||||||
@ -71,6 +81,24 @@ public class PendingApproval {
|
|||||||
this.status = "pending";
|
this.status = "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INTERNAL — recovery constructor for {@code ApprovalWorkflowService.recoverFromDb}.
|
||||||
|
* Preserves the persisted {@code createdAt} and {@code status} so TTL/GC keep working
|
||||||
|
* across JVM restarts. Do not use from business paths.
|
||||||
|
*/
|
||||||
|
PendingApproval(String pendingId, String conversationId, String userId,
|
||||||
|
String toolName, String toolArguments, String reason,
|
||||||
|
Instant createdAt, String status) {
|
||||||
|
this.pendingId = pendingId;
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.toolName = toolName;
|
||||||
|
this.toolArguments = toolArguments;
|
||||||
|
this.reason = reason;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
// === Getters ===
|
// === Getters ===
|
||||||
|
|
||||||
public String getPendingId() { return pendingId; }
|
public String getPendingId() { return pendingId; }
|
||||||
@ -92,6 +120,7 @@ public class PendingApproval {
|
|||||||
public String getFindingsJson() { return findingsJson; }
|
public String getFindingsJson() { return findingsJson; }
|
||||||
public String getMaxSeverity() { return maxSeverity; }
|
public String getMaxSeverity() { return maxSeverity; }
|
||||||
public String getSummary() { return summary; }
|
public String getSummary() { return summary; }
|
||||||
|
public String getChatOrigin() { return chatOrigin; }
|
||||||
|
|
||||||
// === Setters ===
|
// === Setters ===
|
||||||
|
|
||||||
@ -107,4 +136,5 @@ public class PendingApproval {
|
|||||||
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
|
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
|
||||||
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
|
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
|
||||||
public void setSummary(String summary) { this.summary = summary; }
|
public void setSummary(String summary) { this.summary = summary; }
|
||||||
|
public void setChatOrigin(String chatOrigin) { this.chatOrigin = chatOrigin; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
package vip.mate.approval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an {@link ApprovalWorkflowService} state-change operation.
|
||||||
|
* <p>
|
||||||
|
* Returned by {@code resolve}, {@code resolveAndConsume}, {@code consumeApproved},
|
||||||
|
* {@code cancelStalePending}, and (PR 3) {@code denyAllByConversation} so the caller can:
|
||||||
|
* <ul>
|
||||||
|
* <li>broadcast a {@code tool_approval_resolved} SSE event AFTER the DB transaction
|
||||||
|
* commits — SSE is not a rollback-capable resource and must not live inside the
|
||||||
|
* persistence transaction (RFC-067 §4.2);</li>
|
||||||
|
* <li>distinguish "did anything happen" from "nothing to do, idempotent return"
|
||||||
|
* so noisy log spam / repeated UI events stay suppressed when two paths race
|
||||||
|
* to resolve the same approval;</li>
|
||||||
|
* <li>reach the consumed payload (tool call JSON) for replay without going back to
|
||||||
|
* the memory map a second time.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param pendingId the approval that was operated on (always populated)
|
||||||
|
* @param conversationId conversation owning the approval (null on alreadyResolved
|
||||||
|
* when only the id was given)
|
||||||
|
* @param toolName tool that was awaiting approval (null on alreadyResolved)
|
||||||
|
* @param decision one of: {@code approved}, {@code denied}, {@code consumed},
|
||||||
|
* {@code superseded}, {@code timeout}, {@code already_resolved}
|
||||||
|
* @param consumedSnapshot the in-memory record at the moment of consume; non-null
|
||||||
|
* only when {@code decision == "consumed"} (used by replay)
|
||||||
|
* @param dbSynced {@code true} iff the DB row's status flipped successfully
|
||||||
|
* in this call (false on already_resolved or DB failure)
|
||||||
|
* @param messagesRewritten how many {@code mate_message} rows had their
|
||||||
|
* {@code metadata.pendingApproval.status} reconciled
|
||||||
|
*/
|
||||||
|
public record ResolveOutcome(
|
||||||
|
String pendingId,
|
||||||
|
String conversationId,
|
||||||
|
String toolName,
|
||||||
|
String decision,
|
||||||
|
PendingApproval consumedSnapshot,
|
||||||
|
boolean dbSynced,
|
||||||
|
int messagesRewritten
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ResolveOutcome alreadyResolved(String pendingId) {
|
||||||
|
return new ResolveOutcome(pendingId, null, null, "already_resolved", null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolveOutcome resolved(PendingApproval snapshot, String decision,
|
||||||
|
boolean dbSynced, int messagesRewritten) {
|
||||||
|
return new ResolveOutcome(
|
||||||
|
snapshot.getPendingId(),
|
||||||
|
snapshot.getConversationId(),
|
||||||
|
snapshot.getToolName(),
|
||||||
|
decision,
|
||||||
|
null,
|
||||||
|
dbSynced,
|
||||||
|
messagesRewritten
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolveOutcome consumed(PendingApproval snapshot,
|
||||||
|
boolean dbSynced, int messagesRewritten) {
|
||||||
|
return new ResolveOutcome(
|
||||||
|
snapshot.getPendingId(),
|
||||||
|
snapshot.getConversationId(),
|
||||||
|
snapshot.getToolName(),
|
||||||
|
"consumed",
|
||||||
|
snapshot,
|
||||||
|
dbSynced,
|
||||||
|
messagesRewritten
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAlreadyResolved() {
|
||||||
|
return "already_resolved".equals(decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConsumed() {
|
||||||
|
return "consumed".equals(decision);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,12 +36,21 @@ public class ToolApprovalEntity {
|
|||||||
private LocalDateTime resolvedAt;
|
private LocalDateTime resolvedAt;
|
||||||
private LocalDateTime expireAt;
|
private LocalDateTime expireAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: serialized {@link vip.mate.agent.context.ChatOrigin}
|
||||||
|
* snapshot captured when this approval was created. The Memento lets
|
||||||
|
* ChannelMessageRouter.replayApprovedToolCall (and the web ApprovalController
|
||||||
|
* replay path) restore the originating channel/workspace context after
|
||||||
|
* a process restart, so a tool approved hours later still binds back to
|
||||||
|
* the original channel.
|
||||||
|
*/
|
||||||
|
private String chatOrigin;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,5 @@ public class UserEntity {
|
|||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ public class AuthService {
|
|||||||
.eq(UserEntity::getEnabled, true));
|
.eq(UserEntity::getEnabled, true));
|
||||||
|
|
||||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||||
throw new MateClawException("err.auth.invalid_credentials", "用户名或密码错误");
|
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = generateToken(user);
|
String token = generateToken(user);
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.channel.health.ChannelHealth;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -224,6 +226,33 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
|||||||
return running.get();
|
return running.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the existing {@code ConnectionState} machine to a typed
|
||||||
|
* {@link ChannelHealth} snapshot. The "UP" status requires
|
||||||
|
* {@code running=true} AND {@code state==CONNECTED} — this is what
|
||||||
|
* makes the green dot honest: "stopped admins" and "started but
|
||||||
|
* disconnected" both report something other than UP.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChannelHealth health() {
|
||||||
|
Long id = channelEntity != null ? channelEntity.getId() : null;
|
||||||
|
String type = getChannelType();
|
||||||
|
if (!running.get()) {
|
||||||
|
return ChannelHealth.outOfService(type, id);
|
||||||
|
}
|
||||||
|
Instant lastEvent = Instant.ofEpochMilli(lastEventTimeMs.get());
|
||||||
|
ConnectionState s = connectionState.get();
|
||||||
|
return switch (s) {
|
||||||
|
case CONNECTED -> ChannelHealth.up(type, id, lastEvent);
|
||||||
|
case RECONNECTING -> ChannelHealth.reconnecting(type, id,
|
||||||
|
lastError != null ? lastError : "reconnecting", lastEvent);
|
||||||
|
case ERROR -> ChannelHealth.down(type, id,
|
||||||
|
lastError != null ? lastError : "channel error", lastEvent);
|
||||||
|
case DISCONNECTED -> ChannelHealth.down(type, id,
|
||||||
|
"disconnected", lastEvent);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-024 Change 1:刷新"活跃时间"的标准入口。
|
* RFC-024 Change 1:刷新"活跃时间"的标准入口。
|
||||||
*
|
*
|
||||||
@ -458,6 +487,15 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
|||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected long getConfigLong(String key, long defaultValue) {
|
||||||
|
Object value = config.get(key);
|
||||||
|
if (value instanceof Number n) return n.longValue();
|
||||||
|
if (value instanceof String s && !s.isBlank()) {
|
||||||
|
try { return Long.parseLong(s.trim()); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取配置中的列表值
|
* 获取配置中的列表值
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.channel;
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import vip.mate.channel.health.ChannelHealth;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@ -113,6 +114,19 @@ public interface ChannelAdapter {
|
|||||||
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
|
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: extended overload that accepts a
|
||||||
|
* {@link DeliveryOptions} Parameter Object carrying optional hints
|
||||||
|
* (thread id, multi-bot account id, future ext fields).
|
||||||
|
*
|
||||||
|
* <p>Default implementation delegates to {@link #proactiveSend(String, String)},
|
||||||
|
* dropping hints — concrete adapters (Slack, Telegram) override this
|
||||||
|
* variant to read {@code threadId} and route into the threading API.
|
||||||
|
*/
|
||||||
|
default void proactiveSend(String targetId, String content, DeliveryOptions options) {
|
||||||
|
proactiveSend(targetId, content);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前渠道是否支持主动推送
|
* 当前渠道是否支持主动推送
|
||||||
*
|
*
|
||||||
@ -148,4 +162,24 @@ public interface ChannelAdapter {
|
|||||||
default Duration stalenessThreshold() {
|
default Duration stalenessThreshold() {
|
||||||
return Duration.ofMinutes(60);
|
return Duration.ofMinutes(60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time health snapshot of this adapter.
|
||||||
|
*
|
||||||
|
* <p>This is the source of truth the frontend "connected" green dot
|
||||||
|
* should bind to — {@code mate_channel.enabled} only records the
|
||||||
|
* user's intent to run the channel, not whether the underlying
|
||||||
|
* transport (WebSocket / webhook subscription / API token) is
|
||||||
|
* actually healthy.
|
||||||
|
*
|
||||||
|
* <p>Default returns {@code OUT_OF_SERVICE} when {@link #isRunning()}
|
||||||
|
* is false and {@code UP} otherwise. Concrete adapters override to
|
||||||
|
* surface RECONNECTING / DOWN with specific reasons (auth failure,
|
||||||
|
* staleness exceeded, etc).
|
||||||
|
*/
|
||||||
|
default ChannelHealth health() {
|
||||||
|
return isRunning()
|
||||||
|
? ChannelHealth.up(getChannelType(), null, java.time.Instant.now())
|
||||||
|
: ChannelHealth.outOfService(getChannelType(), null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.context.ChannelTarget;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.2: factory that translates an inbound channel message into a
|
||||||
|
* {@link ChatOrigin}. Lives in {@code vip.mate.channel} (not in
|
||||||
|
* {@code vip.mate.agent.context}) so that the dependency direction stays
|
||||||
|
* {@code channel → agent} and never the reverse.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ChannelChatOriginFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a {@link ChatOrigin} for a channel-originated message.
|
||||||
|
*
|
||||||
|
* @param channel channel entity (non-null) — provides id + workspaceId
|
||||||
|
* @param message inbound message (non-null) — provides senderId + reply target
|
||||||
|
* @param conversationId resolved conversation id (channel-scoped)
|
||||||
|
* @param workspaceBasePath workspace activity directory; null = unrestricted
|
||||||
|
*/
|
||||||
|
public ChatOrigin from(ChannelEntity channel,
|
||||||
|
ChannelMessage message,
|
||||||
|
String conversationId,
|
||||||
|
String workspaceBasePath) {
|
||||||
|
ChannelTarget target = new ChannelTarget(
|
||||||
|
resolveTargetId(message),
|
||||||
|
/* threadId */ null, // adapters fill via ChannelMessage extension fields when available
|
||||||
|
/* accountId */ null);
|
||||||
|
return new ChatOrigin(
|
||||||
|
/* agentId */ null,
|
||||||
|
/* conversationId */ conversationId,
|
||||||
|
/* requesterId */ message.getSenderId(),
|
||||||
|
/* workspaceId */ channel.getWorkspaceId(),
|
||||||
|
/* workspaceBasePath */ workspaceBasePath,
|
||||||
|
/* channelId */ channel.getId(),
|
||||||
|
/* channelTarget */ target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the IM target id used for proactive sends.
|
||||||
|
*
|
||||||
|
* <p><b>Critical</b>: must NOT use {@link ChannelMessage#getReplyToken()} —
|
||||||
|
* for DingTalk the reply token encodes a {@code sessionWebhook} URL that
|
||||||
|
* expires ~90 minutes after the inbound message. A cron persisted with an
|
||||||
|
* expired sessionWebhook fails proactive delivery with 401/403 forever
|
||||||
|
* after the window lapses.
|
||||||
|
*
|
||||||
|
* <p>Resolution order — both fields are stable identifiers across all
|
||||||
|
* supported channels:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code chatId} — group / channel / room identifier; preferred so
|
||||||
|
* cron messages land in the same conversation the user triggered
|
||||||
|
* the cron from</li>
|
||||||
|
* <li>{@code senderId} — user identifier; fallback for private chats
|
||||||
|
* where {@code chatId} is null. DingTalk's {@code proactiveSend}
|
||||||
|
* routes a userId through the Robot API ({@code oToMessages/batchSend}),
|
||||||
|
* which works indefinitely.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private String resolveTargetId(ChannelMessage message) {
|
||||||
|
if (message.getChatId() != null && !message.getChatId().isBlank()) {
|
||||||
|
return message.getChatId();
|
||||||
|
}
|
||||||
|
return message.getSenderId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for "is this assistant reply actually an error
|
||||||
|
* surface?" used across the channel layer.
|
||||||
|
*
|
||||||
|
* <p>Why this matters: an LLM-side 400 (DashScope's "Bad request, please
|
||||||
|
* check input", DeepSeek thinking-mode "reasoning_content must be passed
|
||||||
|
* back", Anthropic "does not support assistant message prefill") is rendered
|
||||||
|
* as a normal-looking assistant string by {@code NodeStreamingChatHelper}.
|
||||||
|
* If that string is persisted with {@code status='completed'}, the next
|
||||||
|
* turn's history feeds it back to the LLM as a real assistant turn and the
|
||||||
|
* 400 self-replicates indefinitely.
|
||||||
|
*
|
||||||
|
* <p>The fix has two layers:
|
||||||
|
* <ol>
|
||||||
|
* <li>This classifier flips the persisted status to {@code 'error'} so
|
||||||
|
* {@code BaseAgent.sanitizeForLlm} filters it from history.</li>
|
||||||
|
* <li>The "[错误] " content prefix kept on disk is the legacy backup
|
||||||
|
* filter — both work together.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Heuristics are kept in sync with the error-message templates emitted
|
||||||
|
* by {@code NodeStreamingChatHelper.buildErrorResultWithType} and friends.
|
||||||
|
* Adding a new error template there means adding the matching probe here.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ChannelErrorClassifier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@code true} if the reply text matches one of the known
|
||||||
|
* LLM-side error surfaces and should NOT be treated as a real
|
||||||
|
* assistant turn for memory / history purposes.
|
||||||
|
*/
|
||||||
|
public boolean isErrorReply(String reply) {
|
||||||
|
if (reply == null || reply.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return reply.startsWith("[错误] ")
|
||||||
|
|| reply.contains("Bad request:")
|
||||||
|
|| reply.contains("LLM 调用失败:")
|
||||||
|
|| reply.contains("LLM 调用超时")
|
||||||
|
|| reply.contains("LLM 调用被中断")
|
||||||
|
|| reply.contains("Prompt 过长:")
|
||||||
|
|| reply.contains("认证失败:")
|
||||||
|
|| reply.contains("LLM 返回空响应");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a classification result to the {@code mate_message.status} value. */
|
||||||
|
public String statusFor(String reply) {
|
||||||
|
return isErrorReply(reply) ? "error" : "completed";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,7 @@ public class ChannelManager {
|
|||||||
private final ChannelMessageRouter messageRouter;
|
private final ChannelMessageRouter messageRouter;
|
||||||
private final ChannelSessionStore channelSessionStore;
|
private final ChannelSessionStore channelSessionStore;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
/** 运行中的渠道适配器:channelId -> adapter */
|
/** 运行中的渠道适配器:channelId -> adapter */
|
||||||
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
|
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
|
||||||
@ -329,13 +330,23 @@ public class ChannelManager {
|
|||||||
* @throws IllegalStateException 渠道未启动或不支持主动推送
|
* @throws IllegalStateException 渠道未启动或不支持主动推送
|
||||||
*/
|
*/
|
||||||
public void sendToChannel(Long channelId, String targetId, String content) {
|
public void sendToChannel(Long channelId, String targetId, String content) {
|
||||||
|
sendToChannel(channelId, targetId, content, DeliveryOptions.DEFAULTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: preferred overload — accepts a {@link DeliveryOptions}
|
||||||
|
* Parameter Object so cron delivery (and future callers) can pass
|
||||||
|
* Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
|
||||||
|
* {@code accountId}, etc. without growing a 5-arg signature.
|
||||||
|
*/
|
||||||
|
public void sendToChannel(Long channelId, String targetId, String content, DeliveryOptions options) {
|
||||||
ChannelAdapter adapter = getAdapter(channelId)
|
ChannelAdapter adapter = getAdapter(channelId)
|
||||||
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
|
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
|
||||||
if (!adapter.supportsProactiveSend()) {
|
if (!adapter.supportsProactiveSend()) {
|
||||||
throw new UnsupportedOperationException(
|
throw new UnsupportedOperationException(
|
||||||
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
|
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
|
||||||
}
|
}
|
||||||
adapter.proactiveSend(targetId, content);
|
adapter.proactiveSend(targetId, content, options != null ? options : DeliveryOptions.DEFAULTS);
|
||||||
log.info("Proactive message sent via channel {} to {}: {}chars",
|
log.info("Proactive message sent via channel {} to {}: {}chars",
|
||||||
adapter.getDisplayName(), targetId, content.length());
|
adapter.getDisplayName(), targetId, content.length());
|
||||||
}
|
}
|
||||||
@ -440,7 +451,7 @@ public class ChannelManager {
|
|||||||
String type = channel.getChannelType();
|
String type = channel.getChannelType();
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
|
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper);
|
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper);
|
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
|
|||||||
@ -4,14 +4,15 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.approval.ApprovalService;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
|
import vip.mate.approval.ResolveOutcome;
|
||||||
import vip.mate.approval.PendingApproval;
|
import vip.mate.approval.PendingApproval;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||||
import vip.mate.channel.service.ChannelService;
|
import vip.mate.channel.service.ChannelService;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||||
import vip.mate.memory.event.ConversationCompletedEvent;
|
|
||||||
import vip.mate.tts.TtsService;
|
import vip.mate.tts.TtsService;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
@ -48,12 +49,14 @@ public class ChannelMessageRouter {
|
|||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final ChannelSessionStore channelSessionStore;
|
private final ChannelSessionStore channelSessionStore;
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalWorkflowService approvalService;
|
||||||
private final ApprovalNotificationService approvalNotificationService;
|
private final ApprovalNotificationService approvalNotificationService;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
private final TtsService ttsService;
|
private final TtsService ttsService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
|
private final ChannelChatOriginFactory chatOriginFactory;
|
||||||
|
private final ChannelErrorClassifier errorClassifier;
|
||||||
|
|
||||||
/** 队列条目:封装消息及其路由上下文 */
|
/** 队列条目:封装消息及其路由上下文 */
|
||||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||||
@ -93,22 +96,26 @@ public class ChannelMessageRouter {
|
|||||||
ConversationService conversationService,
|
ConversationService conversationService,
|
||||||
ChannelService channelService,
|
ChannelService channelService,
|
||||||
ChannelSessionStore channelSessionStore,
|
ChannelSessionStore channelSessionStore,
|
||||||
ApprovalService approvalService,
|
ApprovalWorkflowService approvalService,
|
||||||
ApprovalNotificationService approvalNotificationService,
|
ApprovalNotificationService approvalNotificationService,
|
||||||
ApplicationEventPublisher eventPublisher,
|
ConversationCompletionPublisher completionPublisher,
|
||||||
TtsService ttsService,
|
TtsService ttsService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ChatStreamTracker streamTracker) {
|
ChatStreamTracker streamTracker,
|
||||||
|
ChannelChatOriginFactory chatOriginFactory,
|
||||||
|
ChannelErrorClassifier errorClassifier) {
|
||||||
this.agentService = agentService;
|
this.agentService = agentService;
|
||||||
this.conversationService = conversationService;
|
this.conversationService = conversationService;
|
||||||
this.channelService = channelService;
|
this.channelService = channelService;
|
||||||
this.channelSessionStore = channelSessionStore;
|
this.channelSessionStore = channelSessionStore;
|
||||||
this.approvalService = approvalService;
|
this.approvalService = approvalService;
|
||||||
this.approvalNotificationService = approvalNotificationService;
|
this.approvalNotificationService = approvalNotificationService;
|
||||||
this.eventPublisher = eventPublisher;
|
this.completionPublisher = completionPublisher;
|
||||||
this.ttsService = ttsService;
|
this.ttsService = ttsService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
|
this.chatOriginFactory = chatOriginFactory;
|
||||||
|
this.errorClassifier = errorClassifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 防抖辅助类 ====================
|
// ==================== 防抖辅助类 ====================
|
||||||
@ -363,36 +370,40 @@ public class ChannelMessageRouter {
|
|||||||
adapter.getChannelType(), message.getSenderId(), originalRequester);
|
adapter.getChannelType(), message.getSenderId(), originalRequester);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 批准:原子解决+消费审批记录(消除 resolve/consume race condition)
|
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
|
||||||
PendingApproval consumed = approvalService.resolveAndConsume(
|
ResolveOutcome consumeOutcome = approvalService.resolveAndConsume(
|
||||||
pending.getPendingId(), message.getSenderId());
|
pending.getPendingId(), message.getSenderId());
|
||||||
if (consumed == null) {
|
if (consumeOutcome.isAlreadyResolved()) {
|
||||||
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
|
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info("[{}] Approval APPROVED via IM command: pendingId={}, tool={}",
|
PendingApproval consumed = consumeOutcome.consumedSnapshot();
|
||||||
adapter.getChannelType(), consumed.getPendingId(), consumed.getToolName());
|
log.info("[{}] Approval APPROVED via IM command: pendingId={}, tool={}, msgRewritten={}",
|
||||||
|
adapter.getChannelType(), consumed.getPendingId(), consumed.getToolName(),
|
||||||
|
consumeOutcome.messagesRewritten());
|
||||||
|
|
||||||
replayApprovedToolCall(consumed, conversationId, adapter, message, channelEntity);
|
replayApprovedToolCall(consumed, conversationId, adapter, message, channelEntity);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} else if (isDenyCommand(userText)) {
|
} else if (isDenyCommand(userText)) {
|
||||||
// 拒绝 + 清理 DB 残留审批占位消息
|
// Deny via IM: workflow.resolve owns the full state-machine transition.
|
||||||
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
|
ResolveOutcome denyOutcome = approvalService.resolve(
|
||||||
|
pending.getPendingId(), message.getSenderId(), "denied");
|
||||||
conversationService.removeApprovalPlaceholders(conversationId);
|
conversationService.removeApprovalPlaceholders(conversationId);
|
||||||
adapter.sendMessage(replyTarget, "⛔ 已拒绝执行工具: " + pending.getToolName());
|
adapter.sendMessage(replyTarget, "⛔ 已拒绝执行工具: " + pending.getToolName());
|
||||||
log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}",
|
log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}, msgRewritten={}",
|
||||||
adapter.getChannelType(), pending.getPendingId(), pending.getToolName());
|
adapter.getChannelType(), pending.getPendingId(), pending.getToolName(),
|
||||||
|
denyOutcome.messagesRewritten());
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 非审批命令但有 pending → 视为隐式拒绝 + 清理残留
|
// Non-approval message while a pending exists → treat as implicit deny.
|
||||||
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
|
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
|
||||||
conversationService.removeApprovalPlaceholders(conversationId);
|
conversationService.removeApprovalPlaceholders(conversationId);
|
||||||
adapter.sendMessage(replyTarget, "⛔ 审批已取消。将继续处理您的新消息。");
|
adapter.sendMessage(replyTarget, "⛔ 审批已取消。将继续处理您的新消息。");
|
||||||
log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}",
|
log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}",
|
||||||
adapter.getChannelType(), pending.getPendingId());
|
adapter.getChannelType(), pending.getPendingId());
|
||||||
// 继续正常流程处理当前消息
|
// Fall through to process the new message normally.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ======= 审批拦截层结束 =======
|
// ======= 审批拦截层结束 =======
|
||||||
@ -436,11 +447,17 @@ public class ChannelMessageRouter {
|
|||||||
Long savedAssistantId = null;
|
Long savedAssistantId = null;
|
||||||
try {
|
try {
|
||||||
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
|
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
|
||||||
|
// RFC-063r §2.5: build the ChatOrigin once per channel-message
|
||||||
|
// so cron jobs created during this conversation inherit the
|
||||||
|
// channel binding (Issue #25 root path).
|
||||||
|
ChatOrigin chatOrigin = chatOriginFactory.from(
|
||||||
|
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||||
|
|
||||||
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
||||||
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
|
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
|
||||||
} else {
|
} else {
|
||||||
// 同步路径:直接获取完整回复
|
// 同步路径:直接获取完整回复
|
||||||
String reply = agentService.chat(agentId, promptText, conversationId);
|
String reply = agentService.chat(agentId, promptText, conversationId, chatOrigin);
|
||||||
|
|
||||||
// 检查 chat 过程中是否产生了审批 pending
|
// 检查 chat 过程中是否产生了审批 pending
|
||||||
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
||||||
@ -451,10 +468,20 @@ public class ChannelMessageRouter {
|
|||||||
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
|
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
|
||||||
adapter.getChannelType(), newPending.getToolName());
|
adapter.getChannelType(), newPending.getToolName());
|
||||||
} else {
|
} else {
|
||||||
// 正常回复:保存并发送
|
// Tag error replies (matched by ChannelErrorClassifier — the
|
||||||
MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", reply);
|
// "[错误]" content prefix / Bad request: / LLM error templates)
|
||||||
|
// with status='error' so BaseAgent.sanitizeForLlm drops them
|
||||||
|
// from the next turn's LLM history, breaking the self-replicating
|
||||||
|
// 400 loop. Only successful replies fire the ConversationCompletedEvent —
|
||||||
|
// error turns must not pollute memory extraction.
|
||||||
|
boolean isError = errorClassifier.isErrorReply(reply);
|
||||||
|
String status = isError ? "error" : "completed";
|
||||||
|
MessageEntity saved = conversationService.saveMessage(
|
||||||
|
conversationId, "assistant", reply, null, status);
|
||||||
savedAssistantId = saved != null ? saved.getId() : null;
|
savedAssistantId = saved != null ? saved.getId() : null;
|
||||||
|
if (!isError) {
|
||||||
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
|
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
|
||||||
|
}
|
||||||
adapter.renderAndSend(replyTarget, reply);
|
adapter.renderAndSend(replyTarget, reply);
|
||||||
log.info("[{}] Reply sent to {}: {}chars",
|
log.info("[{}] Reply sent to {}: {}chars",
|
||||||
adapter.getChannelType(), replyTarget, reply.length());
|
adapter.getChannelType(), replyTarget, reply.length());
|
||||||
@ -517,14 +544,14 @@ public class ChannelMessageRouter {
|
|||||||
*/
|
*/
|
||||||
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
|
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
|
||||||
String conversationId, Long agentId, String promptText,
|
String conversationId, Long agentId, String promptText,
|
||||||
ChannelEntity channelEntity) {
|
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
|
||||||
String channelType = streamingAdapter.getChannelType();
|
String channelType = streamingAdapter.getChannelType();
|
||||||
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
|
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: 产生事件流
|
// Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId)
|
||||||
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
|
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
|
||||||
agentId, promptText, conversationId, message.getSenderId());
|
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
|
||||||
|
|
||||||
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
|
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
|
||||||
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
|
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
|
||||||
@ -537,9 +564,15 @@ public class ChannelMessageRouter {
|
|||||||
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
|
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
|
||||||
channelType, newPending.getToolName());
|
channelType, newPending.getToolName());
|
||||||
} else if (finalContent != null && !finalContent.isBlank()) {
|
} else if (finalContent != null && !finalContent.isBlank()) {
|
||||||
MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", finalContent);
|
boolean isError = errorClassifier.isErrorReply(finalContent);
|
||||||
|
String status = isError ? "error" : "completed";
|
||||||
|
MessageEntity saved = conversationService.saveMessage(
|
||||||
|
conversationId, "assistant", finalContent, null, status);
|
||||||
|
if (!isError) {
|
||||||
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
|
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
|
||||||
log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length());
|
}
|
||||||
|
log.info("[{}] Streaming completed: contentLen={}, isError={}",
|
||||||
|
channelType, finalContent.length(), isError);
|
||||||
|
|
||||||
// 流式回复完成后也触发语音回复
|
// 流式回复完成后也触发语音回复
|
||||||
String replyTarget = resolveReplyTarget(message);
|
String replyTarget = resolveReplyTarget(message);
|
||||||
@ -552,7 +585,17 @@ public class ChannelMessageRouter {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), e);
|
log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), e);
|
||||||
// 尝试发送错误提示
|
// Persist an error placeholder (status='error') so that the next
|
||||||
|
// turn's history does not show a user → user sequence (which some
|
||||||
|
// providers reject with 400). sanitizeForLlm filters this row out
|
||||||
|
// before the LLM sees it, so it costs nothing at the prompt layer.
|
||||||
|
try {
|
||||||
|
conversationService.saveMessage(conversationId, "assistant",
|
||||||
|
"[错误] " + e.getMessage(), null, "error");
|
||||||
|
} catch (Exception persistErr) {
|
||||||
|
log.warn("[{}] Failed to persist error placeholder: {}",
|
||||||
|
channelType, persistErr.getMessage());
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
String errorTarget = resolveReplyTarget(message);
|
String errorTarget = resolveReplyTarget(message);
|
||||||
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
|
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
|
||||||
@ -563,6 +606,7 @@ public class ChannelMessageRouter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== 审批重放 ====================
|
// ==================== 审批重放 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -587,11 +631,25 @@ public class ChannelMessageRouter {
|
|||||||
String replayPrompt = "继续执行已批准的工具调用。";
|
String replayPrompt = "继续执行已批准的工具调用。";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// RFC-063r §2.12: prefer the persisted Memento (covers
|
||||||
|
// cross-restart approval where the channel session changed) and
|
||||||
|
// only fall back to rebuilding from the current inbound message
|
||||||
|
// when no snapshot was captured (legacy rows from before this PR).
|
||||||
|
ChatOrigin replayOrigin = approvalService.restoreChatOrigin(consumed.getChatOrigin());
|
||||||
|
if (replayOrigin == ChatOrigin.EMPTY) {
|
||||||
|
replayOrigin = chatOriginFactory.from(
|
||||||
|
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
||||||
|
}
|
||||||
String reply = agentService.chatWithReplay(
|
String reply = agentService.chatWithReplay(
|
||||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload());
|
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||||
|
|
||||||
// 保存 replay 结果(这是正常结果,入库)
|
// Persist the replay result. If the LLM 400'd during replay,
|
||||||
conversationService.saveMessage(conversationId, "assistant", reply);
|
// the error reply must also get status='error' — otherwise the
|
||||||
|
// next turn's history would re-feed the error placeholder back
|
||||||
|
// into the prompt and re-trigger the same failure.
|
||||||
|
boolean isError = errorClassifier.isErrorReply(reply);
|
||||||
|
conversationService.saveMessage(conversationId, "assistant", reply, null,
|
||||||
|
isError ? "error" : "completed");
|
||||||
|
|
||||||
// 发送回复
|
// 发送回复
|
||||||
adapter.renderAndSend(replyTarget, reply);
|
adapter.renderAndSend(replyTarget, reply);
|
||||||
@ -612,17 +670,13 @@ public class ChannelMessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布对话完成事件(触发异步记忆提取),失败不影响正常流程
|
* Publish the conversation-completed event (triggers async memory extraction).
|
||||||
|
* Delegates to {@link ConversationCompletionPublisher} so the try/catch and
|
||||||
|
* messageCount lookup no longer live here.
|
||||||
*/
|
*/
|
||||||
private void publishConversationCompletedEvent(Long agentId, String conversationId,
|
private void publishConversationCompletedEvent(Long agentId, String conversationId,
|
||||||
String userMessage, String assistantReply) {
|
String userMessage, String assistantReply) {
|
||||||
try {
|
completionPublisher.publish(agentId, conversationId, userMessage, assistantReply, "channel");
|
||||||
int msgCount = conversationService.getMessageCount(conversationId);
|
|
||||||
eventPublisher.publishEvent(new ConversationCompletedEvent(
|
|
||||||
agentId, conversationId, userMessage, assistantReply, msgCount, "channel"));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("[Memory] Failed to publish ConversationCompletedEvent: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 流式处理(Web 渠道专用,不走队列) ====================
|
// ==================== 流式处理(Web 渠道专用,不走队列) ====================
|
||||||
@ -644,7 +698,11 @@ public class ChannelMessageRouter {
|
|||||||
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
|
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
|
||||||
|
|
||||||
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
||||||
return agentService.chatStream(agentId, promptText, conversationId);
|
// RFC-063r §2.5: forward ChatOrigin so tools created during this
|
||||||
|
// streaming conversation inherit channel binding.
|
||||||
|
ChatOrigin origin = chatOriginFactory.from(
|
||||||
|
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||||
|
return agentService.chatStream(agentId, promptText, conversationId, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 优雅关闭 ====================
|
// ==================== 优雅关闭 ====================
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: Parameter Object that bundles optional delivery hints
|
||||||
|
* (Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
|
||||||
|
* {@code accountId}, etc.) so {@link ChannelManager#sendToChannel} doesn't
|
||||||
|
* grow a 5-arg overload.
|
||||||
|
*
|
||||||
|
* <p>{@link #DEFAULTS} is the canonical "no hints" instance — adapters that
|
||||||
|
* don't override the 4-arg {@code proactiveSend} keep their pre-RFC behavior.
|
||||||
|
*/
|
||||||
|
public record DeliveryOptions(
|
||||||
|
@Nullable String threadId,
|
||||||
|
@Nullable String accountId,
|
||||||
|
Map<String, Object> ext
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static final DeliveryOptions DEFAULTS = new DeliveryOptions(null, null, Map.of());
|
||||||
|
|
||||||
|
public DeliveryOptions {
|
||||||
|
// Defensive: never expose a null map — the receiver should be able to
|
||||||
|
// call .get(...) without a null check.
|
||||||
|
if (ext == null) ext = Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -130,6 +130,55 @@ public class ChannelController {
|
|||||||
return R.ok(channelManager.getStatus());
|
return R.ok(channelManager.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@Operation(summary = "获取指定渠道的实时健康状态(真连接状态,前端绿点应该绑这个)")
|
||||||
|
@GetMapping("/{id}/health")
|
||||||
|
public R<Map<String, Object>> health(@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
ChannelEntity channel = channelService.getChannel(id);
|
||||||
|
verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId);
|
||||||
|
return R.ok(channelManager.getAdapter(id)
|
||||||
|
.map(adapter -> adapter.health().toMap())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
// Adapter not in active map: either disabled, never started,
|
||||||
|
// or still booting. Surface as OUT_OF_SERVICE so the frontend
|
||||||
|
// dot stays gray instead of red.
|
||||||
|
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||||
|
body.put("channelType", channel.getChannelType());
|
||||||
|
body.put("channelId", id);
|
||||||
|
body.put("status", "OUT_OF_SERVICE");
|
||||||
|
body.put("detail", Boolean.TRUE.equals(channel.getEnabled())
|
||||||
|
? "channel enabled but adapter not active" : "channel disabled");
|
||||||
|
return body;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
@Operation(summary = "批量获取所有渠道健康状态")
|
||||||
|
@GetMapping("/health")
|
||||||
|
public R<List<Map<String, Object>>> healthAll(
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
long ws = workspaceId != null ? workspaceId : 1L;
|
||||||
|
return R.ok(channelService.listChannelsByWorkspace(ws).stream()
|
||||||
|
.map(c -> {
|
||||||
|
Map<String, Object> body = channelManager.getAdapter(c.getId())
|
||||||
|
.map(a -> a.health().toMap())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Map<String, Object> m = new java.util.LinkedHashMap<>();
|
||||||
|
m.put("channelType", c.getChannelType());
|
||||||
|
m.put("channelId", c.getId());
|
||||||
|
m.put("status", "OUT_OF_SERVICE");
|
||||||
|
m.put("detail", Boolean.TRUE.equals(c.getEnabled())
|
||||||
|
? "channel enabled but adapter not active" : "channel disabled");
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
body.put("name", c.getName());
|
||||||
|
body.put("enabled", Boolean.TRUE.equals(c.getEnabled()));
|
||||||
|
return body;
|
||||||
|
})
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
|
|||||||
@ -8,20 +8,16 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.channel.ChannelAdapter;
|
import vip.mate.channel.ChannelAdapter;
|
||||||
import vip.mate.channel.ChannelManager;
|
import vip.mate.channel.ChannelManager;
|
||||||
|
import vip.mate.channel.dingtalk.DingTalkAppRegistrationService;
|
||||||
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
||||||
import vip.mate.channel.discord.DiscordChannelAdapter;
|
import vip.mate.channel.discord.DiscordChannelAdapter;
|
||||||
|
import vip.mate.channel.feishu.FeishuAppRegistrationService;
|
||||||
import vip.mate.channel.feishu.FeishuChannelAdapter;
|
import vip.mate.channel.feishu.FeishuChannelAdapter;
|
||||||
|
import vip.mate.channel.qrcode.util.QrCodeImageEncoder;
|
||||||
import vip.mate.channel.telegram.TelegramChannelAdapter;
|
import vip.mate.channel.telegram.TelegramChannelAdapter;
|
||||||
import vip.mate.channel.weixin.ILinkClient;
|
import vip.mate.channel.weixin.ILinkClient;
|
||||||
import vip.mate.channel.weixin.WeixinChannelAdapter;
|
import vip.mate.channel.weixin.WeixinChannelAdapter;
|
||||||
import com.google.zxing.BarcodeFormat;
|
|
||||||
import com.google.zxing.EncodeHintType;
|
|
||||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
|
||||||
import com.google.zxing.common.BitMatrix;
|
|
||||||
import com.google.zxing.qrcode.QRCodeWriter;
|
|
||||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@ -47,6 +43,8 @@ import java.util.Optional;
|
|||||||
public class ChannelWebhookController {
|
public class ChannelWebhookController {
|
||||||
|
|
||||||
private final ChannelManager channelManager;
|
private final ChannelManager channelManager;
|
||||||
|
private final FeishuAppRegistrationService feishuAppRegistrationService;
|
||||||
|
private final DingTalkAppRegistrationService dingTalkAppRegistrationService;
|
||||||
|
|
||||||
@Operation(summary = "钉钉消息回调")
|
@Operation(summary = "钉钉消息回调")
|
||||||
@PostMapping("/dingtalk")
|
@PostMapping("/dingtalk")
|
||||||
@ -61,6 +59,57 @@ public class ChannelWebhookController {
|
|||||||
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
|
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 钉钉一键应用注册(OAuth Device Flow) ====================
|
||||||
|
|
||||||
|
@Operation(summary = "启动钉钉扫码注册应用流程")
|
||||||
|
@PostMapping("/dingtalk/register/begin")
|
||||||
|
public ResponseEntity<Map<String, Object>> dingtalkRegisterBegin() {
|
||||||
|
try {
|
||||||
|
DingTalkAppRegistrationService.RegistrationSession session = dingTalkAppRegistrationService.begin();
|
||||||
|
return ResponseEntity.ok(Map.of("session_id", session.sessionId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[dingtalk-register] begin failed: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("error", "Failed to start registration: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查询钉钉扫码注册状态")
|
||||||
|
@GetMapping("/dingtalk/register/status")
|
||||||
|
public ResponseEntity<Map<String, Object>> dingtalkRegisterStatus(@RequestParam("session") String sessionId) {
|
||||||
|
DingTalkAppRegistrationService.RegistrationSession session = dingTalkAppRegistrationService.getSession(sessionId);
|
||||||
|
if (session == null) {
|
||||||
|
return ResponseEntity.ok(Map.of("status", "expired", "error", "session not found or expired"));
|
||||||
|
}
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", session.status.name().toLowerCase());
|
||||||
|
if (session.qrcodeUrl != null) {
|
||||||
|
body.put("qrcode_url", session.qrcodeUrl);
|
||||||
|
// Same as the feishu register flow: SDK gives us a verification URL string,
|
||||||
|
// browsers can't render that as an image, so encode into a PNG data URI here
|
||||||
|
// and cache it on the session so ZXing only runs once per registration attempt.
|
||||||
|
if (session.qrcodeImgDataUri == null) {
|
||||||
|
try {
|
||||||
|
String base64 = generateQrCodeBase64(session.qrcodeUrl);
|
||||||
|
session.qrcodeImgDataUri = "data:image/png;base64," + base64;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[dingtalk-register] QR encode failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.qrcodeImgDataUri != null) {
|
||||||
|
body.put("qrcode_img", session.qrcodeImgDataUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.status == DingTalkAppRegistrationService.Status.CONFIRMED) {
|
||||||
|
body.put("client_id", session.clientId);
|
||||||
|
body.put("client_secret", session.clientSecret);
|
||||||
|
}
|
||||||
|
if (session.errorMessage != null) {
|
||||||
|
body.put("error", session.errorMessage);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "飞书消息回调")
|
@Operation(summary = "飞书消息回调")
|
||||||
@PostMapping("/feishu")
|
@PostMapping("/feishu")
|
||||||
public ResponseEntity<Map<String, Object>> feishuWebhook(@RequestBody Map<String, Object> payload) {
|
public ResponseEntity<Map<String, Object>> feishuWebhook(@RequestBody Map<String, Object> payload) {
|
||||||
@ -80,6 +129,62 @@ public class ChannelWebhookController {
|
|||||||
return ResponseEntity.ok(Map.of("code", 0));
|
return ResponseEntity.ok(Map.of("code", 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 飞书一键应用注册(oapi-sdk 2.6+) ====================
|
||||||
|
|
||||||
|
@Operation(summary = "启动飞书扫码注册应用流程")
|
||||||
|
@PostMapping("/feishu/register/begin")
|
||||||
|
public ResponseEntity<Map<String, Object>> feishuRegisterBegin(
|
||||||
|
@RequestParam(value = "domain", defaultValue = "feishu") String domain) {
|
||||||
|
try {
|
||||||
|
String sessionId = feishuAppRegistrationService.begin(domain);
|
||||||
|
return ResponseEntity.ok(Map.of("session_id", sessionId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[feishu-register] begin failed: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("error", "Failed to start registration: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查询飞书扫码注册状态")
|
||||||
|
@GetMapping("/feishu/register/status")
|
||||||
|
public ResponseEntity<Map<String, Object>> feishuRegisterStatus(@RequestParam("session") String sessionId) {
|
||||||
|
FeishuAppRegistrationService.RegistrationSession session = feishuAppRegistrationService.getSession(sessionId);
|
||||||
|
if (session == null) {
|
||||||
|
return ResponseEntity.ok(Map.of("status", "expired", "error", "session not found or expired"));
|
||||||
|
}
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", session.status.name().toLowerCase());
|
||||||
|
if (session.qrcodeUrl != null) {
|
||||||
|
body.put("qrcode_url", session.qrcodeUrl);
|
||||||
|
body.put("qrcode_expire_seconds", session.qrcodeExpireSeconds);
|
||||||
|
// SDK gives us a verification URL string (verification_uri_complete);
|
||||||
|
// browsers can't render that as an image, so encode it into a PNG QR
|
||||||
|
// here just like the WeChat flow does. Cache the encoded image on the
|
||||||
|
// session so we only run ZXing once per registration attempt.
|
||||||
|
if (session.qrcodeImgDataUri == null) {
|
||||||
|
try {
|
||||||
|
String base64 = generateQrCodeBase64(session.qrcodeUrl);
|
||||||
|
session.qrcodeImgDataUri = "data:image/png;base64," + base64;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[feishu-register] QR encode failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.qrcodeImgDataUri != null) {
|
||||||
|
body.put("qrcode_img", session.qrcodeImgDataUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.status == FeishuAppRegistrationService.Status.CONFIRMED) {
|
||||||
|
body.put("client_id", session.clientId);
|
||||||
|
body.put("client_secret", session.clientSecret);
|
||||||
|
if (session.userOpenId != null) body.put("user_open_id", session.userOpenId);
|
||||||
|
if (session.userTenantBrand != null) body.put("user_tenant_brand", session.userTenantBrand);
|
||||||
|
}
|
||||||
|
if (session.errorMessage != null) {
|
||||||
|
body.put("error", session.errorMessage);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "Telegram 消息回调")
|
@Operation(summary = "Telegram 消息回调")
|
||||||
@PostMapping("/telegram")
|
@PostMapping("/telegram")
|
||||||
public ResponseEntity<String> telegramWebhook(@RequestBody Map<String, Object> payload) {
|
public ResponseEntity<String> telegramWebhook(@RequestBody Map<String, Object> payload) {
|
||||||
@ -150,19 +255,9 @@ public class ChannelWebhookController {
|
|||||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Delegated to the shared encoder so future tweaks live in one place. */
|
||||||
* 使用 ZXing 生成 QR 码 PNG 图片并返回 Base64 编码
|
|
||||||
*/
|
|
||||||
private String generateQrCodeBase64(String content) throws Exception {
|
private String generateQrCodeBase64(String content) throws Exception {
|
||||||
QRCodeWriter writer = new QRCodeWriter();
|
return QrCodeImageEncoder.toBase64(content);
|
||||||
Map<EncodeHintType, Object> hints = Map.of(
|
|
||||||
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
|
|
||||||
EncodeHintType.MARGIN, 2
|
|
||||||
);
|
|
||||||
BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, 300, 300, hints);
|
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
||||||
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos);
|
|
||||||
return java.util.Base64.getEncoder().encodeToString(baos.toByteArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取微信登录二维码")
|
@Operation(summary = "获取微信登录二维码")
|
||||||
|
|||||||
@ -0,0 +1,225 @@
|
|||||||
|
package vip.mate.channel.dingtalk;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉"一键应用注册"服务(OAuth Device Authorization Grant)
|
||||||
|
* <p>
|
||||||
|
* 钉钉 SDK 没把 Device Flow 端点包装进 dingtalk-stream(那个 SDK 只管 WebSocket 长连接)。
|
||||||
|
* 三个 HTTP 端点是钉钉官方的 OAuth 2.0 Device Flow 标准协议:
|
||||||
|
* <pre>
|
||||||
|
* POST /app/registration/init body {source} → {nonce}(5 分钟 TTL)
|
||||||
|
* POST /app/registration/begin body {nonce} → {device_code, verification_uri_complete}
|
||||||
|
* POST /app/registration/poll body {device_code} → {status: WAITING/SUCCESS/FAIL/EXPIRED, client_id?, client_secret?}
|
||||||
|
* </pre>
|
||||||
|
* <p>
|
||||||
|
* 跟 {@code FeishuAppRegistrationService} 同构,但飞书走 SDK 阻塞调用 + 回调,钉钉这边纯 HTTP,
|
||||||
|
* 我们自己起 worker 线程做轮询(每 5 秒一次直到终态)。
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DingTalkAppRegistrationService {
|
||||||
|
|
||||||
|
private static final String API_BASE = "https://oapi.dingtalk.com";
|
||||||
|
private static final String SOURCE = "MATECLAW";
|
||||||
|
private static final long POLL_INTERVAL_MS = 5000L;
|
||||||
|
private static final long POLL_REQUEST_TIMEOUT_MS = 10_000L;
|
||||||
|
private static final long INIT_REQUEST_TIMEOUT_MS = 15_000L;
|
||||||
|
/** Session lifetime upper bound: device_code expires in ~5 min, give 7 min buffer for late polls. */
|
||||||
|
private static final long SESSION_TTL_MS = 7 * 60_000L;
|
||||||
|
/** Max wall-clock for the polling worker — kills runaway sessions even if DingTalk never returns terminal state. */
|
||||||
|
private static final long WORKER_MAX_RUNTIME_MS = 6 * 60_000L;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/** session_id → registration session */
|
||||||
|
private final ConcurrentHashMap<String, RegistrationSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off a registration: do init + begin synchronously to get the QR URL, then spawn a worker
|
||||||
|
* that polls /poll until terminal. Returns immediately with the session id.
|
||||||
|
*/
|
||||||
|
public RegistrationSession begin() throws Exception {
|
||||||
|
evictExpiredSessions();
|
||||||
|
|
||||||
|
// Step 1: init (synchronous, surfaces errors to caller before any session is recorded)
|
||||||
|
Map<?, ?> init = postJson("/app/registration/init", Map.of("source", SOURCE), INIT_REQUEST_TIMEOUT_MS);
|
||||||
|
Integer initCode = init.get("errcode") instanceof Number n ? n.intValue() : null;
|
||||||
|
if (initCode != null && initCode != 0) {
|
||||||
|
throw new IllegalStateException("DingTalk init failed: errcode=" + initCode + ", errmsg=" + init.get("errmsg"));
|
||||||
|
}
|
||||||
|
String nonce = (String) init.get("nonce");
|
||||||
|
if (nonce == null || nonce.isBlank()) {
|
||||||
|
throw new IllegalStateException("DingTalk init returned empty nonce");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: begin (exchanges nonce for device_code + QR URL)
|
||||||
|
Map<?, ?> begin = postJson("/app/registration/begin", Map.of("nonce", nonce), INIT_REQUEST_TIMEOUT_MS);
|
||||||
|
Integer beginCode = begin.get("errcode") instanceof Number n ? n.intValue() : null;
|
||||||
|
if (beginCode != null && beginCode != 0) {
|
||||||
|
throw new IllegalStateException("DingTalk begin failed: errcode=" + beginCode + ", errmsg=" + begin.get("errmsg"));
|
||||||
|
}
|
||||||
|
String deviceCode = (String) begin.get("device_code");
|
||||||
|
String verificationUri = (String) begin.get("verification_uri_complete");
|
||||||
|
if (deviceCode == null || deviceCode.isBlank() || verificationUri == null || verificationUri.isBlank()) {
|
||||||
|
throw new IllegalStateException("DingTalk begin returned empty device_code or URI");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build session with QR URL ready, then spawn worker
|
||||||
|
String sessionId = UUID.randomUUID().toString();
|
||||||
|
RegistrationSession session = new RegistrationSession(sessionId);
|
||||||
|
session.qrcodeUrl = verificationUri;
|
||||||
|
session.status = Status.WAITING;
|
||||||
|
sessions.put(sessionId, session);
|
||||||
|
|
||||||
|
Thread worker = new Thread(() -> pollUntilTerminal(session, deviceCode),
|
||||||
|
"dingtalk-register-" + sessionId.substring(0, 8));
|
||||||
|
worker.setDaemon(true);
|
||||||
|
worker.start();
|
||||||
|
|
||||||
|
log.info("[dingtalk-register] session {} started (device_code suffix=...{})",
|
||||||
|
sessionId, deviceCode.length() > 6 ? deviceCode.substring(deviceCode.length() - 6) : deviceCode);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RegistrationSession getSession(String sessionId) {
|
||||||
|
evictExpiredSessions();
|
||||||
|
return sessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polling worker. Waits {@link #POLL_INTERVAL_MS} between polls until DingTalk returns a terminal
|
||||||
|
* status, hard wall-clock cap of {@link #WORKER_MAX_RUNTIME_MS} so a server-side hang doesn't
|
||||||
|
* leak threads.
|
||||||
|
*/
|
||||||
|
private void pollUntilTerminal(RegistrationSession session, String deviceCode) {
|
||||||
|
long startMs = System.currentTimeMillis();
|
||||||
|
while (true) {
|
||||||
|
if (System.currentTimeMillis() - startMs > WORKER_MAX_RUNTIME_MS) {
|
||||||
|
session.status = Status.EXPIRED;
|
||||||
|
session.errorMessage = "polling worker timed out";
|
||||||
|
session.lastUpdateMs = System.currentTimeMillis();
|
||||||
|
log.warn("[dingtalk-register] session {} timed out after {} ms",
|
||||||
|
session.sessionId, WORKER_MAX_RUNTIME_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(POLL_INTERVAL_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<?, ?> poll = postJson("/app/registration/poll", Map.of("device_code", deviceCode), POLL_REQUEST_TIMEOUT_MS);
|
||||||
|
String status = (String) poll.get("status");
|
||||||
|
if (status == null) status = "WAITING";
|
||||||
|
|
||||||
|
session.lastUpdateMs = System.currentTimeMillis();
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "SUCCESS" -> {
|
||||||
|
session.clientId = (String) poll.get("client_id");
|
||||||
|
session.clientSecret = (String) poll.get("client_secret");
|
||||||
|
session.status = Status.CONFIRMED;
|
||||||
|
log.info("[dingtalk-register] session {} confirmed, clientId={}",
|
||||||
|
session.sessionId, session.clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "FAIL" -> {
|
||||||
|
Object failReason = poll.get("fail_reason");
|
||||||
|
session.errorMessage = failReason != null ? failReason.toString() : "unknown";
|
||||||
|
session.status = Status.DENIED;
|
||||||
|
log.info("[dingtalk-register] session {} denied: {}",
|
||||||
|
session.sessionId, session.errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "EXPIRED" -> {
|
||||||
|
session.status = Status.EXPIRED;
|
||||||
|
log.info("[dingtalk-register] session {} expired", session.sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "WAITING" -> {
|
||||||
|
// keep polling
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
log.debug("[dingtalk-register] session {} unknown status: {}", session.sessionId, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Transient errors don't fail the session — keep polling, the device_code is still valid
|
||||||
|
log.debug("[dingtalk-register] poll attempt failed (will retry): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> postJson(String path, Map<String, ?> body, long timeoutMs) throws Exception {
|
||||||
|
String json = objectMapper.writeValueAsString(body);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(API_BASE + path))
|
||||||
|
.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
.timeout(Duration.ofMillis(timeoutMs))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new IllegalStateException("DingTalk " + path + " HTTP " + response.statusCode() + ": " + response.body());
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(response.body(), Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void evictExpiredSessions() {
|
||||||
|
long cutoff = System.currentTimeMillis() - SESSION_TTL_MS;
|
||||||
|
Iterator<Map.Entry<String, RegistrationSession>> it = sessions.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
if (it.next().getValue().createdAtMs < cutoff) it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
/** init/begin done, polling for user scan + confirmation */
|
||||||
|
WAITING,
|
||||||
|
/** User confirmed; clientId/clientSecret returned */
|
||||||
|
CONFIRMED,
|
||||||
|
/** device_code expired before user finished */
|
||||||
|
EXPIRED,
|
||||||
|
/** User explicitly denied or DingTalk returned FAIL */
|
||||||
|
DENIED
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RegistrationSession {
|
||||||
|
public final String sessionId;
|
||||||
|
final long createdAtMs = System.currentTimeMillis();
|
||||||
|
|
||||||
|
public volatile Status status = Status.WAITING;
|
||||||
|
public volatile String qrcodeUrl;
|
||||||
|
/** Cached "data:image/png;base64,..." rendered from qrcodeUrl by the controller. */
|
||||||
|
public volatile String qrcodeImgDataUri;
|
||||||
|
public volatile String clientId;
|
||||||
|
public volatile String clientSecret;
|
||||||
|
public volatile String errorMessage;
|
||||||
|
public volatile long lastUpdateMs = System.currentTimeMillis();
|
||||||
|
|
||||||
|
RegistrationSession(String sessionId) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,10 +62,18 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
/** AI Card 管理器(message_type=card 时初始化) */
|
/** AI Card 管理器(message_type=card 时初始化) */
|
||||||
private DingTalkAICardManager aiCardManager;
|
private DingTalkAICardManager aiCardManager;
|
||||||
|
|
||||||
|
/** 钉钉媒体上传器(doStart 时初始化) */
|
||||||
|
private DingTalkMediaUploader mediaUploader;
|
||||||
|
|
||||||
|
/** 工具产生的可下载字节缓存(DocxRenderTool 等用,注入避免回调到自己的 HTTP API) */
|
||||||
|
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
public DingTalkChannelAdapter(ChannelEntity channelEntity,
|
public DingTalkChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
|
||||||
super(channelEntity, messageRouter, objectMapper);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
|
this.generatedFileCache = generatedFileCache;
|
||||||
// 钉钉 Stream 重连:2s→4s→8s→16s→30s,无限重试
|
// 钉钉 Stream 重连:2s→4s→8s→16s→30s,无限重试
|
||||||
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
||||||
}
|
}
|
||||||
@ -97,6 +105,9 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
.connectTimeout(Duration.ofSeconds(10))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// 媒体上传器:sampleFile / sampleImageMsg 都靠它先把字节传到钉钉拿 mediaId
|
||||||
|
this.mediaUploader = new DingTalkMediaUploader(httpClient, objectMapper);
|
||||||
|
|
||||||
// 初始化 AI Card 管理器(message_type=card 且配置了模板 ID)
|
// 初始化 AI Card 管理器(message_type=card 且配置了模板 ID)
|
||||||
String cardTemplateId = getConfigString("card_template_id");
|
String cardTemplateId = getConfigString("card_template_id");
|
||||||
String messageType = getConfigString("message_type", "markdown");
|
String messageType = getConfigString("message_type", "markdown");
|
||||||
@ -165,12 +176,63 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
payload.put("conversationType", msg.getConversationType());
|
payload.put("conversationType", msg.getConversationType());
|
||||||
payload.put("sessionWebhook", msg.getSessionWebhook());
|
payload.put("sessionWebhook", msg.getSessionWebhook());
|
||||||
|
|
||||||
// 消息内容
|
// Dispatch by content type. DingTalk pre-transcribes voice into
|
||||||
if (msg.getText() != null) {
|
// MessageContent.recognition (same shape as WeCom voice.content),
|
||||||
payload.put("msgtype", "text");
|
// so we never need to run STT ourselves.
|
||||||
payload.put("text", Map.of("content", msg.getText().getContent() != null ? msg.getText().getContent() : ""));
|
// - audio (recognition) -> forwarded as text
|
||||||
|
// - picture / downloadCode -> webhook picture branch fetches bytes
|
||||||
|
// - richText (List<MessageContent>) -> rebuild the webhook richText payload
|
||||||
|
// - text -> text.content
|
||||||
|
com.dingtalk.open.app.api.models.bot.MessageContent body = msg.getContent();
|
||||||
|
String recognition = body != null ? body.getRecognition() : null;
|
||||||
|
String pictureDownloadCode = body != null ? body.getPictureDownloadCode() : null;
|
||||||
|
String downloadCode = body != null ? body.getDownloadCode() : null;
|
||||||
|
java.util.List<com.dingtalk.open.app.api.models.bot.MessageContent> richTextItems =
|
||||||
|
body != null ? body.getRichText() : null;
|
||||||
|
String msgtype = msg.getMsgtype();
|
||||||
|
|
||||||
|
if (recognition != null && !recognition.isBlank()) {
|
||||||
|
payload.put("msgtype", "audio");
|
||||||
|
payload.put("audio", Map.of("recognition", recognition));
|
||||||
|
} else if ("picture".equals(msgtype)
|
||||||
|
&& (pictureDownloadCode != null || downloadCode != null)) {
|
||||||
|
payload.put("msgtype", "picture");
|
||||||
|
Map<String, Object> picture = new java.util.HashMap<>();
|
||||||
|
if (pictureDownloadCode != null) picture.put("pictureDownloadCode", pictureDownloadCode);
|
||||||
|
if (downloadCode != null) picture.put("downloadCode", downloadCode);
|
||||||
|
payload.put("picture", picture);
|
||||||
|
} else if ("richText".equalsIgnoreCase(msgtype)
|
||||||
|
|| (richTextItems != null && !richTextItems.isEmpty())) {
|
||||||
|
// richText: rebuild a webhook-compatible richText.richText array.
|
||||||
|
// Without this branch, group @-mentions / formatted text /
|
||||||
|
// quoted replies all fall through to the default webhook else
|
||||||
|
// branch and get silently dropped — that's the "console
|
||||||
|
// doesn't receive" symptom users hit on first DingTalk test.
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>();
|
||||||
|
if (richTextItems != null) {
|
||||||
|
for (com.dingtalk.open.app.api.models.bot.MessageContent item : richTextItems) {
|
||||||
|
if (item == null) continue;
|
||||||
|
Map<String, Object> m = new java.util.HashMap<>();
|
||||||
|
if (item.getText() != null) m.put("text", item.getText());
|
||||||
|
if (item.getType() != null) m.put("type", item.getType());
|
||||||
|
if (item.getDownloadCode() != null) m.put("downloadCode", item.getDownloadCode());
|
||||||
|
if (item.getPictureDownloadCode() != null) {
|
||||||
|
m.put("pictureDownloadCode", item.getPictureDownloadCode());
|
||||||
|
}
|
||||||
|
if (!m.isEmpty()) items.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload.put("msgtype", "richText");
|
||||||
|
payload.put("richText", Map.of("richText", items));
|
||||||
|
} else if (msg.getText() != null) {
|
||||||
|
payload.put("msgtype", "text");
|
||||||
|
payload.put("text", Map.of("content",
|
||||||
|
msg.getText().getContent() != null ? msg.getText().getContent() : ""));
|
||||||
|
} else {
|
||||||
|
log.warn("[dingtalk-stream] unsupported msgtype={}, msgId={}, dropping",
|
||||||
|
msgtype, msg.getMsgId());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// richText 等复杂类型暂由 handleWebhook 内部处理
|
|
||||||
|
|
||||||
handleWebhook(payload);
|
handleWebhook(payload);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -226,10 +288,16 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取机器人编码
|
* 获取机器人编码。
|
||||||
|
* <p>
|
||||||
|
* 钉钉自建应用机器人的 robotCode 大多数情况等于 AppKey(即 client_id),
|
||||||
|
* 所以 robot_code 显式没配时直接 fallback 到 client_id —— 99% 的用户零配置就能用。
|
||||||
|
* 第三方应用 / 单独申请的机器人才需要手动填 robot_code。
|
||||||
*/
|
*/
|
||||||
public String getRobotCode() {
|
public String getRobotCode() {
|
||||||
return getConfigString("robot_code");
|
String configured = getConfigString("robot_code");
|
||||||
|
if (configured != null && !configured.isBlank()) return configured;
|
||||||
|
return getConfigString("client_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== StreamingChannelAdapter ====================
|
// ==================== StreamingChannelAdapter ====================
|
||||||
@ -308,9 +376,16 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
outTrackId, e.getMessage(), e);
|
outTrackId, e.getMessage(), e);
|
||||||
aiCardManager.failCard(outTrackId, e.getMessage());
|
aiCardManager.failCard(outTrackId, e.getMessage());
|
||||||
|
|
||||||
|
// Tag the returned content with the "[错误] " prefix so
|
||||||
|
// ChannelMessageRouter.isErrorReply flips status='error' on the
|
||||||
|
// persisted row and BaseAgent.sanitizeForLlm filters it out of
|
||||||
|
// the next turn's history. Without this, AICard partial output
|
||||||
|
// (e.g. LLM 400'd mid-stream) would re-enter the prompt as a
|
||||||
|
// valid assistant turn and re-trigger the same 400.
|
||||||
String partial = contentAccumulator.toString();
|
String partial = contentAccumulator.toString();
|
||||||
|
String errorPrefix = "[错误] AI Card streaming failed: " + e.getMessage();
|
||||||
if (!partial.isBlank()) {
|
if (!partial.isBlank()) {
|
||||||
return partial;
|
return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("AI Card streaming failed: " + e.getMessage(), e);
|
throw new RuntimeException("AI Card streaming failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
@ -379,12 +454,64 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
String downloadCode = (String) item.get("downloadCode");
|
String downloadCode = (String) item.get("downloadCode");
|
||||||
String pictureUrl = (String) item.get("pictureUrl");
|
String pictureUrl = (String) item.get("pictureUrl");
|
||||||
if (downloadCode != null || pictureUrl != null) {
|
if (downloadCode != null || pictureUrl != null) {
|
||||||
contentParts.add(MessageContentPart.image(downloadCode, pictureUrl));
|
MessageContentPart imgPart = MessageContentPart.image(downloadCode, pictureUrl);
|
||||||
|
// Same as the standalone picture branch: vision needs bytes, not just an opaque
|
||||||
|
// downloadCode. Best-effort fetch; falls back to image-with-id if download fails.
|
||||||
|
if (downloadCode != null && !downloadCode.isBlank()) {
|
||||||
|
DownloadedMedia media = downloadDingTalkMedia(downloadCode);
|
||||||
|
if (media != null) {
|
||||||
|
if (media.path() != null) imgPart.setPath(media.path());
|
||||||
|
// Only override richText pictureUrl if we have a local-served URL.
|
||||||
|
if (media.url() != null) imgPart.setFileUrl(media.url());
|
||||||
|
if (media.fileName() != null) imgPart.setFileName(media.fileName());
|
||||||
|
if (media.contentType() != null) imgPart.setContentType(media.contentType());
|
||||||
|
if (media.size() > 0) imgPart.setFileSize(media.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentParts.add(imgPart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
textContent = textBuilder.toString().trim();
|
textContent = textBuilder.toString().trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if ("audio".equals(msgtype)) {
|
||||||
|
// 钉钉服务端已经把语音转写好放在 audio.recognition 里。这跟企业微信
|
||||||
|
// 的 voice.content 是一个模式 —— webhook 自带 ASR 文本,0 STT 调用。
|
||||||
|
Map<String, Object> audioBody = (Map<String, Object>) payload.get("audio");
|
||||||
|
String recognition = audioBody != null ? (String) audioBody.get("recognition") : null;
|
||||||
|
if (recognition != null && !recognition.isBlank()) {
|
||||||
|
textContent = recognition.trim();
|
||||||
|
contentParts.add(MessageContentPart.text(textContent));
|
||||||
|
}
|
||||||
|
} else if ("picture".equals(msgtype)) {
|
||||||
|
// 单图消息。钉钉只给 downloadCode(不透明 ID),vision 模型直接读不了。
|
||||||
|
// 调 /v1.0/robot/messageFiles/download 拿临时 URL,下载字节存本地,把
|
||||||
|
// path 塞进 MessageContentPart 让多模态 LLM 能从磁盘读图。下载失败仍保留
|
||||||
|
// image part(带 downloadCode)作为占位 — 至少消息不丢。
|
||||||
|
Map<String, Object> pictureBody = (Map<String, Object>) payload.get("picture");
|
||||||
|
String picDownloadCode = pictureBody != null ? (String) pictureBody.get("pictureDownloadCode") : null;
|
||||||
|
String dlCode = pictureBody != null ? (String) pictureBody.get("downloadCode") : null;
|
||||||
|
// Prefer the universal `downloadCode` — that's what the new
|
||||||
|
// api.dingtalk.com/v1.0/robot/messageFiles/download API expects.
|
||||||
|
// `pictureDownloadCode` is the legacy field for the old
|
||||||
|
// oapi.dingtalk.com endpoint; passing it to the new API gets
|
||||||
|
// HTTP 500 "unknownError".
|
||||||
|
String code = dlCode != null ? dlCode : picDownloadCode;
|
||||||
|
if (code != null && !code.isBlank()) {
|
||||||
|
DownloadedMedia media = downloadDingTalkMedia(code);
|
||||||
|
// mediaId stays as downloadCode for traceability; fileUrl is the
|
||||||
|
// browser-renderable URL (or null on download failure).
|
||||||
|
MessageContentPart imgPart = MessageContentPart.image(
|
||||||
|
code, media != null ? media.url() : null);
|
||||||
|
if (media != null) {
|
||||||
|
if (media.path() != null) imgPart.setPath(media.path());
|
||||||
|
if (media.fileName() != null) imgPart.setFileName(media.fileName());
|
||||||
|
if (media.contentType() != null) imgPart.setContentType(media.contentType());
|
||||||
|
if (media.size() > 0) imgPart.setFileSize(media.size());
|
||||||
|
}
|
||||||
|
contentParts.add(imgPart);
|
||||||
|
textContent = "[图片]";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 默认 text 消息
|
// 默认 text 消息
|
||||||
Map<String, Object> msgBody = (Map<String, Object>) payload.get("text");
|
Map<String, Object> msgBody = (Map<String, Object>) payload.get("text");
|
||||||
@ -424,11 +551,17 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
.content(content)
|
.content(content)
|
||||||
.contentType(contentParts.stream().anyMatch(p -> "image".equals(p.getType())) ? "image" : "text")
|
.contentType(contentParts.stream().anyMatch(p -> "image".equals(p.getType())) ? "image" : "text")
|
||||||
.contentParts(contentParts)
|
.contentParts(contentParts)
|
||||||
|
.inputMode("audio".equals(msgtype) ? "voice" : "text")
|
||||||
.timestamp(LocalDateTime.now())
|
.timestamp(LocalDateTime.now())
|
||||||
.rawPayload(payload)
|
.rawPayload(payload)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
message.setReplyToken(sessionWebhook);
|
// Reply token 编码上下文:sessionWebhook 走 Markdown 文本(无附件),
|
||||||
|
// userId / conversationId 走 Robot API(能传 sampleFile / sampleImageMsg 真附件)。
|
||||||
|
// 出站时 sendContentParts 解析这个 token 决定路径。格式是不透明字符串,sendMessage
|
||||||
|
// 也能处理 —— 如果只是发 markdown 就直接走 webhook,不需要 access_token。
|
||||||
|
String dtReplyToken = encodeReplyToken(sessionWebhook, senderId, conversationId, conversationType);
|
||||||
|
message.setReplyToken(dtReplyToken);
|
||||||
onMessage(message);
|
onMessage(message);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -442,19 +575,22 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
log.warn("[dingtalk] Channel not started, cannot send message");
|
log.warn("[dingtalk] Channel not started, cannot send message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Delegate to sendContentParts so the URL sniff for /api/v1/files/generated/ runs on
|
||||||
|
// streaming-response markdown too — processStreamAsText's renderAndSend → sendMessage
|
||||||
|
// path used to bypass it. content==null/blank short-circuits to no-op.
|
||||||
|
if (content == null || content.isEmpty()) return;
|
||||||
|
sendContentParts(targetId, List.of(MessageContentPart.text(content)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing sessionWebhook reply path, factored out so sendMessage and sendContentParts share it. */
|
||||||
|
private void sendMarkdownViaWebhook(String webhookUrl, String content) {
|
||||||
String messageType = getConfigString("message_type", "markdown");
|
String messageType = getConfigString("message_type", "markdown");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody;
|
String jsonBody;
|
||||||
// card 模式的文本回退也使用 markdown 格式
|
|
||||||
if ("markdown".equals(messageType) || "card".equals(messageType)) {
|
if ("markdown".equals(messageType) || "card".equals(messageType)) {
|
||||||
jsonBody = objectMapper.writeValueAsString(Map.of(
|
jsonBody = objectMapper.writeValueAsString(Map.of(
|
||||||
"msgtype", "markdown",
|
"msgtype", "markdown",
|
||||||
"markdown", Map.of(
|
"markdown", Map.of("title", "MateClaw", "text", content)
|
||||||
"title", "MateClaw",
|
|
||||||
"text", content
|
|
||||||
)
|
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
jsonBody = objectMapper.writeValueAsString(Map.of(
|
jsonBody = objectMapper.writeValueAsString(Map.of(
|
||||||
@ -462,20 +598,17 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
"text", Map.of("content", content)
|
"text", Map.of("content", content)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(targetId))
|
.uri(URI.create(webhookUrl))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
log.warn("[dingtalk] Send message failed: status={}, body={}", response.statusCode(), response.body());
|
log.warn("[dingtalk] Send message failed: status={}, body={}", response.statusCode(), response.body());
|
||||||
} else {
|
} else {
|
||||||
log.debug("[dingtalk] Message sent successfully via sessionWebhook");
|
log.debug("[dingtalk] Message sent successfully via sessionWebhook");
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[dingtalk] Failed to send message: {}", e.getMessage(), e);
|
log.error("[dingtalk] Failed to send message: {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
@ -488,26 +621,388 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 钉钉 sessionWebhook 只支持 text/markdown/link/actionCard 等类型。
|
ReplyContext ctx = decodeReplyToken(targetId);
|
||||||
// 图片需要通过上传 media 后发送,这里暂时将媒体内容以 Markdown 图片语法发出。
|
|
||||||
|
// Phase 1 — walk parts. Text concatenates into the markdown reply; image/file
|
||||||
|
// parts (with resolvable bytes) become upload jobs. Text segments are also
|
||||||
|
// scanned for /api/v1/files/generated/{id} URLs (DocxRenderTool output etc.):
|
||||||
|
// hits are pulled out of the cache and queued as file uploads, with the URL
|
||||||
|
// replaced inline by a "📎 filename" badge so the user doesn't see a stale link.
|
||||||
|
List<UploadJob> uploadJobs = new ArrayList<>();
|
||||||
StringBuilder markdown = new StringBuilder();
|
StringBuilder markdown = new StringBuilder();
|
||||||
for (MessageContentPart part : parts) {
|
for (MessageContentPart part : parts) {
|
||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
switch (part.getType()) {
|
switch (part.getType()) {
|
||||||
case "text" -> { if (part.getText() != null) markdown.append(part.getText()); }
|
case "text" -> {
|
||||||
|
String text = part.getText();
|
||||||
|
if (text == null || text.isEmpty()) break;
|
||||||
|
markdown.append(sniffGeneratedFiles(text, uploadJobs));
|
||||||
|
}
|
||||||
case "image" -> {
|
case "image" -> {
|
||||||
if (part.getFileUrl() != null) {
|
byte[] bytes = resolveBytes(part);
|
||||||
|
String fileName = part.getFileName() != null ? part.getFileName() : "image.png";
|
||||||
|
if (bytes != null) {
|
||||||
|
uploadJobs.add(new UploadJob(bytes, fileName, "image"));
|
||||||
|
} else if (part.getFileUrl() != null) {
|
||||||
markdown.append("\n.append(part.getFileUrl()).append(")\n");
|
markdown.append("\n.append(part.getFileUrl()).append(")\n");
|
||||||
} else {
|
} else {
|
||||||
markdown.append("\n[图片]\n");
|
markdown.append("\n[图片]\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "file" -> markdown.append("\n[文件: ").append(part.getFileName() != null ? part.getFileName() : "").append("]\n");
|
case "file" -> {
|
||||||
|
byte[] bytes = resolveBytes(part);
|
||||||
|
String fileName = part.getFileName() != null ? part.getFileName() : "file.bin";
|
||||||
|
if (bytes != null) {
|
||||||
|
uploadJobs.add(new UploadJob(bytes, fileName, "file"));
|
||||||
|
} else {
|
||||||
|
markdown.append("\n[文件: ").append(fileName).append("]\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
default -> { if (part.getText() != null) markdown.append(part.getText()); }
|
default -> { if (part.getText() != null) markdown.append(part.getText()); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(targetId, markdown.toString().trim());
|
// Phase 2 — send the markdown body (if any) via sessionWebhook.
|
||||||
|
String md = markdown.toString().trim();
|
||||||
|
if (!md.isEmpty()) {
|
||||||
|
if (ctx.webhook != null && ctx.webhook.startsWith("http")) {
|
||||||
|
sendMarkdownViaWebhook(ctx.webhook, md);
|
||||||
|
} else {
|
||||||
|
// No sessionWebhook — fall back to Robot API text send. proactiveSend()
|
||||||
|
// re-decodes the same token and routes by userId/conv. Don't call back into
|
||||||
|
// sendMessage() here — sendMessage() now delegates to sendContentParts(),
|
||||||
|
// which would recurse infinitely.
|
||||||
|
proactiveSend(ctx.userId != null ? ctx.userId : targetId, md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3 — upload + send media jobs via Robot API. Skips silently and falls back
|
||||||
|
// to a markdown placeholder if Robot API isn't usable (no robot_code, or
|
||||||
|
// no userId / conversationId in the reply context).
|
||||||
|
if (!uploadJobs.isEmpty()) {
|
||||||
|
sendUploadJobs(ctx, uploadJobs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a text fragment for `/api/v1/files/generated/{id}` URLs; return the rewritten text
|
||||||
|
* with each hit replaced by "📎 filename" and the corresponding bytes pushed onto jobs. */
|
||||||
|
private String sniffGeneratedFiles(String text, List<UploadJob> jobs) {
|
||||||
|
if (generatedFileCache == null) return text;
|
||||||
|
java.util.regex.Matcher m = GENERATED_URL_PATTERN.matcher(text);
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
while (m.find()) {
|
||||||
|
String id = m.group(1);
|
||||||
|
var entry = generatedFileCache.get(id).orElse(null);
|
||||||
|
if (entry != null) {
|
||||||
|
String type = isImageMime(entry.mimeType()) ? "image" : "file";
|
||||||
|
jobs.add(new UploadJob(entry.bytes(), entry.filename(), type));
|
||||||
|
m.appendReplacement(out, java.util.regex.Matcher.quoteReplacement("📎 " + entry.filename()));
|
||||||
|
} else {
|
||||||
|
// Cache miss / expired — leave the URL alone so user can still try clicking.
|
||||||
|
m.appendReplacement(out, java.util.regex.Matcher.quoteReplacement(m.group(0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.appendTail(out);
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final java.util.regex.Pattern GENERATED_URL_PATTERN =
|
||||||
|
java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)");
|
||||||
|
|
||||||
|
private static boolean isImageMime(String mimeType) {
|
||||||
|
return mimeType != null && mimeType.toLowerCase().startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull bytes from disk (part.path) or from the in-memory generated-file cache (part.fileUrl). */
|
||||||
|
private byte[] resolveBytes(MessageContentPart part) {
|
||||||
|
if (part == null) return null;
|
||||||
|
// Disk path: trust the agent's filesystem write, but guard against blowup on big files.
|
||||||
|
String path = part.getPath();
|
||||||
|
if (path != null && !path.isBlank()) {
|
||||||
|
try {
|
||||||
|
java.nio.file.Path p = java.nio.file.Paths.get(path);
|
||||||
|
if (java.nio.file.Files.exists(p) && java.nio.file.Files.size(p) <= DingTalkMediaUploader.MAX_FILE_BYTES) {
|
||||||
|
return java.nio.file.Files.readAllBytes(p);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[dingtalk] resolveBytes from path failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GeneratedFileCache URL: /api/v1/files/generated/{id}
|
||||||
|
String url = part.getFileUrl();
|
||||||
|
if (url != null && generatedFileCache != null) {
|
||||||
|
java.util.regex.Matcher m = GENERATED_URL_PATTERN.matcher(url);
|
||||||
|
if (m.find()) {
|
||||||
|
var entry = generatedFileCache.get(m.group(1)).orElse(null);
|
||||||
|
if (entry != null) return entry.bytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records to keep sendContentParts readable. */
|
||||||
|
private record UploadJob(byte[] bytes, String fileName, String type) {}
|
||||||
|
|
||||||
|
/** Decoded reply context, populated from {@link #encodeReplyToken}. */
|
||||||
|
private static class ReplyContext {
|
||||||
|
String webhook;
|
||||||
|
String userId;
|
||||||
|
String convId;
|
||||||
|
/** "1" = one-to-one, "2" = group; empty/null = unknown. */
|
||||||
|
String chatType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply token is JSON now: {wh, user, conv, ct}. Old bare-URL tokens (e.g. from
|
||||||
|
* an in-flight conversation that started before this change) are still accepted —
|
||||||
|
* the leading-{ check tells JSON apart from a raw HTTP URL.
|
||||||
|
*/
|
||||||
|
private String encodeReplyToken(String webhook, String userId, String conversationId, String chatType) {
|
||||||
|
java.util.LinkedHashMap<String, String> ctx = new java.util.LinkedHashMap<>();
|
||||||
|
if (webhook != null) ctx.put("wh", webhook);
|
||||||
|
if (userId != null) ctx.put("user", userId);
|
||||||
|
if (conversationId != null) ctx.put("conv", conversationId);
|
||||||
|
if (chatType != null) ctx.put("ct", chatType);
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(ctx);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return webhook != null ? webhook : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private ReplyContext decodeReplyToken(String token) {
|
||||||
|
ReplyContext c = new ReplyContext();
|
||||||
|
if (token == null || token.isBlank()) return c;
|
||||||
|
String trimmed = token.trim();
|
||||||
|
if (!trimmed.startsWith("{")) {
|
||||||
|
// Legacy bare sessionWebhook URL or userId — treat http-prefixed as webhook,
|
||||||
|
// otherwise as userId for proactive send.
|
||||||
|
if (trimmed.startsWith("http")) c.webhook = trimmed;
|
||||||
|
else c.userId = trimmed;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> m = objectMapper.readValue(trimmed, Map.class);
|
||||||
|
c.webhook = (String) m.get("wh");
|
||||||
|
c.userId = (String) m.get("user");
|
||||||
|
c.convId = (String) m.get("conv");
|
||||||
|
c.chatType = (String) m.get("ct");
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.webhook = trimmed;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload each job to DingTalk's media endpoint, then send a sampleFile / sampleImageMsg
|
||||||
|
* via the Robot API. Falls back gracefully (one log line per skipped job) if the channel
|
||||||
|
* isn't configured for proactive send (no robot_code, or no usable target id).
|
||||||
|
*/
|
||||||
|
private void sendUploadJobs(ReplyContext ctx, List<UploadJob> jobs) {
|
||||||
|
String robotCode = getRobotCode();
|
||||||
|
if (robotCode == null || robotCode.isBlank()) {
|
||||||
|
log.warn("[dingtalk] {} attachment(s) skipped: robot_code not configured (Robot API needed for sampleFile / sampleImageMsg)", jobs.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean isGroup = "2".equals(ctx.chatType) && ctx.convId != null && !ctx.convId.isBlank();
|
||||||
|
boolean isOneToOne = ctx.userId != null && !ctx.userId.isBlank();
|
||||||
|
if (!isGroup && !isOneToOne) {
|
||||||
|
log.warn("[dingtalk] {} attachment(s) skipped: no usable userId / conversationId in reply context", jobs.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = getDingTalkAccessToken();
|
||||||
|
if (accessToken == null) {
|
||||||
|
log.warn("[dingtalk] {} attachment(s) skipped: failed to get access_token", jobs.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (UploadJob job : jobs) {
|
||||||
|
try {
|
||||||
|
String mediaId = "image".equals(job.type)
|
||||||
|
? mediaUploader.uploadImage(job.bytes, job.fileName, accessToken)
|
||||||
|
: mediaUploader.uploadFile(job.bytes, job.fileName, accessToken);
|
||||||
|
if (mediaId == null) {
|
||||||
|
log.warn("[dingtalk] upload failed for {}, attachment skipped", job.fileName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String msgKey;
|
||||||
|
String msgParam;
|
||||||
|
if ("image".equals(job.type)) {
|
||||||
|
msgKey = "sampleImageMsg";
|
||||||
|
msgParam = objectMapper.writeValueAsString(Map.of("photoURL", mediaId));
|
||||||
|
} else {
|
||||||
|
msgKey = "sampleFile";
|
||||||
|
msgParam = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"mediaId", mediaId,
|
||||||
|
"fileName", job.fileName,
|
||||||
|
"fileType", inferFileType(job.fileName)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||||
|
body.put("robotCode", robotCode);
|
||||||
|
body.put("msgKey", msgKey);
|
||||||
|
body.put("msgParam", msgParam);
|
||||||
|
String endpoint;
|
||||||
|
if (isGroup) {
|
||||||
|
body.put("openConversationId", ctx.convId);
|
||||||
|
endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
|
||||||
|
} else {
|
||||||
|
body.put("userIds", List.of(ctx.userId));
|
||||||
|
endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(endpoint))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-acs-dingtalk-access-token", accessToken)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.warn("[dingtalk] sampleFile send HTTP {}: {}", response.statusCode(), response.body());
|
||||||
|
} else {
|
||||||
|
log.info("[dingtalk] attachment sent: {} ({})", job.fileName, job.type);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[dingtalk] sendUploadJobs error for {}: {}", job.fileName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sampleFile expects fileType as a short extension string (docx / pdf / xlsx / ...). */
|
||||||
|
private static String inferFileType(String fileName) {
|
||||||
|
if (fileName == null) return "";
|
||||||
|
int dot = fileName.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == fileName.length() - 1) return "";
|
||||||
|
return fileName.substring(dot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 入站媒体下载 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a media download: local path (for vision pipeline) + HTTP URL
|
||||||
|
* (for browser rendering) + metadata to populate MessageContentPart so the
|
||||||
|
* UI doesn't fall back to "unknown" filenames.
|
||||||
|
*/
|
||||||
|
private record DownloadedMedia(String path, String url, String fileName,
|
||||||
|
String contentType, long size) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把入站消息里的 downloadCode 解析成本地文件路径 + 浏览器可访问的 URL。
|
||||||
|
* <p>
|
||||||
|
* 钉钉的 inbound 图片 / 文件不直接给字节,只给 downloadCode(不透明的内部 ID)。
|
||||||
|
* 要让 vision 模型读图、或落盘存档给用户回看,必须先调
|
||||||
|
* {@code /v1.0/robot/messageFiles/download} 拿一个短期 downloadUrl,再 GET 拉字节。
|
||||||
|
* <p>
|
||||||
|
* 字节落两份:磁盘 (~/.mateclaw/media/dingtalk/) 给 vision 读 path,
|
||||||
|
* GeneratedFileCache 给 UI 通过 /api/v1/files/generated/{id} 渲染(10 min TTL)。
|
||||||
|
* <p>
|
||||||
|
* 失败 / 没配 robot_code / token 拿不到 → 返回 null,调用方继续把 downloadCode 当占位用。
|
||||||
|
*/
|
||||||
|
private DownloadedMedia downloadDingTalkMedia(String downloadCode) {
|
||||||
|
if (downloadCode == null || downloadCode.isBlank()) return null;
|
||||||
|
String robotCode = getRobotCode();
|
||||||
|
if (robotCode == null || robotCode.isBlank()) {
|
||||||
|
log.debug("[dingtalk] media download skipped: no robot_code");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String accessToken = getDingTalkAccessToken();
|
||||||
|
if (accessToken == null) {
|
||||||
|
log.debug("[dingtalk] media download skipped: no access_token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: ask DingTalk for a short-TTL download URL
|
||||||
|
String body = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"downloadCode", downloadCode,
|
||||||
|
"robotCode", robotCode
|
||||||
|
));
|
||||||
|
HttpRequest infoReq = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://api.dingtalk.com/v1.0/robot/messageFiles/download"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-acs-dingtalk-access-token", accessToken)
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> infoResp = httpClient.send(infoReq, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (infoResp.statusCode() != 200) {
|
||||||
|
// Mask the codes so the log doesn't carry full credentials but is still
|
||||||
|
// useful for diagnosing whether we sent the right shape.
|
||||||
|
String codeTail = downloadCode.length() > 8
|
||||||
|
? "..." + downloadCode.substring(downloadCode.length() - 8) : downloadCode;
|
||||||
|
String robotTail = robotCode.length() > 6
|
||||||
|
? "..." + robotCode.substring(robotCode.length() - 6) : robotCode;
|
||||||
|
log.warn("[dingtalk] media download info HTTP {} (downloadCode={}, robotCode={}): {}",
|
||||||
|
infoResp.statusCode(), codeTail, robotTail, infoResp.body());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<?, ?> infoData = objectMapper.readValue(infoResp.body(), Map.class);
|
||||||
|
String downloadUrl = (String) infoData.get("downloadUrl");
|
||||||
|
if (downloadUrl == null || downloadUrl.isBlank()) {
|
||||||
|
log.warn("[dingtalk] media download info missing downloadUrl: {}", infoResp.body());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: pull bytes from the signed URL
|
||||||
|
HttpRequest dlReq = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(downloadUrl))
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<byte[]> dlResp = httpClient.send(dlReq, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (dlResp.statusCode() != 200) {
|
||||||
|
log.warn("[dingtalk] media download fetch HTTP {}", dlResp.statusCode());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] bytes = dlResp.body();
|
||||||
|
|
||||||
|
// Step 3: persist to ~/.mateclaw/media/dingtalk/. Filename derived from
|
||||||
|
// downloadCode (sanitised) + extension inferred from Content-Type so vision
|
||||||
|
// pipelines that key on extension still work.
|
||||||
|
String contentType = dlResp.headers().firstValue("Content-Type").orElse("image/jpeg");
|
||||||
|
String ext = "jpg";
|
||||||
|
if (contentType.contains("png")) ext = "png";
|
||||||
|
else if (contentType.contains("gif")) ext = "gif";
|
||||||
|
else if (contentType.contains("webp")) ext = "webp";
|
||||||
|
else if (contentType.contains("pdf")) ext = "pdf";
|
||||||
|
|
||||||
|
java.nio.file.Path mediaDir = java.nio.file.Paths.get(
|
||||||
|
System.getProperty("user.home"), ".mateclaw", "media", "dingtalk");
|
||||||
|
java.nio.file.Files.createDirectories(mediaDir);
|
||||||
|
String safeCode = downloadCode.replaceAll("[^a-zA-Z0-9_-]", "_");
|
||||||
|
if (safeCode.length() > 32) safeCode = safeCode.substring(safeCode.length() - 32);
|
||||||
|
java.nio.file.Path filePath = mediaDir.resolve(safeCode + "." + ext);
|
||||||
|
java.nio.file.Files.write(filePath, bytes);
|
||||||
|
|
||||||
|
// Step 4: also stuff into GeneratedFileCache so the UI can render the image
|
||||||
|
// via /api/v1/files/generated/{id}. Best-effort — failure here doesn't kill
|
||||||
|
// the vision pipeline (path is still useful).
|
||||||
|
String url = null;
|
||||||
|
if (generatedFileCache != null) {
|
||||||
|
try {
|
||||||
|
String fileName = safeCode + "." + ext;
|
||||||
|
String id = generatedFileCache.put(bytes, fileName, contentType);
|
||||||
|
url = "/api/v1/files/generated/" + id;
|
||||||
|
} catch (Exception cacheEx) {
|
||||||
|
log.debug("[dingtalk] cache put failed: {}", cacheEx.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileName = safeCode + "." + ext;
|
||||||
|
log.info("[dingtalk] media downloaded: {} ({} bytes, {}) url={}",
|
||||||
|
fileName, bytes.length, contentType, url);
|
||||||
|
return new DownloadedMedia(
|
||||||
|
filePath.toAbsolutePath().toString(), url, fileName, contentType, bytes.length);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[dingtalk] media download failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 主动推送 ====================
|
// ==================== 主动推送 ====================
|
||||||
@ -531,19 +1026,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetId.startsWith("http")) {
|
// targetId may be: sessionWebhook URL, raw userId, or our JSON-encoded reply token
|
||||||
// sessionWebhook 直接发送
|
// (after the replyToken refactor). Decode unifies all three.
|
||||||
sendMessage(targetId, content);
|
ReplyContext ctx = decodeReplyToken(targetId);
|
||||||
|
if (ctx.webhook != null && ctx.webhook.startsWith("http")) {
|
||||||
|
sendMarkdownViaWebhook(ctx.webhook, content);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 Robot API 发送:获取 access_token 后调用 /v1.0/robot/oToMessages/batchSend
|
// 通过 Robot API 发送:获取 access_token 后调用 /v1.0/robot/oToMessages/batchSend
|
||||||
String robotCode = getConfigString("robot_code");
|
String robotCode = getRobotCode();
|
||||||
if (robotCode == null || robotCode.isBlank()) {
|
if (robotCode == null || robotCode.isBlank()) {
|
||||||
log.warn("[dingtalk] robot_code not configured, falling back to sendMessage");
|
log.warn("[dingtalk] robot_code not configured, no usable webhook either — proactive send skipped");
|
||||||
sendMessage(targetId, content);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String resolvedUserId = ctx.userId != null ? ctx.userId : targetId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String accessToken = getDingTalkAccessToken();
|
String accessToken = getDingTalkAccessToken();
|
||||||
@ -565,7 +1062,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
|
|
||||||
String jsonBody = objectMapper.writeValueAsString(Map.of(
|
String jsonBody = objectMapper.writeValueAsString(Map.of(
|
||||||
"robotCode", robotCode,
|
"robotCode", robotCode,
|
||||||
"userIds", List.of(targetId),
|
"userIds", List.of(resolvedUserId),
|
||||||
"msgKey", msgKey,
|
"msgKey", msgKey,
|
||||||
"msgParam", objectMapper.writeValueAsString(msgParam)
|
"msgParam", objectMapper.writeValueAsString(msgParam)
|
||||||
));
|
));
|
||||||
|
|||||||
@ -0,0 +1,146 @@
|
|||||||
|
package vip.mate.channel.dingtalk;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉媒体文件上传 helper
|
||||||
|
* <p>
|
||||||
|
* 钉钉 SampleFile / SampleImageMsg 消息要求 mediaId,必须先把字节传到钉钉的 media
|
||||||
|
* 存储拿到 mediaId(3 天有效)。
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* POST https://oapi.dingtalk.com/media/upload?access_token=XXX&type=file|image|voice|video
|
||||||
|
* Content-Type: multipart/form-data
|
||||||
|
*
|
||||||
|
* form field: media (file content)
|
||||||
|
*
|
||||||
|
* ← { "errcode": 0, "media_id": "@xxx", "type": "file", "created_at": ... }
|
||||||
|
* </pre>
|
||||||
|
* <p>
|
||||||
|
* 单文件上限 20 MB(钉钉服务端限制;客户端上传时 timeout 30s 已经够大文件用)。
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DingTalkMediaUploader {
|
||||||
|
|
||||||
|
private static final String UPLOAD_URL = "https://oapi.dingtalk.com/media/upload";
|
||||||
|
|
||||||
|
/** 钉钉单文件大小上限 20 MB —— 不是我们的政策,是钉钉服务端拒绝上传更大的,提前拦下省一次失败往返 */
|
||||||
|
public static final int MAX_FILE_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public String uploadFile(byte[] bytes, String fileName, String accessToken) {
|
||||||
|
return upload(bytes, fileName, "file", accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String uploadImage(byte[] bytes, String fileName, String accessToken) {
|
||||||
|
return upload(bytes, fileName, "image", accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param type "file" / "image" / "voice" / "video"
|
||||||
|
* @return mediaId(带 `@` 前缀);任何失败返回 null,调用方自己决定怎么回退
|
||||||
|
*/
|
||||||
|
private String upload(byte[] bytes, String fileName, String type, String accessToken) {
|
||||||
|
if (bytes == null || bytes.length == 0) {
|
||||||
|
log.warn("[dingtalk-upload] empty bytes for {} ({})", type, fileName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (bytes.length > MAX_FILE_BYTES) {
|
||||||
|
log.warn("[dingtalk-upload] file too large: {} bytes (limit {} bytes)",
|
||||||
|
bytes.length, MAX_FILE_BYTES);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (accessToken == null || accessToken.isBlank()) {
|
||||||
|
log.warn("[dingtalk-upload] missing access_token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String boundary = "----DingTalkBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||||
|
byte[] body = buildMultipartBody(boundary, fileName, bytes);
|
||||||
|
|
||||||
|
String url = UPLOAD_URL + "?access_token=" + accessToken + "&type=" + type;
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.warn("[dingtalk-upload] HTTP {}: {}", response.statusCode(), response.body());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<?, ?> result = objectMapper.readValue(response.body(), Map.class);
|
||||||
|
Integer errcode = result.get("errcode") instanceof Number n ? n.intValue() : null;
|
||||||
|
if (errcode != null && errcode != 0) {
|
||||||
|
log.warn("[dingtalk-upload] errcode={}, errmsg={}", errcode, result.get("errmsg"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String mediaId = (String) result.get("media_id");
|
||||||
|
if (mediaId == null || mediaId.isBlank()) {
|
||||||
|
log.warn("[dingtalk-upload] empty media_id in response: {}", response.body());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("[dingtalk-upload] uploaded {} ({} bytes) → mediaId suffix=...{}",
|
||||||
|
type, bytes.length,
|
||||||
|
mediaId.length() > 8 ? mediaId.substring(mediaId.length() - 8) : mediaId);
|
||||||
|
return mediaId;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[dingtalk-upload] failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手写最小 multipart 体,避免引入第三方 multipart 库。
|
||||||
|
* 字段名 "media",content-type application/octet-stream(钉钉只看字节,不嫌弃 mime)。
|
||||||
|
*/
|
||||||
|
private byte[] buildMultipartBody(String boundary, String fileName, byte[] fileBytes) {
|
||||||
|
String safeFileName = fileName != null && !fileName.isBlank() ? fileName : "upload.bin";
|
||||||
|
// Use a simple ASCII-only fallback if the filename has non-ASCII chars to avoid header
|
||||||
|
// encoding issues; the actual bytes are unaffected.
|
||||||
|
String headerSafe = safeFileName.replaceAll("[\\r\\n\"]", "_");
|
||||||
|
|
||||||
|
String prefix = "--" + boundary + "\r\n"
|
||||||
|
+ "Content-Disposition: form-data; name=\"media\"; filename=\"" + headerSafe + "\"\r\n"
|
||||||
|
+ "Content-Type: application/octet-stream\r\n\r\n";
|
||||||
|
String suffix = "\r\n--" + boundary + "--\r\n";
|
||||||
|
|
||||||
|
byte[] prefixBytes = prefix.getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] suffixBytes = suffix.getBytes(StandardCharsets.UTF_8);
|
||||||
|
List<byte[]> chunks = new ArrayList<>(3);
|
||||||
|
chunks.add(prefixBytes);
|
||||||
|
chunks.add(fileBytes);
|
||||||
|
chunks.add(suffixBytes);
|
||||||
|
|
||||||
|
int total = prefixBytes.length + fileBytes.length + suffixBytes.length;
|
||||||
|
byte[] body = new byte[total];
|
||||||
|
int pos = 0;
|
||||||
|
for (byte[] c : chunks) {
|
||||||
|
System.arraycopy(c, 0, body, pos, c.length);
|
||||||
|
pos += c.length;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
package vip.mate.channel.feishu;
|
||||||
|
|
||||||
|
import com.lark.oapi.scene.registration.AccessDeniedException;
|
||||||
|
import com.lark.oapi.scene.registration.ExpiredException;
|
||||||
|
import com.lark.oapi.scene.registration.QRCodeInfo;
|
||||||
|
import com.lark.oapi.scene.registration.RegisterApp;
|
||||||
|
import com.lark.oapi.scene.registration.RegisterAppOptions;
|
||||||
|
import com.lark.oapi.scene.registration.RegisterAppResult;
|
||||||
|
import com.lark.oapi.scene.registration.StatusChangeInfo;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞书"一键应用注册"服务
|
||||||
|
* <p>
|
||||||
|
* 包装 oapi-sdk 2.6.x 的 {@link RegisterApp#register} 流程:用户扫码后飞书侧自动建好
|
||||||
|
* 自建应用并把 client_id/client_secret 回传,省掉用户手动到开放平台建应用 + 复制 ID/Secret
|
||||||
|
* 的步骤。
|
||||||
|
* <p>
|
||||||
|
* SDK 的 register() 是阻塞的(内部轮询直到扫码确认或超时),所以这里用一个工作线程执行,
|
||||||
|
* QR URL 通过 onQRCode 回调写到 session,前端按 token 轮询 status 端点查最终结果。
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class FeishuAppRegistrationService {
|
||||||
|
|
||||||
|
/** 会话最长存活时间:5 分钟(QR 自身约 3 分钟过期,留足缓冲让前端读到 expired 状态) */
|
||||||
|
private static final long SESSION_TTL_MS = 5 * 60_000L;
|
||||||
|
|
||||||
|
/** session_id → 注册会话状态 */
|
||||||
|
private final ConcurrentHashMap<String, RegistrationSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动一次注册流程:spawn 后台线程跑 SDK register(),立即返回 sessionId。
|
||||||
|
* QR URL 在 onQRCode 回调里被写入 session,调用方需要轮询 {@link #getSession(String)} 拿。
|
||||||
|
*
|
||||||
|
* @param domainKey "feishu"(国内)或 "lark"(国际) —— 仅用作记录,
|
||||||
|
* SDK 内部会把 domain/larkDomain 都试一遍(QR 二维码扫码端
|
||||||
|
* 本来就支持飞书 / Lark 两种 App 互通),所以这里**不传**
|
||||||
|
* 域名让 SDK 自动用默认 accounts.feishu.cn / accounts.larksuite.com。
|
||||||
|
* 之前误传开放平台域名 https://open.feishu.cn 会让 SDK 拿到
|
||||||
|
* HTML 当 JSON 解析直接 invalid_response。
|
||||||
|
* @return 新创建的 sessionId,前端拿这个去查询 QR / status
|
||||||
|
*/
|
||||||
|
public String begin(String domainKey) {
|
||||||
|
evictExpiredSessions();
|
||||||
|
|
||||||
|
String sessionId = UUID.randomUUID().toString();
|
||||||
|
RegistrationSession session = new RegistrationSession(sessionId);
|
||||||
|
sessions.put(sessionId, session);
|
||||||
|
|
||||||
|
// 不传 .domain()/.larkDomain():SDK 默认用 accounts.feishu.cn 和 accounts.larksuite.com
|
||||||
|
// 这两个是注册账号端点,open.feishu.cn 是开放 API 端点 —— 完全不是一个东西
|
||||||
|
RegisterAppOptions options = RegisterAppOptions.newBuilder()
|
||||||
|
.source("mateclaw")
|
||||||
|
.onQRCode(qr -> session.onQRCode(qr))
|
||||||
|
.onStatusChange(status -> session.onStatusChange(status))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Thread worker = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
RegisterAppResult result = RegisterApp.register(options);
|
||||||
|
session.onSuccess(result);
|
||||||
|
} catch (ExpiredException e) {
|
||||||
|
session.onTerminal(Status.EXPIRED, "QR code expired");
|
||||||
|
} catch (AccessDeniedException e) {
|
||||||
|
session.onTerminal(Status.DENIED, "User denied authorization");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[feishu-register] register() failed: {}", e.getMessage());
|
||||||
|
session.onTerminal(Status.ERROR, e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}, "feishu-register-" + sessionId.substring(0, 8));
|
||||||
|
worker.setDaemon(true);
|
||||||
|
worker.start();
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RegistrationSession getSession(String sessionId) {
|
||||||
|
evictExpiredSessions();
|
||||||
|
return sessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop sessions that have lived past SESSION_TTL_MS. Keeps the map bounded
|
||||||
|
* even if the user closes the browser mid-flow without ever polling the
|
||||||
|
* terminal state.
|
||||||
|
*/
|
||||||
|
private void evictExpiredSessions() {
|
||||||
|
long cutoff = System.currentTimeMillis() - SESSION_TTL_MS;
|
||||||
|
Iterator<Map.Entry<String, RegistrationSession>> it = sessions.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
if (it.next().getValue().createdAtMs < cutoff) it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
/** Worker started but onQRCode hasn't fired yet — QR not ready */
|
||||||
|
PENDING,
|
||||||
|
/** QR available, polling for user scan + confirmation */
|
||||||
|
WAITING,
|
||||||
|
/** User confirmed, clientId/clientSecret returned */
|
||||||
|
CONFIRMED,
|
||||||
|
/** QR expired before user finished scanning */
|
||||||
|
EXPIRED,
|
||||||
|
/** User denied authorization on the feishu side */
|
||||||
|
DENIED,
|
||||||
|
/** Network or SDK error */
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次注册流程的可观察状态。线程安全:所有写操作都在 SDK 工作线程,读操作在 HTTP 轮询线程,
|
||||||
|
* 字段全部 volatile 即可。
|
||||||
|
*/
|
||||||
|
public static class RegistrationSession {
|
||||||
|
public final String sessionId;
|
||||||
|
final long createdAtMs = System.currentTimeMillis();
|
||||||
|
|
||||||
|
public volatile Status status = Status.PENDING;
|
||||||
|
public volatile String qrcodeUrl;
|
||||||
|
public volatile int qrcodeExpireSeconds;
|
||||||
|
/** Cached "data:image/png;base64,..." rendered from qrcodeUrl by the controller. */
|
||||||
|
public volatile String qrcodeImgDataUri;
|
||||||
|
public volatile String clientId;
|
||||||
|
public volatile String clientSecret;
|
||||||
|
public volatile String userOpenId;
|
||||||
|
public volatile String userTenantBrand;
|
||||||
|
public volatile String errorMessage;
|
||||||
|
public volatile long lastUpdateMs = System.currentTimeMillis();
|
||||||
|
|
||||||
|
RegistrationSession(String sessionId) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onQRCode(QRCodeInfo qr) {
|
||||||
|
this.qrcodeUrl = qr.getUrl();
|
||||||
|
this.qrcodeExpireSeconds = qr.getExpireIn();
|
||||||
|
this.status = Status.WAITING;
|
||||||
|
this.lastUpdateMs = System.currentTimeMillis();
|
||||||
|
log.info("[feishu-register] QR ready, expires in {}s", qr.getExpireIn());
|
||||||
|
}
|
||||||
|
|
||||||
|
void onStatusChange(StatusChangeInfo info) {
|
||||||
|
// SDK 状态码:POLLING / SLOW_DOWN / DOMAIN_SWITCHED。三种都是"还在等",没有更细的
|
||||||
|
// "已扫码未确认"信号 —— 飞书侧扫码确认在同一步完成。我们只用 lastUpdateMs 让
|
||||||
|
// 前端知道连接还活着,状态本身保持 WAITING 直到终态。
|
||||||
|
this.lastUpdateMs = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSuccess(RegisterAppResult result) {
|
||||||
|
this.clientId = result.getClientId();
|
||||||
|
this.clientSecret = result.getClientSecret();
|
||||||
|
if (result.getUserInfo() != null) {
|
||||||
|
this.userOpenId = result.getUserInfo().getOpenId();
|
||||||
|
this.userTenantBrand = result.getUserInfo().getTenantBrand();
|
||||||
|
}
|
||||||
|
this.status = Status.CONFIRMED;
|
||||||
|
this.lastUpdateMs = System.currentTimeMillis();
|
||||||
|
log.info("[feishu-register] confirmed, clientId={}", clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onTerminal(Status terminalStatus, String message) {
|
||||||
|
this.status = terminalStatus;
|
||||||
|
this.errorMessage = message;
|
||||||
|
this.lastUpdateMs = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,20 +34,24 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* 飞书渠道适配器
|
* 飞书渠道适配器
|
||||||
* <p>
|
* <p>
|
||||||
* 飞书渠道实现:
|
* 飞书渠道实现:
|
||||||
* - 接入模式:Event Subscription(HTTP 回调)或 WebSocket 长连接
|
* - 接入模式:WebSocket 长连接(默认,无需公网 IP)或 Event Subscription(HTTP 回调)
|
||||||
* - 发送方式:通过 Open API 发送消息
|
* - 发送方式:通过 Open API 发送消息
|
||||||
* - 消息去重:基于 message_id 防止重复处理
|
* - 消息去重:基于 message_id 防止重复处理
|
||||||
|
* - 引用消息:自动拉取 parent_id 对应的父消息内容并注入上下文
|
||||||
* <p>
|
* <p>
|
||||||
* 配置项(configJson):
|
* 配置项(configJson):
|
||||||
* - app_id: 飞书应用 App ID
|
* - app_id: 飞书应用 App ID
|
||||||
* - app_secret: 飞书应用 App Secret
|
* - app_secret: 飞书应用 App Secret
|
||||||
* - connection_mode: 接入模式 "webhook"(默认)或 "websocket"
|
* - connection_mode: 接入模式 "websocket"(默认)或 "webhook"
|
||||||
* - domain: "feishu"(默认)或 "lark"(国际版)
|
* - domain: "feishu"(默认)或 "lark"(国际版)
|
||||||
* - encrypt_key: 事件加密密钥(可选)
|
* - encrypt_key: 事件加密密钥(webhook 模式必填)
|
||||||
* - verification_token: 事件验证 Token(可选)
|
* - verification_token: 事件验证 Token(webhook 模式可选)
|
||||||
* - enable_reaction: 是否在收到消息后添加表情反应(默认 true)
|
* - enable_reaction: 是否在收到消息后添加表情反应(默认 true)
|
||||||
* - enable_nickname_cache: 是否通过 Contact API 获取用户昵称(默认 true)
|
* - enable_nickname_cache: 是否通过 Contact API 获取用户昵称(默认 true)
|
||||||
* - media_download_enabled: 是否下载消息中的媒体文件(默认 false)
|
* - media_download_enabled: 是否下载消息中的媒体文件(默认 false)
|
||||||
|
* - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt(默认 true)
|
||||||
|
* - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值(默认 1800,0 禁用)
|
||||||
|
* - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用)
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -76,6 +80,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
/** WebSocket 连接线程 */
|
/** WebSocket 连接线程 */
|
||||||
private volatile Thread wsThread;
|
private volatile Thread wsThread;
|
||||||
|
|
||||||
|
/** WebSocket 静默断连看门狗:定期检查最近事件时间,超过阈值就强制重连 */
|
||||||
|
private ScheduledFuture<?> silentDisconnectWatchdog;
|
||||||
|
|
||||||
|
/** 是否已收到至少一个事件(用于避免新连接立即触发静默超时) */
|
||||||
|
private volatile boolean hasReceivedFirstEvent = false;
|
||||||
|
|
||||||
|
/** 看门狗检查间隔(秒) */
|
||||||
|
private static final long WATCHDOG_INTERVAL_SECONDS = 60L;
|
||||||
|
|
||||||
|
/** 静默断连默认阈值(秒):30 分钟无事件就视为可疑 */
|
||||||
|
private static final long DEFAULT_SILENT_THRESHOLD_SECONDS = 1800L;
|
||||||
|
|
||||||
|
/** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */
|
||||||
|
private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L;
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@ -106,11 +125,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
// 定时刷新 Token:过期前 5 分钟自动刷新
|
// 定时刷新 Token:过期前 5 分钟自动刷新
|
||||||
scheduleTokenRefresh();
|
scheduleTokenRefresh();
|
||||||
|
|
||||||
String connectionMode = getConfigString("connection_mode", "webhook");
|
String connectionMode = getConfigString("connection_mode", "websocket");
|
||||||
if ("websocket".equals(connectionMode)) {
|
if ("websocket".equals(connectionMode)) {
|
||||||
startWebSocket(appId, appSecret);
|
startWebSocket(appId, appSecret);
|
||||||
} else {
|
} else {
|
||||||
// RFC-025 Change 3: webhook 模式下 encrypt_key 必须配置,否则 fail-fast 拒绝启动;
|
// webhook 模式下 encrypt_key 必须配置,否则 fail-fast 拒绝启动;
|
||||||
// 没有加密密钥 + 无签名校验 = 任何人可伪造 webhook 请求触发 agent 消息
|
// 没有加密密钥 + 无签名校验 = 任何人可伪造 webhook 请求触发 agent 消息
|
||||||
String encryptKey = getConfigString("encrypt_key", null);
|
String encryptKey = getConfigString("encrypt_key", null);
|
||||||
if (encryptKey == null || encryptKey.isBlank()) {
|
if (encryptKey == null || encryptKey.isBlank()) {
|
||||||
@ -142,6 +161,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
this.tenantAccessToken = null;
|
this.tenantAccessToken = null;
|
||||||
this.processedMessageIds.clear();
|
this.processedMessageIds.clear();
|
||||||
this.nicknameCache.clear();
|
this.nicknameCache.clear();
|
||||||
|
this.quotedMessageCache.clear();
|
||||||
log.info("[feishu] Feishu channel stopped");
|
log.info("[feishu] Feishu channel stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,7 +169,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
protected void doReconnect() {
|
protected void doReconnect() {
|
||||||
String appId = getConfigString("app_id");
|
String appId = getConfigString("app_id");
|
||||||
String appSecret = getConfigString("app_secret");
|
String appSecret = getConfigString("app_secret");
|
||||||
String connectionMode = getConfigString("connection_mode", "webhook");
|
String connectionMode = getConfigString("connection_mode", "websocket");
|
||||||
|
|
||||||
// 重新建立 HTTP 客户端
|
// 重新建立 HTTP 客户端
|
||||||
this.httpClient = HttpClient.newBuilder()
|
this.httpClient = HttpClient.newBuilder()
|
||||||
@ -236,6 +256,54 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}, "feishu-ws-" + channelEntity.getId());
|
}, "feishu-ws-" + channelEntity.getId());
|
||||||
wsThread.setDaemon(true);
|
wsThread.setDaemon(true);
|
||||||
wsThread.start();
|
wsThread.start();
|
||||||
|
|
||||||
|
startSilentDisconnectWatchdog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动静默断连看门狗。
|
||||||
|
* <p>
|
||||||
|
* SDK 内部有 ping/pong 心跳,正常情况下连接断开会触发 start() 返回 → onDisconnected。
|
||||||
|
* 但少数场景 TCP 层认为连接还在但事件不再流入(NAT 超时、半开连接、对端 hang 死),
|
||||||
|
* SDK 也察觉不到。看门狗每 60s 检查一次「最近事件时间」,超过阈值就强制重连。
|
||||||
|
* <p>
|
||||||
|
* 静默判定有两个前置条件:
|
||||||
|
* 1. 已经收到过至少一个事件(避免新连接立即触发误报)
|
||||||
|
* 2. silent_disconnect_threshold_seconds > 0(设为 0 可禁用看门狗)
|
||||||
|
* <p>
|
||||||
|
* 默认阈值 30 分钟。安静的渠道(一天没几条消息)建议调大到 1-2 小时;
|
||||||
|
* 高频渠道可以调小到 5-10 分钟以更快发现问题。
|
||||||
|
*/
|
||||||
|
private void startSilentDisconnectWatchdog() {
|
||||||
|
long thresholdSec = getConfigLong("silent_disconnect_threshold_seconds",
|
||||||
|
DEFAULT_SILENT_THRESHOLD_SECONDS);
|
||||||
|
if (thresholdSec <= 0) {
|
||||||
|
log.debug("[feishu] Silent disconnect watchdog disabled (threshold=0)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long thresholdMs = thresholdSec * 1000L;
|
||||||
|
|
||||||
|
cancelSilentDisconnectWatchdog();
|
||||||
|
silentDisconnectWatchdog = ensureReconnectScheduler().scheduleAtFixedRate(() -> {
|
||||||
|
if (!running.get() || wsClient == null) return;
|
||||||
|
if (!hasReceivedFirstEvent) return; // 没收到首个事件前不算静默
|
||||||
|
|
||||||
|
long silentMs = System.currentTimeMillis() - lastEventTimeMs.get();
|
||||||
|
if (silentMs > thresholdMs) {
|
||||||
|
log.warn("[feishu] Silent WebSocket detected: no events for {}s (threshold {}s), forcing reconnect",
|
||||||
|
silentMs / 1000, thresholdSec);
|
||||||
|
onDisconnected("silent disconnect: no events for " + (silentMs / 1000) + "s");
|
||||||
|
}
|
||||||
|
}, WATCHDOG_INTERVAL_SECONDS, WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||||
|
log.info("[feishu] Silent disconnect watchdog started (threshold {}s, check every {}s)",
|
||||||
|
thresholdSec, WATCHDOG_INTERVAL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelSilentDisconnectWatchdog() {
|
||||||
|
if (silentDisconnectWatchdog != null) {
|
||||||
|
silentDisconnectWatchdog.cancel(false);
|
||||||
|
silentDisconnectWatchdog = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -247,6 +315,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
private void startWebSocketSync(String appId, String appSecret) {
|
private void startWebSocketSync(String appId, String appSecret) {
|
||||||
wsClient = createWsClient(appId, appSecret);
|
wsClient = createWsClient(appId, appSecret);
|
||||||
log.info("[feishu] WebSocket connecting (long connection)...");
|
log.info("[feishu] WebSocket connecting (long connection)...");
|
||||||
|
// 看门狗必须在 start() 之前启动,否则 start() 阻塞后这一行永远到不了,
|
||||||
|
// 一旦断连后重连这条路径,watchdog 就再也不会被恢复
|
||||||
|
startSilentDisconnectWatchdog();
|
||||||
wsClient.start(); // 阻塞:成功则永驻,失败则抛异常
|
wsClient.start(); // 阻塞:成功则永驻,失败则抛异常
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +326,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
* SDK 的 start() 在线程中阻塞运行,通过中断线程来触发停止
|
* SDK 的 start() 在线程中阻塞运行,通过中断线程来触发停止
|
||||||
*/
|
*/
|
||||||
private void stopWebSocket() {
|
private void stopWebSocket() {
|
||||||
|
cancelSilentDisconnectWatchdog();
|
||||||
|
hasReceivedFirstEvent = false;
|
||||||
if (wsThread != null) {
|
if (wsThread != null) {
|
||||||
wsThread.interrupt();
|
wsThread.interrupt();
|
||||||
wsThread = null;
|
wsThread = null;
|
||||||
@ -274,18 +347,41 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
var message = eventBody.getMessage();
|
var message = eventBody.getMessage();
|
||||||
var sender = eventBody.getSender();
|
var sender = eventBody.getSender();
|
||||||
|
|
||||||
|
// 任何事件到达都更新活跃时间戳,看门狗用它判断是否静默断连
|
||||||
|
touchActivity();
|
||||||
|
hasReceivedFirstEvent = true;
|
||||||
|
|
||||||
|
// 旧事件过滤:SDK 在重连后可能回放历史事件,按 message.create_time 过滤
|
||||||
|
// 远超 stale 阈值的消息(典型场景:连接断了 5 分钟后恢复,5 分钟前的消息再处理一次没意义)
|
||||||
|
long staleThresholdMs = getConfigLong("stale_event_threshold_seconds",
|
||||||
|
DEFAULT_STALE_THRESHOLD_SECONDS) * 1000L;
|
||||||
|
if (staleThresholdMs > 0 && message.getCreateTime() != null) {
|
||||||
|
try {
|
||||||
|
long msgCreateTimeMs = Long.parseLong(message.getCreateTime());
|
||||||
|
long ageMs = System.currentTimeMillis() - msgCreateTimeMs;
|
||||||
|
if (ageMs > staleThresholdMs) {
|
||||||
|
log.info("[feishu] Dropping stale event: messageId={}, age={}s (threshold {}s)",
|
||||||
|
message.getMessageId(), ageMs / 1000, staleThresholdMs / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// create_time 不是合法的毫秒数,跳过过滤而不是误丢
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String messageId = message.getMessageId();
|
String messageId = message.getMessageId();
|
||||||
String messageType = message.getMessageType();
|
String messageType = message.getMessageType();
|
||||||
String contentStr = message.getContent();
|
String contentStr = message.getContent();
|
||||||
String chatId = message.getChatId();
|
String chatId = message.getChatId();
|
||||||
String chatType = message.getChatType();
|
String chatType = message.getChatType();
|
||||||
|
String parentId = message.getParentId();
|
||||||
|
|
||||||
String senderOpenId = null;
|
String senderOpenId = null;
|
||||||
if (sender != null && sender.getSenderId() != null) {
|
if (sender != null && sender.getSenderId() != null) {
|
||||||
senderOpenId = sender.getSenderId().getOpenId();
|
senderOpenId = sender.getSenderId().getOpenId();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, event);
|
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Token 管理 ====================
|
// ==================== Token 管理 ====================
|
||||||
@ -419,6 +515,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
String contentStr = (String) message.get("content");
|
String contentStr = (String) message.get("content");
|
||||||
String chatId = (String) message.get("chat_id");
|
String chatId = (String) message.get("chat_id");
|
||||||
String chatType = (String) message.get("chat_type");
|
String chatType = (String) message.get("chat_type");
|
||||||
|
String parentId = (String) message.get("parent_id");
|
||||||
|
|
||||||
// 提取发送者 open_id
|
// 提取发送者 open_id
|
||||||
Map<String, Object> sender = (Map<String, Object>) event.get("sender");
|
Map<String, Object> sender = (Map<String, Object>) event.get("sender");
|
||||||
@ -430,7 +527,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, payload);
|
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, payload);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[feishu] Failed to handle webhook: {}", e.getMessage(), e);
|
log.error("[feishu] Failed to handle webhook: {}", e.getMessage(), e);
|
||||||
@ -450,11 +547,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
* @param chatId 群组 ID(私聊为 null)
|
* @param chatId 群组 ID(私聊为 null)
|
||||||
* @param chatType "p2p" 或 "group"
|
* @param chatType "p2p" 或 "group"
|
||||||
* @param senderOpenId 发送者 open_id
|
* @param senderOpenId 发送者 open_id
|
||||||
|
* @param parentId 被引用消息的 message_id(无引用时为 null)
|
||||||
* @param rawPayload 原始负载(用于调试)
|
* @param rawPayload 原始负载(用于调试)
|
||||||
*/
|
*/
|
||||||
private void handleFeishuMessage(String messageId, String messageType, String contentStr,
|
private void handleFeishuMessage(String messageId, String messageType, String contentStr,
|
||||||
String chatId, String chatType, String senderOpenId,
|
String chatId, String chatType, String senderOpenId,
|
||||||
Object rawPayload) {
|
String parentId, Object rawPayload) {
|
||||||
// 消息去重
|
// 消息去重
|
||||||
if (messageId != null && !processedMessageIds.add(messageId)) {
|
if (messageId != null && !processedMessageIds.add(messageId)) {
|
||||||
log.debug("[feishu] Duplicate message_id: {}, skipping", messageId);
|
log.debug("[feishu] Duplicate message_id: {}, skipping", messageId);
|
||||||
@ -482,6 +580,18 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用消息(用户在飞书里"引用"了之前的某条消息回复):拉取被引用消息内容并注入上下文,
|
||||||
|
// 让 agent 能理解 "解释一下" 这种缺主语的引用回复 —— 不然就只看到"解释一下"三个字。
|
||||||
|
if (parentId != null && !parentId.isBlank() && getConfigBoolean("enable_quoted_context", true)) {
|
||||||
|
String quotedText = fetchQuotedMessageText(parentId);
|
||||||
|
if (quotedText != null && !quotedText.isBlank()) {
|
||||||
|
String prefix = "[引用消息: " + quotedText + "]\n";
|
||||||
|
textContent = prefix + (textContent != null ? textContent : "");
|
||||||
|
// 同步加一个 text part 到最前面,让多模态消息也能看到引用上下文
|
||||||
|
contentParts.add(0, MessageContentPart.text(prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 生成短会话后缀
|
// 生成短会话后缀
|
||||||
boolean isGroup = "group".equals(chatType);
|
boolean isGroup = "group".equals(chatType);
|
||||||
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
||||||
@ -632,6 +742,124 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 引用消息上下文 ====================
|
||||||
|
|
||||||
|
/** 引用消息内容缓存:parent_message_id → 文本摘要(避免同一条引用反复拉 API) */
|
||||||
|
private final ConcurrentHashMap<String, String> quotedMessageCache = new ConcurrentHashMap<>();
|
||||||
|
private static final int QUOTED_CACHE_MAX = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取被引用消息的文本摘要。
|
||||||
|
* <p>
|
||||||
|
* GET /open-apis/im/v1/messages/{message_id} 返回 items[0],body.content 是一段 JSON 字符串,
|
||||||
|
* 形如 {"text": "..."} 或 post 富文本。我们只取一个简短文本表示,给 agent 当上下文用,
|
||||||
|
* 不还原完整富文本/媒体(成本高且 prompt 容易冗余)。
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String fetchQuotedMessageText(String parentMessageId) {
|
||||||
|
String cached = quotedMessageCache.get(parentMessageId);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureTokenValid();
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(getApiBaseUrl() + "/open-apis/im/v1/messages/" + parentMessageId))
|
||||||
|
.header("Authorization", "Bearer " + tenantAccessToken)
|
||||||
|
.timeout(Duration.ofSeconds(3))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.debug("[feishu] Fetch quoted message failed: status={}", response.statusCode());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
|
||||||
|
Integer code = result.get("code") instanceof Number n ? n.intValue() : null;
|
||||||
|
if (code == null || code != 0) {
|
||||||
|
log.debug("[feishu] Fetch quoted message API error: code={}, msg={}", code, result.get("msg"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
||||||
|
if (data == null) return null;
|
||||||
|
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
||||||
|
if (items == null || items.isEmpty()) return null;
|
||||||
|
Map<String, Object> item = items.get(0);
|
||||||
|
String msgType = (String) item.get("msg_type");
|
||||||
|
Map<String, Object> body = (Map<String, Object>) item.get("body");
|
||||||
|
if (body == null) return null;
|
||||||
|
String contentJson = (String) body.get("content");
|
||||||
|
String summary = summarizeQuotedContent(msgType, contentJson);
|
||||||
|
|
||||||
|
if (summary != null && !summary.isBlank()) {
|
||||||
|
if (quotedMessageCache.size() >= QUOTED_CACHE_MAX) {
|
||||||
|
int toRemove = quotedMessageCache.size() / 2;
|
||||||
|
var iter = quotedMessageCache.keySet().iterator();
|
||||||
|
while (iter.hasNext() && toRemove > 0) {
|
||||||
|
iter.next(); iter.remove(); toRemove--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotedMessageCache.put(parentMessageId, summary);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[feishu] fetchQuotedMessageText failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把引用消息的 body.content 折叠成一个简短文本表示,控制 prompt 注入大小。
|
||||||
|
* 长文本截断到 200 字符以内,post 取段落首文本,图片/文件给类型占位。
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String summarizeQuotedContent(String msgType, String contentJson) {
|
||||||
|
if (contentJson == null) return null;
|
||||||
|
try {
|
||||||
|
Map<String, Object> obj = objectMapper.readValue(contentJson, Map.class);
|
||||||
|
String text = switch (msgType != null ? msgType : "") {
|
||||||
|
case "text" -> (String) obj.get("text");
|
||||||
|
case "image" -> "[图片]";
|
||||||
|
case "file" -> "[文件: " + obj.getOrDefault("file_name", "") + "]";
|
||||||
|
case "audio" -> "[音频]";
|
||||||
|
case "media" -> "[视频]";
|
||||||
|
case "post" -> extractPostFirstText(obj);
|
||||||
|
default -> "[" + msgType + "]";
|
||||||
|
};
|
||||||
|
if (text == null) return null;
|
||||||
|
text = text.trim();
|
||||||
|
if (text.length() > 200) text = text.substring(0, 200) + "…";
|
||||||
|
return text;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String extractPostFirstText(Map<String, Object> postObj) {
|
||||||
|
// post 结构:{"zh_cn": {"title": "...", "content": [[{tag:text, text:"..."}], ...]}}
|
||||||
|
// 我们只取第一个语种的 title + 第一段的首个 text 元素,作为引用摘要
|
||||||
|
for (Object langValue : postObj.values()) {
|
||||||
|
if (!(langValue instanceof Map<?, ?> lang)) continue;
|
||||||
|
String title = (String) ((Map<String, Object>) lang).get("title");
|
||||||
|
List<List<Map<String, Object>>> content = (List<List<Map<String, Object>>>) ((Map<String, Object>) lang).get("content");
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (title != null && !title.isBlank()) sb.append(title).append(":");
|
||||||
|
if (content != null) {
|
||||||
|
outer:
|
||||||
|
for (var paragraph : content) {
|
||||||
|
for (var inline : paragraph) {
|
||||||
|
if ("text".equals(inline.get("tag"))) {
|
||||||
|
sb.append(inline.get("text"));
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 会话 ID 优化 ====================
|
// ==================== 会话 ID 优化 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
package vip.mate.channel.health;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-channel health snapshot.
|
||||||
|
*
|
||||||
|
* <p>Mirrors Spring Boot's {@code Health} object semantically (status +
|
||||||
|
* details) but kept channel-domain-typed so we don't leak {@code actuator}
|
||||||
|
* types into the channel API and so we can serialize directly into the
|
||||||
|
* {@code /api/v1/channels/{id}/health} JSON response.
|
||||||
|
*
|
||||||
|
* <p>Status semantics:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code UP} — adapter started AND underlying transport
|
||||||
|
* is reachable (e.g. DingTalk Stream WebSocket connected, last
|
||||||
|
* activity within staleness threshold).</li>
|
||||||
|
* <li>{@code RECONNECTING} — adapter is currently retrying after a
|
||||||
|
* transient failure; will return to UP if it recovers.</li>
|
||||||
|
* <li>{@code DOWN} — adapter started but the transport is
|
||||||
|
* unhealthy / authentication failed / max retries exhausted.</li>
|
||||||
|
* <li>{@code OUT_OF_SERVICE}— adapter is not started (channel disabled
|
||||||
|
* or stopped administratively).</li>
|
||||||
|
* <li>{@code UNKNOWN} — adapter starting up; transport state not
|
||||||
|
* yet determined.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>This is the "single source of truth" the frontend "已连接" green dot
|
||||||
|
* should bind to — not the {@code mate_channel.enabled} DB flag, which
|
||||||
|
* only records the user's intent, not transport reality.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChannelHealth {
|
||||||
|
|
||||||
|
public enum Status { UP, RECONNECTING, DOWN, OUT_OF_SERVICE, UNKNOWN }
|
||||||
|
|
||||||
|
private final String channelType;
|
||||||
|
private final Long channelId;
|
||||||
|
private final Status status;
|
||||||
|
private final String detail;
|
||||||
|
private final Instant lastEventAt;
|
||||||
|
private final Map<String, Object> extra;
|
||||||
|
|
||||||
|
public static ChannelHealth up(String type, Long id, Instant lastEvent) {
|
||||||
|
return new ChannelHealth(type, id, Status.UP, "active", lastEvent, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChannelHealth outOfService(String type, Long id) {
|
||||||
|
return new ChannelHealth(type, id, Status.OUT_OF_SERVICE, "not started", null, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChannelHealth unknown(String type, Long id) {
|
||||||
|
return new ChannelHealth(type, id, Status.UNKNOWN, "initializing", null, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChannelHealth down(String type, Long id, String reason, Instant lastEvent) {
|
||||||
|
return new ChannelHealth(type, id, Status.DOWN, reason, lastEvent, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChannelHealth reconnecting(String type, Long id, String reason, Instant lastEvent) {
|
||||||
|
return new ChannelHealth(type, id, Status.RECONNECTING, reason, lastEvent, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize to a JSON-friendly map for the REST endpoint. */
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("channelType", channelType);
|
||||||
|
m.put("channelId", channelId);
|
||||||
|
m.put("status", status.name());
|
||||||
|
if (detail != null) m.put("detail", detail);
|
||||||
|
if (lastEventAt != null) m.put("lastEventAt", lastEventAt.toString());
|
||||||
|
if (extra != null && !extra.isEmpty()) m.putAll(extra);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package vip.mate.channel.health;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.channel.ChannelAdapter;
|
||||||
|
import vip.mate.channel.ChannelManager;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Boot Actuator health indicator aggregating all running channel
|
||||||
|
* adapters' real-time health.
|
||||||
|
*
|
||||||
|
* <p>Surfaces under {@code /actuator/health/channels}. Used by the desktop
|
||||||
|
* bundle's post-install smoke test (and any external monitor) to fail-loud
|
||||||
|
* when a configured channel never reaches {@code UP} after startup.
|
||||||
|
*
|
||||||
|
* <p>Aggregation rule: overall status is UP iff every running adapter
|
||||||
|
* reports UP. Any DOWN or ERROR demotes the aggregate to DOWN.
|
||||||
|
*/
|
||||||
|
@Component("channels")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChannelsHealthIndicator implements HealthIndicator {
|
||||||
|
|
||||||
|
private final ChannelManager channelManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
Map<String, Object> details = new LinkedHashMap<>();
|
||||||
|
boolean anyDown = false;
|
||||||
|
boolean anyReconnecting = false;
|
||||||
|
int total = 0;
|
||||||
|
int up = 0;
|
||||||
|
|
||||||
|
for (ChannelAdapter adapter : channelManager.getActiveAdapters()) {
|
||||||
|
ChannelHealth h = adapter.health();
|
||||||
|
details.put(safeKey(adapter, h), h.toMap());
|
||||||
|
total++;
|
||||||
|
switch (h.getStatus()) {
|
||||||
|
case UP -> up++;
|
||||||
|
case RECONNECTING -> anyReconnecting = true;
|
||||||
|
case DOWN -> anyDown = true;
|
||||||
|
default -> { /* OUT_OF_SERVICE / UNKNOWN don't fail aggregate */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
details.put("totalActive", total);
|
||||||
|
details.put("up", up);
|
||||||
|
|
||||||
|
Health.Builder builder;
|
||||||
|
if (anyDown) {
|
||||||
|
builder = Health.down();
|
||||||
|
} else if (anyReconnecting) {
|
||||||
|
builder = Health.status("RECONNECTING");
|
||||||
|
} else {
|
||||||
|
builder = Health.up();
|
||||||
|
}
|
||||||
|
return builder.withDetails(details).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key in details map: prefer {@code <type>:<id>} fallback to type only. */
|
||||||
|
private String safeKey(ChannelAdapter adapter, ChannelHealth h) {
|
||||||
|
if (h.getChannelId() != null) {
|
||||||
|
return adapter.getChannelType() + ":" + h.getChannelId();
|
||||||
|
}
|
||||||
|
return adapter.getChannelType();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,5 @@ public class ChannelEntity {
|
|||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,5 @@ public class ChannelSessionEntity {
|
|||||||
@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,45 @@
|
|||||||
|
package vip.mate.channel.qrcode;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPI for channels that support QR-code-based bot/app registration.
|
||||||
|
*
|
||||||
|
* <p>Each implementation handles one channel type's "scan QR → confirm →
|
||||||
|
* receive credentials" flow. The framework picks the right provider by
|
||||||
|
* {@link #channelType()} so the controller and frontend share a single
|
||||||
|
* pair of endpoints regardless of how many channels register this way.
|
||||||
|
*
|
||||||
|
* <p>Adding a new channel: implement this interface as a Spring
|
||||||
|
* {@code @Component} and the framework auto-routes to it. No changes to
|
||||||
|
* the controller or the frontend are required for the generic endpoints.
|
||||||
|
*
|
||||||
|
* <p>This intentionally keeps the wire format channel-agnostic via
|
||||||
|
* {@link Map} payloads — implementations are free to surface their own
|
||||||
|
* key set (e.g. DingTalk uses {@code client_id/client_secret}, Feishu
|
||||||
|
* uses {@code app_id/app_secret}).
|
||||||
|
*/
|
||||||
|
public interface ChannelQRCodeAuthProvider {
|
||||||
|
|
||||||
|
/** The {@code mate_channel.channel_type} value this provider handles. */
|
||||||
|
String channelType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off a registration session.
|
||||||
|
*
|
||||||
|
* @param params Optional channel-specific kickoff parameters
|
||||||
|
* (e.g. Feishu's {@code domain=feishu/lark}).
|
||||||
|
* @return A response containing at minimum {@code session_id}.
|
||||||
|
*/
|
||||||
|
Map<String, Object> begin(Map<String, String> params) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll a registration session for current state.
|
||||||
|
*
|
||||||
|
* @param sessionId Token returned from {@link #begin}.
|
||||||
|
* @return Status payload — at minimum {@code status} field, plus
|
||||||
|
* credentials when {@code status=confirmed}, or QR image
|
||||||
|
* when the user hasn't scanned yet.
|
||||||
|
*/
|
||||||
|
Map<String, Object> pollStatus(String sessionId);
|
||||||
|
}
|
||||||