Compare commits

...

2 Commits
dev ... v1.2.0

Author SHA1 Message Date
matevip
d994be3d04 release: v1.2.0 2026-05-05 20:09:58 +08:00
matevip
9ada305b8a release: v1.1.137 2026-04-29 16:40:05 +08:00
1140 changed files with 156652 additions and 8377 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 环境变量配置
# 复制此文件为 .env 并填写实际值cp .env.example .env
#
# LLM API KeyDashScope、OpenAI 等)无需在此配置,启动后在管理界面「模型管理」中添加。
#
# ⚠️ 所有标注「必填」的项若没配置,`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 模式必填) ====================
DB_HOST=localhost
@ -36,3 +28,29 @@ JWT_SECRET=
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
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

7
.gitignore vendored
View File

@ -92,5 +92,12 @@ deploy/nginx/ssl/*.pem
deploy/.env
# Claude Code local settings
CLAUDE.md
.claude/settings.local.json
.claude/plans/
# Codex CLI local artifacts
.codex/
# Sync tooling local state (generated each run; report is intentionally tracked)
scripts/.*-sync-state.json

255
README.md
View File

@ -6,13 +6,13 @@
# 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)
[![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)
[![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)
[![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)
@ -28,114 +28,108 @@
---
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
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
## Three things that make it different
### 1 · Your AI doesn't die when a model does
Primary key expired. Vendor returns 401. Network blip. Quota drained.
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
### 2 · Knowledge that links itself
Upload a PDF, a batch of markdown, a scraped page — raw material in.
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
This is the difference between a warehouse and a library.
### 3 · One product, five surfaces
| Surface | What it is |
|---|---|
| **Web Console** | Full admin — digital employees, models, skills, knowledge, security, cron, **runtime console** (see what every employee is doing, force-recycle in one click) |
| **Desktop** | Electron app with a bundled JRE 21. Double-click, run. No Java install |
| **Webchat Widget** | One `<script>` tag embed. Drop it on any site |
| **IM Channels** | DingTalk · Feishu · WeChat Work · WeChat · Telegram · Discord · QQ · Slack |
| **Plugin SDK** | Java module for third-party capability packs |
Same brain. Same memory. Same tools. Different doors.
<p align="center"><b>$0 · No tokens metered. No seats billed. Your server. Your data. Your keys.</b></p>
---
## What's in the box
### Digital employees, not chatbots
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows
### Skills · MCP · ACP — three ways to extend capability
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons.
### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
---
## AI is becoming infrastructure
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
**MateClaw is that layer — built the Spring Boot way.**
---
## Why MateClaw
Most AI tools do one thing well. MateClaw does the whole thing.
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|:---|:---:|:---:|:---:|:---:|:---:|
| **Multi-vendor failover** | **Chain + health tracker + cooldown** | Swap providers via config | Orchestration w/ retry | Anthropic only | One model |
| **Knowledge digestion** | **LLM Wiki + page-level citations** | Canvas + memory | Skills Hub + memory | — | Code index |
| **Multi-user admin** | **RBAC + approval + audit + runtime console** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
| **Capability extension** | **Skills (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
| **Surfaces** | Web admin + Desktop + Widget + SDK + 8 IM | 25+ chat channels | 15+ channels (CLI-led) | 3 IM preview | IDE only |
| **Stack** | **Java (Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
| **License / Price** | **Apache 2.0 · Free** | MIT · Free | MIT · Free | Proprietary · $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) |
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 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) |
**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.
**What makes MateClaw different?**
**MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
Every product in this table is genuinely strong. Here's where MateClaw carves its own space:
- **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
Same "whole widget" philosophy. Different center of gravity.
---
## Architecture
<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
## Quick start
```bash
# Backend
@ -156,55 +150,64 @@ cp .env.example .env
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 |
|-------|------------|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
| Agent | StateGraph Runtime |
| Database | H2 (dev) / MySQL 8.0+ (prod) |
| ORM | MyBatis Plus 3.5 |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite |
| UI | Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater |
<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>
---
## Project Structure
## Project structure
```
mateclaw/
├── mateclaw-server/ Spring Boot backend
├── mateclaw-ui/ Vue 3 SPA frontend
├── mateclaw-desktop/ Electron desktop app
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
├── 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
└── .env.example
```
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
## Tech stack
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
| Widget | Vite library mode · UMD + ES bundles |
---
## Documentation
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
- Richer multi-agent collaboration
- Smarter model routing
- Deeper multimodal understanding
- Stronger long-term memory
- Richer ClawHub ecosystem
---
Sharper multi-employee collaboration · Smarter model routing · Deeper multimodal understanding · Longer-lived memory · A richer ClawHub · More ACP upstream integrations.
## Contributing
@ -217,14 +220,12 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
---
## Why The Name
## Why the name
**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
[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">
</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)
[![文档](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/官网-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)
[![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)
@ -28,115 +28,109 @@
---
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
> **别的 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>
---
## 盒子里有什么
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt
- **工作区记忆**`AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md用得越多越聪明**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。
### 企业就绪
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
---
## AI 正在变成基础设施
2026 年 3 月 2 日Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
---
## 为什么选 MateClaw
大多数 AI 工具只做好一件事。MateClaw 做好整件事。
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|:---|:---:|:---:|:---:|:---:|:---:|
| **多厂商失败转移** | **Chain + 健康追踪 + 冷却** | 切换供应商(改配置) | 内置编排重试 | 仅 Anthropic | 单模型 |
| **知识消化式加工** | **Wiki + 页面级引用溯源** | Canvas + 记忆 | Skills Hub + 记忆 | — | 代码索引 |
| **多用户管理** | **RBAC + 审批流 + 审计 + 运行时控制台** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
| **能力扩展接口** | **技能 (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
| **用户触点** | Web 管理台 + 桌面 + 嵌入 + SDK + 8 IM | 25+ 聊天渠道 | 15+ 渠道CLI 为主) | 3 IM预览 | 仅 IDE |
| **技术栈** | **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) |
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 智能体编排 | **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) |
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优选它们没问题。两家的社区规模今天都大于 MateClaw。
**MateClaw 的差异化在哪?**
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
这张表里的每个产品都有真正的实力。MateClaw 的独特空间在这里:
- **计划-执行编排** — 把复杂工作分解为有序步骤,逐一执行,动态调整计划。别人有多智能体,但结构化任务规划+动态重规划是稀缺能力
- **LLM Wiki 消化式知识库** — 别人索引和搜索。MateClaw 的 Wiki 把原始资料转化为结构化、有链接的页面——搜索引擎和百科全书的区别
- **Java 生态** — 为已经在生产环境运行 Spring Boot 的团队而生。一个 JAR一次部署。无需 Python 运行时,无需 Node.js 依赖链
- **完整管理仪表盘** — 智能体、模型、工具、技能、渠道、安全、定时任务、Token 用量——全在一个 Web 界面。不是 CLI 优先的附属品
- **完整多模态创作** — TTS、STT、图片、音乐、视频生成作为内置一等功能。OpenClaw 在这方面同样强;其他竞品不具备
- **免费开源,没有星号** — Apache 2.0。无按量计费无按席收费无功能阉割。Claude Code 起步 $20/月Cursor 和 Windsurf 最高 $200/月
---
## 架构全景
<p align="center">
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
</p>
<details>
<summary><b>技术架构</b></summary>
<p align="center">
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
</p>
</details>
---
## 核心能力
### 智能体引擎
- **ReAct 智能体** — 思考、行动、观察、循环。迭代推理直到完成任务
- **计划-执行智能体** — 将复杂工作分解为有序步骤,逐一执行
- **动态配置** — 运行时从数据库加载智能体的人格、工具和约束
- **运行时韧性** — 上下文裁剪、智能截断、僵死流清理、异常恢复
### 知识与记忆
- **LLM Wiki 知识库** — AI 驱动的知识库,将原始资料消化为结构化、有链接的页面
- **工作区记忆**`AGENTS.md`、`SOUL.md`、`PROFILE.md`、`MEMORY.md`、每日笔记
- **记忆生命周期** — 对话后自动提取、定时整理、记忆涌现工作流
- **记忆应该积累** — 理解随时间加深,而非每次查询都从零开始
### 工具、技能与 MCP
- **内置工具** — 联网搜索、文件操作、记忆访问、日期时间等
- **MCP 集成** — 支持 stdio、SSE、Streamable HTTP 三种传输
- **技能系统** — 可安装的 `SKILL.md` 技能包 + ClawHub 市场
- **工具防护** — 审批流、文件路径保护、运行时过滤
### 多模态创作
语音合成 · 语音识别 · 图片生成 · 音乐生成 · 视频生成
### 模型灵活性
14+ 供应商支持,包括 DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等。在 Web 界面中配置一切。
### 用户触点
- **Web 控制台** — 对话、智能体、工具、技能、知识、模型、安全、设置
- **桌面端** — Electron + 内嵌 JRE 21无需安装 Java
- **多渠道** — 钉钉、飞书、企业微信、Telegram、Discord、QQ
**同一套"完整一整套"哲学,不同的重心。**
---
## 快速开始
### 环境要求
- Java 17+ · Node.js 18+ · pnpm · Maven 3.9+
### 本地开发
```bash
# 后端
cd mateclaw-server
@ -158,22 +152,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。
---
## 技术栈
## 架构全景
| 层次 | 技术 |
|------|------|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
| 智能体 | StateGraph 运行时 |
| 数据库 | H2开发/ MySQL 8.0+(生产)|
| ORM | MyBatis Plus 3.5 |
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite |
| UI | Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater |
<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>
---
@ -181,30 +175,39 @@ docker compose up -d # http://localhost:18080
```
mateclaw/
├── mateclaw-server/ Spring Boot 后端
├── mateclaw-ui/ Vue 3 SPA 前端
├── mateclaw-desktop/ Electron 桌面端
├── mateclaw-server/ Spring Boot 3.5 后端Spring AI Alibaba · StateGraph 运行时)
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA构建产物打进后端 JAR
├── mateclaw-webchat/ 网页嵌入式聊天组件UMD / ES bundle
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
├── mateclaw-plugin-sample/ 参考插件实现
├── docker-compose.yml
└── .env.example
```
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
## 技术栈
| 层次 | 技术 |
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
| 能力扩展 | SKILL.md 包 · MCP 协议stdio / SSE / HTTP· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· MySQL 8.0+(生产)|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
| Webchat | Vite library 模式 · UMD + ES bundle |
---
## 文档
完整文档请访问 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
---
完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
## 路线图
- 更丰富的多智能体协作
- 更智能的模型路由
- 更深度的多模态理解
- 更强的长期记忆
- 更丰富的 ClawHub 生态
---
更强的多员工协作 · 更智能的模型路由 · 更深度的多模态理解 · 更长久的记忆 · 更繁荣的 ClawHub · 更多 ACP 上游集成。
## 参与贡献
@ -221,10 +224,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
**Mate** 是陪伴。**Claw** 是能力。
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。
---
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
## 许可证
[Apache License 2.0](LICENSE)
[Apache License 2.0](LICENSE)。没有星号。

View File

@ -32,18 +32,19 @@
<!-- ===== Center: Agent Core ===== -->
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Agent</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Reason · Plan · Execute</text>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
<!-- ===== Top: User Surfaces ===== -->
<rect x="310" y="82" width="340" 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)"/>
<!-- ===== Top: User Surfaces (5 items) ===== -->
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<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="355" 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="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM Channels</text>
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</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"/>
<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)"/>
<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="282" text-anchor="middle" font-size="10" fill="#665245">Structured Digestion</text>
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Memory Extraction</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">Workspace Context</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="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">Citations + Soft Archive</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">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"/>
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
<!-- ===== Right: 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="916" y="210" width="4" height="140" 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="264" text-anchor="middle" font-size="10" fill="#665245">Built-in Tool Suite</text>
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP Protocol</text>
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">Skill Packages + Hub</text>
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">Guard + Approval</text>
<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"/>
<!-- ===== Right Top: Tools & Skills ===== -->
<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="65" rx="2" fill="url(#primary)"/>
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Skills · MCP · ACP</text>
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Even Claude Code joins as a hire</text>
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="554,237 546,242 554,247" 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="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="452" text-anchor="middle" font-size="10" fill="#665245">Short-term Context</text>
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Extraction + Consolidation</text>
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">Memory should compound</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 + Extraction</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">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"/>
<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"/>
<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)"/>
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Model Layer</text>
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ Providers</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="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"/>
<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.3 KiB

View File

@ -33,18 +33,19 @@
<!-- ===== Center: Agent Core ===== -->
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">智能体</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">推理 · 规划 · 执行</text>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
<!-- ===== Top: User Surfaces ===== -->
<rect x="310" y="82" width="340" 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)"/>
<!-- ===== Top: User Surfaces (5 items) ===== -->
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<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="355" 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="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</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 -->
<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"/>
@ -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="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="264" text-anchor="middle" font-size="10" fill="#665245">Wiki 知识库</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="318" text-anchor="middle" font-size="10" fill="#665245">工作区上下文文件</text>
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">知识不是存储,是塑造</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="300" 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>
<!-- Arrow right -->
<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"/>
<!-- ===== Right: 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="916" y="210" width="4" height="140" 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="264" text-anchor="middle" font-size="10" fill="#665245">内置工具集</text>
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP 协议扩展</text>
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">技能包 + ClawHub</text>
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">安全审批与防护</text>
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">能力需要边界</text>
<!-- 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"/>
<!-- ===== Right Top: Tools & Skills ===== -->
<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="65" rx="2" fill="url(#primary)"/>
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">技能 · MCP · ACP</text>
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Claude Code 也来当员工</text>
<!-- Arrow to Tools -->
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="554,237 546,242 554,247" 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="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="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="488" text-anchor="middle" font-size="9" fill="#9b7d6c">记忆应该积累而非消散</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="470" text-anchor="middle" font-size="10" fill="#665245">夜里整合,早上接着</text>
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">你睡了它在工作</text>
<!-- 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"/>
<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"/>
<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)"/>
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型供应</text>
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
<!-- Arrow up -->
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
<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"/>
<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,19 +56,21 @@
</g>
<g transform="translate(632, 118)">
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">DingTalk / Feishu / WeCom</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="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 transform="translate(776, 118)">
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Telegram / Discord / QQ</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="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>
<!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT ENGINE</text>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
<g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -84,9 +86,9 @@
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool System</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in + MCP + Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval + Guard Rules</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
</g>
<g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -132,9 +134,9 @@
<g transform="translate(516, 356)">
<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="36" text-anchor="middle" font-size="9" fill="#665245">Unified Abstraction</text>
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</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">Spring AI · Failover</text>
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Health Tracker · Cooldown</text>
</g>
<g transform="translate(650, 356)">
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -176,8 +178,9 @@
</g>
<g transform="translate(632, 488)">
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Task Automation</text>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron → Channel</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 transform="translate(776, 488)">
<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="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)"/>
<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>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -59,19 +59,21 @@
</g>
<g transform="translate(632, 118)">
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">钉钉 / 飞书 / 企微 / TG</text>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">国内 IM (5)</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 transform="translate(776, 118)">
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Discord / QQ</text>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">海外 IM (3)</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>
<!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">智能体引擎</text>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
<g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -87,15 +89,15 @@
</g>
<g transform="translate(432, 222)">
<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="36" text-anchor="middle" font-size="9" fill="#665245">内置 + MCP + 技能包</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">安全审批 + 防护规则</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
</g>
<g transform="translate(620, 222)">
<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="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 transform="translate(808, 222)">
<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)">
<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="36" text-anchor="middle" font-size="9" fill="#665245">统一模型抽象</text>
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</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">Spring AI 统一抽象</text>
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">健康追踪 · 自动切换</text>
</g>
<g transform="translate(650, 356)">
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -179,8 +181,9 @@
</g>
<g transform="translate(632, 488)">
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Cron 调度引擎</text>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">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 transform="translate(776, 488)">
<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"/>
<!-- 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)"/>
<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>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 897 KiB

View File

@ -21,8 +21,9 @@ services:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
# Schema and seed data are managed by Flyway on application startup.
# 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
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
@ -30,18 +31,26 @@ services:
timeout: 5s
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:
image: searxng/searxng:latest
build:
context: ./docker/searxng
container_name: mateclaw-searxng
restart: unless-stopped
environment:
- SEARXNG_BASE_URL=http://searxng:8080
volumes:
- searxng_data:/etc/searxng
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
- UWSGI_WORKERS=2
- UWSGI_THREADS=4
ports:
- "8088:8080"
healthcheck:
# Healthz needs json format, so this also doubles as an integration check.
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
@ -50,8 +59,10 @@ services:
# MateClaw 后端服务
mateclaw-server:
build:
context: ./mateclaw-server
dockerfile: Dockerfile
context: .
dockerfile: mateclaw-server/Dockerfile
args:
MAVEN_FLAGS: ${MAVEN_FLAGS:-}
container_name: mateclaw-server
restart: unless-stopped
depends_on:
@ -66,16 +77,27 @@ services:
DB_NAME: ${DB_NAME:-mateclaw}
DB_USERNAME: ${DB_USERNAME:-mateclaw}
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:-}
JWT_SECRET: ${JWT_SECRET:-}
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:
- "18080:18080"
- "18080:18088" # host:container — app listens on 18088 inside the container
volumes:
- server_data:/app/data
volumes:
mysql_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 @@
# 多阶段构建
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
# Multi-stage build
#
# Stage 1 — Frontend (Node / pnpm)
# Builds the Vue 3 admin SPA and emits static files to /static inside the
# build container. These files are later copied into the JAR's classpath so
# Spring Boot serves the SPA at the root URL.
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 -Dmaven.test.skip=true -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 -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Stage 3 — Runtime
#
# Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three
# browsers (Chromium / Firefox / WebKit) and every system library Chromium needs
# pre-installed. This avoids the `playwright install` step and the Alpine/musl
# incompatibility that blocks browser_use on minimal images.
#
# We pin to the exact Playwright version declared in 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
# 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
EXPOSE 18088
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]

View File

@ -6,7 +6,7 @@
<groupId>vip.mate</groupId>
<artifactId>mateclaw-server</artifactId>
<version>1.1.0</version>
<version>1.2.0</version>
<packaging>jar</packaging>
<name>MateClaw Server</name>
@ -15,15 +15,15 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
<version>3.5.14</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.4 正式版 -->
<spring-ai.version>1.1.4</spring-ai.version>
<!-- Spring AI 1.1.5 正式版patch upgrade from 1.1.4 -->
<spring-ai.version>1.1.5</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
@ -67,6 +67,12 @@
<artifactId>spring-boot-starter-web</artifactId>
</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 ===== -->
<!--
1.1.2.2 需单独指定版本,不在 BOM 中
@ -189,14 +195,14 @@
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.5</version>
<version>1.3.12</version>
</dependency>
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== -->
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId>
<version>2.5.3</version>
<version>2.6.1</version>
</dependency>
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== -->
@ -274,6 +280,77 @@
<version>2.2.0</version>
</dependency>
<!-- ===== Apache POI (in-process .docx generation) ===== -->
<!--
Used by DocxRenderTool to render Markdown into a .docx in-JVM,
replacing the Node.js docx-js subprocess (3-5 min cold install).
-->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<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) ===== -->
<dependency>
<groupId>org.flywaydb</groupId>
@ -290,6 +367,52 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>
<!-- ShedLock: distributed lock for the cron scheduler so a
multi-instance deployment doesn't fire the same job N times.
JDBC mode reuses the existing DataSource — no Redis dependency
on the desktop / single-node footprint. -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.16.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.16.0</version>
</dependency>
<!-- Graph algorithms (community detection, shortest path, centrality)
used by the wiki page-to-page relevance and insights features. -->
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>
<version>1.5.2</version>
</dependency>
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
Used to walk PDPage resources and pull out PDImageXObject instances
for downstream captioning. -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
</dependencies>
<build>
@ -306,6 +429,143 @@
</excludes>
</configuration>
</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>
</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>

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;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
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
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

View File

@ -0,0 +1,368 @@
package vip.mate.acp.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* RFC-090 Phase 7 minimal Java ACP (Agent Communication Protocol)
* client over stdio.
*
* <p>Implements just enough of the JSON-RPC 2.0 framing to:
* <ol>
* <li>Spawn the agent process ({@code command} + {@code args}).</li>
* <li>Send {@code initialize} and capture {@code agentCapabilities}
* / {@code protocolVersion}.</li>
* <li>Optionally open a {@code session/new} handshake.</li>
* <li>Tear the process down cleanly.</li>
* </ol>
*
* <p>This is intentionally a one-shot connection tester (RFC §10.2 Q3
* recommended starting order: codex claude opencode qwen). Full
* bidirectional session prompting / streaming / permission requests is
* a future increment that needs a proper async bus and ties into the
* agent graph layer.
*
* <p>Why not the official {@code acp} Python SDK: MateClaw runs on the
* JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio; the
* surface we need for "test connection" is small enough to implement
* directly.
*
* <p>Each {@link AcpStdioClient} instance owns one Process. Use
* try-with-resources or call {@link #close()} explicitly.
*/
@Slf4j
public class AcpStdioClient implements AutoCloseable {
/** ACP protocol version we advertise (matches v1 ACP-compatible agents). */
public static final int PROTOCOL_VERSION = 1;
private final ObjectMapper mapper;
private final Process process;
private final Writer stdin;
private final BufferedReader stdout;
private final Thread readerThread;
private final AtomicLong nextRequestId = new AtomicLong(1);
private final Map<Long, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
private volatile boolean closed = false;
/**
* RFC-090 Phase 7b invoked when the agent sends a JSON-RPC
* notification (no id). Notification objects passed in have shape
* {@code {jsonrpc, method, params}}; the most common is
* {@code session/update} carrying agent message chunks.
*
* <p>Default no-op so existing test-only callers don't need to set
* a handler. {@link AcpDelegationService} installs an accumulator
* that scrapes {@code agent_message_chunk} text into a
* {@code StringBuilder}.
*/
private volatile Consumer<JsonNode> notificationHandler = msg -> { /* drop */ };
/**
* RFC-090 Phase 7b invoked when the agent sends a JSON-RPC
* request (has id). The handler returns the JSON-RPC
* {@code result} object (or null to send back -32601 method-not-
* implemented). Used for {@code session/request_permission};
* trusted endpoints auto-allow, untrusted ones cancel.
*/
private volatile Function<JsonNode, JsonNode> requestHandler = msg -> null;
private AcpStdioClient(ObjectMapper mapper, Process process) {
this.mapper = mapper;
this.process = process;
this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
this.stdout = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
this.readerThread.setDaemon(true);
this.readerThread.start();
}
/**
* Spawn the configured agent process. Caller is responsible for
* closing the returned client; failure to do so leaks a child
* process.
*/
public static AcpStdioClient spawn(ObjectMapper mapper,
String command,
List<String> args,
Map<String, String> envOverrides,
String cwd)
throws IOException {
if (command == null || command.isBlank()) {
throw new IllegalArgumentException("ACP command is required");
}
java.util.List<String> cmdline = new java.util.ArrayList<>();
cmdline.add(command);
if (args != null) cmdline.addAll(args);
ProcessBuilder pb = new ProcessBuilder(cmdline);
Map<String, String> env = pb.environment();
if (envOverrides != null) env.putAll(envOverrides);
if (cwd != null && !cwd.isBlank()) {
pb.directory(new java.io.File(cwd));
}
// Keep stderr separate from stdout so we don't poison JSON-RPC
// framing when the child agent writes a banner / log line.
pb.redirectErrorStream(false);
Process proc = pb.start();
// Drain stderr in the background many CLIs print diagnostics
// there (e.g. Zed agents print version on startup).
Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
errDrain.setDaemon(true);
errDrain.start();
return new AcpStdioClient(mapper, proc);
}
private static void drainStream(java.io.InputStream in) {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
}
} catch (IOException ignore) {
// Process exited; nothing to do.
}
}
/**
* Send {@code initialize} and wait for the response. Returns the
* response payload's {@code result} object, or throws on protocol
* mismatch / timeout.
*/
public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
params.put("protocolVersion", PROTOCOL_VERSION);
// ClientCapabilities we don't yet implement any client-side
// optional features. Send an empty object so strict agents
// don't reject the request.
params.set("clientCapabilities", mapper.createObjectNode());
ObjectNode info = mapper.createObjectNode();
info.put("name", "mateclaw-acp-client");
info.put("version", "1.0.0");
params.set("clientInfo", info);
return sendRequest("initialize", params, timeoutMillis);
}
/**
* Send {@code session/new} establishes a session for prompting.
* For the connection-test path we don't actually prompt, just
* verify the server accepts the handshake.
*
* <p>The {@code cwd} parameter is always written into the request
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
* agent-acp} and the codex variant) marks {@code cwd} as a required
* string and returns {@code -32602 Invalid params} when it's
* missing. If the caller passes null/blank we substitute the JVM
* working directory a workspace-aware default lives in
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
* the protocol never sees {@code undefined} regardless of caller.
*/
public JsonNode newSession(String cwd, long timeoutMillis)
throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
String safeCwd = (cwd == null || cwd.isBlank())
? System.getProperty("user.dir", ".")
: cwd;
params.put("cwd", safeCwd);
params.set("mcpServers", mapper.createArrayNode());
return sendRequest("session/new", params, timeoutMillis);
}
/**
* Lower-level request helper. Synchronously awaits the response
* matching the request id. Server-pushed requests (e.g. permission
* prompts) are dropped the test-only connection path doesn't need
* to handle them.
*/
public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
throws IOException, InterruptedException {
if (closed) throw new IOException("ACP client is closed");
long id = nextRequestId.getAndIncrement();
CompletableFuture<JsonNode> future = new CompletableFuture<>();
pending.put(id, future);
ObjectNode envelope = mapper.createObjectNode();
envelope.put("jsonrpc", "2.0");
envelope.put("id", id);
envelope.put("method", method);
envelope.set("params", params);
synchronized (stdin) {
stdin.write(mapper.writeValueAsString(envelope));
stdin.write('\n');
stdin.flush();
}
try {
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException io) throw io;
throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
} catch (java.util.concurrent.TimeoutException e) {
pending.remove(id);
throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
}
}
private void readLoop() {
try {
String line;
while (!closed && (line = stdout.readLine()) != null) {
if (line.isEmpty()) continue;
try {
JsonNode msg = mapper.readTree(line);
routeMessage(msg);
} catch (Exception e) {
log.warn("ACP malformed line, skipping: {}", e.getMessage());
}
}
} catch (IOException e) {
if (!closed) {
log.debug("ACP stdio reader closed: {}", e.getMessage());
}
} finally {
// If the process exited mid-await, fail every pending future.
for (Map.Entry<Long, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
entry.getValue().completeExceptionally(
new IOException("ACP process exited before responding"));
}
pending.clear();
}
}
private void routeMessage(JsonNode msg) {
JsonNode idNode = msg.get("id");
boolean hasId = idNode != null && !idNode.isNull();
boolean hasMethod = msg.has("method");
// (1) Response to one of *our* outbound requests.
if (hasId && idNode.isNumber() && !hasMethod) {
long id = idNode.asLong();
CompletableFuture<JsonNode> future = pending.remove(id);
if (future != null) {
JsonNode error = msg.get("error");
if (error != null && !error.isNull()) {
future.completeExceptionally(
new IOException("ACP error: " + error.toString()));
} else {
future.complete(msg.get("result"));
}
return;
}
}
// (2) Server-initiated request has both method and id.
if (hasMethod && hasId) {
JsonNode result = null;
try {
result = requestHandler.apply(msg);
} catch (Exception e) {
log.warn("ACP requestHandler threw on method '{}': {}",
msg.path("method").asText(""), e.getMessage());
}
sendReplyTo(idNode, result, msg.path("method").asText(""));
return;
}
// (3) Notification has method but no id.
if (hasMethod) {
try {
notificationHandler.accept(msg);
} catch (Exception e) {
log.warn("ACP notificationHandler threw on method '{}': {}",
msg.path("method").asText(""), e.getMessage());
}
}
}
private void sendReplyTo(JsonNode idNode, JsonNode result, String method) {
try {
ObjectNode reply = mapper.createObjectNode();
reply.put("jsonrpc", "2.0");
reply.set("id", idNode);
if (result != null) {
reply.set("result", result);
} else {
ObjectNode error = mapper.createObjectNode();
error.put("code", -32601);
error.put("message", "Method not implemented: " + method);
reply.set("error", error);
}
synchronized (stdin) {
stdin.write(mapper.writeValueAsString(reply));
stdin.write('\n');
stdin.flush();
}
} catch (IOException e) {
log.debug("ACP failed to reply to server-initiated request '{}': {}", method, e.getMessage());
}
}
/**
* Replace the notification handler. Pass {@code null} to fall back
* to the no-op default.
*/
public void setNotificationHandler(Consumer<JsonNode> handler) {
this.notificationHandler = handler != null ? handler : msg -> {};
}
/**
* Replace the server-request handler. Pass {@code null} to fall
* back to the default which returns -32601 for every method.
*/
public void setRequestHandler(Function<JsonNode, JsonNode> handler) {
this.requestHandler = handler != null ? handler : msg -> null;
}
@Override
public void close() {
closed = true;
try {
stdin.close();
} catch (IOException ignore) {
/* best effort */
}
try {
// Give the agent ~1s to exit gracefully after EOF on stdin.
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroy();
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroyForcibly();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
}
try {
stdout.close();
} catch (IOException ignore) {
/* best effort */
}
}
/** Convenience for callers that just want a fresh empty env map. */
public static Map<String, String> emptyEnv() {
return new HashMap<>();
}
}

View File

@ -0,0 +1,79 @@
package vip.mate.acp.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.service.AcpConnectionTester;
import vip.mate.acp.service.AcpEndpointService;
import vip.mate.common.result.R;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 REST surface for managing ACP endpoints.
*
* <p>Mirrors the McpServers controller so the frontend page can be a
* close cousin of {@code McpServers.vue}.
*/
@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
@RestController
@RequestMapping("/api/v1/acp/endpoints")
@RequiredArgsConstructor
public class AcpEndpointController {
private final AcpEndpointService service;
private final AcpConnectionTester tester;
@Operation(summary = "List ACP endpoints")
@GetMapping
public R<List<AcpEndpointEntity>> list() {
return R.ok(service.list());
}
@Operation(summary = "Get ACP endpoint by id")
@GetMapping("/{id}")
public R<AcpEndpointEntity> get(@PathVariable Long id) {
return R.ok(service.get(id));
}
@Operation(summary = "Create a custom ACP endpoint")
@PostMapping
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
return R.ok(service.create(body));
}
@Operation(summary = "Update an ACP endpoint")
@PutMapping("/{id}")
public R<AcpEndpointEntity> update(@PathVariable Long id,
@RequestBody AcpEndpointEntity body) {
return R.ok(service.update(id, body));
}
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
service.delete(id);
return R.ok();
}
@Operation(summary = "Enable / disable an ACP endpoint")
@PutMapping("/{id}/toggle")
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
@RequestParam boolean enabled) {
return R.ok(service.toggle(id, enabled));
}
/**
* Spawn the configured CLI, run {@code initialize} + {@code
* session/new}, persist the outcome, and return diagnostics.
*/
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
@PostMapping("/{id}/test")
public R<Map<String, Object>> test(@PathVariable Long id) {
AcpEndpointEntity endpoint = service.get(id);
return R.ok(tester.testEndpoint(endpoint));
}
}

View File

@ -0,0 +1,27 @@
package vip.mate.acp.event;
/**
* Lifecycle event for ACP endpoint rows.
*
* <p>Published by {@code AcpEndpointService} whenever a row is created,
* updated, toggled, or deleted. Listened to by
* {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
* skill cards and their wrapper tool registrations without a full
* application restart.
*
* <p>Mirrors the {@code SkillWorkspaceEvent} pattern a small immutable
* record carrying just enough context for listeners to fan out.
*/
public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
public enum Type {
/** Row inserted. */
CREATED,
/** Row attributes updated (command/args/env/etc.). */
UPDATED,
/** {@code enabled} flag flipped. */
TOGGLED,
/** Row deleted. */
DELETED
}
}

View File

@ -0,0 +1,79 @@
package vip.mate.acp.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* RFC-090 Phase 7 ACP (Agent Communication Protocol) endpoint registry.
*
* <p>Each row describes one external coding agent that MateClaw can
* delegate to over stdio (codex / claude-code / opencode / qwen-code by
* default). Bundled via Flyway V68 so the user only has to enable the
* row once the matching CLI is on their PATH.
*/
@Data
@TableName("mate_acp_endpoint")
public class AcpEndpointEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Stable slug, lowercase. Referenced by skill manifests via {@code type: acp} + {@code endpoint:}. */
private String name;
private String displayName;
private String description;
/** Process command, e.g. {@code npx} or {@code codex}. */
private String command;
/**
* JSON array of CLI args, e.g. {@code ["-y","@zed-industries/codex-acp"]}.
* MyBatis Plus stores it as a string; the service layer parses on read.
*/
@TableField(value = "args_json", updateStrategy = FieldStrategy.ALWAYS)
private String argsJson;
/** JSON object of environment variables to inject (merged onto System.getenv()). */
@TableField(value = "env_json", updateStrategy = FieldStrategy.ALWAYS)
private String envJson;
/**
* call_title | call_detail | update_detail (mirrors the ACP
* {@code tool_parse_mode} convention). Drives how the wrapper
* renders ACP tool-call events into MateClaw's stream protocol.
*/
private String toolParseMode;
private Boolean builtin;
/** When true, accept the agent's tool calls without re-prompting the user. */
private Boolean trusted;
private Boolean enabled;
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
private Long stdioBufferLimitBytes;
/** UNKNOWN / OK / ERROR — last test result. */
private String lastStatus;
private LocalDateTime lastTestedAt;
@TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS)
private String lastError;
private Long workspaceId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

View File

@ -0,0 +1,12 @@
package vip.mate.acp.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.acp.model.AcpEndpointEntity;
/**
* RFC-090 Phase 7 MyBatis Plus mapper for {@link AcpEndpointEntity}.
*/
@Mapper
public interface AcpEndpointMapper extends BaseMapper<AcpEndpointEntity> {
}

View File

@ -0,0 +1,128 @@
package vip.mate.acp.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.acp.client.AcpStdioClient;
import vip.mate.acp.model.AcpEndpointEntity;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 connection tester for ACP endpoints.
*
* <p>Runs the {@code initialize} + {@code session/new} handshake, with
* a generous-but-bounded timeout, and persists the outcome on the row.
* The wired CLI doesn't have to be installed for the user to add a row;
* they can install it later and re-run the test.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpConnectionTester {
/** Hard cap so a hung CLI doesn't block the request thread forever. */
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Spawn the configured agent, exchange initialize + session/new,
* tear it down, and return a structured result. The endpoint row
* is updated with {@code last_status / last_tested_at / last_error}.
*/
public Map<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
long started = System.currentTimeMillis();
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", endpoint.getName());
result.put("command", endpoint.getCommand());
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
result.put("args", args);
// Same as AcpDelegationService Zed's ACP server requires a
// non-blank cwd at session/new, so the connection test must
// also default it. The "Test" button used to fail at session/new
// with -32602 even when the CLI itself was healthy.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, resolvedCwd);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Spawn failed: " + e.getMessage(), started);
}
try (AcpStdioClient autoClose = client) {
JsonNode initResp;
try {
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize failed: " + e.getMessage(), started);
}
if (initResp == null) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize returned no result", started);
}
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
result.put("protocolVersion", agentProtocolVersion);
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
return persistAndReturn(endpoint, result, "ERROR", msg, started);
}
// Capture agent capabilities for diagnostics the UI can
// surface this as "supports: file_system, terminal, …".
JsonNode agentCaps = initResp.path("agentCapabilities");
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
result.put("agentCapabilities", agentCaps);
}
// session/new validates that the agent really stands up a
// working session, not just initialize handshake.
try {
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
if (sessionResp != null && sessionResp.has("sessionId")) {
result.put("sessionId", sessionResp.path("sessionId").asText(""));
}
} catch (Exception e) {
// session/new may fail for legitimate reasons (e.g. agent
// requires auth flow first). Still report OK on initialize
// but flag in the message translated when it smells
// like an auth error so the test page UI shows actionable
// text instead of raw JSON-RPC.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
}
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Connection test crashed: " + e.getMessage(), started);
}
long elapsed = System.currentTimeMillis() - started;
result.put("elapsedMs", elapsed);
return persistAndReturn(endpoint, result, "OK", null, started);
}
private Map<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
Map<String, Object> result,
String status,
String error,
long started) {
endpointService.recordTestResult(endpoint.getId(), status, error);
result.put("status", status);
if (error != null) result.put("error", error);
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
return result;
}
}

View File

@ -0,0 +1,241 @@
package vip.mate.acp.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.acp.client.AcpStdioClient;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.exception.MateClawException;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7b fire-and-forget delegation to an external ACP
* agent.
*
* <p>One {@link #prompt(String, String, String)} call:
* <ol>
* <li>Looks up the endpoint row, refuses if disabled or undefined.</li>
* <li>Spawns a fresh {@link AcpStdioClient} (no session caching in
* v1 stateless tool calls keep failure surface small;
* multi-turn caching can be a follow-up RFC).</li>
* <li>Runs {@code initialize session/new session/prompt}.</li>
* <li>Accumulates {@code agent_message_chunk} text from
* {@code session/update} notifications into the response.</li>
* <li>Auto-allows or cancels {@code session/request_permission}
* based on the endpoint's {@code trusted} flag untrusted
* endpoints reject every permission request, surfacing a
* transparent "this endpoint can't be used non-interactively"
* error to the LLM caller.</li>
* <li>Returns the accumulated text or a JSON error blob on failure.</li>
* </ol>
*
* <p>The streaming surface (chunk-by-chunk relay back through MateClaw's
* own SSE stream) is intentionally not done yet the wrapper tool is
* synchronous so it composes cleanly with the existing ReAct graph.
* When we want native streaming, we'll add a second method that takes
* an {@code Sinks.Many<String>}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpDelegationService {
/** Hard ceiling on a single ACP delegation. Long enough for a
* multi-turn coding session, short enough that a hung agent can't
* permanently block an LLM tool call. */
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Run a one-shot ACP prompt against {@code endpointName}. Returns
* the agent's accumulated reply text. Throws
* {@link MateClawException} for configuration / runtime errors so
* the caller (typically a wrapper tool) can serialize a friendly
* JSON error.
*/
public String prompt(String endpointName, String userPrompt, String cwdHint) {
if (endpointName == null || endpointName.isBlank()) {
throw new MateClawException("err.acp.endpoint_required",
"ACP endpoint name is required");
}
if (userPrompt == null || userPrompt.isBlank()) {
throw new MateClawException("err.acp.prompt_required",
"ACP prompt is required");
}
AcpEndpointEntity endpoint = endpointService.findByName(endpointName);
if (endpoint == null) {
throw new MateClawException("err.acp.endpoint_not_found",
"ACP endpoint not found: " + endpointName);
}
if (!Boolean.TRUE.equals(endpoint.getEnabled())) {
throw new MateClawException("err.acp.endpoint_disabled",
"ACP endpoint '" + endpointName + "' is disabled — enable it in Settings ▸ ACP Endpoints");
}
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
// Always resolve cwd to a real directory: Zed's ACP Zod schema
// marks cwd as a required string and rejects {@code undefined}
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
StringBuilder accumulator = new StringBuilder();
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, resolvedCwd);
} catch (IOException e) {
throw new MateClawException("err.acp.spawn_failed",
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
}
try (AcpStdioClient autoClose = client) {
wireHandlers(autoClose, accumulator, trusted, endpointName);
JsonNode initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
if (initResp == null || initResp.path("protocolVersion").asInt(-1)
!= AcpStdioClient.PROTOCOL_VERSION) {
throw new MateClawException("err.acp.protocol_mismatch",
"ACP protocol mismatch with endpoint '" + endpointName + "'");
}
JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
String sessionId = session == null ? null : session.path("sessionId").asText("");
if (sessionId == null || sessionId.isBlank()) {
throw new MateClawException("err.acp.session_failed",
"ACP session/new returned no sessionId for '" + endpointName + "'");
}
ObjectNode promptParams = objectMapper.createObjectNode();
promptParams.put("sessionId", sessionId);
promptParams.set("prompt", buildPromptArray(userPrompt));
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
// Upstream CLIs (claude-code / codex / qwen-code) wrap their
// own auth failures in opaque JSON-RPC noise. Recognise the
// 401/403/forbidden/unauthorized fingerprints and rewrite
// the message into something the user can act on, with the
// exact env var name they need to set.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
if (authHint != null) {
throw new MateClawException("err.acp.auth_failed", authHint);
}
throw new MateClawException("err.acp.delegation_failed",
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
}
return accumulator.toString().trim();
}
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
boolean trusted, String endpointName) {
// Notifications carry session/update messages; agent_message_chunk
// is what we accumulate. Other update kinds (tool_call_*, plan,
// current_mode) are observed but not relayed in v1.
client.setNotificationHandler(msg -> {
String method = msg.path("method").asText("");
if (!"session/update".equals(method)) return;
JsonNode update = msg.path("params").path("update");
if (update.isMissingNode() || update.isNull()) return;
String type = update.path("sessionUpdate").asText(
update.path("type").asText(""));
if ("agent_message_chunk".equals(type) || "agent-message-chunk".equals(type)) {
String text = extractText(update.path("content"));
if (!text.isEmpty()) buf.append(text);
}
});
// Permission requests: trusted endpoints auto-allow the FIRST
// option (which Zed-style agents make the "allow" choice);
// untrusted refuse every request explicitly so the agent
// exits cleanly instead of hanging.
client.setRequestHandler(msg -> {
String method = msg.path("method").asText("");
if (!"session/request_permission".equals(method)) return null;
JsonNode params = msg.path("params");
if (!trusted) {
log.info("[ACP] declining permission for untrusted endpoint '{}'", endpointName);
return cancelledOutcome();
}
JsonNode options = params.path("options");
String optionId = "";
if (options.isArray() && options.size() > 0) {
JsonNode first = options.get(0);
optionId = first.path("optionId").asText(first.path("id").asText(""));
}
if (optionId.isEmpty()) {
return cancelledOutcome();
}
return selectedOutcome(optionId);
});
}
private JsonNode buildPromptArray(String text) {
// Spring AI / Zed ACP prompt format: array of content blocks.
// For now we only emit a single text block; future iterations
// can attach images / file references via additional blocks.
var arr = objectMapper.createArrayNode();
ObjectNode block = objectMapper.createObjectNode();
block.put("type", "text");
block.put("text", text);
arr.add(block);
return arr;
}
/**
* Extract plain text from an ACP {@code content} field. The shape
* varies between agents Zed uses {@code [{type:"text",text:"..."}]},
* some emit a single object, others nest in {@code resource.text}.
* Tolerant extractor that handles all known shapes.
*/
private String extractText(JsonNode content) {
if (content == null || content.isNull()) return "";
if (content.isArray()) {
StringBuilder sb = new StringBuilder();
for (JsonNode item : content) sb.append(extractText(item));
return sb.toString();
}
JsonNode text = content.get("text");
if (text != null && text.isTextual()) return text.asText("");
JsonNode resource = content.get("resource");
if (resource != null) {
JsonNode rt = resource.get("text");
if (rt != null && rt.isTextual()) return rt.asText("");
}
return "";
}
private ObjectNode selectedOutcome(String optionId) {
ObjectNode result = objectMapper.createObjectNode();
ObjectNode outcome = objectMapper.createObjectNode();
outcome.put("outcome", "selected");
outcome.put("optionId", optionId);
result.set("outcome", outcome);
return result;
}
private ObjectNode cancelledOutcome() {
ObjectNode result = objectMapper.createObjectNode();
ObjectNode outcome = objectMapper.createObjectNode();
outcome.put("outcome", "cancelled");
result.set("outcome", outcome);
return result;
}
}

View File

@ -0,0 +1,192 @@
package vip.mate.acp.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.acp.event.AcpEndpointChangedEvent;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.repository.AcpEndpointMapper;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 CRUD layer for {@link AcpEndpointEntity}.
*
* <p>Keeps three guarantees:
* <ol>
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted
* the user can only disable them. Mirrors {@code SkillService}.</li>
* <li>Names are unique; {@code create} validates against the live
* (non-deleted) set.</li>
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
* so the controller can hand structured data to the UI without
* leaking string-encoded JSON.</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpEndpointService {
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
public List<AcpEndpointEntity> list() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
.orderByDesc(AcpEndpointEntity::getBuiltin)
.orderByAsc(AcpEndpointEntity::getName));
}
/**
* Subset of {@link #list()} that returns only enabled rows.
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
* (one per enabled endpoint).
*/
public List<AcpEndpointEntity> listEnabled() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
.eq(AcpEndpointEntity::getEnabled, true)
.orderByAsc(AcpEndpointEntity::getName));
}
public AcpEndpointEntity get(Long id) {
AcpEndpointEntity ep = mapper.selectById(id);
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
"ACP endpoint not found: " + id);
return ep;
}
public AcpEndpointEntity findByName(String name) {
return mapper.selectOne(new LambdaQueryWrapper<AcpEndpointEntity>()
.eq(AcpEndpointEntity::getName, name));
}
public AcpEndpointEntity create(AcpEndpointEntity input) {
if (input.getName() == null || input.getName().isBlank()) {
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
}
if (input.getCommand() == null || input.getCommand().isBlank()) {
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
}
if (findByName(input.getName()) != null) {
throw new MateClawException("err.acp.name_exists",
"ACP endpoint name already exists: " + input.getName());
}
// User-created rows are never builtin; default-enable false so a
// misconfigured row can't auto-spawn a process at startup.
input.setBuiltin(false);
if (input.getEnabled() == null) input.setEnabled(false);
if (input.getTrusted() == null) input.setTrusted(true);
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
input.setToolParseMode("call_title");
}
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
}
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
publish(input, AcpEndpointChangedEvent.Type.CREATED);
return input;
}
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
AcpEndpointEntity existing = get(id);
if (Boolean.TRUE.equals(existing.getBuiltin())
&& patch.getCommand() != null
&& !patch.getCommand().equals(existing.getCommand())) {
throw new MateClawException("err.acp.builtin_command_locked",
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
}
// Allow surgical updates: only fields the caller actually set.
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
}
public void delete(Long id) {
AcpEndpointEntity existing = get(id);
if (Boolean.TRUE.equals(existing.getBuiltin())) {
throw new MateClawException("err.acp.builtin_readonly",
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
}
mapper.deleteById(id);
log.info("Deleted ACP endpoint: {}", existing.getName());
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
}
public AcpEndpointEntity toggle(Long id, boolean enabled) {
AcpEndpointEntity existing = get(id);
existing.setEnabled(enabled);
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
return existing;
}
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
try {
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
ep.getId(), ep.getName(), type));
} catch (Exception e) {
// Listener failures must not break the CRUD path. The bridge
// will resync on the next ApplicationReady tick anyway.
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
ep.getName(), e.getMessage());
}
}
/** Persist a connection-test outcome on the row. */
public void recordTestResult(Long id, String status, String error) {
AcpEndpointEntity existing = mapper.selectById(id);
if (existing == null) return;
existing.setLastStatus(status);
existing.setLastTestedAt(LocalDateTime.now());
existing.setLastError(error);
mapper.updateById(existing);
}
public List<String> parseArgs(AcpEndpointEntity ep) {
return parseStringList(ep.getArgsJson());
}
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
try {
return objectMapper.readValue(ep.getEnvJson(),
new TypeReference<Map<String, String>>() {});
} catch (Exception e) {
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
ep.getName(), e.getMessage());
return Map.of();
}
}
private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to parse args_json: {}", e.getMessage());
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,188 @@
package vip.mate.acp.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.service.WorkspaceService;
import java.io.File;
import java.util.Locale;
/**
* Shared runtime helpers for ACP code paths.
*
* <p>Two responsibilities, both motivated by upstream ACP servers
* (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
* inputs and noisy in failure modes:
*
* <ul>
* <li>{@link #resolveCwd} pick a non-blank cwd for {@code session/new}.
* Zed's ACP Zod schema marks {@code cwd} as a required string and
* returns {@code -32602 Invalid params} when it's missing. We
* prefer the endpoint's bound workspace {@code base_path} (per-
* workspace context) and fall back to the JVM working directory
* only as a last resort. Never returns null/blank.</li>
*
* <li>{@link #translateAuthError} turn upstream JSON-RPC noise like
* {@code "API Error: 403 {...forbidden...}"} into an actionable
* hint that names the env var the user actually has to set in
* Settings ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
* claude-code, {@code OPENAI_API_KEY} for codex). Returns
* {@code null} when the error doesn't smell like an auth failure.</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AcpRuntimeSupport {
private final WorkspaceService workspaceService;
/**
* Resolution order (first non-blank wins):
* <ol>
* <li>Caller-provided hint (skill manifest's {@code acp.cwd},
* wrapper tool {@code cwd} arg, or explicit override).</li>
* <li>Workspace {@code base_path} when the endpoint is bound to a
* workspace and the workspace declares one.</li>
* <li>{@code System.getProperty("user.dir")} the JVM working
* directory at server launch. Reasonable for a single-user
* desktop install, but exposes the server's launch dir to the
* upstream agent, which is why it's last.</li>
* </ol>
*/
public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
if (callerHint != null && !callerHint.isBlank()) {
return callerHint;
}
if (endpoint != null && endpoint.getWorkspaceId() != null) {
try {
WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
File f = new File(ws.getBasePath());
if (f.isDirectory()) return f.getAbsolutePath();
}
} catch (Exception e) {
log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
endpoint.getWorkspaceId(), e.getMessage());
}
}
return System.getProperty("user.dir", ".");
}
/**
* Detect upstream auth errors and emit an actionable hint string.
* Returns null when the message doesn't match caller should keep
* the original error as-is.
*
* <p>Heuristic: looks for HTTP-like 401/403 markers OR the words
* {@code forbidden / unauthorized / not allowed / api key / token}
* in the original message (case-insensitive). The patterns are loose
* on purpose different ACP CLIs phrase auth errors differently
* and the cost of a false positive (a slightly more verbose error
* banner) is much smaller than a false negative (user staring at a
* raw JSON-RPC blob).
*
* <p>Special case: a claude-code endpoint returning {@code 403
* "Request not allowed"} is almost always the keychain-hijack
* scenario rather than a wrong API key. The third-party
* {@code @zed-industries/claude-agent-acp} package wraps
* {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
* checks the macOS keychain ({@code Claude Code-credentials}) /
* {@code ~/.claude/credentials.json} BEFORE the
* {@code ANTHROPIC_API_KEY} env var. So a host that's done
* {@code claude login} silently shadows whatever API key the user
* configured in the endpoint env, and Anthropic's API rejects the
* subscription OAuth token (first-party-only) with the very
* specific {@code "Request not allowed"} error string. We detect
* that exact combination and surface the keychain-clearing remedy
* instead of the generic "set ANTHROPIC_API_KEY" hint, which
* doesn't apply here.
*/
public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
if (originalMessage == null) return null;
String lower = originalMessage.toLowerCase(Locale.ROOT);
boolean looksLikeAuth =
lower.contains("403")
|| lower.contains("401")
|| lower.contains("forbidden")
|| lower.contains("unauthorized")
|| lower.contains("not allowed")
|| lower.contains("invalid api key")
|| lower.contains("invalid token")
|| lower.contains("authenticate");
if (!looksLikeAuth) return null;
String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
String slug = lower(name);
String command = endpoint != null ? lower(endpoint.getCommand()) : "";
// Keychain-hijack detection must come before the generic env-
// missing branch because both would superficially match.
boolean keychainHijack = lower.contains("request not allowed")
&& (slug.contains("claude") || command.contains("claude-agent-acp"));
if (keychainHijack) {
StringBuilder sb = new StringBuilder();
sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
sb.append("credentials are hijacking the SDK auth path. ");
sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
sb.append("third-party processes aren't allowed to use it. ");
sb.append("To fix: ");
sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
sb.append("-s \"Claude Code-credentials\"`; ");
sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
sb.append("Then click Test connection again. Original: ").append(originalMessage);
return sb.toString();
}
String envVar = expectedAuthEnvVar(endpoint);
StringBuilder sb = new StringBuilder();
sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
sb.append("Most likely the endpoint env has no API key. ");
sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
if (envVar != null) {
sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
} else {
sb.append("add the appropriate API key for this CLI");
}
sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
sb.append("so a real API key is required. Original: ").append(originalMessage);
return sb.toString();
}
/**
* Best-effort guess of the API key env var the upstream CLI expects.
* Returns null when we don't recognise the endpoint caller emits a
* generic "appropriate API key" hint instead.
*/
public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
if (endpoint == null) return null;
String name = lower(endpoint.getName());
String command = lower(endpoint.getCommand());
// Match by name first (slug is the stable identifier); fall back
// to command keywords for user-defined rows.
if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
return "ANTHROPIC_API_KEY";
}
if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
return "OPENAI_API_KEY";
}
if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
return "DASHSCOPE_API_KEY";
}
if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
return "GOOGLE_API_KEY";
}
// opencode multi-model no single canonical env var.
return null;
}
private static String lower(String s) {
return s == null ? "" : s.toLowerCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,219 @@
package vip.mate.activity;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.audit.model.AuditEventEntity;
import vip.mate.audit.repository.AuditEventMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-090 §4.5 / §7 unified Activity feed.
*
* <p>Merges three sources into one chronologically-ordered stream:
* <ul>
* <li>{@code audit_event} CRUD-style events on agents / channels /
* skills / wiki / workspace (the existing audit log)</li>
* <li>{@code tool_approval} approval requests + their resolution
* (granted / denied / expired). Ties tool gating decisions
* directly to the audit timeline.</li>
* <li>Successful tool calls RFC §4.5 mentions these, but the
* runtime doesn't yet persist a row per successful call.
* Returning an empty bucket keeps the API contract stable so
* the UI can light up automatically once a future commit adds
* persistence.</li>
* </ul>
*
* <p>Pagination is best-effort: each source is paged from index 0
* up to {@code size * 2}, then the merged list is trimmed and offset
* in-memory. For workspaces with >>1k events / day a follow-up should
* push merging into SQL; this is good enough for v1.
*/
@Tag(name = "Activity Feed (RFC-090)")
@RestController
@RequestMapping("/api/v1/activity")
@RequiredArgsConstructor
public class ActivityFeedController {
private final AuditEventService auditEventService;
private final AuditEventMapper auditEventMapper;
private final ToolApprovalMapper toolApprovalMapper;
/**
* RFC-090 §4.5 paginated activity feed.
*
* <p>Pagination strategy:
* <ul>
* <li><b>Single-source filter</b> (source=audit | approval)
* direct {@code BaseMapper.selectPage(...)} on the matching
* table. Both total and records are SQL-accurate.</li>
* <li><b>Combined feed</b> (source unset) fetch
* {@code page*size} rows from each side, merge by time-desc,
* slice to the requested window. {@code total} is the sum
* of {@code selectCount} across both tables exact for
* count, best-effort for time-merge ordering at very deep
* page numbers (the merge buffer is bounded but typical
* use stays within a few hundred rows).</li>
* </ul>
*
* <p>Caps: {@code size} clamped to [1, 200]; {@code page} 1.
*/
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
@GetMapping("/feed")
public R<Map<String, Object>> feed(
@RequestParam(required = false) Long workspaceId,
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
if (size <= 0) size = 20;
if (size > 200) size = 200;
if (page <= 0) page = 1;
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
// Single-source path: direct SQL pagination
if (wantAudit && !wantApproval) {
return R.ok(pageAuditOnly(workspaceId, page, size));
}
if (wantApproval && !wantAudit) {
return R.ok(pageApprovalOnly(page, size));
}
// Combined path: per-source paginate + merge
// Fetch page*size from each side so the merged window contains
// the requested slice even in the worst case where one source
// dominates the timeline. This is wasteful at very deep pages
// but bounded a follow-up can push merging into SQL via a
// UNION ALL view if event volume gets into 10k+/day territory.
int bufferSize = Math.max(size * page, 50);
LambdaQueryWrapper<AuditEventEntity> auditQ = new LambdaQueryWrapper<AuditEventEntity>()
.orderByDesc(AuditEventEntity::getCreateTime);
if (workspaceId != null) auditQ.eq(AuditEventEntity::getWorkspaceId, workspaceId);
IPage<AuditEventEntity> auditPage = auditEventMapper.selectPage(new Page<>(1, bufferSize), auditQ);
LambdaQueryWrapper<ToolApprovalEntity> approvalQ = new LambdaQueryWrapper<ToolApprovalEntity>()
.orderByDesc(ToolApprovalEntity::getCreatedAt);
IPage<ToolApprovalEntity> approvalPage = toolApprovalMapper.selectPage(new Page<>(1, bufferSize), approvalQ);
List<ActivityRow> rows = new ArrayList<>();
for (AuditEventEntity ev : auditPage.getRecords()) rows.add(fromAuditEvent(ev));
for (ToolApprovalEntity ap : approvalPage.getRecords()) rows.add(fromApproval(ap));
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
long total = auditPage.getTotal() + approvalPage.getTotal();
int from = Math.min((page - 1) * size, rows.size());
int to = Math.min(from + size, rows.size());
List<ActivityRow> sliced = rows.subList(from, to);
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", total);
resp.put("records", sliced);
return R.ok(resp);
}
/** Pure SQL pagination on the audit_event table; total + records both
* come from the underlying {@link Page} object. */
private Map<String, Object> pageAuditOnly(Long workspaceId, int page, int size) {
LambdaQueryWrapper<AuditEventEntity> q = new LambdaQueryWrapper<AuditEventEntity>()
.orderByDesc(AuditEventEntity::getCreateTime);
if (workspaceId != null) q.eq(AuditEventEntity::getWorkspaceId, workspaceId);
IPage<AuditEventEntity> p = auditEventMapper.selectPage(new Page<>(page, size), q);
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
for (AuditEventEntity ev : p.getRecords()) records.add(fromAuditEvent(ev));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", p.getTotal());
resp.put("records", records);
return resp;
}
/** Pure SQL pagination on the tool_approval table. */
private Map<String, Object> pageApprovalOnly(int page, int size) {
LambdaQueryWrapper<ToolApprovalEntity> q = new LambdaQueryWrapper<ToolApprovalEntity>()
.orderByDesc(ToolApprovalEntity::getCreatedAt);
IPage<ToolApprovalEntity> p = toolApprovalMapper.selectPage(new Page<>(page, size), q);
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
for (ToolApprovalEntity ap : p.getRecords()) records.add(fromApproval(ap));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", p.getTotal());
resp.put("records", records);
return resp;
}
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("detailJson", ev.getDetailJson());
detail.put("userAgent", ev.getUserAgent());
detail.put("workspaceId", ev.getWorkspaceId());
return new ActivityRow(
"audit-" + ev.getId(),
"audit",
ev.getCreateTime(),
ev.getUsername(),
ev.getAction(),
ev.getResourceType(),
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
ev.getIpAddress(),
detail);
}
private ActivityRow fromApproval(ToolApprovalEntity ap) {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("toolArguments", ap.getToolArguments());
detail.put("summary", ap.getSummary());
detail.put("maxSeverity", ap.getMaxSeverity());
detail.put("status", ap.getStatus());
detail.put("resolvedAt", ap.getResolvedAt());
// Map approval status onto an audit-style action so the UI's
// existing action coloring (CREATE / DELETE / etc.) keeps
// working without a special case.
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
return new ActivityRow(
"approval-" + ap.getId(),
"approval",
ap.getCreatedAt(),
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
action,
"TOOL_APPROVAL",
ap.getToolName(),
null,
detail);
}
/**
* Wire-format row. Public record so Jackson serializes it directly
* without needing a separate DTO.
*/
public record ActivityRow(
String id,
String source,
LocalDateTime time,
String username,
String action,
String resourceType,
String resourceName,
String ipAddress,
Map<String, Object> detail
) {}
}

View File

@ -7,15 +7,22 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
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.repository.AgentMapper;
import vip.mate.exception.MateClawException;
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 java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Agent 业务服务
@ -33,6 +40,8 @@ public class AgentService {
private final AgentMapper agentMapper;
private final AgentGraphBuilder agentGraphBuilder;
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
@ -91,28 +100,67 @@ public class AgentService {
// ==================== 运行时入口 ====================
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);
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) {
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message);
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) {
return chatStructuredStream(agentId, message, conversationId, "", null);
return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
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,
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);
BaseAgent agent = getOrBuildAgent(agentId);
@ -129,22 +177,45 @@ public class AgentService {
}
}
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
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 : "")
.doFinally(signal -> ThinkingLevelHolder.clear());
.doFinally(signal -> ThinkingLevelHolder.clear()),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
}
// 降级不支持结构化流的 Agent包装为纯内容流
ThinkingLevelHolder.clear();
return agent.chatStream(message, conversationId)
.map(chunk -> new StreamDelta(chunk, null));
return Flux.defer(() -> {
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) {
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
}
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, goal);
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,
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);
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,
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,
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);
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
requesterId != null ? requesterId : "");
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
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) {
@ -208,6 +304,66 @@ public class AgentService {
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) {

View File

@ -5,8 +5,8 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.LinkedHashMap;
/**
* Agent 统一工具集合
@ -14,6 +14,20 @@ import java.util.LinkedHashMap;
* @Tool BeanToolCallbackProviderMCP server 暴露的 tool callbacks
* 统一收集为一致的 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
*/
public class AgentToolSet {
@ -21,26 +35,69 @@ public class AgentToolSet {
private final List<Object> toolBeans;
private final List<ToolCallback> callbacks;
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);
// 按工具名去重内置工具在前先添加MCP 工具在后同名时保留内置工具
// 使用 LinkedHashMap 保证插入顺序确保内置工具始终排在 MCP 工具前面影响 LLM 工具选择倾向
this.callbackByName = callbacks.stream()
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
.collect(Collectors.toMap(
cb -> cb.getToolDefinition().name(),
cb -> cb,
(a, b) -> a,
LinkedHashMap::new));
this.callbackByName = byName;
// 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 的场景
*/
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 工具不会暴露给模型模型完全不知道它们的存在
*
* @param deniedTools denied 工具名集合为空或 null 时直接返回 this
* @param deniedTools denied 工具名集合接受 function name / bean name / class simple name
* 为空或 null 时直接返回 this
*/
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
if (deniedTools == null || deniedTools.isEmpty()) {
return this;
}
List<ToolCallback> filtered = new ArrayList<>(callbacks);
filtered.removeIf(cb -> deniedTools.contains(cb.getToolDefinition().name()));
return new AgentToolSet(toolBeans, filtered);
Set<ToolCallback> denied = resolveAliases(deniedTools);
if (denied.isEmpty()) {
return this;
}
List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !denied.contains(cb))
.toList();
return rebuild(filtered);
}
/**
* 仅保留指定名称的工具白名单模式用于 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) {
if (allowedTools == null) {
return this; // null = 无绑定使用全局默认
}
List<ToolCallback> filtered = new ArrayList<>(callbacks);
filtered.removeIf(cb -> !allowedTools.contains(cb.getToolDefinition().name()));
return new AgentToolSet(toolBeans, filtered);
Set<ToolCallback> allowed = resolveAliases(allowedTools);
List<ToolCallback> filtered = callbacks.stream()
.filter(allowed::contains)
.toList();
return rebuild(filtered);
}
/**
@ -122,15 +188,21 @@ public class AgentToolSet {
/**
* 返回排除指定工具名后的新 AgentToolSet
*
* @param toolNames 要排除的工具名集合接受 function name / bean name / class simple name
*/
public AgentToolSet excluding(Set<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) {
return this;
}
Set<ToolCallback> excluded = resolveAliases(toolNames);
if (excluded.isEmpty()) {
return this;
}
List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !toolNames.contains(cb.getToolDefinition().name()))
.filter(cb -> !excluded.contains(cb))
.toList();
return new AgentToolSet(toolBeans, filtered);
return rebuild(filtered);
}
/**
@ -146,4 +218,107 @@ public class AgentToolSet {
public int 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

@ -10,6 +10,7 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -18,7 +19,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
/**
@ -43,8 +46,13 @@ public abstract class BaseAgent {
/** 系统提示词 */
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).
*/
public static final int MAX_ITERATIONS_HARD_CEILING = 100;
protected int maxIterations = 100;
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */
protected String workspaceBasePath;
@ -52,6 +60,13 @@ public abstract class BaseAgent {
/** 模型名称 */
protected String modelName;
/**
* Modalities the chat model can natively consume (resolved at agent build time
* by {@link vip.mate.llm.service.ModelCapabilityService}). Empty set = unknown model,
* fall back to text-only behavior. See issue #44.
*/
protected Set<ModelCapabilityService.Modality> modelCapabilities = EnumSet.noneOf(ModelCapabilityService.Modality.class);
/** 采样温度 */
protected Double temperature;
@ -217,20 +232,124 @@ public abstract class BaseAgent {
List<Message> messages = new ArrayList<>(limit);
for (int i = 0; i < limit; i += 1) {
MessageEntity entity = 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);
Message springMessage = sanitizeForLlm(history.get(i));
if (springMessage != null) {
messages.add(springMessage);
}
}
// Tail guard orphan-user strip (issue #47).
//
// Invariant: every caller of buildConversationHistory appends the
// current user message AFTER this history (BaseAgent.buildClient
// via .user(), StateGraphReActAgent / StateGraphPlanExecuteAgent
// via messages.add(buildCurrentUserMessage)). So the final prompt is
// [system, ...history, current_user]
// and is *always* terminated by a user message. That means a trailing
// assistant in history is FINE for every provider we support it
// produces the correct [..., user, assistant, current_user] alternation
// (OpenAI, Anthropic, DeepSeek regular/thinking, Gemini, Qwen, ).
//
// The actual hazard is the opposite: a trailing USER in history.
// That happens when the immediately-prior turn's assistant message
// was dropped by Stage 1 (approval placeholder) or Stage 1.5 (errored
// turn / "[错误] " row), or never persisted at all (turn interrupted
// before doOnComplete saved the assistant). In that case the history
// ends with an orphan unanswered user, and appending the current user
// produces TWO consecutive user messages. Most providers concatenate
// those and answer both leaking the orphan question's answer
// alongside the current answer. (This was the symptom reported in
// issue #47, originally caused by a tail guard that stripped trailing
// ASSISTANT messages instead of trailing USER ones a direction-
// reversed version of this loop.)
//
// Stripping orphan users is safe: the user re-asked or asked a new
// question; the orphan turn produced no answer the model can build
// on. We lose a small amount of conversational context in exchange
// for clean alternation across every provider.
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) {
messages.remove(messages.size() - 1);
}
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 0: drop cron-run header rows (system role + "📋 " prefix)
// inserted by CronJobLifecycleService.startRun. These are UI dividers
// for the unified tasks_<wsId> view and the IM channel-session
// mirror they carry no semantic context for the LLM. Without this
// skip, every subsequent IM turn would feed the model unsolicited
// SystemMessage rows like "📋 每日新闻 · 定时触发 · 2026-04-30T10:55"
// and bloat the prompt with scheduler metadata.
if ("system".equals(entity.getRole())
&& entity.getContent() != null
&& entity.getContent().startsWith("📋 ")) {
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 +375,86 @@ public abstract class BaseAgent {
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) {
if (message == null) {
return null;
@ -264,10 +463,33 @@ public abstract class BaseAgent {
if (renderedContent == null || renderedContent.isBlank()) {
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()) {
case "assistant" -> new AssistantMessage(renderedContent);
case "system" -> new SystemMessage(renderedContent);
case "user" -> buildUserMessage(message, renderedContent);
// History user messages: text only. Re-injecting Media on every replay
// accumulates attachments across turns many providers cap at 1 video
// per request (e.g. Zhipu GLM-5V returns code 1210). The current turn
// gets Media via buildCurrentUserMessage, which is the only path that
// should send raw bytes to the model.
case "user" -> buildUserMessage(message, renderedContent, false);
default -> null;
};
}
@ -276,15 +498,15 @@ public abstract class BaseAgent {
/**
* 判断当前模型是否支持视频输入
* 仅已知支持视频分析的视觉模型Qwen-VLGPT-4oGemini 才注入视频 Media
* {@link ModelCapabilityService} agent 构建时解析并注入到
* {@link #modelCapabilities}per-model 粒度区分如 glm-4v vs glm-4v-plus
*/
private boolean modelSupportsVideo() {
if (modelName == null) return false;
String n = modelName.toLowerCase();
return (n.contains("qwen") && n.contains("vl"))
|| n.contains("gpt-4o")
|| n.contains("gemini")
|| (n.contains("glm") && n.contains("v"));
return modelCapabilities.contains(ModelCapabilityService.Modality.VIDEO);
}
private boolean modelSupportsVision() {
return modelCapabilities.contains(ModelCapabilityService.Modality.VISION);
}
/**
@ -292,9 +514,27 @@ public abstract class BaseAgent {
* 让模型在 prompt 中直接看到媒体内容不需要再调 MCP read_media_file 工具
*/
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent) {
return buildUserMessage(message, renderedContent, true);
}
/**
* @param injectMedia when {@code false} (history replay), skip the Media-loading
* branch entirely and return text-only providers like Zhipu
* GLM-5V cap at 1 video per request, so re-injecting historical
* attachments on every turn breaks the call.
*/
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent, boolean injectMedia) {
if (!injectMedia) {
return new UserMessage(renderedContent == null ? "" : renderedContent);
}
List<MessageContentPart> parts = conversationService.parseMessageParts(message);
List<Media> mediaList = new ArrayList<>();
// Reasons for attachments that the model cannot consume surfaced to the agent
// via the user message text so it does not hallucinate a tool call to read them.
// See issue #44.
List<String> skippedAttachments = new ArrayList<>();
boolean videoSupported = modelSupportsVideo();
boolean visionSupported = modelSupportsVision();
for (MessageContentPart part : parts) {
if (part == null) continue;
@ -315,6 +555,15 @@ public abstract class BaseAgent {
if (isImage && contentType.contains("svg")) {
log.debug("[{}] Skipping SVG attachment (not supported by multimodal API): {}",
agentName, part.getFileName());
skippedAttachments.add(part.getFileName() + "(SVG 格式,多模态 API 不支持)");
continue;
}
// 图片仅在模型支持视觉时注入纯文本模型 GLM-5-Turbo / DeepSeek-V3会被跳过
if (isImage && !visionSupported) {
log.debug("[{}] Skipping image attachment (model '{}' does not support vision): {}",
agentName, modelName, part.getFileName());
skippedAttachments.add(part.getFileName() + "(当前模型 " + modelName + " 不支持图片输入)");
continue;
}
@ -322,6 +571,7 @@ public abstract class BaseAgent {
if (isVideo && !videoSupported) {
log.debug("[{}] Skipping video attachment (model '{}' does not support video): {}",
agentName, modelName, part.getFileName());
skippedAttachments.add(part.getFileName() + "(当前模型 " + modelName + " 不支持视频输入)");
continue;
}
@ -329,6 +579,7 @@ public abstract class BaseAgent {
if (isVideo && part.getFileSize() != null && part.getFileSize() > MAX_VIDEO_SIZE_BYTES) {
log.warn("[{}] Skipping oversized video attachment ({}MB > 20MB): {}",
agentName, part.getFileSize() / (1024 * 1024), part.getFileName());
skippedAttachments.add(part.getFileName() + "(视频超过 20MB 大小限制)");
continue;
}
@ -340,6 +591,7 @@ public abstract class BaseAgent {
if (mediaPath == null) {
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
skippedAttachments.add(part.getFileName() + "(文件未找到)");
continue;
}
try {
@ -351,14 +603,23 @@ public abstract class BaseAgent {
} catch (Exception e) {
log.warn("[{}] Failed to create Media for {} {}: {}",
agentName, isVideo ? "video" : "image", part.getFileName(), e.getMessage());
skippedAttachments.add(part.getFileName() + "(媒体加载失败)");
}
}
String finalText = renderedContent;
if (!skippedAttachments.isEmpty()) {
finalText = (renderedContent == null ? "" : renderedContent)
+ "\n\n[系统提示] 以下附件未能传入当前模型:" + String.join("", skippedAttachments)
+ "\n请用对话语言清晰、友好地告诉用户当前模型无法处理这类附件建议切换到具备相应能力的多模态模型图片需视觉模型视频需视频理解模型后重新上传。"
+ "不要调用任何工具(包括 ffmpeg、浏览器、文件读取等尝试解析这些附件。";
}
if (mediaList.isEmpty()) {
return new UserMessage(renderedContent);
return new UserMessage(finalText);
}
return UserMessage.builder()
.text(renderedContent)
.text(finalText)
.media(mediaList)
.build();
}

View File

@ -27,6 +27,23 @@ public final class GraphEventPublisher {
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_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
/** RFC-06 D-6: lightweight performance summary emitted per-phase. */
public static final String EVENT_PERF_SUMMARY = "perf_summary";
/**
* RFC-052: a tool with returnDirect=true completed; its full result is
* carried in the payload and is intended to be rendered as part of the
* assistant message (renderAs=assistant_message), bypassing the LLM.
*/
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
/**
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
* emitted at FinalAnswerNode so channel-side accumulators can persist it
* into message metadata. Downstream filters (e.g. memory promotion gate)
* branch on this structured value instead of doing brittle text matching
* on the assistant content.
*/
public static final String EVENT_FINISH_REASON = "finish_reason";
/**
* 事件记录
@ -44,8 +61,23 @@ public final class GraphEventPublisher {
}
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();
return new GraphEvent(EVENT_TOOL_START, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName,
"arguments", arguments != null ? arguments : "",
"timestamp", ts
@ -53,10 +85,20 @@ public final class GraphEventPublisher {
}
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();
// Carry the full tool result; transport-layer chunking lives in
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
// into ordered tool_result_chunk events when they exceed the 8 KB
// single-event budget. The previous unconditional 500-char truncation
// here destroyed data that the front-end could otherwise render in full.
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName,
"result", result != null ? truncateResult(result) : "",
"result", result != null ? result : "",
"success", success,
"timestamp", ts
), ts);
@ -82,9 +124,11 @@ public final class GraphEventPublisher {
public static GraphEvent stepCompleted(int index, String result) {
long ts = System.currentTimeMillis();
// Full step result; broadcastChunked splits at the transport layer
// when the payload exceeds the per-event size budget.
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
"index", index,
"result", result != null ? truncateResult(result) : "",
"result", result != null ? result : "",
"timestamp", ts
), ts);
}
@ -95,7 +139,7 @@ public final class GraphEventPublisher {
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"pendingId", pendingId,
"toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? truncateResult(arguments) : "",
"arguments", arguments != null ? arguments : "",
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
@ -112,7 +156,7 @@ public final class GraphEventPublisher {
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("pendingId", pendingId);
data.put("toolName", toolName != null ? toolName : "");
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
data.put("arguments", arguments != null ? arguments : "");
data.put("reason", reason != null ? reason : "");
data.put("summary", summary);
data.put("maxSeverity", maxSeverity);
@ -121,6 +165,58 @@ public final class GraphEventPublisher {
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
}
/**
* RFC-06 D-6: emit a lightweight performance summary for a phase.
* Consumers (dashboard, audit, _usage_final) can aggregate these
* to reconstruct per-turn latency profiles without full tracing.
*
* @param phase e.g. "triage", "reasoning", "tool_execution"
* @param metrics arbitrary key-value pairs (e.g. "retry_count", "backoff_wait_ms")
*/
/**
* RFC-052: emit a tool result that was produced by a returnDirect tool.
* The full text is carried verbatim and the {@code renderAs="assistant_message"}
* hint instructs the SSE consumer (front-end / accumulator) to fold the
* payload into the assistant bubble rather than into a tool card.
*/
public static GraphEvent toolDirectResult(String toolCallId, String toolName, String fullResult) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("toolCallId", toolCallId != null ? toolCallId : "");
data.put("toolName", toolName != null ? toolName : "");
data.put("result", fullResult != null ? fullResult : "");
data.put("renderAs", "assistant_message");
data.put("timestamp", ts);
return new GraphEvent(EVENT_TOOL_DIRECT_RESULT, Map.copyOf(data), ts);
}
public static GraphEvent perfSummary(String phase, Map<String, Object> metrics) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.HashMap<>(metrics);
data.put("phase", phase);
data.put("timestamp", ts);
return new GraphEvent(EVENT_PERF_SUMMARY, Map.copyOf(data), ts);
}
/**
* Terminal {@code finish_reason} event. Emitted from FinalAnswerNode so it
* rides through the same PENDING_EVENTS StreamDelta pipeline that
* channel-side accumulators consume a sibling SSE-only broadcast would
* bypass {@code ChatController.StreamAccumulator.accept(...)} and fail to
* persist the reason into message metadata.
*
* @param reason {@link vip.mate.agent.graph.state.FinishReason#getValue()}
* (e.g. {@code "incomplete"}, {@code "stopped"},
* {@code "evidence_insufficient"}, {@code "normal"}).
*/
public static GraphEvent finishReason(String reason) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FINISH_REASON, Map.of(
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
}
// ===== 提取方法 =====
/**
@ -135,14 +231,90 @@ public final class GraphEventPublisher {
.orElse(List.of());
}
/**
* Pass-through; preserved for source/binary compatibility with older callers.
* Truncation at the SSE transport layer is now handled by {@code
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
* ordered chunk events instead of dropping bytes. Logs a one-time
* deprecation hint when invoked.
*
* @deprecated callers should pass full payloads and let the transport layer
* decide whether to chunk.
*/
@Deprecated
private static String truncateResult(String result) {
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
warnTruncateDeprecation();
return result;
}
/**
* 截断字符串用于直推广播公共方法 Node 直接构造广播数据时使用
* Pass-through, kept for source compatibility with code that built broadcast
* payloads directly. Same deprecation reason as {@link #truncateResult}.
*
* @deprecated callers should pass full payloads.
*/
@Deprecated
public static String truncateForBroadcast(String text) {
return truncateResult(text);
warnTruncateDeprecation();
return text;
}
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
new java.util.concurrent.atomic.AtomicBoolean(false);
private static void warnTruncateDeprecation() {
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
"no-op pass-throughs; payloads are no longer truncated here. " +
"Move callers to send the full string and rely on " +
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
}
}
// ===== Iteration lifecycle events =====
public static final String EVENT_ITERATION_START = "iteration_start";
public static final String EVENT_ITERATION_END = "iteration_end";
/**
* Marks the entry of an iteration boundary so consumers can group later
* tool / content / thinking events under a single logical step. The
* {@code scope} field distinguishes the parent agent ("parent") from a
* delegated sub-agent ("subagent"); when scope is "subagent" the
* {@code subagentId} payload field is populated by the producer.
*/
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("index", index);
data.put("reason", reason != null ? reason : "");
data.put("scope", scope != null ? scope : "parent");
if (subagentId != null && !subagentId.isEmpty()) {
data.put("subagentId", subagentId);
}
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
}
/**
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
* and {@code thinkingChars} let consumers render a compact "this turn
* produced X content / Y thinking" header without re-aggregating the
* underlying delta events.
*/
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
int contentChars, int thinkingChars) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("index", index);
data.put("scope", scope != null ? scope : "parent");
if (subagentId != null && !subagentId.isEmpty()) {
data.put("subagentId", subagentId);
}
data.put("contentChars", contentChars);
data.put("thinkingChars", thinkingChars);
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
}
}

View File

@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
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.AgentToolBinding;
import vip.mate.agent.binding.service.AgentBindingService;
@ -102,6 +103,33 @@ public class AgentBindingController {
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 ====================
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;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -16,6 +16,5 @@ public class AgentToolBinding {
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
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

@ -3,13 +3,20 @@ package vip.mate.agent.binding.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
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.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@ -25,11 +32,28 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentBindingService {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
private final AgentProviderPreferenceMapper providerPreferenceMapper;
/**
* {@code @Lazy} SkillRuntimeService and AgentBindingService both sit
* near the agent boot path; the lazy proxy avoids a circular bean
* graph when SkillRuntimeService initializes after binding.
*/
private final SkillRuntimeService skillRuntimeService;
@Autowired
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
AgentToolBindingMapper toolBindingMapper,
AgentProviderPreferenceMapper providerPreferenceMapper,
@Lazy SkillRuntimeService skillRuntimeService) {
this.skillBindingMapper = skillBindingMapper;
this.toolBindingMapper = toolBindingMapper;
this.providerPreferenceMapper = providerPreferenceMapper;
this.skillRuntimeService = skillRuntimeService;
}
// ==================== Skill Bindings ====================
@ -125,6 +149,166 @@ public class AgentBindingService {
.collect(Collectors.toSet());
}
/**
* RFC-090 §14.2 single entry point that maps an agent's bindings to
* the set of tool names allowed at runtime.
*
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
* {@link #getBoundToolNames}):
* <ul>
* <li><b>{@code null} bound skills + {@code null} bound tools</b>
* returns {@code null}. Caller treats this as "no agent-level
* restriction; let the upstream {@code ToolSet} pass through
* its global default".</li>
* <li><b>at least one side non-null</b> returns the union, which
* may be empty (= "this agent is explicitly restricted to no
* tools"). The caller must distinguish empty from null.</li>
* </ul>
*
* <p>Skill expansion rules (§14.2):
* <ul>
* <li>Resolved skill found contribute
* {@code ResolvedSkill.getEffectiveAllowedTools()} only tools
* whose owning feature is READY (unavailable features stay
* hidden from the LLM, §10.2 Q8).</li>
* <li>Skill bound but unresolved (e.g. legacy or missing manifest)
* contribute nothing through this path; legacy SKILL.md prompt
* enhancement still runs separately.</li>
* </ul>
*/
public Set<String> getEffectiveToolNames(Long agentId) {
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
Set<String> directTools = getBoundToolNames(agentId);
// (1) null + null no restriction; defer to the global default.
if (boundSkillIds == null && directTools == null) {
return null;
}
Set<String> merged = new LinkedHashSet<>();
if (boundSkillIds != null) {
for (Long skillId : boundSkillIds) {
ResolvedSkill resolved = findResolvedSkillById(skillId);
if (resolved == null) continue;
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
// §14.2 fix: a disabled / security-blocked / setup-needed
// skill must not contribute tools to the LLM
// advertisement even if it's still bound. Without this
// guard, users see ghost tools for skills they thought
// were off.
continue;
}
Set<String> skillTools = resolved.getEffectiveAllowedTools();
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
}
}
if (directTools != null) {
// Advanced 直选的原子 tool§9.2 调整 B
merged.addAll(directTools);
}
// System-level tools that don't belong to any single skill but
// are agent-wide capabilities. Without this carve-out, binding
// any skill silently strips record_lesson / remember / structured-
// memory tools, breaking the §11 self-evolution loop entirely
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS);
return merged;
}
/**
* RFC-090 §11 tools that exist outside the skill scope and must
* survive any agent-level skill binding restriction.
*
* <p>Add new entries here only after verifying the tool is genuinely
* agent-wide, not skill-specific. Tools added here bypass the
* {@link #getEffectiveToolNames} allowlist completely.
*/
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
// Structured memory primitives used by every agent regardless
// of skill bindings, otherwise the self-evolution path collapses
// (§11.3 / §11.4).
"record_lesson",
"remember",
"remember_structured",
"recall_structured",
"forget_structured",
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
// memory/YYYY-MM-DD.md). Prior versions whitelisted
// "read_workspace_file" / "write_workspace_file" /
// "list_workspace_files" those names match no @Tool bean; the
// actual function names carry the "_memory" segment, so the
// earlier carve-out was silently dead.
"list_workspace_memory_files",
"read_workspace_memory_file",
"write_workspace_memory_file",
"edit_workspace_memory_file",
// Skill discovery / dispatch skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts.
"readSkillFile",
"runSkillScript",
"listSkillFiles",
"listAvailableSkills",
// Date / time prior whitelist had a fictional "datetime"; the
// real DateTimeTool exposes three separate methods.
"getCurrentDate",
"getCurrentDateTime",
"getCurrentTime",
// Multi-agent delegation prior whitelist had "delegate_agent",
// but DelegateAgentTool's @Tool methods are delegateToAgent /
// delegateParallel / listAvailableAgents. Same dead-name bug.
"delegateToAgent",
"delegateParallel",
"listAvailableAgents",
// Document / media generation agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever
// any skill is bound, breaking "generate a Word doc / image /
// song / video" intents on agents that happen to have a skill
// on. Regression observed 2026-05-01: a Code Reviewer agent with
// skills bound dropped renderDocx and fell back to dumping the
// markdown body for the user to copy.
"renderDocx",
"renderDocxFromFile",
"renderDocxFromFiles",
"image_generate",
"music_generate",
"video_generate",
// Universal capabilities the global system prompts (SOUL.md /
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
// available; the new gate silently hid them on any agent with
// skills bound, so the prompt promises a tool the registry then
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
// Code Reviewer agent the model called search got
// not-found gave up before ever reaching renderDocx.
"search",
"browser_use",
"read_file",
"write_file",
"edit_file",
"execute_shell_command",
"detect_file_type",
"extract_document_text",
"extract_pdf_text",
"extract_docx_text",
"readMateClawDoc"
);
private ResolvedSkill findResolvedSkillById(Long skillId) {
if (skillId == null || skillRuntimeService == null) return null;
// resolveAllSkillsStatus returns every skill in the catalog, not
// just the active ones, so we still see READY/SETUP_NEEDED status
// for bound but partially-unsatisfied skills.
return skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(s -> s != null && skillId.equals(s.getId()))
.findFirst()
.orElse(null);
}
public AgentToolBinding bindTool(Long agentId, String toolName) {
AgentToolBinding existing = toolBindingMapper.selectOne(
new LambdaQueryWrapper<AgentToolBinding>()
@ -167,4 +351,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,250 @@
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, model.getRequestTimeoutSeconds());
AnthropicChatOptions options = buildAnthropicOptions(model);
return AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
}
AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
return buildAnthropicApi(provider, null);
}
/**
* RFC-03 Lane B1 overload accepts a per-model read-timeout override
* (seconds). Null falls back to the default 180s.
*/
AnthropicApi buildAnthropicApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
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), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
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) {
return applyHttpTimeouts(builder, null);
}
/**
* RFC-03 Lane B1 overload accepts a per-model read-timeout override
* (seconds). Null / zero / negative falls back to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_READ_TIMEOUT}
* so unset model configs keep the historical 180s.
*/
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
/**
* Streaming counterpart of {@link #applyHttpTimeouts(RestClient.Builder)}.
* Without this, Spring AI's AnthropicApi would back its streaming chat
* call by a default WebClient with neither connect nor read timeout a
* stalled provider could hang the agent thread indefinitely while the
* failover chain idles (no exception = no signal).
* <p>
* Mirrors AgentGraphBuilder.applyHttpTimeoutsToWebClient: same JDK
* HttpClient + JdkClientHttpConnector path, so the dependency surface
* doesn't pull in reactor-netty (excluded by this project's pom).
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
return applyHttpTimeoutsToWebClient(builder, null);
}
/**
* RFC-03 Lane B1 overload same per-model override semantics as
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
}

View File

@ -0,0 +1,185 @@
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, model.getRequestTimeoutSeconds());
// 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) {
return buildOauthAnthropicApi(accessToken, null);
}
/**
* RFC-03 Lane B1 overload same OAuth-stamped Anthropic client, with a
* per-model read-timeout override threaded through to the underlying
* RestClient + WebClient timeouts.
*/
AnthropicApi buildOauthAnthropicApi(String accessToken, Integer readTimeoutOverride) {
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), readTimeoutOverride)
.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 = AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
.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,264 @@
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();
}
/**
* Bailian's built-in web search is only accepted by a subset of models;
* sending {@code enable_search} to a model that doesn't support it returns
* 400 InvalidParameter and the failover layer then evicts the entire
* provider as MODEL_NOT_FOUND. Per the public docs, only Qwen-Plus,
* Qwen-Max, Qwen-Turbo and the Qwen3-Max series accept the parameter.
*
* <p>Resolution order:</p>
* <ol>
* <li>Explicit {@code enableSearch} in the model row (per-model toggle)</li>
* <li>Explicit {@code enableSearch} in provider kwargs (admin-level toggle)</li>
* <li>Default: enabled only when the model name matches a known-supporting
* prefix; disabled for everything else (coder / thinking / DeepSeek /
* Long / Vision)</li>
* </ol>
*
* <p>Public so {@code AgentGraphBuilder.build()} can surface the
* "built-in search active" log once per agent.</p>
*/
public boolean isBuiltinSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
if (runtimeModel != null && runtimeModel.getEnableSearch() != null) {
return Boolean.TRUE.equals(runtimeModel.getEnableSearch());
}
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
Object kwargsSearch = kwargs.get("enableSearch");
if (kwargsSearch != null) {
return Boolean.TRUE.equals(kwargsSearch);
}
return modelSupportsBuiltinSearch(runtimeModel);
}
/**
* Model id prefixes that the Bailian text-generation endpoint documents as
* accepting {@code enable_search}. Anything else (qwen-coder-*, qwen-long,
* qwen3-*-thinking-*, deepseek-*, qwen*-vl-*) returns 400 InvalidParameter
* when the parameter is sent.
*/
private static final java.util.List<String> BUILTIN_SEARCH_SUPPORTED_PREFIXES = java.util.List.of(
"qwen-plus",
"qwen-max",
"qwen-turbo",
"qwen3-max",
"qwen3.5-flash",
"qwen3.6-flash"
);
private static boolean modelSupportsBuiltinSearch(ModelConfigEntity model) {
if (model == null) return false;
String name = model.getModelName();
if (!StringUtils.hasText(name)) return false;
String lower = name.trim().toLowerCase();
for (String prefix : BUILTIN_SEARCH_SUPPORTED_PREFIXES) {
if (lower.startsWith(prefix)) return true;
}
return false;
}
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,66 @@
package vip.mate.agent.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
* Delegates to {@link AgentGraphBuilder}'s helpers; see
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
* pattern and the {@code @Lazy} cycle break.
*/
@Component
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public AgentOpenAiCompatibleChatModelBuilder(
@Lazy AgentGraphBuilder agentGraphBuilder,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.agentGraphBuilder = agentGraphBuilder;
this.observationRegistryProvider = observationRegistryProvider;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_COMPATIBLE;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
// RFC-03 Lane B1 pass model.requestTimeoutSeconds so providers /
// models with extended-thinking p99s don't false-positive on the
// hardcoded 180s read timeout.
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
// those directly wrap with a per-request payload patcher. See
// DeepSeekV4ThinkingDecorator javadoc.
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
return new DeepSeekV4ThinkingDecorator(raw);
}
return raw;
}
}

View File

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

@ -11,6 +11,7 @@ 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.tool.ToolCallback;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.stereotype.Component;
import vip.mate.agent.prompt.PromptLoader;
@ -71,6 +72,21 @@ public class ConversationWindowManager {
private static final int CONTENT_MAX = 6000;
private static final int CONTENT_HEAD = 4000;
private static final int CONTENT_TAIL = 1500;
private static final int OLD_TOOL_RESULT_SUMMARY_THRESHOLD = 500;
/**
* Tool names whose results must never be compacted into a one-line
* summary. Sub-agent delegations are irreplaceable: the child runs an
* independent LLM session that the parent cannot reproduce, so dropping
* earlier batches forces the parent to re-dispatch the same children to
* recover what was lost. Every other tool (read_file, shell, search,
* memory) can be re-invoked cheaply if the parent decides it needs
* the data again.
*/
private static final java.util.Set<String> PRUNE_EXEMPT_TOOLS = java.util.Set.of(
"delegateToAgent",
"delegateParallel"
);
// ==================== 冷却机制 ====================
@ -116,9 +132,27 @@ public class ConversationWindowManager {
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId) {
return fitToWindow(messages, systemPrompt, currentUserMessage,
maxInputTokens, chatModel, conversationId, agentId, null);
}
/**
* Same as the 7-arg overload but additionally accounts for the tool
* definitions sent on every LLM call. Without {@code toolCallbacks},
* the budget calculation underestimates the actual request size by the
* full size of the tools schema (often several thousand tokens for
* agents bound to multiple MCP servers), making compression fire too
* late and producing HTTP 400 once the request hits the model.
*/
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection<ToolCallback> toolCallbacks) {
if (messages == null || messages.isEmpty()) {
return messages;
}
messages = pruneOldToolResultsForModelInput(messages);
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
@ -127,20 +161,21 @@ public class ConversationWindowManager {
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
int historyTokens = TokenEstimator.estimateTokens(messages);
int totalTokens = systemTokens + currentMsgTokens + historyTokens;
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
int totalTokens = systemTokens + currentMsgTokens + historyTokens + toolsTokens;
if (totalTokens <= triggerThreshold) {
return messages;
}
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conv={}",
totalTokens, systemTokens, currentMsgTokens, historyTokens,
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}, tools={}) > {} 触发阈值 (max={}), conv={}",
totalTokens, systemTokens, currentMsgTokens, historyTokens, toolsTokens,
triggerThreshold, effectiveMax, conversationId);
evictExpiredEntries();
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
// RFC-025 Change 1: reserve 硬封顶到 effectiveMax 50%
// 小上下文模型Ollama 16K本地 8KsystemTokens + currentMsgTokens 很容易
// 接近或超过 effectiveMax不封顶会让 historyBudget 变负数导致死循环压缩
@ -339,6 +374,85 @@ public class ConversationWindowManager {
// ==================== 工具结果处理 ====================
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
int latestToolResponseIndex = -1;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof ToolResponseMessage) {
latestToolResponseIndex = i;
break;
}
}
if (latestToolResponseIndex <= 0) {
return messages;
}
List<Message> pruned = new ArrayList<>(messages);
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
int changed = 0;
for (int i = pruned.size() - 1; i >= 0; i--) {
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
continue;
}
boolean keepFull = i == latestToolResponseIndex;
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String data = r.responseData();
boolean exempt = r.name() != null && PRUNE_EXEMPT_TOOLS.contains(r.name());
if (keepFull || exempt || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
newResponses.add(r);
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
seenLargeOutputs.add(data);
}
continue;
}
String replacement;
if (seenLargeOutputs.contains(data)) {
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
} else {
replacement = summarizeToolResponse(r.name(), data);
seenLargeOutputs.add(data);
}
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
messageChanged = true;
}
if (messageChanged) {
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
changed++;
}
}
if (changed > 0) {
log.info("[ConversationWindow] Pruned {} older tool response message(s) before model request", changed);
}
return changed > 0 ? pruned : messages;
}
private static String summarizeToolResponse(String toolName, String data) {
int chars = data.length();
int lines = data.isBlank() ? 0 : data.split("\\R", -1).length;
String firstLine = firstNonBlankLine(data);
if (firstLine.length() > 160) {
firstLine = firstLine.substring(0, 160) + "...";
}
StringBuilder sb = new StringBuilder();
sb.append('[').append(toolName).append("] previous tool output summarized for model context: ")
.append(chars).append(" chars, ").append(lines).append(" lines");
if (!firstLine.isBlank()) {
sb.append(". First line: ").append(firstLine);
}
return sb.toString();
}
private static String firstNonBlankLine(String data) {
for (String line : data.split("\\R")) {
String trimmed = line.trim();
if (!trimmed.isBlank()) {
return trimmed.replace('|', '/');
}
}
return "";
}
/**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
*/
@ -435,19 +549,20 @@ public class ConversationWindowManager {
String systemPrompt;
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) {
// 迭代更新模式旧摘要 + 新轮次
systemPrompt = STRUCTURED_SUMMARY_SYSTEM;
// Iterative update: previous summary + new turns.
userPrompt = STRUCTURED_SUMMARY_UPDATE
.replace("{previous_summary}", previousSummary)
.replace("{conversation}", conversationText)
.replace("{summary_budget}", String.valueOf(summaryBudget));
.replace("{conversation}", conversationText);
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
} else {
// 首次压缩
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
.replace("{summary_budget}", String.valueOf(summaryBudget));
userPrompt = STRUCTURED_SUMMARY_USER
.replace("{conversation}", conversationText);
log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId);

View File

@ -1,7 +1,10 @@
package vip.mate.agent.context;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.Collection;
import java.util.List;
/**
@ -21,6 +24,13 @@ public final class TokenEstimator {
/** 每条消息的固定开销 tokenrole 标记、分隔符等) */
static final int PER_MESSAGE_OVERHEAD = 4;
/**
* Per-tool wrapper overhead: function/type:object boilerplate, name and
* description framing, parameters key, and JSON braces around the schema.
* Conservative slightly overestimates so budget guards don't underrun.
*/
static final int PER_TOOL_OVERHEAD = 12;
private TokenEstimator() {
}
@ -71,6 +81,38 @@ public final class TokenEstimator {
.sum();
}
/**
* Estimate the token cost of the tool definitions sent on every LLM call
* (name + description + JSON inputSchema, plus per-tool wrapper overhead).
* <p>
* A heavily-bound agent (multiple MCP servers, many built-ins) can carry
* several thousand tokens of tool schema on every request leaving them
* out of the context-window budget makes compression decisions fire too
* late and on small models triggers HTTP 400 once the request actually
* goes out.
*/
public static int estimateToolsTokens(Collection<ToolCallback> callbacks) {
if (callbacks == null || callbacks.isEmpty()) {
return 0;
}
int total = 0;
for (ToolCallback cb : callbacks) {
if (cb == null) continue;
ToolDefinition def;
try {
def = cb.getToolDefinition();
} catch (Exception e) {
continue;
}
if (def == null) continue;
total += estimateTokens(def.name())
+ estimateTokens(def.description())
+ estimateTokens(def.inputSchema())
+ PER_TOOL_OVERHEAD;
}
return total;
}
/**
* 判断是否为 CJK 字符中日韩统一表意文字 + 常用标点
*/

View File

@ -4,15 +4,20 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.model.AgentEntity;
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.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.workspace.core.service.WorkspaceService;
import java.io.IOException;
import java.util.List;
@ -33,6 +38,8 @@ public class AgentController {
private final AgentService agentService;
private final AuditEventService auditEventService;
private final AuthService authService;
private final WorkspaceService workspaceService;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@Operation(summary = "获取Agent列表")
@ -60,9 +67,12 @@ public class AgentController {
@RequireWorkspaceRole("member")
public R<AgentEntity> create(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestBody AgentEntity agent) {
@RequestBody AgentEntity agent,
Authentication auth) {
// 始终注入 workspace_id header 时使用默认
agent.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
// RFC-077 §4.4: 记录创建者 member 后续可删除自建 Agent
agent.setCreatorUserId(resolveUserId(auth));
AgentEntity created = agentService.createAgent(agent);
auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null);
return R.ok(created);
@ -84,11 +94,24 @@ public class AgentController {
@Operation(summary = "删除Agent")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
@RequireWorkspaceRole("member")
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);
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);
auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent.getName(), null);
return R.ok();
@ -105,7 +128,8 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id);
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(() -> {
try {
agentService.chatStream(id, message, conversationId)
@ -183,4 +207,23 @@ public class AgentController {
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.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.model.TemplateDTO;
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.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
@ -23,6 +28,7 @@ import java.util.List;
public class TemplateController {
private final TemplateService templateService;
private final AuthService authService;
@Operation(summary = "获取模板列表")
@GetMapping
@ -32,7 +38,29 @@ public class TemplateController {
@Operation(summary = "应用模板创建Agent")
@PostMapping("/{id}/apply")
public R<AgentEntity> apply(@PathVariable String id) {
return R.ok(templateService.applyTemplate(id));
@RequireWorkspaceRole("member")
public R<AgentEntity> apply(
@PathVariable String id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
// Accept-Language is forwarded by the frontend (zh-CN, zh, en, en-US, ...)
// so the new agent's display name matches the user's locale
// a Chinese user hiring "客服助理" should not get an English
// "Customer Support" agent in their list.
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage,
Authentication auth) {
long wsId = workspaceId != null ? workspaceId : 1L;
Long userId = resolveUserId(auth);
return R.ok(templateService.applyTemplate(id, wsId, userId, acceptLanguage));
}
private Long resolveUserId(Authentication auth) {
if (auth == null) {
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");
}
UserEntity user = authService.findByUsername(auth.getName());
if (user == null) {
throw new MateClawException("err.auth.user_not_found", 401, "User not found: " + auth.getName());
}
return user.getId();
}
}

View File

@ -0,0 +1,189 @@
package vip.mate.agent.delegation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.conversation.ConversationService;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* REST surface for managing live sub-agents:
* <ul>
* <li>{@code POST /interrupt} stop a running sub-agent.</li>
* <li>{@code POST /spawn-pause} toggle the per-parent spawn-pause flag.</li>
* <li>{@code GET /active} list sub-agents under one parent conversation.</li>
* </ul>
*
* <p>Every endpoint authorizes the caller against the parent conversation's
* owner before mutating or revealing anything; the {@code parentConversationId}
* query parameter on {@code /active} is mandatory so the route cannot be used
* to enumerate cross-tenant sub-agents.
*
* <p>Authorization mirrors the {@link vip.mate.workspace.conversation.ConversationService#isConversationOwner}
* pattern used by the chat stop / fork routes usernames are the principal
* identity carried on {@link Authentication#getName()}, and shared "system"
* conversations are accessible to all logged-in users (matches the existing
* cron-job convention).
*/
@Slf4j
@Tag(name = "Sub-agents")
@RestController
@RequestMapping("/api/v1/subagents")
@RequiredArgsConstructor
public class SubagentController {
private final SubagentRegistry registry;
private final ConversationService conversationService;
private final AuditEventService auditEventService;
private final ObjectMapper objectMapper;
/**
* Resolve the record and verify the caller owns its parent conversation.
* Throws a 403-coded exception when ownership fails so the global handler
* can render a uniform JSON error envelope.
*/
private SubagentRegistry.SubagentRecord requireOwnership(String subagentId, Authentication auth) {
Optional<SubagentRegistry.SubagentRecord> opt = registry.get(subagentId);
if (opt.isEmpty()) {
throw new MateClawException(404, "subagent " + subagentId + " not found");
}
SubagentRegistry.SubagentRecord rec = opt.get();
String username = currentUsername(auth);
if (!conversationService.isConversationOwner(rec.parentConversationId(), username)) {
// Audit denial separately from the operation itself so admins can
// see what cross-tenant attempts hit the registry. Best-effort
// serialization the audit insert is async on the service side.
auditEventService.record("subagent.interrupt.denied", "subagent",
subagentId, rec.subagentId(),
safeJson(Map.of(
"callerUsername", username,
"parent", rec.parentConversationId(),
"agentId", rec.agentId() == null ? -1L : rec.agentId()
)));
throw new MateClawException(403, "not the owner of subagent's parent conversation");
}
return rec;
}
/**
* Stop a running sub-agent. The registry flips status to {@code interrupted}
* and disposes the streaming subscription if one was registered. Returns
* the {@code interrupted} flag so the caller can distinguish "we did stop
* something" from "the subagent was already finished" (404 case is handled
* separately by {@link #requireOwnership}).
*/
@Operation(summary = "Interrupt a running sub-agent")
@PostMapping("/{subagentId}/interrupt")
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
boolean ok = registry.interrupt(subagentId);
auditEventService.record("subagent.interrupt", "subagent",
subagentId, rec.subagentId(),
safeJson(Map.of(
"by", currentUsername(auth),
"parent", rec.parentConversationId(),
"result", ok
)));
return R.ok(Map.of("interrupted", ok));
}
/**
* Toggle whether new sub-agent spawns are accepted under a parent
* conversation. Used by the operator UI to halt runaway parent agents
* mid-turn without killing the parent's own LLM call.
*/
@Operation(summary = "Set sub-agent spawn-pause for a conversation")
@PostMapping("/spawn-pause")
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
Object parentObj = body == null ? null : body.get("parentConversationId");
String parent = parentObj == null ? null : parentObj.toString();
if (parent == null || parent.isBlank()) {
throw new MateClawException(400, "parentConversationId required");
}
String username = currentUsername(auth);
if (!conversationService.isConversationOwner(parent, username)) {
throw new MateClawException(403, "not the owner of conversation " + parent);
}
boolean paused = Boolean.TRUE.equals(body.get("paused"));
registry.setSpawnPaused(parent, paused);
auditEventService.record("subagent.spawn-pause", "conversation",
parent, parent,
safeJson(Map.of(
"paused", paused,
"by", username
)));
return R.ok(Map.of("paused", paused));
}
/**
* List the sub-agents currently active under {@code parentConversationId}.
* The query parameter is mandatory: returning all subagents process-wide
* would let any logged-in user enumerate other tenants' delegation trees.
*/
@Operation(summary = "List active sub-agents under a parent conversation")
@GetMapping("/active")
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
Authentication auth) {
if (parentConversationId == null || parentConversationId.isBlank()) {
throw new MateClawException(400, "parentConversationId required");
}
String username = currentUsername(auth);
if (!conversationService.isConversationOwner(parentConversationId, username)) {
throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
}
List<Map<String, Object>> snapshot = registry.snapshot(parentConversationId).stream()
.map(this::toResponseDto)
.toList();
return R.ok(Map.of("subagents", snapshot));
}
/** Username from auth context; falls back to "anonymous" only when null. */
private String currentUsername(Authentication auth) {
return auth != null ? auth.getName() : "anonymous";
}
/**
* DTO projection that drops the {@link reactor.core.Disposable} (not
* serializable to the wire) and exposes only the user-facing fields.
*/
private Map<String, Object> toResponseDto(SubagentRegistry.SubagentRecord rec) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("subagentId", rec.subagentId());
dto.put("parentConversationId", rec.parentConversationId());
dto.put("childConversationId", rec.childConversationId());
dto.put("agentId", rec.agentId());
dto.put("goal", rec.goal());
dto.put("startedAt", rec.startedAt());
dto.put("status", rec.status().get());
dto.put("toolCount", rec.toolCount().get());
dto.put("lastTool", rec.lastTool().get());
dto.put("currentPhase", rec.currentPhase().get());
return dto;
}
/**
* Best-effort JSON serialization for audit detail. Falling back to a
* marker string keeps the audit row insertable when payload contains
* a non-serializable value the alternative (throwing) would lose the
* audit record entirely.
*/
private String safeJson(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
return "{\"error\":\"audit_serialization_failed\"}";
}
}
}

View File

@ -0,0 +1,101 @@
package vip.mate.agent.delegation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Periodic watchdog that flips a sub-agent's status to {@code stale} when its
* child stream stops making observable progress.
*
* <p>Progress is probed via {@link ChatStreamTracker#getRunningToolName} and
* {@link ChatStreamTracker#getCurrentPhase}. When neither has changed across
* the configured number of cycles, the record is marked stale and a
* {@code subagent_stale} event is broadcast on the parent conversation so the
* UI can surface the issue. Cycle count uses two separate thresholds one
* for idle children and one for children mid-tool because legitimately slow
* tools (large file scans, slow LLM calls) need a longer window than an idle
* model that has simply gone quiet.
*
* <p>The runtime tool name + phase combination is a deliberately coarse
* progress signal: it does not require introspecting LLM token deltas, which
* keeps the watchdog cheap and avoids racing with the streaming hot path.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SubagentHeartbeat {
private final SubagentRegistry registry;
private final SubagentHeartbeatConfig cfg;
private final ChatStreamTracker streamTracker;
/**
* Scheduled tick. Defaults to every 30 s; controlled by
* {@code mateclaw.delegation.heartbeat.intervalSec}.
*/
@Scheduled(fixedRateString = "#{@subagentHeartbeatConfig.intervalSec * 1000L}")
public void check() {
for (var rec : registry.allActive()) {
if (!"running".equals(rec.status().get())) {
continue;
}
evaluate(rec);
}
}
/**
* Visible for testing apply one heartbeat tick to a single record so
* tests can drive the watchdog deterministically without scheduling.
*/
void evaluate(SubagentRegistry.SubagentRecord rec) {
// Probe child progress. We use (currentTool, currentPhase) as the
// monotonic-progress signal: any change in either implies the child
// advanced at least one observable step. We deliberately do NOT
// depend on a private apiCallCount field the RunState does not
// expose one, and counting deltas across the streaming hot path
// would race with token emission. Tool/phase ticks are atomic
// volatile writes from the streaming layer, so reading them here
// is cheap and correct.
String currentTool = streamTracker.getRunningToolName(rec.childConversationId());
String currentPhase = streamTracker.getCurrentPhase(rec.childConversationId());
int phaseHash = currentPhase != null ? currentPhase.hashCode() : 0;
boolean toolChanged = !Objects.equals(currentTool, rec.lastSeenTool().get());
boolean phaseChanged = phaseHash != rec.lastSeenIter().get();
if (toolChanged || phaseChanged) {
rec.lastSeenTool().set(currentTool);
rec.lastSeenIter().set(phaseHash);
rec.staleCount().set(0);
return;
}
int sc = rec.staleCount().incrementAndGet();
int limit = (currentTool != null && !currentTool.isEmpty())
? cfg.getStaleCyclesInTool()
: cfg.getStaleCyclesIdle();
if (sc >= limit) {
// Atomic transition: only the first thread to flip running -> stale
// emits the event. Subsequent ticks fall through the running guard
// in check().
if (rec.status().compareAndSet("running", "stale")) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("subagentId", rec.subagentId());
payload.put("cycles", sc);
payload.put("lastTool", currentTool != null ? currentTool : "");
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
streamTracker.broadcastObject(rec.parentConversationId(), "subagent_stale", payload);
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
rec.subagentId(), sc, limit);
}
}
}
}

View File

@ -0,0 +1,70 @@
package vip.mate.agent.delegation;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Configuration knobs for {@link SubagentHeartbeat}.
*
* <p>Defaults are tuned so a wedged child surfaces visibly to the parent UI
* without firing on legitimately slow tool runs:
* <ul>
* <li>Idle (no tool running) 5 cycles × 30 s = 150 s before stale.</li>
* <li>In a tool 20 cycles × 30 s = 600 s before stale.</li>
* </ul>
*
* <p>The in-tool threshold MUST stay greater than or equal to
* {@code child_hard_timeout / intervalSec}. If it fires before the per-child
* hard cap then the cap stops being the source of truth for "this child is
* dead" and operators see ambiguous telemetry.
*/
@Component
@ConfigurationProperties("mateclaw.delegation.heartbeat")
public class SubagentHeartbeatConfig {
/**
* Heartbeat check interval in seconds. Lower values make the parent
* transcript more responsive at the cost of scheduler overhead.
*/
private int intervalSec = 30;
/**
* Stale threshold (in heartbeat cycles) when the child has no current
* tool in flight. With the default 30 s interval this is 150 s, tight
* enough that a wedged child does not mask a legitimate gateway timeout.
*/
private int staleCyclesIdle = 5;
/**
* Stale threshold (in heartbeat cycles) while the child is inside a
* tool. Generous enough to tolerate slow tools (large file reads, slow
* LLM calls). Must be at least the per-child hard timeout divided by
* {@link #intervalSec}, otherwise stale fires before the hard cap and
* obscures fallback semantics.
*/
private int staleCyclesInTool = 20;
public int getIntervalSec() {
return intervalSec;
}
public void setIntervalSec(int intervalSec) {
this.intervalSec = intervalSec;
}
public int getStaleCyclesIdle() {
return staleCyclesIdle;
}
public void setStaleCyclesIdle(int staleCyclesIdle) {
this.staleCyclesIdle = staleCyclesIdle;
}
public int getStaleCyclesInTool() {
return staleCyclesInTool;
}
public void setStaleCyclesInTool(int staleCyclesInTool) {
this.staleCyclesInTool = staleCyclesInTool;
}
}

View File

@ -0,0 +1,173 @@
package vip.mate.agent.delegation;
import org.springframework.stereotype.Component;
import reactor.core.Disposable;
import java.security.SecureRandom;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
/**
* Process-wide registry of live sub-agents spawned through the delegation flow.
*
* <p>Holds the in-memory subagent tree so the parent transcript, the heartbeat
* watcher, and the operator UI can observe / interrupt children that the parent
* conversation spawned. Records use atomic accessors throughout because the
* heartbeat thread may mutate {@code staleCount} / {@code status} concurrently
* with the spawning thread that registered the record.
*
* <p>The pause flag is keyed per parent conversation so two unrelated users
* cannot freeze each other's spawning by toggling a global switch.
*/
@Component
public class SubagentRegistry {
/**
* Single live sub-agent.
*
* <p>Mutable counters are atomics so the heartbeat scheduler and the
* spawn / completion thread can update them without locking. Status is
* driven by external lifecycle events; allowed values are
* {@code running} / {@code completed} / {@code interrupted} / {@code stale}
* / {@code timeout}.
*/
public record SubagentRecord(
String subagentId,
String parentConversationId,
String childConversationId,
Long agentId,
String goal,
long startedAt,
AtomicReference<String> status,
AtomicInteger toolCount,
AtomicReference<String> lastTool,
AtomicReference<String> currentPhase,
AtomicInteger lastSeenIter,
AtomicReference<String> lastSeenTool,
AtomicInteger staleCount,
AtomicLong firstApiCallAt,
Disposable disposable
) {}
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
/**
* Per-parent pause flag set: scoping prevents one user from freezing
* another user's spawning. A parent conversation appears in this set iff
* spawning is currently paused for it.
*/
private final Set<String> pausedParents = ConcurrentHashMap.newKeySet();
private final SecureRandom rng = new SecureRandom();
/**
* Register a freshly spawned sub-agent. Returns the assigned subagentId
* which the caller must thread through to {@link #unregister(String)} on
* completion (success / failure / timeout) so the registry does not leak.
*
* <p>ID format {@code sa-<epoch_ms>-<8 hex chars>} keeps IDs sortable by
* spawn time while the random suffix prevents collisions when many
* children spawn within the same millisecond.
*/
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
active.put(sid, new SubagentRecord(
sid,
parentConvId,
childConvId,
agentId,
goal,
System.currentTimeMillis(),
new AtomicReference<>("running"),
new AtomicInteger(0),
new AtomicReference<>(""),
new AtomicReference<>("starting"),
new AtomicInteger(0),
new AtomicReference<>(null),
new AtomicInteger(0),
new AtomicLong(0),
d));
return sid;
}
/**
* Mark a sub-agent as interrupted and dispose its underlying stream
* subscription if one was registered. Returns {@code false} when the
* subagentId is unknown (already cleaned up or never registered) so
* callers can distinguish "not running anymore" from "interrupted".
*/
public boolean interrupt(String subagentId) {
if (subagentId == null) return false;
SubagentRecord r = active.get(subagentId);
if (r == null) return false;
r.status().set("interrupted");
Disposable d = r.disposable();
if (d != null && !d.isDisposed()) {
d.dispose();
}
return true;
}
public Optional<SubagentRecord> get(String subagentId) {
return subagentId == null ? Optional.empty() : Optional.ofNullable(active.get(subagentId));
}
/**
* Snapshot of all sub-agents whose parent matches {@code parentConvId}.
* Filtering at the registry boundary prevents callers from accidentally
* surfacing other tenants' subagents in API responses.
*/
public List<SubagentRecord> snapshot(String parentConvId) {
if (parentConvId == null) return List.of();
return active.values().stream()
.filter(r -> parentConvId.equals(r.parentConversationId()))
.toList();
}
public void unregister(String subagentId) {
if (subagentId == null) return;
active.remove(subagentId);
}
public boolean isSpawnPaused(String parentConvId) {
if (parentConvId == null) return false;
return pausedParents.contains(parentConvId);
}
/**
* Toggle the pause flag for one parent conversation. Returns the new
* paused state so the caller can echo the resulting flag without an
* extra read.
*/
public boolean setSpawnPaused(String parentConvId, boolean paused) {
if (parentConvId == null) return false;
if (paused) {
pausedParents.add(parentConvId);
} else {
pausedParents.remove(parentConvId);
}
return paused;
}
public Collection<SubagentRecord> allActive() {
return active.values();
}
/** Lowercase 8-hex-char suffix sourced from a SecureRandom. */
private String nextHexSuffix() {
byte[] bytes = new byte[4];
rng.nextBytes(bytes);
StringBuilder sb = new StringBuilder(8);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}

View File

@ -1,157 +0,0 @@
package vip.mate.agent.graph;
import lombok.extern.slf4j.Slf4j;
/**
* 流式输出重复检测器
* <p>
* 检测 LLM 流式输出中的退化重复模式degenerate repetition
* 当检测到内容在滑动窗口内高度重复时返回 true调用方应截断 LLM
* <p>
* 算法维护一个滑动窗口缓冲区每次追加新 delta
* 检查窗口尾部是否存在连续重复的 n-gram 模式
*
* @author MateClaw Team
*/
@Slf4j
public class RepetitionDetector {
/** 滑动窗口大小(字符数) */
private static final int WINDOW_SIZE = 1024;
/** 最小重复片段长度 */
private static final int MIN_PATTERN_LEN = 8;
/** 最大检测的模式长度 */
private static final int MAX_PATTERN_LEN = 200;
/** 模式需要连续出现的最小次数才判定为重复 */
private static final int MIN_REPEATS = 4;
/** 已累积内容的最小长度才开始检测(避免误判短内容) */
private static final int MIN_CONTENT_LEN = 200;
private final StringBuilder buffer = new StringBuilder();
private boolean repetitionDetected = false;
/**
* 追加新的 delta 并检测是否存在重复
*
* @param delta 新增的文本片段
* @return true 表示检测到退化重复调用方应截断流
*/
public boolean appendAndCheck(String delta) {
if (delta == null || delta.isEmpty() || repetitionDetected) {
return repetitionDetected;
}
buffer.append(delta);
// 内容太短不检测
if (buffer.length() < MIN_CONTENT_LEN) {
return false;
}
// 保持窗口大小
if (buffer.length() > WINDOW_SIZE * 2) {
buffer.delete(0, buffer.length() - WINDOW_SIZE);
}
// 在窗口尾部检测重复模式
String window = buffer.toString();
int windowLen = window.length();
// 从短模式到长模式扫描
for (int patternLen = MIN_PATTERN_LEN;
patternLen <= Math.min(MAX_PATTERN_LEN, windowLen / MIN_REPEATS);
patternLen++) {
// 取窗口末尾的 pattern
String pattern = window.substring(windowLen - patternLen);
// 向前数这个 pattern 连续出现了几次
int count = 1;
int pos = windowLen - patternLen * 2;
while (pos >= 0) {
String segment = window.substring(pos, pos + patternLen);
if (segment.equals(pattern)) {
count++;
pos -= patternLen;
} else {
break;
}
}
if (count >= MIN_REPEATS) {
// 排除装饰性重复代码缩进ASCII 图表Markdown 分隔线常见
if (isDecorativePattern(pattern)) {
continue;
}
repetitionDetected = true;
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
"pattern length={}, repeats={}, pattern preview=\"{}\"",
patternLen, count,
pattern.length() > 50 ? pattern.substring(0, 50) + "..." : pattern);
return true;
}
}
return false;
}
/**
* 判断 pattern 是否为装饰性字符不应判定为退化重复
* <p>
* 排除场景
* <ul>
* <li>纯空白/缩进{@code " "}代码缩进</li>
* <li>单一重复字符{@code "────────"} {@code "════════"} {@code "--------"} {@code "********"}分隔线表格边框</li>
* <li>Box Drawing 字符族{@code "┌──────┐"} {@code "│ │"}ASCII 图表</li>
* </ul>
*/
private boolean isDecorativePattern(String pattern) {
if (pattern.isBlank()) {
return true; // 纯空白
}
// 统计不同的非空白字符种类
long distinctNonWhitespace = pattern.chars()
.filter(c -> !Character.isWhitespace(c))
.distinct()
.count();
// 只有 1-2 种不同的非空白字符 装饰性 "────────" "│ │"
if (distinctNonWhitespace <= 2) {
return true;
}
// 检查是否全部是 Box Drawing / 装饰字符
boolean allDecorative = pattern.chars().allMatch(c ->
Character.isWhitespace(c)
|| isBoxDrawing(c)
|| "─━│┃┄┅┆┇┈┉┊┋═║╌╍╎╏╔╗╚╝╠╣╦╩╬├┤┬┴┼┌┐└┘".indexOf(c) >= 0
|| "-=_*+|#~<>".indexOf(c) >= 0);
return allDecorative;
}
private boolean isBoxDrawing(int codePoint) {
// Unicode Box Drawing block: U+2500 U+257F
return codePoint >= 0x2500 && codePoint <= 0x257F;
}
/**
* 重置检测器状态
*/
public void reset() {
buffer.setLength(0);
repetitionDetected = false;
}
/**
* 是否已检测到重复
*/
public boolean isRepetitionDetected() {
return repetitionDetected;
}
}

View File

@ -53,15 +53,32 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
private final CompiledGraph compiledGraph;
private final org.springframework.ai.chat.model.ChatModel chatModel;
private final ConversationWindowManager conversationWindowManager;
/**
* Held only so {@link #buildInitialState} can include the tools schema in
* the context-window budget those bytes ride along on every LLM call
* and were previously ignored, making compression decisions fire late.
* Nullable for the legacy 5-arg constructor used by older tests.
*/
private final vip.mate.agent.AgentToolSet toolSet;
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager) {
this(chatClient, conversationService, compiledGraph, chatModel,
conversationWindowManager, null);
}
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager,
vip.mate.agent.AgentToolSet toolSet) {
super(chatClient, conversationService);
this.compiledGraph = compiledGraph;
this.chatModel = chatModel;
this.conversationWindowManager = conversationWindowManager;
this.toolSet = toolSet;
}
@Override
@ -175,6 +192,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 防重保护 chatStructuredStream
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
// Silent-termination guard (mirrors chatStructuredStream)
AtomicInteger lastIteration = new AtomicInteger(0);
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
@ -192,6 +214,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean contentAlreadyStreamed = output.state().value(CONTENT_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)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
@ -213,6 +243,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
if (hasFinalAnswer(output)
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
sawLegitimateExit.set(true);
}
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
@ -226,7 +264,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnComplete(() -> {
setState(AgentState.IDLE);
if (!sawLegitimateExit.get()) {
log.error("[{}] StateGraph replay stream completed WITHOUT a final answer / "
+ "limit_exceeded / finish_reason — likely framework-level silent "
+ "termination. conversationId={}, lastIteration={}, softCap={}",
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
}
})
.doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
@ -265,6 +311,20 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// compareAndSet 保证只取第一次避免 content/thinking 被重复追加
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
// STREAMED_CONTENT REPLACE 策略每轮 ReasoningNode/SummarizingNode 覆写
// lastEmitted 跟踪已发送的值避免在 ActionNode/ObservationNode NodeOutput 上重复发送同一段内容
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
// Silent-termination guardrail: track the highest iteration / soft cap
// observed and whether the graph reached a legitimate exit (final answer
// or limit-exceeded node). If the framework completes the Flux without
// either signal we log.error in doOnComplete the graph framework
// historically treated its own recursion cap as a silent normal
// completion, which masked turns ending mid-execution. Decoupling the
// recursionLimit at compile time should keep this from firing, but the
// guard catches any future regression instead of letting it ship silent.
AtomicInteger lastIteration = new AtomicInteger(0);
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
@ -287,6 +347,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean thinkingAlreadyStreamed = output.state()
.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)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
@ -310,6 +380,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
// 4. Silent-termination guard inputs
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
if (hasFinalAnswer(output)
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
sawLegitimateExit.set(true);
}
return deltas;
})
// 流正常完成后追加内部 usage 事件
@ -324,7 +403,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnComplete(() -> {
setState(AgentState.IDLE);
if (!sawLegitimateExit.get()) {
log.error("[{}] StateGraph structured stream completed WITHOUT a final answer / "
+ "limit_exceeded / finish_reason — likely framework-level silent "
+ "termination (recursionLimit reached or upstream truncation). "
+ "conversationId={}, lastIteration={}, softCap={}",
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
}
})
.doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
@ -350,7 +438,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
maxInputTokens,
chatModel,
conversationId,
parsedAgentId);
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null);
}
List<Message> messages = new ArrayList<>(historyMessages);
@ -366,9 +455,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
inputs.put(MESSAGES, messages);
// 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用
// maxIterations<=0 表示软上限解除 LLM 自己决定何时收尾加分要短路
// 否则 thinking-on 会把"无限"误算成 5变成"5 步就停"
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations;
int effectiveMaxIterations = (maxIterations <= 0)
? 0
: (thinkingOn ? maxIterations + 5 : maxIterations);
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
inputs.put(CURRENT_ITERATION, 0);
// 初始化新字段
@ -388,6 +481,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
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;
}

View File

@ -38,6 +38,17 @@ public class ObservationDispatcher implements EdgeAction {
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 表示不限制
if (maxIterations > 0 && currentIteration >= maxIterations) {
log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " +

View File

@ -34,7 +34,7 @@ public class ReasoningDispatcher implements EdgeAction {
public String apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 1. 迭代超限检查最高优先级
// 1. 迭代超限检查
if (accessor.isLimitReached()) {
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
accessor.iterationCount(), accessor.maxIterations());

View File

@ -0,0 +1,123 @@
package vip.mate.agent.graph.executor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
import java.util.Set;
/**
* Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
*
* <p>Layer 1 per-tool cap is implemented inside each tool itself.
* Layer 2 per-result spill when a single tool result exceeds {@link #perResultThresholdChars}
* the full output is written to disk and only a {@link #previewHeadChars} preview
* (plus a pointer line) is sent back to the LLM.
* Layer 3 per-turn aggregate budget after all tools in a turn complete, if
* the cumulative response size exceeds {@link #perTurnBudgetChars}, the largest
* non-spilled responses are spilled in turn until the aggregate fits.</p>
*
* <p>Spill files live under {@link #storageBaseDir} when set, otherwise under
* {@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a
* workspace is bound to the agent, otherwise under
* {@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/}.</p>
*
* <pre>
* mate:
* agent:
* tool-result:
* enabled: true
* per-result-threshold-chars: 16000
* per-turn-budget-chars: 32000
* preview-head-chars: 800
* excluded-tool-inline-chars: 4000
* storage-base-dir:
* </pre>
*/
@ConfigurationProperties(prefix = "mate.agent.tool-result")
public class ToolResultProperties {
/** Master switch. When false, the executor falls back to plain truncation. */
private boolean enabled = true;
/**
* Layer 2 a single tool result larger than this is spilled to disk.
* The executor evaluates this against the raw result before applying the
* final inline cap, so oversized content is preserved before it is shortened
* for the model request.
*/
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;
/**
* Retrieval-style tools are not spilled, but their inline content still must
* fit the model context. When aggregate turn budget is exceeded and only
* excluded tools remain, their results are compacted to this size.
*/
private int excludedToolInlineChars = 2500;
/**
* Optional absolute path to override the default spill location.
* When blank, falls back to {@code <workspace>/.mateclaw/tool-results/} or
* {@code ${java.io.tmpdir}/mateclaw/tool-results/}.
*/
private String storageBaseDir = "";
/**
* Tools whose results must NEVER be spilled. These are the tools the agent
* uses to <i>retrieve</i> spilled content spilling their output would
* cause infinite recursion (read spill path produces another spill
* agent reads new spill ) and starve {@code MAX_TOOL_CALLS_PER_STEP}.
*
* <p>Defaults to file-read tools that already cap their own output internally.
* Configurable so deployments can add more retrieval-style tools (e.g.,
* MCP-provided readers) without code changes.</p>
*/
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPerResultThresholdChars() { return perResultThresholdChars; }
public void setPerResultThresholdChars(int perResultThresholdChars) {
this.perResultThresholdChars = perResultThresholdChars;
}
public int getPerTurnBudgetChars() { return perTurnBudgetChars; }
public void setPerTurnBudgetChars(int perTurnBudgetChars) {
this.perTurnBudgetChars = perTurnBudgetChars;
}
public int getPreviewHeadChars() { return previewHeadChars; }
public void setPreviewHeadChars(int previewHeadChars) {
this.previewHeadChars = previewHeadChars;
}
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
this.excludedToolInlineChars = excludedToolInlineChars;
}
public String getStorageBaseDir() { return storageBaseDir; }
public void setStorageBaseDir(String storageBaseDir) {
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
}
public List<String> getExcludedTools() { return excludedTools; }
public void setExcludedTools(List<String> excludedTools) {
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
}
/** 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,296 @@
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) {
int compactedIdx = compactLargestExcludedResult(mutable);
if (compactedIdx >= 0) {
aggregate = aggregateSize(mutable);
continue;
}
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
aggregate);
break;
}
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
Path file = spillFor(conversationId, target.id(), workspaceBasePath);
if (file == null) {
break;
}
try {
Files.createDirectories(file.getParent());
Files.writeString(file, target.responseData(), StandardCharsets.UTF_8);
} catch (IOException ioe) {
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
ioe.getMessage());
break;
}
String preview = buildPreview(target.responseData(), target.name(), file);
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), preview));
aggregate = aggregateSize(mutable);
}
return mutable;
}
private int compactLargestExcludedResult(List<ToolResponseMessage.ToolResponse> mutable) {
int targetIdx = -1;
int targetLen = props.getExcludedToolInlineChars();
for (int i = 0; i < mutable.size(); i++) {
ToolResponseMessage.ToolResponse r = mutable.get(i);
String body = r.responseData();
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
if (!isExcluded(r.name())) continue;
if (body.length() > targetLen) {
targetLen = body.length();
targetIdx = i;
}
}
if (targetIdx < 0) {
return -1;
}
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
String compacted = compactInline(target.responseData(), target.name(), props.getExcludedToolInlineChars());
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), compacted));
log.info("[ToolResultStorage] compacted excluded tool result: tool={} chars={} -> {}",
target.name(), targetLen, compacted.length());
return targetIdx;
}
static String compactInline(String body, String toolName, int maxChars) {
if (body == null || body.length() <= maxChars) {
return body;
}
int markerBudget = 120;
int available = Math.max(200, maxChars - markerBudget);
int headLen = Math.max(100, (int) (available * 0.45));
int tailLen = Math.max(100, available - headLen);
if (headLen + tailLen >= body.length()) {
return body;
}
String marker = "\n\n... [tool result compacted for model context: tool="
+ toolName + ", original_chars=" + body.length() + "] ...\n\n";
return body.substring(0, headLen) + marker + body.substring(body.length() - tailLen);
}
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

@ -24,6 +24,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
* </pre>
* <p>
* Note: this listener is intentionally read-only / log-only. Surfacing
* graph state to channel-side accumulators (e.g. publishing the resolved
* {@code FinishReason} to message metadata) lives in {@code FinalAnswerNode}
* via a {@code finish_reason} GraphEvent that path goes through the
* PENDING_EVENTS StreamDelta pipeline that {@code ChatController.StreamAccumulator}
* actually consumes. A sibling sink that called {@code streamTracker.broadcastObject}
* here would only reach the browser SSE bus and bypass the accumulator entirely.
*
* @author MateClaw Team
*/

View File

@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.*;
import java.util.concurrent.CancellationException;
@ -65,25 +66,68 @@ public class ActionNode implements NodeAction {
// 获取工作区活动目录
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.ToolExecutionResult result = executor.execute(
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses())
.build();
// Use the executor's raw-stage ledger instead of re-parsing the
// spill-compacted responses. ToolExecutionExecutor builds this
// ledger from the full pre-truncate text, so a 30 KB grep result
// whose head/tail-cut version no longer mentions a path will still
// contribute that path to the evidence pool. Falls back to empty
// for legacy executor stubs (tests, mocks) that didn't populate
// the new field fine, the merge with `accessor.sourceEvidenceLedger`
// is no-op in that case.
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
? result.rawEvidenceLedger()
: SourceEvidenceLedger.empty();
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
.toolResults(result.responses())
.messages(List.of((Message) toolResponseMessage))
.currentPhase("action")
.events(result.events());
.events(result.events())
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
if (result.awaitingApproval()) {
output.awaitingApproval(true);
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防止下一轮再触发
if (isReplay) {
output.forcedToolCall("");

View File

@ -3,9 +3,13 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.List;
import java.util.Map;
/**
@ -34,6 +38,36 @@ public class FinalAnswerNode implements NodeAction {
String finalThinking;
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)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.RETURN_DIRECT.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
return builder.build();
}
log.warn("[FinalAnswerNode] RETURN_DIRECT_TRIGGERED=true but DIRECT_TOOL_OUTPUTS empty; " +
"falling through to default final-answer assembly");
}
// 审批等待路径Graph AWAITING_APPROVAL 终止保留已流式推送的内容用于持久化
if (accessor.awaitingApproval()) {
String preservedContent = accessor.streamedContent();
@ -46,7 +80,9 @@ public class FinalAnswerNode implements NodeAction {
.finalAnswer(preservedContent)
.finishReason(FinishReason.NORMAL)
.contentStreamed(true)
.thinkingStreamed(true);
.thinkingStreamed(true)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.NORMAL.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
@ -103,10 +139,28 @@ public class FinalAnswerNode implements NodeAction {
}
}
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
var builder = MateClawStateAccessor.output()
.finalAnswer(finalAnswer)
.finishReason(finishReason);
.finishReason(finishReason)
// Emit the resolved FinishReason as a GraphEvent so it rides
// the PENDING_EVENTS StreamDelta pipeline that the channel-
// side accumulator subscribes to. A sibling SSE broadcast (e.g.
// streamTracker.broadcastObject) reaches the browser but never
// touches the accumulator, so toMetadataJson() would not see
// it and MemorySummarizationGate would lose the structured
// signal. APPEND-strategy on PENDING_EVENTS means this
// composes safely with any earlier events upstream nodes
// attached.
.events(List.of(GraphEventPublisher.finishReason(finishReason.getValue())));
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);
@ -115,6 +169,33 @@ public class FinalAnswerNode implements NodeAction {
return builder.build();
}
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
}
/**
* RFC-052 §2.5: assemble the final answer from direct tool outputs.
* Single output verbatim full text. Multiple outputs each prefixed
* with a Markdown heading so the user can tell them apart.
*/
private static String assembleDirectAnswer(List<DirectToolOutput> outputs) {
if (outputs.size() == 1) {
return outputs.get(0).fullResult();
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < outputs.size(); i++) {
DirectToolOutput out = outputs.get(i);
if (i > 0) {
sb.append("\n\n");
}
sb.append("### ").append(out.toolName()).append("\n");
sb.append(out.fullResult());
}
return sb.toString();
}
private FinishReason parseFinishReason(String reason) {
if (reason == null || reason.isEmpty()) {
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.MateClawStateAccessor;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.i18n.I18nService;
import java.util.ArrayList;
import java.util.List;
@ -44,20 +45,28 @@ public class LimitExceededNode implements NodeAction {
private final ChatModel chatModel;
private final ObservationProcessor observationProcessor;
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,
NodeStreamingChatHelper streamingHelper) {
this(chatModel, observationProcessor, streamingHelper, null);
}
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper, I18nService i18n) {
this.chatModel = chatModel;
this.observationProcessor = observationProcessor;
this.streamingHelper = streamingHelper;
this.i18n = i18n;
}
/**
* @deprecated Use constructor with NodeStreamingChatHelper
* @deprecated use the constructor with {@link NodeStreamingChatHelper} (and optionally {@link I18nService})
*/
@Deprecated
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
this(chatModel, observationProcessor, null);
this(chatModel, observationProcessor, null, null);
}
@Override
@ -88,7 +97,7 @@ public class LimitExceededNode implements NodeAction {
contextForLLM = observationProcessor.truncate(sb.toString(),
observationProcessor.getMaxTotalObservationChars());
} else {
contextForLLM = "(尚未收集到工具调用结果)";
contextForLLM = i18n != null ? i18n.msg("agent.limit_exceeded.empty_context") : "(no tool results)";
}
// 构建 prompt
@ -110,8 +119,11 @@ public class LimitExceededNode implements NodeAction {
log.info("[LimitExceededNode] Generated limit-exceeded final answer: {} chars",
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()
.finalAnswerDraft(finalDraft != null ? finalDraft : "抱歉,已达到最大推理步数,未能获得完整结果。")
.finalAnswerDraft(finalDraft != null ? finalDraft : fallbackMsg)
.currentThinking(result.thinking())
.limitExceeded(true)
.contentStreamed(true)

View File

@ -4,6 +4,7 @@ import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
@ -120,6 +121,17 @@ public class ObservationNode implements NodeAction {
.shouldSummarize(shouldSummarize)
.toolCallCount(newToolCallCount);
// Close out the iteration we just observed. We use currentIteration
// (not nextIteration) so the index pairs with whatever
// iteration_start the ReasoningNode emitted at the top of this turn.
// Char totals are best-effort: ObservationNode doesn't see the LLM
// delta stream directly, so 0/0 is acceptable for now consumers
// that care fall back to summing the deltas themselves.
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
builder.events(List.of(
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
}
// 重复观察时标记错误 ObservationDispatcher 路由到 limitExceededNode
if (duplicateObservation) {
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");

View File

@ -7,6 +7,7 @@ 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.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
@ -24,6 +25,7 @@ import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.channel.web.ChatStreamTracker;
@ -51,12 +53,52 @@ public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
return MateClawStateAccessor.output();
}
/**
* 单次 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 List<ToolCallback> toolCallbacks;
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 ConversationWindowManager conversationWindowManager;
private final ChatStreamTracker streamTracker;
@ -85,9 +127,31 @@ public class ReasoningNode implements NodeAction {
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
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.toolCallbacks = toolSet.callbacks();
this.reasoningEffort = reasoningEffort;
this.supportsReasoningEffort = supportsReasoningEffort;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
this.streamTracker = streamTracker;
@ -119,6 +183,7 @@ public class ReasoningNode implements NodeAction {
this.chatModel = chatModel;
this.toolCallbacks = toolCallbacks;
this.reasoningEffort = null;
this.supportsReasoningEffort = false;
this.streamingHelper = null;
this.conversationWindowManager = null;
this.streamTracker = null;
@ -151,7 +216,7 @@ public class ReasoningNode implements NodeAction {
.toolCalls(List.of(toolCall))
.build();
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.toolCalls(List.of(toolCall))
.messages(List.of((Message) syntheticMsg))
@ -172,16 +237,93 @@ public class ReasoningNode implements NodeAction {
// ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt();
// RFC-049 follow-up: append a tool-use enforcement clause to every
// ReasoningNode call. Without this, models (especially DeepSeek thinking
// and Claude Opus) tend to "narrate" emit a final_answer like "现在
// 直接生成立项材料 docx" instead of actually calling renderDocx, which
// makes the graph silently terminate at final_answer_node with the
// narration as the user-facing reply.
//
// 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();
// 消息列表膨胀防护
// 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;
if (messages.size() > MAX_LOOP_MESSAGES) {
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
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);
trimmed.addAll(messages.subList(0, Math.min(4, messages.size())));
trimmed.addAll(messages.subList(messages.size() - (MAX_LOOP_MESSAGES - 4), messages.size()));
trimmed.addAll(messages.subList(0, headKeep));
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;
}
@ -205,6 +347,9 @@ public class ReasoningNode implements NodeAction {
}
}
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
}
promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
@ -227,6 +372,17 @@ public class ReasoningNode implements NodeAction {
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
Map.of("iteration", accessor.iterationCount()));
// Iteration boundary marker for the parent ReAct loop. Reason
// distinguishes the very first turn of the conversation from a
// mid-loop repeat for consumers grouping events into per-turn cards.
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
GraphEventPublisher.GraphEvent iterStartEvent = iterationEventsOn
? GraphEventPublisher.iterationStart(
accessor.iterationCount(),
accessor.iterationCount() == 0 ? "first_turn" : "react_step",
"parent",
null)
: null;
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
"llmCallCount", nextLlmCallCount
@ -266,7 +422,7 @@ public class ReasoningNode implements NodeAction {
// 必须显式清零 needsToolCall/shouldSummarize防止前一轮残留标志导致误路由
log.info("[ReasoningNode] CancellationException during LLM call (user stopped before first token), " +
"returning empty answer with STOPPED, llmCallCount={}", nextLlmCallCount);
return MateClawStateAccessor.output()
return reasonOutput()
.finalAnswer("")
.needsToolCall(false)
.shouldSummarize(false)
@ -285,7 +441,7 @@ public class ReasoningNode implements NodeAction {
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), flushing as final answer",
partialText.length(), partialThinking.length());
var builder = MateClawStateAccessor.output()
var builder = reasonOutput()
.finalAnswer(partialText)
.needsToolCall(false)
.shouldSummarize(false)
@ -300,13 +456,46 @@ public class ReasoningNode implements NodeAction {
return builder.build();
}
// Order matters: the partial-truncation branch MUST sit before
// hasFatalError(). hasFatalError() is "no text + no tool calls + non-
// null errorMessage", which is also the shape of a thinking-only cap
// result (text is empty by definition). Without this ordering the
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
// INCOMPLETE semantics.
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
// Soft thinking-only loop: the helper disposed the upstream stream
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
// reasoning_content without emitting any visible content or tool
// calls. Treat as INCOMPLETE rather than fatal the thinking text
// has already been streamed and is preserved for the UI's collapse
// panel; the user gets a short fallback line they can retry from.
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
"INCOMPLETE",
partialThinking.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
.contentStreamed(false)
.thinkingStreamed(true)
.mergeUsage(state, result);
if (!partialThinking.isEmpty()) {
builder.finalThinking(partialThinking);
}
return builder.build();
}
// Fatal error直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK
// 不走 LimitExceededNode后者会再发一次 LLM 调用语义不对且对认证/配额错误会再失败
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize finalAnswerNode
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用finishReason 保持 ERROR_FALLBACK
if (result.hasFatalError()) {
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("[错误] " + result.errorMessage())
@ -319,7 +508,8 @@ public class ReasoningNode implements NodeAction {
}
if (result.partial()) {
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
}
if (result.hasToolCalls()) {
@ -331,7 +521,7 @@ public class ReasoningNode implements NodeAction {
"toolCount", result.toolCalls().size()
));
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.shouldSummarize(false)
.toolCalls(result.toolCalls())
@ -344,7 +534,7 @@ public class ReasoningNode implements NodeAction {
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.events(buildEvents(phaseEvent, iterStartEvent))
.build();
} else {
String content = result.text();
@ -353,23 +543,49 @@ public class ReasoningNode implements NodeAction {
"iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0
));
SourceEvidenceLedger.Validation validation =
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
boolean evidenceInsufficient = !validation.valid();
String finalAnswer = evidenceInsufficient
? evidenceWarning(validation.unsupportedReferences())
: (content != null ? content : "");
if (evidenceInsufficient) {
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
return MateClawStateAccessor.output()
// Final-answer path: iteration ends in this same node because
// ReAct never re-enters the loop afterwards.
GraphEventPublisher.GraphEvent iterEndEvent = iterationEventsOn
? GraphEventPublisher.iterationEnd(accessor.iterationCount(),
"parent", null,
content != null ? content.length() : 0,
result.thinking() != null ? result.thinking().length() : 0)
: null;
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(content != null ? content : "")
.finalAnswer(finalAnswer)
.finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.contentStreamed(true)
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
.contentStreamed(!evidenceInsufficient)
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.events(buildEvents(phaseEvent, iterStartEvent, iterEndEvent))
.build();
}
}
private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
}
private AssistantMessage.ToolCall deserializeToolCall(String json) {
try {
@SuppressWarnings("unchecked")
@ -386,6 +602,19 @@ public class ReasoningNode implements NodeAction {
}
}
/**
* Compose the per-call event list, dropping any null entries so the
* iteration-boundary toggle ({@code mateclaw.stream.iteration-events})
* works without forcing every caller into branching code.
*/
private static List<GraphEventPublisher.GraphEvent> buildEvents(GraphEventPublisher.GraphEvent... events) {
List<GraphEventPublisher.GraphEvent> out = new ArrayList<>(events.length);
for (GraphEventPublisher.GraphEvent ev : events) {
if (ev != null) out.add(ev);
}
return out;
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
return;
@ -454,6 +683,13 @@ public class ReasoningNode implements NodeAction {
* 解析有效的 reasoningEffort
* 优先级ThinkingLevelHolder请求级 > 构造时的 reasoningEffortAgent/模型默认
* "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() {
String requestLevel = ThinkingLevelHolder.get();
@ -461,6 +697,11 @@ public class ReasoningNode implements NodeAction {
if ("off".equalsIgnoreCase(requestLevel)) {
return null;
}
if (!this.supportsReasoningEffort) {
log.debug("[ReasoningNode] Ignoring thinkingLevel='{}' — bound model family does not support reasoning_effort",
requestLevel);
return null;
}
// thinkingLevel reasoningEffort 映射
return switch (requestLevel.toLowerCase()) {
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.action.NodeAction;
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.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
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 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
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(promptMessages), conversationId, "summarizing");
chatModel, summarizePrompt, conversationId, "summarizing");
// 错误处理摘要失败时用原始观察的前 500 字符作为 fallback
if (result.hasFatalError()) {
@ -173,6 +183,9 @@ public class SummarizingNode implements NodeAction {
// 摘要的 content 已流式推送但它不是最终回答标记防重即可
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
// 把当轮 summary 文本写入 STREAMED_CONTENT StateGraphReActAgent persistOnly
// StreamDelta 推给 Accumulator 持久化用户刷新页面后能看到摘要正文否则只剩 tool_call 卡片
.streamedContent(summaryContent)
.mergeUsage(state, result)
// 不设 finishReason summarizing 不是终止循环继续
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
@ -181,6 +194,26 @@ public class SummarizingNode implements NodeAction {
.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) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;

View File

@ -45,16 +45,28 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
private final PlanningService planningService;
private final org.springframework.ai.chat.model.ChatModel chatModel;
private final ConversationWindowManager conversationWindowManager;
/** Held only so context-window budget includes the tools schema. Nullable for legacy constructor. */
private final vip.mate.agent.AgentToolSet toolSet;
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph, PlanningService planningService,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager) {
this(chatClient, conversationService, compiledGraph, planningService,
chatModel, conversationWindowManager, null);
}
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph, PlanningService planningService,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager,
vip.mate.agent.AgentToolSet toolSet) {
super(chatClient, conversationService);
this.compiledGraph = compiledGraph;
this.planningService = planningService;
this.chatModel = chatModel;
this.conversationWindowManager = conversationWindowManager;
this.toolSet = toolSet;
}
@Override
@ -254,7 +266,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
maxInputTokens,
chatModel,
conversationId,
parsedAgentId);
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null);
}
List<Message> messages = new ArrayList<>(historyMessages);
@ -285,6 +298,19 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
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;
}

View File

@ -5,21 +5,24 @@ import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
/**
* 计划生成后的路由分发器
* <p>
* 根据 needs_planning 判断
* Routes the graph after the triage node.
* <ul>
* <li>false 路由到 DIRECT_ANSWER_NODE简单问答快速退出</li>
* <li>true 路由到 STEP_EXECUTION_NODE开始步骤执行</li>
* <li>{@code needs_planning=false} {@code DIRECT_ANSWER_NODE} (direct answer, no tools)</li>
* <li>{@code needs_planning=true} {@code STEP_EXECUTION_NODE} (single- or multi-step plan)</li>
* </ul>
*
* @author MateClaw Team
* <p>
* 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 {
@Override
public String apply(OverAllState state) {
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, true);
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false);
if (!needsPlanning) {
return PlanStateKeys.DIRECT_ANSWER_NODE;
}

View File

@ -3,19 +3,37 @@ package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.Map;
/**
* 直接回答节点
* <p>
* PlanGenerationNode 判定用户消息是简单问答时
* direct_answer 透传为 final_summary直接结束图执行
* When PlanGenerationNode classifies the user's message as a simple
* question, this node propagates {@code direct_answer} into
* {@code FINAL_SUMMARY} so the graph terminates with the answer in the
* canonical place every downstream consumer reads from.
* <p>
* 如果 PlanGenerationNode 已通过 broadcastContent() 推送了内容
* contentStreamed=true则不再复制到 FINAL_SUMMARY
* 避免 StreamAccumulator 重复收集导致持久化内容翻倍
* Earlier versions skipped writing FINAL_SUMMARY when
* {@code CONTENT_STREAMED=true}, on the theory that broadcastContent had
* already pushed the text and a second copy in FINAL_SUMMARY would cause
* double persistence. That was wrong: broadcastContent goes directly to
* the SSE side-channel via {@code streamTracker.broadcastDelta} and does
* NOT participate in the DB segment that ChatController accumulates from
* the structured stream. Skipping FINAL_SUMMARY left
* {@code AgentService.chat()} (the sync entry used by every IM channel)
* with an empty reply, which silently dropped DingTalk / Slack /
* Telegram replies on the direct-answer path. It also left
* {@code mate_message.content} empty on the web channel the SSE
* client saw the answer in real time but reopening the conversation
* showed a blank assistant turn.
* <p>
* Re-broadcast suppression is the responsibility of the stream layer,
* not this node:
* {@link vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent#chatStructuredStream}
* tags the FINAL_SUMMARY delta as {@code persistOnly} when
* CONTENT_STREAMED is true, and {@code ChatController} respects that flag
* to persist without re-pushing.
*
* @author MateClaw Team
*/
@ -23,11 +41,6 @@ public class DirectAnswerNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) {
boolean alreadyStreamed = state.value(MateClawStateKeys.CONTENT_STREAMED, false);
if (alreadyStreamed) {
// broadcastContent 已推送并被 accumulator 收集不重复写入 FINAL_SUMMARY
return Map.of();
}
String directAnswer = state.value(PlanStateKeys.DIRECT_ANSWER, "");
return Map.of(PlanStateKeys.FINAL_SUMMARY, directAnswer);
}

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.action.NodeAction;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
@ -26,21 +26,23 @@ import java.util.Map;
import java.util.stream.Collectors;
/**
* 计划生成节点
* Task triage node for the Plan-Execute graph.
* <p>
* 职责
* <ol>
* <li>判断是否需要规划简单问答快速退出</li>
* <li>如需规划生成计划 JSON解析校验</li>
* <li> PlanningService.createPlan() 持久化</li>
* <li>发布 plan_created 事件</li>
* </ol>
* Decides one of three routes for the user's goal and emits a JSON directive:
* <ul>
* <li>{@code direct_answer} pure knowledge question, no tools, no planning</li>
* <li>single-step plan needs tools but a single coherent action (steps=1)</li>
* <li>multi-step plan genuinely independent subtasks (26 steps)</li>
* </ul>
* 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>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用
* 即便最终返回 JSON也允许模型的 planning 输出以流式产生最终再聚合解析
* 直接回答路径也通过流式 helper 实时输出给前端
*
* @author MateClaw Team
* The previous version forced {@code needs_planning=true} whenever any tool
* was required, producing multi-step plans for trivial single-hop tasks.
* 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).
*/
@Slf4j
public class PlanGenerationNode implements NodeAction {
@ -50,32 +52,44 @@ public class PlanGenerationNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
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 = """
你是任务规划器不是聊天助手
你是任务分流不是聊天助手根据用户目标把请求分到三类之一并只输出一个 JSON 对象
你的输出必须满足以下规则
1. 只能返回一个 JSON 对象
2. 不允许输出任何 JSON 之外的文字
3. 不允许使用 markdown 代码块
4. 不要解释不要寒暄不要先说"我来...""我先..."
硬性规则
1. 只返回一个 JSON 对象不允许 markdown 代码块不允许任何 JSON 以外的文字
2. 不要解释不要寒暄不要说"我来...""我先..."
3. 不确定时优先选择"单步"而不是拆成多步
返回格式二选一
三类分流
不需要规划时
{"needs_planning": false, "direct_answer": "..."}
(A) 直接回答 纯知识问答模型凭自身知识即可回答不需要任何工具不需要读文件不需要查询当前状态
输出{"needs_planning": false, "direct_answer": "<你的回答>"}
需要规划时
{"needs_planning": true, "steps": ["步骤1", "步骤2", "步骤3"]}
(B) 单步任务 需要工具但本质是一个连贯动作一次文件读取 / 一次搜索 / 一次命令 / 一次记忆读写 / 一次计算
执行器会在这一步内部迭代调用多次工具**不要**提前拆分
输出{"needs_planning": true, "steps": ["<将用户目标复述为一句清晰可执行的指令>"]}
要求
- steps 数量 2 6
- 每个步骤必须是可执行动作不要写空话
- 默认不要把 MEMORY.mdPROFILE.md记忆文件当成独立步骤但如果用户目标明显依赖历史偏好长期约束过往决策或持续上下文可以加入必要的记忆读取步骤
- 不要把技能文件当成独立步骤除非用户任务明确要求
- 如果用户目标需要调用任何工具才能完成包括记忆读写文件操作搜索命令执行等必须返回 needs_planning: true只有纯知识问答不需要调用任何工具的简单问题才返回 needs_planning: false
- 如果无法确定也必须返回合法 JSON不能输出自然语言
(C) 多步任务 用户目标包含 2 个及以上明显独立必须先后完成的子任务例如"先调研 A 再调研 B 然后对比"
"读配置、迁移数据、验证结果"子任务之间如果可以合并应当合并
输出{"needs_planning": true, "steps": ["步骤1", "步骤2", ...]}2 6 个步骤
关键原则
- 单工具调用绝对不拆成多步"读 A 文件并总结" 是单步B不是两步
- 默认不要把 MEMORY.md / PROFILE.md / 技能文件读取当成独立步骤仅当用户明确询问偏好历史决策或长期约束时才加入
- 每个步骤必须是可执行动作不写"思考一下""确认一下"之类的空话
- 解析不出来时视作(B) 单步宁愿单步也不要无脑拆分
""";
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
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
@ -110,7 +124,7 @@ public class PlanGenerationNode implements NodeAction {
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
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);
if (existingPlanId != null) {
List<String> existingSteps = accessor.planSteps();
@ -128,42 +142,56 @@ public class PlanGenerationNode implements NodeAction {
}
try {
// 构建 prompt 消息列表PLANNING_PROMPT 作为独立 system message
// 不拼接完整 systemPromptwiki/技能/记忆指南等与规划决策无关
// 拼接后会稀释 PLANNING_PROMPT 的指令优先级
// PLANNING_PROMPT is the sole system message; we deliberately do NOT
// concatenate the agent's full systemPrompt (wiki / skill / memory guidance),
// which would dilute the triage instructions.
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
// 注入运行时上下文当前时间 + 工作目录
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
// 注入可用工具名称帮助 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()) {
String toolNames = toolSet.callbacks().stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.joining(", "));
promptMessages.add(new UserMessage(
"以使以下工具:" + toolNames
+ "\n如果用户目标需要调用任何工具才能完成,必须返回 needs_planning: true"));
"可用工具:" + toolNames
+ "\n单次工具调用应归为单步B不要拆成多步"));
}
// 注入 working context对话历史摘要让规划能感知之前对话的约束和补充条件
// Inject working context (rolling conversation summary) so triage respects
// prior constraints without re-reading full history.
String workingContext = accessor.workingContext();
if (!workingContext.isEmpty()) {
promptMessages.add(new UserMessage(
"以下是此前对话中用户提出的约束、说明和上下文,请在规划时充分考\n\n"
"以下是此前对话中用户提出的约束、说明和上下文,请在流时参考:\n\n"
+ workingContext));
}
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);
// 静默流式调用 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(
chatModel, prompt, conversationId, "plan_generation");
// PTL 处理压缩后重试
// Prompt-too-long handling: compact the conversation window and retry once.
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[PlanGeneration] Prompt too long, attempting compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
@ -177,23 +205,28 @@ public class PlanGenerationNode implements NodeAction {
}
}
long triageMs = System.currentTimeMillis() - triageStartMs;
String llmResponse = result.text();
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
// 清理 markdown 代码块标记
String cleanedJson = cleanJsonResponse(llmResponse);
// D-6: emit triage perf summary
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
"triage_ms", triageMs,
"prompt_tokens", result.promptTokens(),
"completion_tokens", result.completionTokens()
)));
// 解析 JSON
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
TriageResult triage = converter.convert(llmResponse);
boolean needsPlanning = triage != null && triage.needsPlanning();
if (!needsPlanning) {
// 简单问答快速退出 解析出 direct_answer 后手动推送给前端
String directAnswer = parsed.get("direct_answer") != null
? parsed.get("direct_answer").toString() : llmResponse;
log.info("[PlanGeneration] Simple question detected, returning direct answer");
// Category (A): direct answer push to client and terminate via DirectAnswerNode.
String directAnswer = triage != null && triage.directAnswer() != null
? triage.directAnswer() : llmResponse;
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
// 手动广播 direct_answer 文本而不是原始 JSON
streamingHelper.broadcastContent(conversationId, directAnswer);
return PlanStateAccessor.output()
@ -207,27 +240,21 @@ public class PlanGenerationNode implements NodeAction {
.build();
}
// 需要规划提取步骤
@SuppressWarnings("unchecked")
List<String> steps = (List<String>) parsed.get("steps");
// Categories (B) single-step or (C) multi-step: extract steps.
List<String> steps = triage != null ? triage.steps() : null;
if (steps == null || steps.isEmpty()) {
log.warn("[PlanGeneration] LLM returned needs_planning=true but empty steps, falling back to direct answer");
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(llmResponse)
.currentPhase("direct_answer")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
// LLM asked for planning but produced no steps fall back to a
// synthetic 1-step plan using the user's goal so the executor
// can still reach the tools. (Previous behavior dropped back to
// direct_answer, which silently stripped tool capability.)
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
steps = List.of(goal);
}
// 持久化计划
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));
return PlanStateAccessor.output()
@ -244,36 +271,33 @@ public class PlanGenerationNode implements NodeAction {
.build();
} 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()
.needsPlanning(false)
.directAnswer("抱歉,我暂时无法完成规划,请重试或换一种方式描述任务。")
.directAnswer("抱歉,我暂时无法完成任务分流,请重试或换一种方式描述任务。")
.currentPhase("direct_answer")
.events(events)
.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.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.util.StringUtils;
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.plan.state.PlanStateAccessor;
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.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector;
@ -36,7 +36,12 @@ import java.util.Map;
* 步骤执行节点
* <p>
* 执行当前步骤使用显式工具执行循环internalToolExecutionEnabled=false
* 单步最大工具调用次数限制为 5 防止无限循环
* 单步最大工具调用次数限制为 {@link #MAX_TOOL_CALLS_PER_STEP}
* {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING} 对齐因此实际生效的上限
* 永远是 agent {@code max_iterations}DB 单步本身不会先于 agent
* 的整体预算被打掉早期 5 次的硬限制对"查新闻 + 整理 Word"这种合理多
* 工具任务过紧 LimitExceededNode 提前拦截后用户看到的是冷冰冰的
* "工具调用次数超出最大限制"
* <p>
* 支持 NEEDS_APPROVAL 审批流程对需要审批的工具调用创建 pending
* 发出 SSE 事件后立即返回审批提示非阻塞审批通过后通过 replay 重新执行
@ -54,8 +59,29 @@ public class StepExecutionNode implements NodeAction {
private final ConversationWindowManager conversationWindowManager;
private final String reasoningEffort;
private final NodeStreamingChatHelper streamingHelper;
private final long stepWallClockTimeoutMs;
private static final int MAX_TOOL_CALLS_PER_STEP = 5;
/**
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
* Matching the agent-level cap means this constant is never the bottleneck
* the agent's own {@code max_iterations} (DB column) will fire first if a
* task is genuinely runaway, and a well-budgeted multi-tool step (e.g.
* web_search + browser_navigate + browser_read*N + file_write) is no longer
* cut short by an arbitrary 5-call ceiling.
*/
private static final int MAX_TOOL_CALLS_PER_STEP = 100;
/**
* Wall-clock budget per step, complementing {@link #MAX_TOOL_CALLS_PER_STEP}.
* The call-count cap doesn't help when a single LLM stream stalls or a
* concurrency-unsafe tool runs synchronously without a per-tool deadline
* (the parallel batch path enforces {@code ToolTimeoutProperties}, but the
* single-unsafe path in {@code ToolExecutionExecutor#executeSingleTool}
* currently does not). 10 minutes is generous for legitimate long steps
* (large file edits, multi-page browser flows) while still cutting off the
* pathological cases where the agent appears frozen to the user.
*/
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
private static final ObjectMapper MAPPER = new ObjectMapper();
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
@ -64,6 +90,19 @@ public class StepExecutionNode implements NodeAction {
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager) {
this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager,
STEP_WALL_CLOCK_TIMEOUT_MS);
}
/** Test-friendly overload — production callers use the default timeout. */
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
long stepWallClockTimeoutMs) {
this.chatModel = chatModel;
this.toolSet = toolSet;
this.executor = executor;
@ -72,6 +111,7 @@ public class StepExecutionNode implements NodeAction {
this.conversationWindowManager = conversationWindowManager;
this.reasoningEffort = reasoningEffort;
this.streamingHelper = streamingHelper;
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
}
@Override
@ -86,6 +126,12 @@ public class StepExecutionNode implements NodeAction {
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
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()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@ -100,6 +146,15 @@ public class StepExecutionNode implements NodeAction {
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
// Iteration boundary for the plan-execute loop: each step is one
// iteration that may itself fan out to multiple LLM calls. Reason is
// "plan_step" so consumers can distinguish it from ReAct's
// "react_step" / "first_turn" markers when both stream into the
// same SSE feed.
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
}
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
@ -117,21 +172,46 @@ public class StepExecutionNode implements NodeAction {
int stepPromptTokens = 0;
int stepCompletionTokens = 0;
// RFC-052: any returnDirect tool that fires inside this step must
// short-circuit the entire plan (not just this step). We accumulate
// outputs across the inner loop and break out as soon as one appears.
List<DirectToolOutput> stepDirectOutputs = new ArrayList<>();
// Wall-clock budget guards against a single hung LLM stream or
// synchronous unsafe-tool call (the per-tool timeout is enforced only
// in the parallel batch path of ToolExecutionExecutor). Checked at the
// top of each iteration so we never issue another LLM call after the
// budget is gone.
long stepStartedAtMs = System.currentTimeMillis();
boolean wallClockExceeded = false;
try {
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
ChatOptions options;
if (StringUtils.hasText(reasoningEffort)) {
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
if (elapsedMs > stepWallClockTimeoutMs) {
log.warn("[StepExecution] Step {} exceeded wall-clock budget " +
"({} ms > {} ms) after {} tool round(s); aborting step",
stepIndex, elapsedMs, stepWallClockTimeoutMs, toolCallCount);
wallClockExceeded = true;
break;
}
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
// producer in NodeStreamingChatHelper.doStreamCall can attach the
// user-token. Using ToolCallingChatOptions when reasoningEffort is
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
// would 400 again.
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.reasoningEffort(reasoningEffort)
.build();
if (StringUtils.hasText(reasoningEffort)) {
oaiOpts.setReasoningEffort(reasoningEffort);
}
oaiOpts.setInternalToolExecutionEnabled(false);
options = oaiOpts;
} else {
options = ToolCallingChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.internalToolExecutionEnabled(false)
.build();
ChatOptions options = oaiOpts;
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
@ -180,16 +260,23 @@ public class StepExecutionNode implements NodeAction {
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
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(
toolCall, storedArguments, events);
toolCall, storedArguments, events, conversationId, workspaceBasePath,
stepDirectOutputs);
toolResponses.add(response);
preApprovedPayload = ""; // 只消费一次
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = toolCall.name();
@ -200,9 +287,12 @@ public class StepExecutionNode implements NodeAction {
} else {
// 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
@ -221,6 +311,16 @@ public class StepExecutionNode implements NodeAction {
if (approvalTriggered) {
break;
}
// RFC-052: returnDirect short-circuit. Any direct tool in this
// step ends the plan immediately; the dispatcher routes via
// currentPhase=plan_aborted so no further LLM call happens.
if (!stepDirectOutputs.isEmpty()) {
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
"tool output(s); aborting plan execution",
stepIndex, stepDirectOutputs.size());
break;
}
}
// 处理审批暂停
@ -239,10 +339,53 @@ public class StepExecutionNode implements NodeAction {
.build();
}
// RFC-052: direct tool short-circuit at the plan level. Treat the
// assembled direct text as the final summary and abort the plan;
// the dispatcher routes plan_aborted to END so no further LLM call
// is made. Persisting RETURN_DIRECT_TRIGGERED + DIRECT_TOOL_OUTPUTS
// lets the SSE accumulator pick up directToolNames metadata so
// history scrub (BaseAgent.isDirectToolMessage) kicks in next turn.
//
// Plan status is "completed" (not "failed"): the user got their
// answer correctly, the plan just terminated earlier than the
// model's planning stage anticipated. Marking as failed would skew
// operational dashboards and confuse plan-history readers.
if (!stepDirectOutputs.isEmpty()) {
String assembled = assembleDirectAnswerText(stepDirectOutputs);
planningService.updateSubPlanResult(planId, stepIndex, assembled);
planningService.completePlan(planId,
"Plan completed via returnDirect tool: " +
stepDirectOutputs.get(0).toolName());
events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
assembled != null ? assembled.length() : 0, 0));
}
return PlanStateAccessor.output()
.currentStepResult(assembled)
.currentStepIndex(steps.size()) // 越界 dispatcher 收束
.currentPhase("plan_aborted")
.finalSummary(assembled)
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.put(MateClawStateKeys.PROMPT_TOKENS,
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS,
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
if (finalResult == null) {
if (wallClockExceeded) {
finalResult = "步骤执行超过最大耗时限制("
+ (stepWallClockTimeoutMs / 1000) + "秒),已中止本步骤";
} else {
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
}
}
} catch (Exception e) {
log.error("[StepExecution] Step {} execution failed: {}", stepIndex, e.getMessage(), e);
@ -250,6 +393,10 @@ public class StepExecutionNode implements NodeAction {
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
shortError != null ? shortError.length() : 0, 0));
}
return PlanStateAccessor.output()
.currentStepResult(shortError)
.currentPhase("plan_aborted")
@ -262,15 +409,32 @@ public class StepExecutionNode implements NodeAction {
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
finalResult != null ? finalResult.length() : 0,
stepThinking != null ? stepThinking.length() : 0));
}
log.info("[StepExecution] Step {}/{} completed: {}",
stepIndex + 1, steps.size(),
finalResult.length() > 100 ? finalResult.substring(0, 100) + "..." : finalResult);
// 更新 working context将最新完成的步骤结果纳入摘要
List<String> allCompleted = new ArrayList<>(accessor.completedResults());
allCompleted.add(formatStepResult(stepIndex, finalResult));
String updatedWorkingContext = rebuildWorkingContext(accessor, allCompleted);
// RFC-008 P4.2: incremental working-context update.
// Previous behavior rebuilt the entire context from history + every
// completed result on every step (O(N) per step). On long plans this
// 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()
.currentStepResult(finalResult)
@ -287,6 +451,26 @@ public class StepExecutionNode implements NodeAction {
.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) {
List<Message> messages = new ArrayList<>();
@ -414,9 +598,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 中的会话历史消息和更新后的已完成步骤结果
* 重建 working context复用与 StateGraphPlanExecuteAgent.buildWorkingContext 相同的逻辑
* Incrementally extend the previous working context with one new step
* 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) {
List<Message> messages = accessor.messages();

View File

@ -48,7 +48,10 @@ public final class PlanStateAccessor {
}
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

@ -19,8 +19,18 @@ public enum FinishReason {
/** 发生错误后降级回答 */
ERROR_FALLBACK("error_fallback"),
/** 响应未完整完成,需要继续生成或重试 */
INCOMPLETE("incomplete"),
/** 最终回答引用了未被工具结果验证的源码事实 */
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
/** 用户主动停止 */
STOPPED("stopped");
STOPPED("stopped"),
/** RFC-052: a tool with returnDirect=true short-circuited the loop;
* result was delivered to the user without re-entering the LLM. */
RETURN_DIRECT("return_direct");
private final String value;

View File

@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import java.util.*;
@ -195,12 +196,38 @@ public final class MateClawStateAccessor {
return state.value(AWAITING_APPROVAL, false);
}
// ===== RFC-052: returnDirect =====
public boolean returnDirectTriggered() {
return state.value(RETURN_DIRECT_TRIGGERED, false);
}
@SuppressWarnings("unchecked")
public List<DirectToolOutput> directToolOutputs() {
return state.<List<DirectToolOutput>>value(DIRECT_TOOL_OUTPUTS).orElse(List.of());
}
public SourceEvidenceLedger sourceEvidenceLedger() {
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
}
// ===== 审批重放 =====
public String forcedToolCall() {
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 =====
public int promptTokens() {
@ -373,11 +400,29 @@ public final class MateClawStateAccessor {
return put(AWAITING_APPROVAL, awaiting);
}
// ---- RFC-052: returnDirect ----
public OutputBuilder returnDirectTriggered(boolean triggered) {
return put(RETURN_DIRECT_TRIGGERED, triggered);
}
public OutputBuilder directToolOutputs(List<DirectToolOutput> outputs) {
return put(DIRECT_TOOL_OUTPUTS, outputs);
}
public OutputBuilder sourceEvidenceLedger(SourceEvidenceLedger ledger) {
return put(SOURCE_EVIDENCE_LEDGER, ledger);
}
// ---- 审批重放 ----
public OutputBuilder forcedToolCall(String json) {
return put(FORCED_TOOL_CALL, json);
}
// ---- RFC-063r: ChatOrigin ----
public OutputBuilder chatOrigin(ChatOrigin origin) {
return put(CHAT_ORIGIN, origin);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */

View File

@ -140,4 +140,34 @@ public final class MateClawStateKeys {
// ===== 运行时模型快照REPLACE 策略buildInitialState 注入=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
public static final String RUNTIME_PROVIDER_ID = "runtime_provider_id";
// ===== RFC-052: Tool returnDirect 与数据隔离 =====
/**
* RFC-052: when true the latest tool batch contained at least one tool
* declared as returnDirect, so the graph must short-circuit to
* {@link #FINAL_ANSWER_NODE} without re-entering the LLM.
*/
public static final String RETURN_DIRECT_TRIGGERED = "return_direct_triggered";
/**
* RFC-052: list of {@code DirectToolOutput} accumulated from the most recent
* tool batch, used by FinalAnswerNode to assemble the final answer.
*/
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
/** Source references observed from successful tool results during this run. */
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
// ===== 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

@ -0,0 +1,220 @@
package vip.mate.agent.graph.state;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.io.Serializable;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Tracks source references that were actually observed through tool results.
*/
public record SourceEvidenceLedger(
Set<String> sourcePaths,
Set<String> sourceSymbols,
Set<String> failedPaths
) implements Serializable {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Pattern JAVA_PATH = Pattern.compile(
"(?:[A-Za-z]:)?[A-Za-z0-9_./\\\\-]+\\.java\\b");
private static final Pattern JAVA_FILE_REF = Pattern.compile("\\b[A-Za-z][A-Za-z0-9_]*\\.java\\b");
private static final Pattern JAVA_SYMBOL_REF = Pattern.compile(
"\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b");
private static final Pattern DECLARED_TYPE = Pattern.compile(
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
public SourceEvidenceLedger {
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths);
}
public static SourceEvidenceLedger empty() {
return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of());
}
public static SourceEvidenceLedger fromToolResponses(List<ToolResponseMessage.ToolResponse> responses) {
if (responses == null || responses.isEmpty()) {
return empty();
}
Builder builder = new Builder();
for (ToolResponseMessage.ToolResponse response : responses) {
String data = response.responseData();
if (data == null || data.isBlank()) {
continue;
}
if (isReadFileTool(response.name())) {
recordReadFile(data, builder);
} else {
recordPlainTextEvidence(data, builder);
}
}
return builder.build();
}
public SourceEvidenceLedger merge(SourceEvidenceLedger other) {
if (other == null || !other.hasEvidence()) {
return this;
}
Builder builder = new Builder();
sourcePaths.forEach(builder::sourcePath);
sourceSymbols.forEach(builder::symbol);
failedPaths.forEach(builder::failedPath);
other.sourcePaths.forEach(builder::sourcePath);
other.sourceSymbols.forEach(builder::symbol);
other.failedPaths.forEach(builder::failedPath);
return builder.build();
}
public SourceEvidenceLedger withSourcePath(String path) {
Builder builder = new Builder();
sourcePaths.forEach(builder::sourcePath);
sourceSymbols.forEach(builder::symbol);
failedPaths.forEach(builder::failedPath);
builder.sourcePath(path);
return builder.build();
}
public boolean hasEvidence() {
return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty();
}
public boolean hasPath(String path) {
String normalized = normalizePath(path);
return sourcePaths.contains(normalized) || sourcePaths.stream().anyMatch(p -> p.endsWith("/" + normalized));
}
public boolean hasSymbol(String symbol) {
return sourceSymbols.contains(symbol);
}
public Validation validateAnswer(String answer) {
if (answer == null || answer.isBlank() || !hasEvidence()) {
return Validation.ok();
}
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
while (fileMatcher.find()) {
String ref = fileMatcher.group();
if (!hasFileName(ref)) {
unsupported.add(ref);
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
}
}
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
while (symbolMatcher.find()) {
String ref = symbolMatcher.group();
if (!unsupportedFileStems.contains(ref) && !sourceSymbols.contains(ref) && !hasFileName(ref + ".java")) {
unsupported.add(ref);
}
}
return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported));
}
private boolean hasFileName(String fileName) {
String normalized = normalizePath(fileName);
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
}
private static boolean isReadFileTool(String name) {
if (name == null) {
return false;
}
String normalized = name.toLowerCase(Locale.ROOT).replace("-", "_");
return normalized.equals("read_file");
}
private static void recordReadFile(String data, Builder builder) {
try {
JsonNode root = MAPPER.readTree(data);
String filePath = root.path("filePath").asText("");
if (root.path("error").asBoolean(false)) {
builder.failedPath(filePath);
return;
}
builder.sourcePath(filePath);
String content = root.path("content").asText("");
recordSymbols(content, builder);
} catch (Exception ignored) {
recordPlainTextEvidence(data, builder);
}
}
private static void recordPlainTextEvidence(String text, Builder builder) {
Matcher matcher = JAVA_PATH.matcher(text);
while (matcher.find()) {
builder.sourcePath(matcher.group());
}
recordSymbols(text, builder);
}
private static void recordSymbols(String text, Builder builder) {
Matcher matcher = DECLARED_TYPE.matcher(text);
while (matcher.find()) {
builder.symbol(matcher.group(1));
}
}
private static String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "";
}
String normalized = path.replace('\\', '/').trim();
while (normalized.contains("//")) {
normalized = normalized.replace("//", "/");
}
return normalized;
}
private static final class Builder {
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
private final LinkedHashSet<String> failedPaths = new LinkedHashSet<>();
void sourcePath(String path) {
String normalized = normalizePath(path);
if (normalized.isBlank()) {
return;
}
sourcePaths.add(normalized);
String fileName = Path.of(normalized).getFileName() != null
? Path.of(normalized).getFileName().toString() : normalized;
if (fileName.endsWith(".java")) {
sourceSymbols.add(fileName.substring(0, fileName.length() - ".java".length()));
}
}
void symbol(String symbol) {
if (symbol != null && !symbol.isBlank()) {
sourceSymbols.add(symbol.trim());
}
}
void failedPath(String path) {
String normalized = normalizePath(path);
if (!normalized.isBlank()) {
failedPaths.add(normalized);
}
}
SourceEvidenceLedger build() {
return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths);
}
}
public record Validation(boolean valid, List<String> unsupportedReferences) {
public static Validation ok() {
return new Validation(true, List.of());
}
}
}

View File

@ -31,10 +31,30 @@ public class AgentEntity {
private String systemPrompt;
/**
* 保留但不再生效运行时统一使用全局默认模型ModelConfigService.getDefaultModel()
* 该字段为历史残留仅保留以避免数据库迁移
* Per-Agent model override.
*
* <p>When non-blank, the runtime resolves this value via
* {@code ModelConfigService.resolveModel(...)} a case-sensitive,
* enabled-only lookup against {@code mate_model_config.model_name}.
* On match, the resolved entity is used as the primary model in
* place of {@code getDefaultModel()}.
*
* <p>Null / blank fall back to the global default (preserves the
* original behavior). Stale rows whose named model has been removed
* or disabled also fall back, since {@code resolveModel} returns the
* default when no enabled match is found.
*
* <p>{@link FieldStrategy#ALWAYS} so a {@code PUT} with explicit null
* actually clears the column the MyBatis-Plus default {@code NOT_NULL}
* strategy silently drops null fields from UPDATE, which means a user
* who once picked a model could never revert back to "use global default"
* via the UI (only by directly editing the DB). Smoke test on 2026-05-02
* caught it.
*
* <p>RFC-03 Lane G1 re-enables this field after it was silently
* deprecated in earlier work; the database column is unchanged.
*/
@Deprecated
@TableField(value = "model_name", updateStrategy = FieldStrategy.ALWAYS)
private String modelName;
/** 最大迭代次数 */
@ -52,6 +72,9 @@ public class AgentEntity {
/** 所属工作区 ID默认 1 = default */
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 表示跟随模型默认 */
private String defaultThinkingLevel;
@ -61,6 +84,5 @@ public class AgentEntity {
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -21,6 +21,13 @@ public class TemplateDTO {
private String agentType;
private String tags;
private Integer maxIterations;
/**
* Optional pre-rendered system prompt seeded into the new agent. Templates
* use H2 sections (## Role / ## Goal / ## Backstory / ## Additional
* Instructions) so the editor UI can split the prompt into structured
* fields and derive a one-line tagline for the agent card.
*/
private String systemPrompt;
private List<WorkspaceFileTemplate> workspaceFiles;
@Data

View File

@ -9,14 +9,14 @@ import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
/**
* Prompt 文件加载器
* <p>
* classpath:/prompts/ 目录加载 .txt 文件使用 ConcurrentHashMap 做线程安全的懒加载缓存
* <p>
* 未来扩展点可在 loadPrompt() 中增加"先查数据库覆盖 → 再读 resource → 最后代码兜底"的优先级链
* 但本次只实现 resource 读取
* Loads prompt text files from {@code classpath:/prompts/} with a thread-safe
* lazy cache.
*
* @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
public final class PromptLoader {
@ -28,44 +28,19 @@ public final class PromptLoader {
private PromptLoader() {}
/**
* 加载 prompt 文件内容默认语言
* Load a prompt file's contents.
*
* @param promptName 文件名不含路径前缀和 .txt 后缀例如 "graph/summarize-system"
* @return 文件文本内容
* @throws RuntimeException 文件不存在或读取失败时抛出不会静默返回空字符串
* @param promptName file name without the {@code prompts/} prefix or {@code .txt} suffix
* (e.g. {@code "graph/summarize-system"})
* @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) {
return promptCache.computeIfAbsent(promptName, name -> readPromptFile(name, null));
return promptCache.computeIfAbsent(promptName, PromptLoader::readPromptFile);
}
/**
* 加载指定语言的 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) {}
}
// 回退到默认目录
private static String readPromptFile(String name) {
String fileName = PROMPT_PATH_PREFIX + name + ".txt";
try (InputStream inputStream = PromptLoader.class.getClassLoader().getResourceAsStream(fileName)) {
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() {
promptCache.clear();
}
/**
* 获取缓存大小
*
* @return 已缓存的 prompt 数量
*/
/** Number of prompts currently cached. */
public static int getCacheSize() {
return promptCache.size();
}

View File

@ -0,0 +1,241 @@
package vip.mate.agent.runtime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry})
* with agent metadata so the admin Backstage UI can render one card per
* working agent without making the frontend traverse three independent
* services.
*
* <p>The "stuck" verdict is computed here rather than persisted on
* {@link RunSnapshot} so the thresholds can be tuned at runtime without
* touching every producer.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentRuntimeAggregator {
/**
* Idle threshold: a run with no events for this long while NOT inside a
* tool call is treated as wedged. Aligns with the upstream cli reference
* (5 × 30s heartbeat cycles) so pre-token latency does not false-alarm.
*/
private static final long STUCK_IDLE_MS = 150_000L;
/**
* In-tool threshold: a run with a {@code runningToolName} but no events
* for this long. Looser than idle because slow tool calls (LLM-backed
* tools, long-running shell commands) routinely sit silent for minutes.
*/
private static final long STUCK_TOOL_MS = 600_000L;
/**
* Hard cap regardless of activity. A run older than this is suspicious
* even when the bytes are flowing: the user has likely walked away and
* the model is in a feedback loop.
*/
private static final long STUCK_HARD_CAP_MS = 1_800_000L;
private final ChatStreamTracker streamTracker;
private final SubagentRegistry subagentRegistry;
private final AgentService agentService;
/** One in-flight run, enriched with agent label and stuck verdict. */
public record RunCard(
String conversationId,
Long agentId,
String agentName,
String agentIcon,
String username,
String currentPhase,
String runningToolName,
String waitingReason,
boolean done,
boolean stopRequested,
boolean firstTokenReceived,
int subscriberCount,
int queueLen,
long ageMs,
long msSinceLastEvent,
String stuckReason,
boolean orphan,
int subagentCount
) {}
/** One sub-agent under a parent run, ready for tree rendering. */
public record SubagentCard(
String subagentId,
String parentConversationId,
String childConversationId,
Long agentId,
String agentName,
String agentIcon,
String goal,
String status,
String currentPhase,
String lastTool,
int toolCount,
long ageMs
) {}
/** Top-level summary used to drive the breathing sidebar dot. */
public record Summary(
int running,
int stuck,
int orphan,
int queued,
int subagentsActive
) {}
/** Full snapshot envelope returned to the admin UI. */
public record RuntimeSnapshot(
Summary summary,
List<RunCard> runs,
List<SubagentCard> subagents,
long timestamp
) {}
public RuntimeSnapshot snapshot() {
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
Set<Long> agentIds = rawRuns.stream()
.map(RunSnapshot::agentId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
for (var rec : subagentRegistry.allActive()) {
if (rec.agentId() != null) agentIds.add(rec.agentId());
}
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
Map<String, Long> subagentCountByParent = new HashMap<>();
for (var rec : subagentRegistry.allActive()) {
String parent = rec.parentConversationId();
if (parent != null) {
subagentCountByParent.merge(parent, 1L, Long::sum);
}
}
List<RunCard> cards = new ArrayList<>(rawRuns.size());
int stuckCount = 0;
int orphanCount = 0;
int queuedTotal = 0;
int runningCount = 0;
for (RunSnapshot s : rawRuns) {
if (s.done()) continue;
runningCount++;
String stuckReason = computeStuckReason(s);
boolean orphan = s.subscriberCount() == 0;
if (stuckReason != null) stuckCount++;
if (orphan) orphanCount++;
queuedTotal += s.queueLen();
int subCount = subagentCountByParent.getOrDefault(s.conversationId(), 0L).intValue();
AgentEntity ag = s.agentId() == null ? null : agentInfo.get(s.agentId());
cards.add(new RunCard(
s.conversationId(),
s.agentId(),
ag == null ? null : ag.getName(),
ag == null ? null : ag.getIcon(),
s.username(),
s.currentPhase(),
s.runningToolName(),
s.waitingReason(),
s.done(),
s.stopRequested(),
s.firstTokenReceived(),
s.subscriberCount(),
s.queueLen(),
s.ageMs(),
s.msSinceLastEvent(),
stuckReason,
orphan,
subCount
));
}
// Sort: stuck first (loudest first), then orphan, then by lastEventAt asc
cards.sort((a, b) -> {
int aStuck = a.stuckReason() != null ? 1 : 0;
int bStuck = b.stuckReason() != null ? 1 : 0;
if (aStuck != bStuck) return bStuck - aStuck;
int aOrph = a.orphan() ? 1 : 0;
int bOrph = b.orphan() ? 1 : 0;
if (aOrph != bOrph) return bOrph - aOrph;
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
});
List<SubagentCard> subCards = subagentRegistry.allActive().stream()
.map(rec -> {
long now = System.currentTimeMillis();
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
return new SubagentCard(
rec.subagentId(),
rec.parentConversationId(),
rec.childConversationId(),
rec.agentId(),
ag == null ? null : ag.getName(),
ag == null ? null : ag.getIcon(),
rec.goal(),
rec.status() != null ? rec.status().get() : null,
rec.currentPhase() != null ? rec.currentPhase().get() : null,
rec.lastTool() != null ? rec.lastTool().get() : null,
rec.toolCount() != null ? rec.toolCount().get() : 0,
now - rec.startedAt()
);
})
.toList();
Summary summary = new Summary(
runningCount,
stuckCount,
orphanCount,
queuedTotal,
subCards.size()
);
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis());
}
/**
* Returns null when the run looks healthy. The returned tag is a stable
* machine-readable code (not a translated label) so the frontend can
* decide presentation: {@code idle_silent} / {@code tool_silent} /
* {@code hard_cap}.
*/
private String computeStuckReason(RunSnapshot s) {
if (s.ageMs() > STUCK_HARD_CAP_MS) return "hard_cap";
boolean inTool = s.runningToolName() != null && !s.runningToolName().isBlank();
long since = s.msSinceLastEvent();
if (inTool && since > STUCK_TOOL_MS) return "tool_silent";
if (!inTool && since > STUCK_IDLE_MS) return "idle_silent";
return null;
}
private Map<Long, AgentEntity> resolveAgents(Set<Long> ids) {
Map<Long, AgentEntity> out = new LinkedHashMap<>();
for (Long id : ids) {
if (id == null) continue;
try {
AgentEntity a = agentService.getAgent(id);
if (a != null) out.put(id, a);
} catch (Exception e) {
log.debug("agent lookup failed for id={}: {}", id, e.getMessage());
}
}
return out;
}
}

View File

@ -0,0 +1,164 @@
package vip.mate.agent.runtime;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.audit.service.AuditEventService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.i18n.I18nService;
import vip.mate.workspace.conversation.ConversationService;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Admin-only Backstage surface: the global view of every in-flight agent
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
* owner-scoped this controller is intentionally cross-tenant for the
* operator role.
*/
@Slf4j
@Tag(name = "Agent Runtime (Backstage)")
@RestController
@RequestMapping("/api/v1/admin/agent-runtime")
@RequiredArgsConstructor
public class AgentRuntimeController {
private final AgentRuntimeAggregator aggregator;
private final ChatStreamTracker streamTracker;
private final SubagentRegistry subagentRegistry;
private final AuditEventService auditEventService;
private final ConversationService conversationService;
private final I18nService i18nService;
@Operation(summary = "Snapshot of every in-flight agent turn")
@GetMapping("/snapshot")
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
requireAdmin(auth);
return R.ok(aggregator.snapshot());
}
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
@PostMapping("/runs/{conversationId}/stop")
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
Authentication auth) {
requireAdmin(auth);
boolean ok = streamTracker.requestStop(conversationId);
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
return R.ok(Map.of("stopped", ok));
}
@Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored")
@PostMapping("/runs/{conversationId}/recycle")
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
Authentication auth) {
requireAdmin(auth);
boolean ok = streamTracker.forceRecycle(conversationId);
if (ok) {
finalizeRecycledConversation(conversationId);
}
recordAudit(auth, "agent-runtime.recycle", conversationId, Map.of("result", ok));
return R.ok(Map.of("recycled", ok));
}
@Operation(summary = "Interrupt one sub-agent (admin override of ownership check)")
@PostMapping("/subagents/{subagentId}/interrupt")
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
Authentication auth) {
requireAdmin(auth);
boolean ok = subagentRegistry.interrupt(subagentId);
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
return R.ok(Map.of("interrupted", ok));
}
/**
* Bulk recycle every run that the aggregator currently flags as stuck.
* Returns the conversationIds that were touched so the caller can render
* a confirmation toast without re-fetching.
*/
@Operation(summary = "Recycle every run currently flagged as stuck")
@PostMapping("/sweep")
public R<Map<String, Object>> sweep(Authentication auth) {
requireAdmin(auth);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
List<String> ids = snap.runs().stream()
.filter(r -> r.stuckReason() != null)
.map(AgentRuntimeAggregator.RunCard::conversationId)
.toList();
int recycled = 0;
for (String cid : ids) {
if (streamTracker.forceRecycle(cid)) {
recycled++;
finalizeRecycledConversation(cid);
}
}
recordAudit(auth, "agent-runtime.sweep", "all",
Map.of("targets", ids, "recycled", recycled));
return R.ok(Map.of("recycled", recycled, "ids", ids));
}
/**
* Common DB-side cleanup after a successful {@code forceRecycle}:
* <ol>
* <li>Flip {@code stream_status} off 'running' so the sidebar drops the
* 生成中 badge immediately. (The late doOnCancel / doOnComplete may
* re-set this to 'idle' when the agent finally yields same value,
* no-op.)</li>
* <li>If the conversation's last message is still a user turn i.e.
* the agent was disposed before any text streamed and the
* emergencySaveCallback found nothing to persist write a
* "已被用户中止" assistant marker so the UI shows what happened
* instead of a blank reply.</li>
* </ol>
*/
private void finalizeRecycledConversation(String conversationId) {
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.warn("recycle: failed to reset stream_status for {}: {}",
conversationId, e.getMessage());
}
try {
conversationService.saveStopMarkerIfDangling(
conversationId, i18nService.msg("chat.stopMarker.userAborted"), "stopped");
} catch (Exception e) {
log.warn("recycle: failed to save stop marker for {}: {}",
conversationId, e.getMessage());
}
}
private void requireAdmin(Authentication auth) {
if (auth == null) {
throw new MateClawException(401, "authentication required");
}
boolean isAdmin = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch("ROLE_ADMIN"::equals);
if (!isAdmin) {
throw new MateClawException(403, "admin role required");
}
}
private void recordAudit(Authentication auth, String action,
String resourceId, Map<String, Object> detail) {
try {
String username = auth != null ? auth.getName() : "anonymous";
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("by", username);
payload.putAll(detail);
auditEventService.record(action, "agent-runtime", resourceId, resourceId,
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload));
} catch (Exception e) {
log.warn("audit serialization failed for {}: {}", action, e.getMessage());
}
}
}

View File

@ -63,26 +63,54 @@ public class TemplateService {
}
/**
* 应用模板创建 Agent 及其工作区文件
*
* @param templateId 模板 ID
* @return 创建的 AgentEntity
* Backwards-compatible overload that defaults to the template's English
* display strings (existing callers without locale context).
*/
@Transactional
public AgentEntity applyTemplate(String templateId) {
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId) {
return applyTemplate(templateId, workspaceId, creatorUserId, null);
}
/**
* Apply a template, picking name/description in the caller's preferred
* language so the resulting agent reads natively in their locale. Falls
* back to the template's primary (English) fields when the localized
* variant is missing or no locale was supplied.
*
* @param templateId template ID
* @param workspaceId target workspace ID (from X-Workspace-Id header)
* @param creatorUserId current user ID (creator attribution)
* @param acceptLanguage raw Accept-Language header; null/blank English
*/
@Transactional
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId, String acceptLanguage) {
TemplateDTO template = listTemplates().stream()
.filter(t -> t.getId().equals(templateId))
.findFirst()
.orElseThrow(() -> new MateClawException("err.agent.template_not_found", "模板不存在: " + templateId));
// 1. 创建 Agent
boolean preferZh = isChineseLocale(acceptLanguage);
String displayName = preferZh && template.getNameZh() != null && !template.getNameZh().isBlank()
? template.getNameZh()
: template.getName();
String displayDesc = preferZh && template.getDescriptionZh() != null && !template.getDescriptionZh().isBlank()
? template.getDescriptionZh()
: template.getDescription();
// 1. Create the Agent. workspaceId/creatorUserId are passed in
// explicitly so the DB default does not silently fall back to 1.
AgentEntity agent = new AgentEntity();
agent.setName(template.getName());
agent.setDescription(template.getDescription());
agent.setName(displayName);
agent.setDescription(displayDesc);
agent.setAgentType(template.getAgentType());
agent.setIcon(template.getIcon());
agent.setTags(template.getTags());
agent.setMaxIterations(template.getMaxIterations());
if (template.getSystemPrompt() != null && !template.getSystemPrompt().isBlank()) {
agent.setSystemPrompt(template.getSystemPrompt());
}
agent.setWorkspaceId(workspaceId);
agent.setCreatorUserId(creatorUserId);
AgentEntity created = agentService.createAgent(agent);
// 2. 创建工作区文件
@ -109,4 +137,15 @@ public class TemplateService {
return created;
}
/**
* True when the raw Accept-Language header best-matches a Chinese locale.
* Implementation is intentionally simple we only need to disambiguate
* "Chinese vs not" for picking nameZh / descriptionZh.
*/
private boolean isChineseLocale(String acceptLanguage) {
if (acceptLanguage == null || acceptLanguage.isBlank()) return false;
String first = acceptLanguage.split(",")[0].trim().toLowerCase();
return first.startsWith("zh");
}
}

View File

@ -2,12 +2,10 @@ package vip.mate.approval;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R;
import vip.mate.workspace.conversation.ConversationService;
@ -15,10 +13,16 @@ import java.util.List;
import java.util.Map;
/**
* 工具执行审批接口
* Approval read-only endpoints.
* <p>
* 提供 approve / deny 端点供前端在收到 tool_approval_requested SSE 事件后调用
* 批准后自动触发工具重放结果通过 SSE 流推送给前端
* Web approve / deny actions ride the SSE {@code POST /chat/stream} path with
* {@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
*/
@ -29,72 +33,13 @@ import java.util.Map;
@RequiredArgsConstructor
public class ApprovalController {
private final ApprovalService approvalService;
private final ApprovalWorkflowService approvalService;
private final ConversationService conversationService;
private final ChatStreamTracker streamTracker;
/**
* 批准或拒绝工具执行
* <p>
* 批准后自动触发工具重放异步执行结果通过已有的 SSE 连接推送给前端
*/
@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
* Hydration query for page refresh: returns every pending approval still
* waiting in the conversation. The frontend uses this to rebuild the
* approval banner after a reload.
*/
@Operation(summary = "查询待审批记录")
@GetMapping("/{conversationId}/pending-approvals")
@ -114,11 +59,4 @@ public class ApprovalController {
List<Map<String, Object>> pending = approvalService.getPendingByConversation(conversationId);
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;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -9,21 +7,26 @@ import java.time.Duration;
import java.time.Instant;
import java.util.*;
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>
* 核心变化不再阻塞线程等待审批
* 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>
* <li>{@link #createPending} 创建待审批记录后立即返回</li>
* <li>{@link #resolve} 更新状态为 approved/denied</li>
* <li>{@link #findPendingByConversation} 查找会话最早的 pendingFIFO</li>
* <li>{@link #consumeApproved} 一次性消费已批准记录供重放</li>
* <li>{@link #garbageCollect} 定时清理过期记录</li>
* <li>{@link #createPending} used by tool guard to register a new approval;
* paired with {@link ApprovalWorkflowService#createPending} for DB persistence</li>
* <li>read-only queries: {@link #getPending}, {@link #findPendingByConversation},
* {@link #getPendingByConversation}</li>
* <li>package-private snapshot / mutate helpers consumed by {@link ApprovalWorkflowService}
* (recovery, GC, two-phase resolve)</li>
* </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
*/
@ -33,31 +36,13 @@ public class ApprovalService {
private final ConcurrentHashMap<String, PendingApproval> pendingMap = new ConcurrentHashMap<>();
/** GC 常量 */
private static final Duration PENDING_TTL = Duration.ofMinutes(30);
private static final Duration RESOLVED_TTL = Duration.ofHours(1);
private static final int MAX_PENDING = 200;
private static final int MAX_RESOLVED = 500;
private ScheduledExecutorService gcScheduler;
@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();
}
}
/** GC constants. Package-visible so {@link ApprovalWorkflowService}'s GC loop
* (RFC-067 §4.4) can apply the same TTL / cap thresholds while owning the
* scheduler clock + DB+metadata sync. */
static final Duration PENDING_TTL = Duration.ofMinutes(30);
static final Duration RESOLVED_TTL = Duration.ofHours(1);
static final int MAX_PENDING = 200;
static final int MAX_RESOLVED = 500;
// ==================== 创建 ====================
@ -93,31 +78,41 @@ public class ApprovalService {
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);
}
/**
* 解决审批批准或拒绝
*
* @param pendingId 待审批 ID
* @param userId 操作用户
* @param decision "approved" "denied"
* @throws IllegalArgumentException 如果 pending 不存在
* INTERNAL register a {@link PendingApproval} reconstructed from DB during JVM startup.
* Bypasses id generation and pre-existing-entry checks; the snapshot's {@code pendingId}
* must already match the DB row. Idempotent: if the same id already lives in the map
* (concurrent recovery path), the second call is logged and dropped.
* <p>
* Only {@code ApprovalWorkflowService.recoverFromDb} should call this.
*/
public void resolve(String pendingId, String userId, String decision) {
PendingApproval pending = pendingMap.get(pendingId);
if (pending == null) {
throw new IllegalArgumentException("审批记录不存在或已过期: " + pendingId);
void registerRecovered(PendingApproval snapshot) {
if (snapshot == null || snapshot.getPendingId() == null) {
log.warn("[Approval] registerRecovered: ignoring null snapshot");
return;
}
if ("approved".equalsIgnoreCase(decision)) {
pending.setStatus("approved");
} else {
pending.setStatus("denied");
PendingApproval existing = pendingMap.putIfAbsent(snapshot.getPendingId(), snapshot);
if (existing != null) {
log.warn("[Approval] registerRecovered: pending id {} already in map, skipping",
snapshot.getPendingId());
return;
}
pending.setResolvedAt(Instant.now());
pending.setResolvedBy(userId);
log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId);
log.info("[Approval] Recovered pending from DB: id={}, tool={}, conversation={}",
snapshot.getPendingId(), snapshot.getToolName(), snapshot.getConversationId());
}
// ==================== 查询 ====================
@ -172,154 +167,110 @@ public class ApprovalService {
return result;
}
// ==================== 原子解决+消费IM 渠道 /approve 命令 ====================
/**
* 原子地 resolve consume 审批记录用于 IM 渠道 /approve 命令
* <p>
* 合并 resolve() + consumeApproved() 为单一操作消除 race condition
*
* @param pendingId 待审批 ID
* @param userId 操作用户
* @return 已消费的 PendingApproval toolCallPayload不存在或已处理返回 null
* INTERNAL return the earliest {@code approved} pending matching the conversation +
* tool, WITHOUT removing it. Workflow uses this to take a snapshot before the
* two-phase DB / metadata write; the actual map removal happens via
* {@link #removeFromMap(String)} after commit.
*/
public synchronized PendingApproval resolveAndConsume(String pendingId, String userId) {
PendingApproval pending = pendingMap.get(pendingId);
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()
PendingApproval findApprovedForConsume(String conversationId, String toolName) {
return pendingMap.values().stream()
.filter(p -> conversationId.equals(p.getConversationId()))
.filter(p -> "approved".equals(p.getStatus()))
.filter(p -> toolName == null || toolName.equals(p.getToolName()))
.filter(p -> toolArguments == null || toolArguments.equals(p.getToolArguments()))
.min(Comparator.comparing(PendingApproval::getCreatedAt))
.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());
});
}
/**
* 定时清理过期记录
* <ul>
* <li>pending 超过 30 分钟 标记 TIMEOUT 并清除</li>
* <li>resolved pending超过 1 小时 清除</li>
* <li>上限pending 200 resolved 500 </li>
* </ul>
* INTERNAL return a list snapshot of every {@code pending} record in the
* conversation, optionally excluding one id. Read-only; no map mutation.
* Workflow iterates this list and runs the two-phase resolve on each.
*/
public void garbageCollect() {
Instant now = Instant.now();
int expiredPending = 0;
int expiredResolved = 0;
List<String> toRemove = new ArrayList<>();
List<PendingApproval> snapshotPendingByConversation(String conversationId,
String excludePendingId) {
List<PendingApproval> out = new ArrayList<>();
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) {
p.setStatus("timeout");
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++;
out.add(p);
}
}
return out;
}
toRemove.forEach(pendingMap::remove);
// 上限检查
enforceLimit("pending", MAX_PENDING);
enforceLimit("resolved", MAX_RESOLVED);
if (expiredPending > 0 || expiredResolved > 0) {
log.info("[Approval] GC: expired {} pending, {} resolved, remaining={}",
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()))
/**
* INTERNAL when total pending count is over {@code maxPending}, return the
* oldest excess entries so the workflow GC loop can {@code markTimeout} each.
* Read-only; sorts by createdAt ascending.
*/
List<PendingApproval> snapshotExcessPending(int maxPending) {
List<PendingApproval> pending = pendingMap.values().stream()
.filter(p -> "pending".equals(p.getStatus()))
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
.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++) {
PendingApproval oldest = matching.get(i);
if (isPending) {
oldest.setStatus("timeout");
oldest.setResolvedAt(Instant.now());
pendingMap.remove(resolved.get(i).getPendingId());
}
pendingMap.remove(oldest.getPendingId());
}
log.info("[Approval] Evicted {} {} records (exceeded limit {})", toEvict, statusType, maxCount);
dropped += toEvict;
}
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
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.repository.ToolApprovalMapper;
import vip.mate.tool.guard.model.GuardEvaluation;
import vip.mate.tool.guard.model.GuardFinding;
import vip.mate.workspace.conversation.ConversationService;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
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 双写
@ -37,14 +49,48 @@ public class ApprovalWorkflowService implements ApplicationRunner {
private final ApprovalService approvalService;
private final ToolApprovalMapper approvalMapper;
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
public void run(ApplicationArguments args) {
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() {
try {
@ -55,56 +101,96 @@ public class ApprovalWorkflowService implements ApplicationRunner {
);
int recovered = 0;
int expired = 0;
Instant now = Instant.now();
for (ToolApprovalEntity entity : pendingRecords) {
// 检查是否已过期30 分钟
if (entity.getCreatedAt() != null) {
Instant createdAt = entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant();
if (Instant.now().minusSeconds(1800).isAfter(createdAt)) {
// 已过期更新 DB 状态
entity.setStatus("TIMEOUT");
entity.setResolvedAt(LocalDateTime.now());
approvalMapper.updateById(entity);
// Defensive null handling: a row with neither createdAt nor expireAt is
// treated as freshly created so the next GC tick can revisit it instead
// of being silently lost.
Instant createdAt = entity.getCreatedAt() != null
? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
: now;
Instant effectiveExpireAt = entity.getExpireAt() != null
? entity.getExpireAt().atZone(ZoneId.systemDefault()).toInstant()
: createdAt.plus(ApprovalService.PENDING_TTL);
if (now.isAfter(effectiveExpireAt)) {
expireRecoveredRow(entity);
expired++;
continue;
}
}
// 恢复到内存
String pendingId = approvalService.createPending(
PendingApproval snapshot = new PendingApproval(
entity.getPendingId(),
entity.getConversationId(),
entity.getUserId(),
entity.getToolName(),
entity.getToolArguments(),
entity.getSummary(),
entity.getToolCallPayload(),
entity.getSiblingToolCalls(),
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()
createdAt,
"pending"
);
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++;
}
if (recovered > 0) {
log.info("[ApprovalWorkflow] Recovered {} pending approvals from DB", recovered);
if (recovered > 0 || expired > 0) {
log.info("[ApprovalWorkflow] DB recovery: recovered={}, expired={}", recovered, expired);
}
} catch (Exception e) {
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
*/
@ -117,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
conversationId, userId, toolName, toolArguments, reason,
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. 增强内存记录
approvalService.getPending(pendingId).ifPresent(pending -> {
if (evaluation != null) {
@ -124,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
pending.setSummary(evaluation.summary());
}
pending.setChatOrigin(chatOriginJson);
});
// 3. DB
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
toolCallPayload, siblingToolCalls, agentId, evaluation);
toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
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) {
approvalService.resolve(pendingId, userId, decision);
updateDbStatus(pendingId, decision.toUpperCase(), userId);
@Transactional
public ResolveOutcome resolve(String pendingId, String userId, String decision) {
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) {
PendingApproval consumed = approvalService.resolveAndConsume(pendingId, userId);
if (consumed != null) {
updateDbStatus(pendingId, "CONSUMED", userId);
}
return consumed;
@Transactional
public ResolveOutcome resolveAndConsume(String pendingId, String userId) {
return performResolve(pendingId, userId, "CONSUMED", MetadataDecision.APPROVED,
"consumed", /* removeFromMap */ true);
}
/**
* 消费已批准记录
* 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) {
PendingApproval consumed = approvalService.consumeApproved(conversationId, toolName);
if (consumed != null) {
updateDbStatus(consumed.getPendingId(), "CONSUMED", null);
@Transactional
public ResolveOutcome consumeApproved(String conversationId, String toolName) {
PendingApproval target = approvalService.findApprovedForConsume(conversationId, toolName);
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) {
approvalService.cancelStalePending(conversationId, excludePendingId);
@Transactional
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 {
approvalMapper.update(null, new LambdaUpdateWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getConversationId, conversationId)
.eq(ToolApprovalEntity::getStatus, "PENDING")
.ne(excludePendingId != null, ToolApprovalEntity::getPendingId, excludePendingId)
.set(ToolApprovalEntity::getStatus, "SUPERSEDED")
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now()));
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "DENIED",
MetadataDecision.DENIED, "denied", /* removeFromMap */ true);
if (outcome.dbSynced()) outcomes.add(outcome);
} 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,
String toolName, String toolArguments,
String toolCallPayload, String siblingToolCalls, String agentId,
GuardEvaluation evaluation) {
GuardEvaluation evaluation, String chatOriginJson) {
try {
ToolApprovalEntity entity = new ToolApprovalEntity();
entity.setPendingId(pendingId);
@ -221,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
entity.setStatus("PENDING");
entity.setCreatedAt(LocalDateTime.now());
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) {
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) {
try {
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()

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