Compare commits

...

1 Commits

Author SHA1 Message Date
matevip
9ada305b8a release: v1.1.137 2026-04-29 16:40:05 +08:00
613 changed files with 50994 additions and 5957 deletions

28
.dockerignore Normal file
View 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/**

View File

@ -1,18 +1,10 @@
# MateClaw 环境变量配置 # MateClaw 环境变量配置
# 复制此文件为 .env 并填写实际值cp .env.example .env # 复制此文件为 .env 并填写实际值cp .env.example .env
# #
# LLM API KeyDashScope、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 channelchrome / 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
View 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
View 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
View 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
View 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
View 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
View File

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

@ -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>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) [![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java Version](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) [![Last Commit](https://img.shields.io/github/last-commit/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 · $20200/mo | Proprietary · $0200/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) | $20200/mo | $0200/mo | $0200/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.

View File

@ -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>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) [![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java 版本](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Java 版本](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![最后提交](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) [![最后提交](https://img.shields.io/github/last-commit/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 |
| **技术栈** | **JavaSpring Boot** | TypeScript | Python | TypeScript | Electron/TS |
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20200/月 | 闭源 · $0200/月 |
| 能力 | 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 菜单栏 | ElectronBeta | Win/Mac 应用 | Claude DesktopMac/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 | 部分 | 否(源码可见) | 否 | 否 |
| 定价 | **免费** | 免费 | 免费 | 免费(公测) | $20200/月 | $0200/月 | $0200/月 |
| 技术栈 | **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)。没有星号。

View File

@ -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 &amp; Skills</text> <text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Tools &amp; 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 &amp; 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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

@ -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 SDKWebSocket 长连接 + 事件分发) ===== --> <!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== -->
<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 ![alt](*.svg) 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>

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

View File

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

View File

@ -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 &lt;memory-context&gt; 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) {

View File

@ -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 BeanToolCallbackProviderMCP server 暴露的 tool callbacks * @Tool BeanToolCallbackProviderMCP 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);
}
} }

View File

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

View File

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

View File

@ -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);
}
// ===== 提取方法 ===== // ===== 提取方法 =====
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 全部 401walker 末尾会把最后一次 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 summarizingreasoning
// 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
} }

View File

@ -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_CONTENTNodeStreamingChatHelper 已实时广播
// 给前端 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;
} }

View File

@ -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 {}, " +

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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请求级 > 构造时的 reasoningEffortAgent/模型默认 * 优先级ThinkingLevelHolder请求级 > 构造时的 reasoningEffortAgent/模型默认
* "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";

View File

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

View File

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

View File

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

View File

@ -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 (26 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.mdPROFILE.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
// 不拼接完整 systemPromptwiki/技能/记忆指南等与规划决策无关 // 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);
}
} }

View File

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

View File

@ -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);
} }
// ===== 步骤控制 ===== // ===== 步骤控制 =====

View File

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

View File

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

View File

@ -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 已有值上 */

View File

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

View File

@ -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 / maxnull 表示跟随模型默认 */ /** 默认思考深度off / low / medium / high / maxnull 表示跟随模型默认 */
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;
} }

View File

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

View File

@ -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 默认值兜底成 1issue #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. 创建工作区文件

View File

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

View File

@ -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} 查找会话最早的 pendingFIFO</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();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
/** /**
* 获取配置中的列表值 * 获取配置中的列表值
*/ */

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
} }
// ==================== 优雅关闭 ==================== // ==================== 优雅关闭 ====================

View File

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

View File

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

View File

@ -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 = "获取微信登录二维码")

View File

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

View File

@ -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 重连2s4s8s16s30s无限重试 // 钉钉 Stream 重连2s4s8s16s30s无限重试
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不透明 IDvision 模型直接读不了
// /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)
)); ));

View File

@ -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
* 存储拿到 mediaId3 天有效
*
* <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;
}
}

View File

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

View File

@ -34,20 +34,24 @@ import java.util.concurrent.TimeUnit;
* 飞书渠道适配器 * 飞书渠道适配器
* <p> * <p>
* 飞书渠道实现 * 飞书渠道实现
* - 接入模式Event SubscriptionHTTP 回调 WebSocket 长连接 * - 接入模式WebSocket 长连接默认无需公网 IP Event SubscriptionHTTP 回调
* - 发送方式通过 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: 事件验证 Tokenwebhook 模式可选
* - 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 静默断连阈值默认 18000 禁用
* - stale_event_threshold_seconds: 过滤旧事件阈值默认 300 禁用
* *
* @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 &gt; 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 优化 ====================
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

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