Compare commits

...

9 Commits
dev ... v1.6.0

Author SHA1 Message Date
matevip
db6caea824 release: v1.6.0 2026-06-22 15:17:06 +08:00
matevip
84375da3c5 release: v1.5.0 2026-06-05 07:55:59 +08:00
matevip
68c010ecf9 release: v1.4.0 2026-05-25 09:58:38 +08:00
matevip
493910bf5a release: v1.3.0 (hotfix bundle — #120 + UI/build fixes) 2026-05-14 09:30:43 +08:00
matevip
da8005a8cb release: v1.3.0 (pnpm build fix) 2026-05-13 11:30:28 +08:00
matevip
1d64194e15 release: v1.3.0 2026-05-13 10:14:27 +08:00
matevip
f47cf8c6be release: v1.3.0 2026-05-13 10:05:53 +08:00
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
2578 changed files with 378819 additions and 20185 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,83 @@ JWT_SECRET=
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
MATECLAW_CORS_ALLOWED_ORIGINS=
# 公开访问基址(如 https://mateclaw.example.com。用于把智能体生成文件的下载
# 链接拼成绝对地址,便于在 Web 之外IM 消息、复制链接、外部下载)直接打开。
# 留空时回退到当前请求的 host再退回相对路径。反代后部署建议显式设置。
MATECLAW_PUBLIC_BASE_URL=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
# ==================== 浏览器工具(可选) ====================
#
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。
# 只有在下述场景才需要 override
#
# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接:
# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222
#
# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome
# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable
#
# 3) 强制使用 Playwright channelchrome / msedge / chrome-beta …):
# MATECLAW_BROWSER_CHANNEL=chrome
MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== OpenAI OAuthDocker可选 ====================
#
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code
# 不需要自定义 client secret。
#
# 默认留空即可。后端会根据访问 Host 自动选择:
# - localhost / 127.0.0.1 / ::1 → LOCALPKCE 回调)
# - IP / 域名 / 反向代理访问 → DEVICE_CODE无缝远程授权
#
# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成
# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL并让容器内
# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到:
# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local
# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0
#
# 强制模式调试时也可设为local / device_code / manual_paste
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
# ==================== Wiki 知识库目录白名单Docker 模式,可选)====================
#
# Docker 生产部署开启了路径安全校验fail-closed
# 知识库使用「目录扫描」功能时,扫描路径必须在此白名单内,否则返回 400 错误。
# 多个路径用英文逗号分隔;留空则禁止所有目录扫描。
#
# 示例MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
#
# 同时在 docker-compose.yml 的 volumes 里把宿主机目录挂进容器,例如:
# volumes:
# - /your/host/path:/data/wiki
MATE_WIKI_ALLOWED_SOURCE_ROOTS=
# ── Wiki 知识源自动同步(变更监测)总开关 ────────────────────────
# 定时扫描各知识库的源目录、自动消化新文件。默认关闭,运维主动开启。
# AND 语义:全局这个开关开 *且* 某知识库自己的「自动同步」开关也开,
# 该库才会被定时扫描;手动「立即扫描」不受此开关影响。
# 间隔单位毫秒,默认 5 分钟(目前为全局,暂不支持按库配置)。
MATE_WIKI_WATCHER_ENABLED=false
MATE_WIKI_WATCHER_INTERVAL_MS=300000
# ── Skill 工作区目录 ─────────────────────────────────────────────
# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。
# 默认(容器内)已指向 /app/data/skills由 docker-compose 的 server_data 卷
# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。
# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。
# 仅当你想把 skill 目录放到别处(如独立的 bind mount时才覆盖此项
# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。
MATECLAW_SKILL_WORKSPACE_ROOT=
# ── Maven 镜像(国内加速)─────────────────────────────────────────
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
#MAVEN_FLAGS=-Paliyun-first

74
.github/ISSUE_TEMPLATE/bug-en.yml vendored Normal file
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

22
.gitignore vendored
View File

@ -29,6 +29,12 @@ nbbuild/
nbdist/
.nb-gradle/
### frontend build artifacts ###
# Vite's primary output goes to mateclaw-server/.../static; the only thing
# that lands here is rollup-plugin-visualizer's stats.html when running
# ANALYZE=1 pnpm build.
mateclaw-ui/dist/
### maven ###
target/
*.war
@ -92,5 +98,21 @@ 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
# Sandbox / external client work that lives in this directory
# but should not ship in the repo.
outputs/
# This is a pnpm monorepo — pnpm-lock.yaml is the only lockfile we track.
# Ignore stray npm/yarn lockfiles so they are not committed by mistake.
package-lock.json
yarn.lock

276
README.md
View File

@ -6,13 +6,15 @@
# MateClaw
<p align="center"><b>Build AI that thinks, acts, remembers, and ships.</b></p>
<p align="center"><b>Your second brain</b></p>
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
[![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 +30,115 @@
---
An AI agent. A knowledge engine. A memory system. A tool runtime. A multi-channel presence.
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
>
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
>
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
**One product. The whole widget.**
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
MateClaw is a personal AI operating system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). It's not a chatbox, not a workflow builder, not just another coding assistant. It's the entire system — from reasoning to remembering to shipping — in one deployment.
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
Three things make it insanely different:
---
1. **Agents do work, not just talk** — ReAct + Plan-and-Execute. Not one-shot answers — iterative reasoning that actually completes tasks
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. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
### Skills · MCP · ACP — three ways to extend capability
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
### Business orchestration (1.3.0+)
- **Workflow** — compose multiple employees plus system actions (approval / channel dispatch / write-memory) into a publishable, triggerable, replayable linear DSL. Seven step modes (`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`). JSON-first authoring with Monaco + schema validation, or natural-language → draft generation
- **Triggers** — wire system events to workflows or to employee conversations. Six pattern types (`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`). Default-on event governance: dedup, per-trigger rate limit, bot-self filter, recursion guard, fail-closed unknown patterns
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
---
## AI is becoming infrastructure
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
**MateClaw is that layer — built the Spring Boot way.**
---
## Why MateClaw
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 +159,78 @@ 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 |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
| Widget | Vite library mode · UMD + ES bundles |
---
## Documentation
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
**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0).
---
**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0).
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0).
**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*:
- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters
- **Native code execution**`execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side
- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading
- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user
- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]`
- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution
## Contributing
@ -217,14 +243,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,17 @@
<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>
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></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 +30,116 @@
---
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
>
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
>
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
**一个产品。完整交付。**
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
MateClaw 是基于 **Java + Vue 3** 构建的个人 AI 操作系统,由 [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba) 驱动。它不是聊天框,不是工作流编排器,不只是又一个编码助手。它是从推理到记忆到交付的完整系统——一次部署,全部搞定。
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完
三件事让它截然不同:
---
1. **智能体做事,不只聊天** — ReAct + 计划执行。不是一问一答——是迭代推理,直到任务真正完成
2. **知识被塑造,而非仅仅被存储** — LLM Wiki 把原始资料消化成结构化的链接页面。仓库和图书馆的区别
3. **端到端,不妥协** — Web 控制台、桌面端、7 个 IM 渠道、工具防护、企业认证。一个团队、一次部署、一个体验
## 三件让它与众不同的事
### 1 · 模型挂了AI 不挂
Key 过期。厂商返回 401。网络抖动。配额耗尽。
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
### 2 · 知识会自己长出链接
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题得到的页面是从对应片段拼出来的——带可核对的出处。
这是**仓库**和**图书馆**的区别。
### 3 · 一个产品,五个入口
| 入口 | 它是什么 |
|---|---|
| **Web 控制台** | 完整的管理后台——数字员工、模型、技能、知识、安全、定时任务、**运行时控制台**(看见每位员工正在干什么、一键回收) |
| **桌面端** | Electron + 内嵌 JRE 21双击即用无需装 Java |
| **网页嵌入式聊天** | 一个 `<script>` 标签就能嵌进任何网站 |
| **IM 渠道** | 钉钉 · 飞书 · 企业微信 · 微信 · Telegram · Discord · QQ · Slack |
| **插件 SDK** | Java 模块,供第三方扩展能力包 |
同一个大脑。同一份记忆。同一套工具。不同的门。
<p align="center"><b>$0 · 无 token 计费。无座位收费。你的服务器,你的数据,你的 Key。</b></p>
---
## 盒子里有什么
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
- **工作区记忆**`AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md用得越多越聪明**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
### 业务流程编排1.3.0+
- **工作流Workflow** — 把多位员工 + 系统动作(审批 / 渠道分发 / 写记忆)按线性 step DSL 编排成一条可发布、可触发、可重放的业务流程。7 种 step mode`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`。JSON-first 编辑Monaco + JSON schema + Pebble 静态检查),或者用一句话生成草稿
- **触发器Trigger** — 把"系统里发生的事"自动接到工作流或员工对话上。6 种 pattern type`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`。事件治理默认开去重、per-trigger 限速、bot 自循环过滤、A→B→A 递归保护、未知 pattern fail-closed
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
### 企业就绪
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
---
## AI 正在变成基础设施
2026 年 3 月 2 日Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
---
## 为什么选 MateClaw
大多数 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 +161,22 @@ docker compose up -d # http://localhost:18080
### 桌面端
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
---
## 技术栈
## 架构全景
| 层次 | 技术 |
|------|------|
| 后端 | 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 +184,53 @@ 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 自我进化 |
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· MySQL 8.0+(生产)|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
| Webchat | Vite library 模式 · UMD + ES bundle |
---
## 文档
完整文档请访问 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
---
完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
## 路线图
- 更丰富的多智能体协作
- 更智能的模型路由
- 更深度的多模态理解
- 更强的长期记忆
- 更丰富的 ClawHub 生态
**v1.5.02026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
---
**v1.4.02026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBACOwner / Admin / Member / Viewer· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
**v1.3.02026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*
- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载
- **原生代码执行**`execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内
- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传
- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆
- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]`
- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门
## 参与贡献
@ -221,10 +247,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
**Mate** 是陪伴。**Claw** 是能力。
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。
---
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
## 许可证
[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">+ Transformations (1.3.0+)</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,12 +98,19 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Bottom Center: Models ===== -->
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
<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>
<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"/>
<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>
<!-- Arrow up to agent (short, stops before orchestration band) -->
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
<text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

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">+ 加工器流水线1.3.0+</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,13 +105,19 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Bottom Center: Models ===== -->
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
<!-- ===== Bottom Center: Models + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
<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 -->
<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"/>
<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 (short, stops before orchestration band) -->
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
<!-- Footer -->
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -56,37 +56,39 @@
</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)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Think Act Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Iterative Reasoning Loop</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
</g>
<g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Decompose → Step Execute</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Complex Task Orchestration</text>
<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">Workflow + Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
</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)"/>
@ -97,8 +99,8 @@
<g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">Digestion</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->
@ -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 · Event Bus</text>
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock distributed</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Ambient AI · Proactive</text>
</g>
<g 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,49 +59,51 @@
</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)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</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="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
</g>
<g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</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>
<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">7 step mode · 6 pattern</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排1.3.0+</text>
</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)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">结构化页面</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器1.3.0</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->
@ -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">ShedLock 分布式锁</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付 · Ambient AI</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: 1.0 MiB

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,51 @@ 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}
# LLM provider keys (DashScope / OpenAI / Anthropic / DeepSeek / Kimi / …) are
# NOT configured via env vars. After startup, add providers in the admin UI:
# Settings → Models → Add Provider
# Keys are stored in mate_model_provider and hot-reloaded.
SERPER_API_KEY: ${SERPER_API_KEY:-}
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:-}
# OAuth 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 DEVICE_CODE。
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
# Wiki 知识库目录扫描白名单(逗号分隔,留空则禁止所有目录扫描)。
# 示例MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
# 记得同步在 volumes 里把宿主机路径挂进容器。
MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
# Wiki 知识源自动同步总开关运维总闸默认关。AND 语义:全局开关与
# 每个知识库自己的「自动同步」开关都开,该库才会被定时扫描。
# 间隔单位毫秒,默认 5 分钟。
MATE_WIKI_WATCHER_ENABLED: ${MATE_WIKI_WATCHER_ENABLED:-false}
MATE_WIKI_WATCHER_INTERVAL_MS: ${MATE_WIKI_WATCHER_INTERVAL_MS:-300000}
# Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化
# 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。
# 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。
MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills}
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
# recommendation for Playwright / headless chrome.
shm_size: 2gb
ports:
- "18080:18080"
- "18080:18088" # host:container — app listens on 18088 inside the container
- "1455:1455"
volumes:
# server_data covers /app/data — H2 DB, wiki-uploads, AND the skill
# workspace (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a
# single volume persists everything. No separate skills volume needed.
- server_data:/app/data
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

@ -4,36 +4,21 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>MateClaw Plugin API</name>
<description>Plugin SDK contract for MateClaw — external plugins depend only on this module</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<description>Plugin SDK contract for MateClaw - external plugins depend only on this module</description>
<dependencies>
<!-- Spring AI core — for ToolCallback, ChatModel -->
<!-- Spring AI core for ToolCallback and ChatModel. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
@ -44,7 +29,6 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope>
</dependency>
@ -52,16 +36,7 @@
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
<scope>provided</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project>

View File

@ -4,40 +4,24 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-sample</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>MateClaw Sample Plugin</name>
<description>A sample plugin demonstrating the MateClaw Plugin SDK</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- MateClaw Plugin API -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
@ -52,16 +36,7 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project>

View File

@ -1,13 +1,122 @@
# 多阶段构建
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
# Pin pnpm to a major version so the Docker build doesn't break when the npm
# `latest` tag jumps majors. pnpm v10+ blocks dependency lifecycle scripts by
# default; the allowed packages live under `pnpm.onlyBuiltDependencies` in
# mateclaw-ui/package.json.
RUN npm install -g pnpm@10 --silent
WORKDIR /frontend
# Install dependencies first (layer cache)
COPY mateclaw-ui/package.json mateclaw-ui/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy source and build
COPY mateclaw-ui/ ./
# Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which
# is outside this container; call vite directly to control --outDir.
# NODE_OPTIONS=--max-old-space-size=6144 keeps Rollup's `rendering chunks`
# phase from getting SIGKILL'd by the host kernel's OOM-killer on memory-
# constrained servers. The earlier removal of this flag relied on lazy-
# loading + manualChunks dropping the per-chunk peak, but Rollup still
# minifies several vendor chunks (monaco / mermaid / echarts) in parallel
# so the cumulative working set blows past Node's default ~1.5 GB heap
# and trips the OOM-killer mid-build. The fix is not the heap flag
# itself; it is keeping the build reproducible on smaller hosts.
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
# the production Docker image build.
RUN NODE_OPTIONS=--max-old-space-size=6144 pnpm exec vite build --outDir /static --emptyOutDir
FROM eclipse-temurin:21-jre-alpine
# Stage 2 — Backend (Maven)
FROM maven:3.9-eclipse-temurin-21 AS builder
# Optional Maven extra flags passed at build time.
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun
# repos first. This speeds up builds inside mainland China.
ARG MAVEN_FLAGS=""
# Inject mirror settings to avoid Maven Central timeouts in restricted networks
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
# Copy the root parent plus module POMs first for Docker layer caching.
WORKDIR /build
COPY pom.xml ./pom.xml
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
# Pre-fetch backend dependencies through the reactor so the parent POM,
# dependencyManagement, and internal module versions all resolve consistently.
RUN mvn -pl mateclaw-server -am dependency:go-offline -q ${MAVEN_FLAGS}
# Copy backend source and inject pre-built frontend into the right classpath location
COPY mateclaw-plugin-api/src mateclaw-plugin-api/src
COPY mateclaw-server/src mateclaw-server/src
COPY --from=frontend-builder /static mateclaw-server/src/main/resources/static
RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Stage 3 — Runtime
#
# Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three
# browsers (Chromium / Firefox / WebKit) and every system library Chromium needs
# pre-installed. This avoids the `playwright install` step and the Alpine/musl
# incompatibility that blocks browser_use on minimal images.
#
# We pin to the exact Playwright version declared in the root pom.xml. If you
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
# tag with the matching driver, so mismatched versions cause the java driver to
# re-download browsers at runtime (defeating the whole point of this image).
FROM mcr.microsoft.com/playwright:v1.59.0-noble
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
# Install openjdk-21 explicitly and add CJK fonts so Chinese pages render
# correctly in screenshots and snapshots.
#
# PDF extraction toolchain — DocumentExtractTool tries pdftotext first, then
# Python pdfplumber/pypdf, then falls through to a naive Java parser that
# reads bytes as ISO_8859_1 (mojibake for CJK). Without poppler-utils the
# Docker image always hits the naive path and feeds garbled text to the
# Wiki pipeline.
#
# We install poppler-utils (backend 1) and tesseract (backend 4), which
# together cover the vast majority of PDFs including scanned docs. The
# Python backend is intentionally skipped — pip install against aliyun
# mirrors in CN networks hits transient hash-mismatch failures on cffi /
# cryptography transitive deps, and RFC-051 PR-1c will replace the Python
# hop with JVM-native Tika extraction anyway. Leaving it out keeps the
# image ~200 MB smaller and the build reproducible.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openjdk-21-jre-headless \
fonts-noto-cjk \
fonts-noto-color-emoji \
poppler-utils \
tesseract-ocr \
tesseract-ocr-chi-sim \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Tell Playwright Java where Microsoft's image stored the browsers.
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
TZ=Asia/Shanghai \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai -Dsun.jnu.encoding=UTF-8"
# Default DB profile, overridable by the SPRING_PROFILES_ACTIVE env var
# (compose sets it explicitly: mysql / postgres / kingbase). It must be an ENV,
# not a -D system property on the ENTRYPOINT: a hardcoded
# -Dspring.profiles.active outranks the SPRING_PROFILES_ACTIVE env var and would
# silently pin the profile regardless of what compose passes.
ENV SPRING_PROFILES_ACTIVE=mysql
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
EXPOSE 18088
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
EXPOSE 1455
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -4,79 +4,47 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-server</artifactId>
<version>1.1.0</version>
<packaging>jar</packaging>
<name>MateClaw Server</name>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.4 正式版 -->
<spring-ai.version>1.1.4</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<hutool.version>5.8.26</hutool.version>
<springdoc.version>2.8.16</springdoc.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM统一管理 spring-ai-* 版本) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- SpringDoc OpenAPI BOM统一管理 springdoc-* 版本) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-bom</artifactId>
<version>${springdoc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- ===== MateClaw Plugin API ===== -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
</dependency>
<!-- ===== Web MVC(不引入 WebFlux避免自动切换为响应式模式 ===== -->
<!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<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 中
内置 DashScope ChatModel / EmbeddingModel / ImageModel
Version is managed centrally because this artifact is outside the Spring AI BOM.
Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version>
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
<!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
@ -85,11 +53,10 @@
</exclusions>
</dependency>
<!-- ===== Spring AI Alibaba Graph CoreStateGraph 工作流引擎) ===== -->
<!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency>
<!-- ===== Spring AI OpenAI Compatible ===== -->
@ -98,16 +65,15 @@
<artifactId>spring-ai-openai</artifactId>
</dependency>
<!-- ===== Spring AI AnthropicClaude 模型支持) ===== -->
<!-- ===== Spring AI Anthropic (Claude model support) ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic</artifactId>
</dependency>
<!-- ===== Spring AI MCP Client(动态 MCP server 连接管理) ===== -->
<!-- ===== Spring AI MCP Client (dynamic MCP server connection management) ===== -->
<!--
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库,
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
-->
<dependency>
<groupId>org.springframework.ai</groupId>
@ -120,31 +86,29 @@
</exclusions>
</dependency>
<!-- ===== H2 内嵌数据库(开发环境) ===== -->
<!-- ===== H2 embedded database (development) ===== -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ===== MySQL 驱动(生产环境) ===== -->
<!-- ===== MySQL driver (production) ===== -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ===== MyBatis Plus(不引入 JPA避免双 ORM 冲突) ===== -->
<!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis Plus 分页插件3.5.16 拆分为独立模块) -->
<!-- MyBatis Plus pagination support is split into a separate module. -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- ===== Spring Security ===== -->
@ -157,55 +121,49 @@
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- ===== SpringDoc OpenAPISwagger UI for Spring MVC ===== -->
<!-- ===== SpringDoc OpenAPI (Swagger UI for Spring MVC) ===== -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- ===== Hutool 工具库 ===== -->
<!-- ===== Hutool utilities ===== -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- ===== 钉钉 Stream SDKWebSocket 长连接,无需公网 IP ===== -->
<!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.5</version>
</dependency>
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== -->
<!-- ===== Lark Open API SDK (WebSocket long connection and event dispatch) ===== -->
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId>
<version>2.5.3</version>
</dependency>
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== -->
<!-- ===== Caffeine cache for skill runtime caching ===== -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== -->
<!-- ===== SnakeYAML for SKILL.md frontmatter parsing ===== -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
@ -222,28 +180,24 @@
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- ===== Playwright (Browser Automation) ===== -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.52.0</version>
</dependency>
<!-- ===== JDADiscord Bot Gateway WebSocket 长连接) ===== -->
<!-- ===== JDA (Discord Bot Gateway WebSocket long connection) ===== -->
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>5.2.3</version>
<exclusions>
<!-- 排除 audio 相关依赖MateClaw 不需要语音功能) -->
<!-- Exclude audio dependencies because voice features are not used. -->
<exclusion>
<groupId>club.minnced</groupId>
<artifactId>opus-java</artifactId>
@ -251,27 +205,127 @@
</exclusions>
</dependency>
<!-- ===== Spring WebSocketTalk Mode ===== -->
<!-- ===== Spring WebSocket (Talk Mode) ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- ===== Slack SDKSocket Mode + Web API ===== -->
<!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>
<version>1.44.2</version>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>bolt-socket-mode</artifactId>
<version>1.44.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId>
<version>2.2.0</version>
</dependency>
<!-- ===== Apache POI (in-process .docx generation) ===== -->
<!--
Used by DocxRenderTool to render Markdown into a .docx in-JVM,
replacing the Node.js docx-js subprocess (3-5 min cold install).
-->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
<!--
Used by MarkdownDocxRenderer to convert ![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>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
</dependency>
<!-- ===== jsoup (HTML cleanup for Wiki ingest) ===== -->
<!--
Used by WikiContentNormalizer to strip nav/footer/script/style/aside
and ad-class nodes from URL/HTML uploads before chunking. Small
(~430KB), no transitive deps, JVM-only, and safe for the desktop bundle.
-->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
<!-- ===== Apache Tika (Java-side last-resort document extractor) ===== -->
<!--
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
chains, after every system command + Python + POI-based path has failed.
Used in production primarily by Windows users without Python or poppler
installed; otherwise idle.
Pinned to the precise format modules the extractor calls directly. This
deliberately avoids `tika-parsers-standard-package`, which pulls in mail,
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
tika-parser-microsoft-module (POI-scratchpad ~10MB) is about 16MB.
-->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-pdf-module</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-microsoft-module</artifactId>
</dependency>
<!-- ===== Markdown -> PDF rendering =====
Flying Saucer ships a single `flying-saucer-pdf` artifact that
writes PDF via OpenPDF (LGPL fork of iText). It does NOT depend on
PDFBox, so it sidesteps a version conflict with the existing
pdfbox dependency. CSS3 paged-media features (@page,
counter(page), counter(pages), @top-center / @bottom-center) are
supported, which the cover / header / footer rendering relies on.
commonmark-java is the reference CommonMark implementation,
actively maintained on a monthly cadence (vs. flexmark, whose
upstream stalled at 0.64.8 in 2023). It parses markdown into the
XHTML Flying Saucer consumes. The alternative LibreOffice path in
PdfRenderTool reuses MarkdownDocxRenderer + a soffice subprocess
and adds no dependencies of its own. -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-autolink</artifactId>
</dependency>
<!-- ===== Database Migration (Flyway) ===== -->
@ -283,6 +337,30 @@
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- Flyway PostgreSQL support (used by KingbaseES as well since KingbaseES is PostgreSQL-compatible) -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.7</version>
<scope>runtime</scope>
</dependency>
<!--
KingbaseES (人大金仓) JDBC driver is NOT on Maven Central, so it is
declared in the opt-in `kingbase` Maven profile instead of here.
The default build never resolves it. To build with KingbaseES:
1. install the driver: mvn install:install-file \
-Dfile=${KINGBASE_HOME}/Interface/jdbc/kingbase8-8.6.0.jar \
-DgroupId=com.kingbase8 -DartifactId=kingbase8 \
-Dversion=8.6.0 -Dpackaging=jar
2. build with the profile: mvn package -Pkingbase
No Java code imports com.kingbase8.* — the driver is loaded at
runtime via spring.datasource.driver-class-name only.
-->
<!-- ===== Spring Boot Test ===== -->
<dependency>
@ -290,6 +368,57 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- ===== ArchUnit architecture invariants =====
test-scope only, guards:
- every ToolCallback implementation overrides call(String, ToolContext)
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
- CronJobRunner must not carry @Transactional
because it would silently fail under self-invocation
-->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<!-- ShedLock: distributed lock for the cron scheduler so a
multi-instance deployment doesn't fire the same job N times.
JDBC mode reuses the existing DataSource, so there is no Redis dependency
on the desktop / single-node footprint. -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
</dependency>
<!-- Graph algorithms (community detection, shortest path, centrality)
used by the wiki page-to-page relevance and insights features. -->
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>
</dependency>
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
Used to walk PDPage resources and pull out PDImageXObject instances
for downstream captioning. -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- Expression language used by the workflow compiler to evaluate
conditional step expressions and template variable references.
Restricted to a small subset (~20 operators / filters) at the
evaluator wrapper layer; arbitrary template includes / extends
are blocked. -->
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
</dependency>
</dependencies>
<build>
@ -297,6 +426,13 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
@ -306,6 +442,76 @@
</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>
<profiles>
<!--
Profile: focused test run for image / video generation features.
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
Limits surefire to JUnit 5 tests carrying @Tag("media-gen") so the
full ~50-min suite is skipped when iterating on this surface.
Add a tag to a new test with @Tag("media-gen") to opt it in.
-->
<profile>
<id>media-gen</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>media-gen</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<!--
Profile: KingbaseES (人大金仓) JDBC driver.
The driver is not published to Maven Central, so it is kept out of the
default build to keep `mvn package` resolvable for everyone. Install the
driver into the local repository, then build with `mvn package -Pkingbase`.
Runtime selection is via the `kingbase` Spring profile (application-kingbase.yml).
-->
<profile>
<id>kingbase</id>
<dependencies>
<dependency>
<groupId>com.kingbase8</groupId>
<artifactId>kingbase8</artifactId>
<version>8.6.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>
</project>

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

@ -3,42 +3,120 @@ 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 jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.EnableScheduling;
import javax.sql.DataSource;
import java.sql.Connection;
/**
* MateClaw - Personal AI Assistant
* Powered by Spring AI Alibaba
*
* @author MateClaw Team
*/
@Slf4j
@SpringBootApplication(exclude = {
// 禁用 Spring AI MCP Client 自动配置 McpClientManager 自行管理生命周期
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
// not the chat model. We don't use it model configuration is admin-UI driven and
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
// ApplicationContext fail when users deploy via Docker without setting the key.
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
})
@EnableScheduling
@MapperScan("vip.mate.**.repository")
public class MateClawApplication {
@Autowired
private DataSource dataSource;
/** Cached DbType for the PaginationInnerInterceptor. */
private volatile DbType resolvedDbType;
public static void main(String[] args) {
SpringApplication.run(MateClawApplication.class, args);
}
/**
* MyBatis Plus 分页插件
* Detect the actual database type from the live DataSource so the
* {@link PaginationInnerInterceptor} always uses the correct dialect,
* even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.).
*
* <p>DbType is cached after the first successful detection; a failure
* falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type},
* or eventually to {@link DbType#MYSQL} but by then the connection
* pool would already have failed.
*/
@PostConstruct
void detectDbType() {
try (Connection conn = dataSource.getConnection()) {
String productName = conn.getMetaData().getDatabaseProductName().toLowerCase();
if (productName.contains("kingbase")) {
resolvedDbType = DbType.KINGBASE_ES;
} else if (productName.contains("postgresql")) {
resolvedDbType = DbType.POSTGRE_SQL;
} else if (productName.contains("mysql") || productName.contains("mariadb")) {
resolvedDbType = DbType.MYSQL;
} else if (productName.contains("h2")) {
resolvedDbType = DbType.H2;
} else {
// Let the PaginationInnerInterceptor auto-detect at query time
resolvedDbType = null;
}
if (resolvedDbType != null) {
log.info("Detected database type: {} (product={})", resolvedDbType, productName);
}
} catch (Exception e) {
log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}",
e.getMessage());
}
}
/**
* MyBatis Plus pagination plugin.
*
* <p>When {@code resolvedDbType} is available the interceptor uses it directly;
* otherwise it falls back to JDBC-URL auto-detection, which works for
* {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
PaginationInnerInterceptor pagination = resolvedDbType != null
? new PaginationInnerInterceptor(resolvedDbType)
: new PaginationInnerInterceptor();
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
/**
* Print a clear "READY" banner after all post-startup initialization,
* so operators can tell at a glance when the application is ready to serve.
*/
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("");
log.info("╔══════════════════════════════════════════════════════════════════════╗");
log.info("║ MateClaw is READY ✓ ║");
log.info("║ Web UI → http://localhost:18088 ║");
log.info("║ Swagger → http://localhost:18088/swagger-ui.html ║");
log.info("╚══════════════════════════════════════════════════════════════════════╝");
log.info("");
}
}

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

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

View File

@ -0,0 +1,313 @@
package vip.mate.agent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Agent-callable employee authoring tool.
*
* <p>Lets an agent design and persist a new specialized employee (Agent)
* from a plain-language role spec, then bind a focused capability set to
* it. Pairs with the workflow drafting tool so a single chat turn can plan
* a team of employees and chain them into a workflow:
* design roles {@link #create_employee} for each workflow drafting tool
* referencing the just-created employees.
*
* <p>Workspace is taken from {@link ChatOrigin} on the active
* {@link ToolContext}; the LLM can never write into a foreign workspace
* even if its prompt tried to forge one. Mirrors the create-then-bind
* sequence used when applying an agent template.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentAuthoringTool {
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final SkillMapper skillMapper;
private final AvailableToolService availableToolService;
private final ObjectMapper objectMapper;
/** Cap on names listed per catalog section so the tool result stays small. */
private static final int CATALOG_MAX_PER_SECTION = 200;
@Tool(description = """
Create a new specialized employee (Agent) in the current workspace from a role spec, \
and optionally bind a focused set of skills and tools to it. \
Use this when a task needs a role that does not exist yet design the role, then create it. \
Returns the new agentId (string) and a short summary. \
Leave skillNames/toolNames empty to make a generalist that inherits all globally-enabled capabilities. \
Call list_capability_catalog first to learn the exact skill and tool names you can assign. \
The created employee is enabled immediately and can be referenced by the workflow drafting tool.""")
public String create_employee(
@ToolParam(description = "Employee name, unique within the workspace, e.g. \"market-research-analyst\".")
String name,
@ToolParam(description = "One-line description of the employee's role and responsibility. Shown in pickers and used by the workflow planner to route work.")
String description,
@ToolParam(description = "System prompt that defines the employee's persona, expertise, and working style. Be specific about its specialty.")
String systemPrompt,
@ToolParam(description = "Agent type: \"react\" (single-loop reasoning, default) or \"plan_execute\" (decompose then execute). Leave empty for react.", required = false)
String agentType,
@ToolParam(description = "Optional model name override (must match an enabled model). Leave empty to use the workspace default model.", required = false)
String modelName,
@ToolParam(description = "Skills to bind, as a JSON array of skill names or a comma-separated list, e.g. [\"sql_query\",\"make_plan\"]. Empty = inherit all globally-enabled skills. Names must come from list_capability_catalog.", required = false)
String skillNames,
@ToolParam(description = "Tools to bind, as a JSON array of tool names or a comma-separated list, e.g. [\"web_search\",\"read_file\"]. Empty = inherit all globally-enabled tools. Names must come from list_capability_catalog.", required = false)
String toolNames,
@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin.workspaceId();
if (workspaceId == null || workspaceId <= 0) {
return "[error] Cannot determine the current workspace; invoke this tool within a workspace context.";
}
if (name == null || name.isBlank()) {
return "[error] Employee name is required.";
}
AgentEntity agent = new AgentEntity();
agent.setName(name.trim());
agent.setDescription(blankToNull(description));
if (systemPrompt != null && !systemPrompt.isBlank()) {
agent.setSystemPrompt(systemPrompt);
}
agent.setAgentType(normalizeAgentType(agentType));
agent.setModelName(blankToNull(modelName));
agent.setWorkspaceId(workspaceId);
agent.setCreatorUserId(parseUserId(origin.requesterId()));
AgentEntity created;
try {
created = agentService.createAgent(agent);
} catch (MateClawException e) {
// Duplicate name / blank name surface here as a friendly message
// so the planner can rename and retry instead of aborting.
return "[error] Failed to create employee: " + e.getMessage();
}
List<String> requestedSkills = parseNameList(skillNames);
List<String> requestedTools = parseNameList(toolNames);
List<String> boundSkills = bindSkills(created, workspaceId, requestedSkills);
List<String> boundTools = bindTools(created, requestedTools);
Map<String, Object> result = new LinkedHashMap<>();
result.put("agentId", String.valueOf(created.getId()));
result.put("name", created.getName());
result.put("agentType", created.getAgentType());
result.put("skillsBound", boundSkills.isEmpty() ? "(inherits global defaults)" : boundSkills);
result.put("toolsBound", boundTools.isEmpty() ? "(inherits global defaults)" : boundTools);
result.put("note", "Employee created and enabled. Reference it by name in the workflow drafting tool to chain it into a workflow.");
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
return "Employee created: id=" + created.getId() + " name=" + created.getName();
}
}
@Tool(description = """
List the capabilities you can assign when creating an employee: the enabled skill names \
and the bindable tool names in the current workspace. \
Call this before create_employee so you assign real, resolvable names rather than guessing.""")
public String list_capability_catalog(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin.workspaceId();
// Skills: builtin (global) + skills owned by this workspace, enabled only.
List<SkillEntity> skills = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getEnabled, true)
.eq(SkillEntity::getDeleted, 0)
.orderByAsc(SkillEntity::getName));
long effectiveWs = workspaceId == null ? 1L : workspaceId;
List<Map<String, String>> skillCatalog = new ArrayList<>();
for (SkillEntity s : skills) {
if (s.getName() == null || s.getName().isBlank()) continue;
boolean builtin = Boolean.TRUE.equals(s.getBuiltin());
long skillWs = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
if (!builtin && skillWs != effectiveWs) continue;
Map<String, String> m = new LinkedHashMap<>();
m.put("name", s.getName());
m.put("description", s.getDescription() == null ? "" : s.getDescription());
skillCatalog.add(m);
if (skillCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
}
// Tools: only those the binding service would accept (available == true).
List<Map<String, String>> toolCatalog = new ArrayList<>();
try {
for (AvailableToolDTO t : availableToolService.listAvailable()) {
if (t == null || !t.isAvailable() || t.getName() == null || t.getName().isBlank()) continue;
Map<String, String> m = new LinkedHashMap<>();
m.put("name", t.getName());
m.put("description", t.getDescription() == null ? "" : t.getDescription());
toolCatalog.add(m);
if (toolCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
}
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool catalog lookup failed: {}", e.getMessage());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("skills", skillCatalog);
result.put("tools", toolCatalog);
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
return "{\"skills\":[],\"tools\":[]}";
}
}
// ==================== helpers ====================
/**
* Resolve requested skill names to ids within reach of this agent
* (builtin skills are global; otherwise the skill must belong to the
* agent's workspace) and bind them. Returns the names actually bound;
* unresolved names are skipped with a warning so a single typo does not
* abort the whole hire.
*/
private List<String> bindSkills(AgentEntity agent, long workspaceId, List<String> requestedSkills) {
if (requestedSkills.isEmpty()) return List.of();
List<Long> ids = new ArrayList<>();
List<String> boundNames = new ArrayList<>();
for (String raw : requestedSkills) {
String skillName = raw.trim();
if (skillName.isEmpty()) continue;
List<SkillEntity> matches = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, skillName)
.eq(SkillEntity::getDeleted, 0));
SkillEntity chosen = matches.stream()
.filter(s -> {
if (Boolean.TRUE.equals(s.getBuiltin())) return true;
long ws = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
return ws == workspaceId;
})
.findFirst()
.orElse(null);
if (chosen == null) {
log.warn("[AgentAuthoringTool] skill '{}' not resolvable for workspace {}; skipping", skillName, workspaceId);
continue;
}
ids.add(chosen.getId());
boundNames.add(chosen.getName());
}
if (ids.isEmpty()) return List.of();
try {
// Best-effort: the employee is already persisted, so a late
// binding failure (e.g. a skill row deleted between resolve and
// bind) must not throw out of the tool and strand the caller with
// an error on top of an already-created agent. The agent simply
// keeps the default capability set instead.
agentBindingService.setSkillBindings(agent.getId(), ids);
} catch (Exception e) {
log.warn("[AgentAuthoringTool] skill binding failed for agent {}; left on global defaults: {}",
agent.getId(), e.getMessage());
return List.of();
}
return boundNames;
}
/**
* Filter requested tool names through the picker (only available == true
* names are bindable) and bind them. Returns the names actually bound.
*/
private List<String> bindTools(AgentEntity agent, List<String> requestedTools) {
if (requestedTools.isEmpty()) return List.of();
Set<String> bindable;
try {
bindable = availableToolService.listAvailable().stream()
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.collect(Collectors.toSet());
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool picker unavailable; skipping tool bind: {}", e.getMessage());
return List.of();
}
List<String> filtered = new ArrayList<>();
for (String raw : requestedTools) {
String toolName = raw == null ? "" : raw.trim();
if (toolName.isEmpty()) continue;
if (bindable.contains(toolName)) {
filtered.add(toolName);
} else {
log.warn("[AgentAuthoringTool] tool '{}' not bindable; skipping", toolName);
}
}
if (filtered.isEmpty()) return List.of();
try {
// Best-effort, same rationale as bindSkills: never throw after the
// employee has been created.
agentBindingService.setToolBindings(agent.getId(), filtered);
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool binding failed for agent {}; left on global defaults: {}",
agent.getId(), e.getMessage());
return List.of();
}
return filtered;
}
/** Parse a JSON array of strings or a comma-separated list into a name list. */
private List<String> parseNameList(String raw) {
if (raw == null || raw.isBlank()) return List.of();
String trimmed = raw.trim();
if (trimmed.startsWith("[")) {
try {
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
return parsed == null ? List.of() : parsed;
} catch (Exception ignored) {
// Fall through to comma split the model occasionally emits a
// malformed array; a comma split still recovers most names.
}
}
List<String> out = new ArrayList<>();
for (String part : trimmed.replace("[", "").replace("]", "").split(",")) {
String p = part.trim().replaceAll("^[\"']|[\"']$", "");
if (!p.isEmpty()) out.add(p);
}
return out;
}
private static String normalizeAgentType(String agentType) {
if (agentType == null || agentType.isBlank()) return "react";
String t = agentType.trim().toLowerCase();
return "plan_execute".equals(t) ? "plan_execute" : "react";
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
/** Best-effort numeric parse of the requester id for creator attribution. */
private static Long parseUserId(String requesterId) {
if (requesterId == null || requesterId.isBlank()) return null;
try {
return Long.parseLong(requesterId.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@ -3,19 +3,33 @@ package vip.mate.agent;
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.ApplicationEventPublisher;
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.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Agent 业务服务
@ -33,9 +47,25 @@ public class AgentService {
private final AgentMapper agentMapper;
private final AgentGraphBuilder agentGraphBuilder;
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
* to keep this a leaf dependency with no risk of a bean cycle. */
private final ConversationMapper conversationMapper;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
/** Field-injected publisher for agent_lifecycle trigger events; the
* trigger module's bridge listens and forwards into ingest. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
* variant instead of mutating the one every other conversation shares.
* The model key is {@code ""} for the Agent / global-default model.
*/
private final Map<Long, Map<String, BaseAgent>> agentInstances = new ConcurrentHashMap<>();
// ==================== CRUD ====================
@ -48,9 +78,26 @@ public class AgentService {
* 按工作区列出 Agent
*/
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.orderByDesc(AgentEntity::getCreateTime));
return listAgentsByWorkspace(workspaceId, null);
}
/**
* 按工作区列出 Agent可选过滤启用状态
*
* @param enabled non-null restricts the result set to agents whose
* {@code enabled} column matches the given value.
* Pass {@code true} from chat selectors so disabled
* agents disappear from the picker; the admin
* management page passes {@code null} to keep
* disabled rows visible for re-enabling.
*/
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId, Boolean enabled) {
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId);
if (enabled != null) {
q.eq(AgentEntity::getEnabled, enabled);
}
return agentMapper.selectList(q.orderByDesc(AgentEntity::getCreateTime));
}
public AgentEntity getAgent(Long id) {
@ -66,19 +113,100 @@ public class AgentService {
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}
requireUniqueName(agent, null);
agentMapper.insert(agent);
publishLifecycle(agent, "spawned");
return agent;
}
public AgentEntity updateAgent(AgentEntity agent) {
// Detect enabled-flag flip so the lifecycle event reflects the
// intent rather than every metadata edit. Reading the prior row
// is cheap and gives us a clean diff source.
AgentEntity prior = agentMapper.selectById(agent.getId());
// Only re-validate uniqueness when the name actually changes
// a pure metadata edit (icon, prompt, ...) shouldn't pay the
// SELECT cost or risk a false positive against the row itself.
if (prior != null
&& agent.getName() != null
&& !agent.getName().equals(prior.getName())) {
// Workspace cannot be moved (Controller pins it to prior.workspaceId),
// so reuse it for the lookup even if the incoming DTO left it null.
if (agent.getWorkspaceId() == null) {
agent.setWorkspaceId(prior.getWorkspaceId());
}
requireUniqueName(agent, agent.getId());
}
agentMapper.updateById(agent);
agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null
&& !prior.getEnabled().equals(agent.getEnabled())) {
publishLifecycle(agent,
Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled");
}
return agent;
}
/**
* Friendly business-code surface for the {@code (workspace_id, name)}
* unique index added in V102.
*
* <p>The wire shape is the project-wide R&lt;T&gt; envelope: HTTP status
* stays 200 (per the convention in {@code R.fail} and the axios
* interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in
* the response body's {@code code} field so the front-end can branch
* without breaking on an axios error. Without this pre-check the
* duplicate save would surface as an opaque
* {@code DataIntegrityViolation} stack trace.
*
* @param excludeId when non-null, skip this row in the lookup so
* {@link #updateAgent} doesn't mistake the row for its
* own duplicate.
*/
private void requireUniqueName(AgentEntity agent, Long excludeId) {
if (agent.getName() == null || agent.getName().isBlank()) {
throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空");
}
Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.eq(AgentEntity::getName, agent.getName());
if (excludeId != null) {
q.ne(AgentEntity::getId, excludeId);
}
Long count = agentMapper.selectCount(q);
if (count != null && count > 0) {
throw new MateClawException("err.agent.duplicate_name", 409,
"工作区内已存在同名 Agent: " + agent.getName());
}
}
public void deleteAgent(Long id) {
AgentEntity prior = agentMapper.selectById(id);
agentMapper.deleteById(id);
agentInstances.remove(id);
if (prior != null) publishLifecycle(prior, "terminated");
}
/**
* Best-effort publish of an {@link AgentLifecycleEvent}. A publish
* failure must never roll back the agent CRUD that just succeeded
* the agent_lifecycle trigger surface is observability, not the
* canonical record.
*/
private void publishLifecycle(AgentEntity agent, String phase) {
if (events == null || agent == null) return;
try {
events.publishEvent(new AgentLifecycleEvent(
agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(),
agent.getId() == null ? 0L : agent.getId(),
agent.getName(),
phase,
System.currentTimeMillis()));
} catch (Exception e) {
log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}",
agent.getId(), phase, e.getMessage());
}
}
/**
@ -88,33 +216,106 @@ public class AgentService {
agentInstances.remove(agentId);
}
/**
* Invalidate the cached agent instance whenever one of its workspace files
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured
* memory) is baked into the cached instance at build time, so memory edits made
* via tools, consolidation, or cleanup would otherwise stay invisible until an
* agent config change or restart. Rebuilding on the next turn picks them up.
*/
@org.springframework.context.event.EventListener
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
if (event.agentId() != null) {
agentInstances.remove(event.agentId());
}
}
// ==================== 运行时入口 ====================
public String chat(Long agentId, String message, String conversationId) {
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);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, message, conversationId,
(msg, convId) -> agent.chat(msg, convId));
} finally {
ChatOriginHolder.clear();
}
}
/**
* Sync chat that also captures token usage and runtime model attribution
* from the agent graph's {@code _usage_final} event. Equivalent to
* subscribing to {@link #chatStructuredStream} and joining all content
* deltas produces the same assistant text as {@link #chat} but exposes
* the usage figures so callers can persist them on the assistant message.
*
* <p>Prefer this entry over {@link #chat} for any path that writes the
* reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket,
* cron task, post-approval replay); the plain {@link #chat} stays as the
* thin wrapper for fire-and-forget invocations where usage is not needed.
*/
public ChatResult chatWithUsage(Long agentId, String message, String conversationId) {
return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin));
}
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
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);
BaseAgent agent = getOrBuildAgentForConversation(agentId, 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);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
@ -129,22 +330,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);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, goal, conversationId,
(msg, convId) -> agent.execute(msg, convId));
} finally {
ChatOriginHolder.clear();
}
}
/**
@ -158,9 +382,56 @@ public class AgentService {
*/
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
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);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
} finally {
ChatOriginHolder.clear();
}
}
/**
* Replay-after-approval that also captures token usage and runtime model
* attribution. Mirrors {@link #chatWithUsage} for the
* approval-resumption path used by {@code ChannelMessageRouter}.
*/
public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId,
toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY));
}
/**
* Subscribe to a structured stream and collapse it into a single
* {@link ChatResult}: append all content deltas, capture the trailing
* {@code _usage_final} event for token and model attribution.
*/
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
}
/**
@ -168,20 +439,46 @@ public class AgentService {
*/
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
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 : "");
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
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) {
BaseAgent agent = agentInstances.get(agentId);
return agent != null ? agent.getState() : AgentState.IDLE;
Map<String, BaseAgent> variants = agentInstances.get(agentId);
if (variants == null || variants.isEmpty()) {
return AgentState.IDLE;
}
// An Agent may have several cached graph variants (one per pinned
// model). Report the first non-IDLE state so a turn running on any
// variant stays visible.
for (BaseAgent agent : variants.values()) {
AgentState state = agent.getState();
if (state != AgentState.IDLE) {
return state;
}
}
return AgentState.IDLE;
}
// ==================== 缓存管理 ====================
@ -208,38 +505,193 @@ public class AgentService {
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
}
/**
* Issue #289: an MCP server connecting / disconnecting / reconnecting
* changes the live tool set, but cached agents snapshot their tools at
* build time. Clear the cache so the next turn rebuilds against the
* current MCP tools instead of replying "from memory" with a stale,
* tool-less graph.
*/
@EventListener
public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) {
refreshAllAgents();
log.info("Agent caches refreshed after MCP server change: {}", event.reason());
}
// ==================== Lifecycle helpers ====================
/**
* Wraps a synchronous agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage as &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);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
String result = invoke.apply(enrichedMessage, conversationId);
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result;
}
/**
* Wraps a streaming agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage.
* P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error.
*/
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();
return invoke.apply(enrichedMessage, conversationId)
.doOnNext(item -> {
String text = contentExtractor.apply(item);
if (text != null) {
reply.append(text);
}
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()));
}
/**
* Prepend memory-context block to user message if non-empty.
* Does not pollute build-time system prompt snapshot.
*/
private String injectMemoryContext(String message, String memoryContext) {
if (memoryContext == null || memoryContext.isBlank()) return message;
return memoryContext + "\n\n" + message;
}
// ==================== 内部方法 ====================
/**
* Resolve (and cache) the Agent graph for a conversation, honouring the
* conversation's pinned model. Conversations with no pin IM channels
* before issue #183 fix, cron, sub-tasks, or rows not yet created
* resolve to the shared Agent / global-default graph.
*
* <p>Defensive normalisation: a half-populated pair (provider but no
* model, or vice versa) is treated as unpinned. Without this guard, a
* partially-cleared admin UI write could end up cached as a key like
* {@code "volcano::"} which {@link #getOrBuildAgent} would then try to
* build, only to fail at provider-resolution time on every turn.
*/
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
String provider = null;
String modelName = null;
if (conversationId != null && !conversationId.isBlank()) {
ConversationEntity conv = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
provider = blankToNull(conv.getModelProvider());
modelName = blankToNull(conv.getModelName());
// Half-populated pair treat as unpinned. Pinning requires
// a complete (provider, model) tuple see #183 follow-up
// hardening so a stale row written by an earlier broken
// admin UI release doesn't loop the cache on an invalid key.
if (provider == null || modelName == null) {
provider = null;
modelName = null;
}
}
}
return getOrBuildAgent(agentId, provider, modelName);
}
/** Map empty / whitespace strings to null so the pinned-check is one branch. */
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private BaseAgent getOrBuildAgent(Long agentId) {
return agentInstances.computeIfAbsent(agentId, id -> {
AgentEntity entity = getAgent(id);
return getOrBuildAgent(agentId, null, null);
}
private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) {
boolean pinned = modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank();
String modelKey = pinned ? modelProvider + "::" + modelName : "";
return agentInstances
.computeIfAbsent(agentId, id -> new ConcurrentHashMap<>())
.computeIfAbsent(modelKey, key -> {
AgentEntity entity = getAgent(agentId);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
}
return agentGraphBuilder.build(entity);
return agentGraphBuilder.build(entity, modelProvider, modelName);
});
}
// ==================== StreamDelta ====================
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) {
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
boolean persistenceOnly, boolean segmentOnly) {
// 兼容构造器广播+持久化
public StreamDelta(String content, String thinking) {
this(content, thinking, null, null, false);
this(content, thinking, null, null, false, false);
}
// 显式 5-参构造器保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
public StreamDelta(String content, String thinking, String eventType,
Map<String, Object> eventData, boolean persistenceOnly) {
this(content, thinking, eventType, eventData, persistenceOnly, false);
}
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
public static StreamDelta persistOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true);
return new StreamDelta(content, thinking, null, null, true, false);
}
/**
* Per-iteration narrative routing for ReasoningNode / SummarizingNode output.
*
* <p>The accumulator should:
* <ul>
* <li>append the text to the in-flight {@code segments} entry so the UI's
* segmented view still renders the intermediate "I'll look it up…"
* narration between tool cards;</li>
* <li>NOT broadcast already broadcast live by NodeStreamingChatHelper;</li>
* <li>NOT append to the top-level {@code content} StringBuilder, which is
* what gets persisted as {@code mate_message.content}. That field
* should hold the final-answer span only otherwise multiple
* iterations stack into "我来…让我…然后…" walls that next-turn replay
* sees as unanswered chain-of-thought (issue #120 narration leg).</li>
* </ul>
*
* <p>Implies {@code persistenceOnly} (no broadcast) at the accumulator
* layer, but is a stricter promise: <em>nothing</em> reaches the top-level
* persisted content field via this flavor.
*/
public static StreamDelta segmentOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true, true);
}
public static StreamDelta empty() {
return new StreamDelta(null, null, null, null, false);
return new StreamDelta(null, null, null, null, false, false);
}
public static StreamDelta event(String type, Map<String, Object> data) {
return new StreamDelta(null, null, type, data, false);
return new StreamDelta(null, null, type, data, false, false);
}
public boolean isEvent() {
@ -258,4 +710,23 @@ public class AgentService {
return thinking != null ? thinking.length() : 0;
}
}
// ==================== ChatResult ====================
/**
* Sync chat result carrying the assistant reply alongside the usage
* attribution that the streaming path exposes via the {@code _usage_final}
* event. Use this when callers need to persist {@code promptTokens} /
* {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider}
* on the assistant message row but cannot subscribe to the structured
* stream directly (cron tasks, sync HTTP endpoints, voice WebSocket,
* post-approval replays).
*/
public record ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider) {
public static ChatResult contentOnly(String content) {
return new ChatResult(content != null ? content : "", 0, 0, null, null);
}
}
}

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,123 @@ public class AgentToolSet {
public int size() {
return callbacks.size();
}
/**
* Resolve a mix of aliases (function name / Spring bean name / Java class simple name)
* to the {@code @Tool} function names they map to. Used to bridge persistence layers
* that key a tool by its class or bean name (e.g. {@code mate_tool.name}) onto the
* runtime callback name ({@code cb.getToolDefinition().name()}). Unknown aliases yield
* nothing.
*/
public Set<String> functionNamesFor(Set<String> aliases) {
if (aliases == null || aliases.isEmpty()) {
return Set.of();
}
return resolveAliases(aliases).stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
// ==================== Internals ====================
/**
* Resolve a set of aliases (any mix of function name / bean name / class simple name)
* into the set of {@link ToolCallback} instances they refer to. Unknown aliases are
* silently dropped the caller is expected to be tolerant of stale persistence data.
*/
private Set<ToolCallback> resolveAliases(Set<String> aliases) {
Set<ToolCallback> resolved = new LinkedHashSet<>();
for (String alias : aliases) {
Set<ToolCallback> hits = aliasIndex.get(alias);
if (hits != null) {
resolved.addAll(hits);
}
}
return resolved;
}
/**
* Reconstruct a new {@code AgentToolSet} after filtering callbacks, carrying forward
* only the alias entries whose targets survived. This avoids re-running
* {@link ToolCallbacks#from(Object)} reflection on every {@code with*} call.
*/
private AgentToolSet rebuild(List<ToolCallback> filteredCallbacks) {
Set<ToolCallback> survivors = new HashSet<>(filteredCallbacks);
Map<String, Set<ToolCallback>> filteredAliases = new LinkedHashMap<>();
for (Map.Entry<String, Set<ToolCallback>> e : aliasIndex.entrySet()) {
Set<ToolCallback> kept = new LinkedHashSet<>();
for (ToolCallback cb : e.getValue()) {
if (survivors.contains(cb)) {
kept.add(cb);
}
}
if (!kept.isEmpty()) {
filteredAliases.put(e.getKey(), Set.copyOf(kept));
}
}
return new AgentToolSet(toolBeans, filteredCallbacks, filteredAliases);
}
/**
* Build the alias index. Function names are always indexed (they are the runtime truth);
* bean names and class simple names are indexed when {@code beanNameResolver} is provided
* typically only the production registry has the {@link org.springframework.context.ApplicationContext}
* needed to map bean instances to names. Unit tests that pass empty {@code toolBeans}
* naturally get a function-name-only index.
*/
private static Map<String, Set<ToolCallback>> buildAliasIndex(
List<Object> toolBeans,
Map<String, ToolCallback> callbackByName,
Function<Object, String> beanNameResolver) {
Map<String, Set<ToolCallback>> aliases = new LinkedHashMap<>();
// 1. Always index by function name (the runtime identifier)
for (Map.Entry<String, ToolCallback> e : callbackByName.entrySet()) {
aliases.computeIfAbsent(e.getKey(), k -> new LinkedHashSet<>()).add(e.getValue());
}
// 2. If we have bean info, also index by Spring bean name and Java class simple name.
// A single bean may expose multiple @Tool methods the alias maps to a set.
if (beanNameResolver != null) {
for (Object bean : toolBeans) {
String beanName = beanNameResolver.apply(bean);
String simpleName = bean.getClass().getSimpleName();
// Find which callbacks belong to this bean, looking them up in the
// (possibly i18n-wrapped) callbackByName so we point at the same
// instances the rest of the set uses.
Set<ToolCallback> beanCallbacks = new LinkedHashSet<>();
ToolCallback[] rawCallbacks;
try {
rawCallbacks = ToolCallbacks.from(bean);
} catch (Exception ignored) {
// Defensive: a misbehaving bean shouldn't break the whole tool set
continue;
}
for (ToolCallback raw : rawCallbacks) {
ToolCallback wrapped = callbackByName.get(raw.getToolDefinition().name());
if (wrapped != null) {
beanCallbacks.add(wrapped);
}
}
if (beanCallbacks.isEmpty()) {
continue;
}
if (beanName != null && !beanName.isBlank()) {
aliases.computeIfAbsent(beanName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
}
if (simpleName != null && !simpleName.isBlank()) {
aliases.computeIfAbsent(simpleName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
}
}
}
// Freeze inner sets
Map<String, Set<ToolCallback>> frozen = new LinkedHashMap<>();
for (Map.Entry<String, Set<ToolCallback>> e : aliases.entrySet()) {
frozen.put(e.getKey(), Set.copyOf(e.getValue()));
}
return Map.copyOf(frozen);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,48 @@ 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";
/**
* User-facing recovery affordances offered after a turn ends in a
* non-transient error. Carries the error type + message + a
* data-driven list of actions ({@code retry}, {@code regenerate},
* {@code report}) so the frontend can render the right buttons
* without hard-coding which categories deserve which actions.
*
* <p>Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
* machine-readable reason). The two are kept separate so legacy
* consumers of {@code finish_reason} don't have to learn a new
* payload shape and so a future graph branch (e.g. evidence-
* insufficient "rerun with the listed files attached") can emit
* feedback affordances without abusing the finish_reason channel.
*/
public static final String EVENT_FEEDBACK = "feedback_event";
/**
* Multimodal sidecar routing decision for the current turn. Emitted once
* per turn before the graph starts streaming; the channel-side accumulator
* stores it under {@code metadata.routing} so the chat UI can show which
* sidecar (if any) was invoked. Underscore-prefixed name keeps it out of
* IM channel rebroadcast (see {@code ChannelMessageRouter}).
*/
public static final String EVENT_ROUTING_DECISION = "_routing_decision";
/**
* 事件记录
@ -44,8 +86,23 @@ public final class GraphEventPublisher {
}
public static GraphEvent toolStart(String toolName, String arguments) {
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 +110,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 +149,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 +164,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 +181,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 +190,82 @@ 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);
}
/**
* Emit a recovery-affordance event for the frontend. {@code errorType}
* mirrors the {@code NodeStreamingChatHelper.ErrorType} value (e.g.
* {@code AUTH_ERROR}, {@code BILLING}, {@code MODEL_NOT_FOUND}, or
* the generic {@code UNKNOWN}); {@code errorMessage} is the
* user-friendly text already displayed in the bubble; {@code actions}
* is the ordered list of buttons to render. Default offering is the
* standard {@code retry / regenerate / report} triad call sites
* can narrow this if a category has limitations (e.g. AUTH_ERROR
* shouldn't offer "retry" until the key is fixed).
*/
public static GraphEvent feedback(String errorType, String errorMessage,
java.util.List<String> actions) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FEEDBACK, Map.of(
"errorType", errorType != null ? errorType : "",
"errorMessage", errorMessage != null ? errorMessage : "",
"actions", actions != null && !actions.isEmpty()
? actions
: java.util.List.of("retry", "regenerate", "report"),
"timestamp", ts
), ts);
}
// ===== 提取方法 =====
/**
@ -135,14 +280,90 @@ public final class GraphEventPublisher {
.orElse(List.of());
}
/**
* 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

@ -1,33 +0,0 @@
package vip.mate.agent;
/**
* 请求级思考深度的 ThreadLocal 持有器
* <p>
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口
* <p>
* 支持的值off / low / medium / high / maxnull 表示跟随模型默认
*
* @author MateClaw Team
*/
public final class ThinkingLevelHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private ThinkingLevelHolder() {}
public static void set(String level) {
HOLDER.set(level);
}
/**
* 获取当前请求的思考级别null 表示未设置跟随模型默认
*/
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -5,8 +5,10 @@ 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.model.AgentWikiKbBinding;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.audit.service.AuditEventService;
@ -51,8 +53,12 @@ public class AgentBindingController {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setSkillBindings(agentId, skillIds);
agentService.invalidateAgentCache(agentId);
// The Vue client always sends an array, but a non-Vue caller (curl /
// SDK) can POST a body of just `null`, which Spring binds to a null
// list. The service tolerates that guard the audit message too.
int count = skillIds == null ? 0 : skillIds.size();
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
"skills=" + skillIds.size(), null);
"skills=" + count, null);
return R.ok();
}
@ -97,8 +103,63 @@ public class AgentBindingController {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setToolBindings(agentId, toolNames);
agentService.invalidateAgentCache(agentId);
// Same null-safety rationale as setSkills above.
int count = toolNames == null ? 0 : toolNames.size();
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
"tools=" + toolNames.size(), null);
"tools=" + count, null);
return R.ok();
}
// ==================== Provider Preferences ====================
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
@GetMapping("/provider-preferences")
@RequireWorkspaceRole("viewer")
public R<List<AgentProviderPreference>> listProviderPreferences(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
return R.ok(bindingService.listProviderPreferences(agentId));
}
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R<Void> setProviderPreferences(
@PathVariable Long agentId,
@RequestBody List<String> providerIds,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setProviderPreferences(agentId, providerIds);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
"providers=" + providerIds.size(), null);
return R.ok();
}
// ==================== Knowledge Base Access Scope ====================
@Operation(summary = "获取 Agent 的知识库访问范围")
@GetMapping("/kbs")
@RequireWorkspaceRole("viewer")
public R<List<AgentWikiKbBinding>> listKbs(@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
return R.ok(bindingService.listKbBindings(agentId));
}
@Operation(summary = "批量设置 Agent 的知识库访问范围(替换模式,空表示不限制)")
@PutMapping("/kbs")
@RequireWorkspaceRole("member")
public R<Void> setKbs(@PathVariable Long agentId, @RequestBody List<Long> kbIds,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setKbBindings(agentId, kbIds);
agentService.invalidateAgentCache(agentId);
// A non-Vue caller can POST a bare `null`; the service tolerates it.
int count = kbIds == null ? 0 : kbIds.size();
auditEventService.record("UPDATE", "AGENT_WIKI_KB", String.valueOf(agentId),
"kbs=" + count, null);
return R.ok();
}
@ -111,7 +172,7 @@ public class AgentBindingController {
}
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
}
}
}

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,27 @@
package vip.mate.agent.binding.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Agent knowledge base access scope row.
* <p>
* Each enabled row whitelists one KB for one agent. When an agent has at
* least one row the wiki tools restrict their visible KB set to the bound
* ones; an agent with no rows stays workspace-wide (legacy behavior).
*/
@Data
@TableName("mate_agent_wiki_kb")
public class AgentWikiKbBinding {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long agentId;
private Long kbId;
private Boolean enabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

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

@ -0,0 +1,9 @@
package vip.mate.agent.binding.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
@Mapper
public interface AgentWikiKbBindingMapper extends BaseMapper<AgentWikiKbBinding> {
}

View File

@ -0,0 +1,53 @@
package vip.mate.agent.binding.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.skill.event.SkillRemovedEvent;
/**
* Drops {@code mate_agent_skill} rows that pointed at a now-removed skill.
*
* <p>Without this listener, deleting a skill from the skill management page
* leaves orphan binding rows behind:
* <ul>
* <li>the agent edit modal still shows a non-zero badge from
* {@code GET /agents/{id}/skills},</li>
* <li>the picker list (sourced from {@code /skills} enabled set) no longer
* contains a checkbox for that id so the user can't uncheck it, and</li>
* <li>a subsequent {@code PUT /agents/{id}/skills} payload that still
* carries the orphan id is rejected by
* {@code AgentBindingService.setSkillBindings} with
* {@code err.skill.not_found}, leaving the user with no way to clear
* the stale binding.</li>
* </ul>
*
* <p>The event is dispatched synchronously from {@code SkillService} after
* the {@code mate_skill} row deletion, so the cleanup is part of the same
* request and observable in the very next list call.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentBindingSkillRemovalListener {
private final AgentSkillBindingMapper skillBindingMapper;
@EventListener
public void onSkillRemoved(SkillRemovedEvent event) {
if (event == null || event.skillId() == null) {
return;
}
int dropped = skillBindingMapper.delete(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, event.skillId()));
if (dropped > 0) {
log.info("Cleaned {} agent-skill binding row(s) for removed skill {} (id={})",
dropped, event.skillName(), event.skillId());
}
}
}

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,167 @@
package vip.mate.agent.context;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable;
import java.util.Map;
/**
* Immutable value object that travels alongside an agent invocation describing
* <em>where the request came from</em> channel, conversation, requester,
* workspace, and optional delivery target.
*
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
*
* <h2>Field evolution rule</h2>
* <ul>
* <li>Only add never delete; deprecate at least 90 days (covers approval TTL)
* before physical removal.</li>
* <li>Never rename add a new field plus deprecate-old-field, double-write
* during the migration window.</li>
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
* compatibility when older approval rows are deserialized after upgrades.</li>
* </ul>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatOrigin(
@Nullable Long agentId,
@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable Long channelId,
@Nullable ChannelTarget channelTarget,
// True only when the agent invocation was triggered by the scheduled-job
// runner. An explicit discriminator (rather than inferring from
// requesterId/channelId) so the runtime can branch on "is this a cron
// run" without coupling to factory internals.
boolean cronOrigin,
/**
* Display name of the user that sent the inbound IM message. Used by
* the prompt-context injector so the agent's system prompt can
* personalise replies ("You are talking to {{senderName}}"). Null
* for non-IM origins (web, cron). {@code requesterId} carries the
* stable identifier; this one is purely the human-readable surface.
*/
@Nullable String senderName,
/**
* Source channel type ("feishu" / "wecom" / "dingtalk" / ...).
* Lets the agent know which platform it's responding on, e.g. to
* tailor formatting or hint at supported features.
*/
@Nullable String channelType,
/**
* Group / chat identifier for IM channels distinguishes private
* vs. group conversations. Null for 1:1 chats. Distinct from
* {@link #channelTarget()} (which targets cron / proactive sends).
*/
@Nullable String chatId,
/**
* Public base URL ({@code scheme://host[:port][/contextPath]}) resolved
* from the inbound HTTP request on the request thread. Carried here so
* tools running on async/streaming threads where no request is bound
* can still mint absolute download links. Null for IM/cron origins, which
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
*/
@Nullable String baseUrl
) {
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
public static final String CTX_KEY = "mateclaw.chatOrigin";
/** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY =
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
// ---------------- Factories per entry point ----------------
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath) {
return web(conversationId, requesterId, workspaceId, workspaceBasePath, null);
}
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable String baseUrl) {
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
}
public static ChatOrigin cron(@Nullable String conversationId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable Long channelId,
@Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system",
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
}
// ---------------- Wither-style updates ----------------
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl);
}
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl);
}
/**
* Carry the inbound message's sender display name, source channel
* type, and chat (group) id. Called by the channel-side origin
* factory so prompt-context injection can show the agent "who"
* is talking and "where".
*/
public ChatOrigin withSender(@Nullable String newSenderName,
@Nullable String newChannelType,
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl);
}
// ---------------- Spring AI ToolContext interop ----------------
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
public ToolContext toToolContext() {
return new ToolContext(Map.of(CTX_KEY, this));
}
/**
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
* no entry, or the value is not a ChatOrigin (defensive keeps single-tool
* callers safe even if wiring is partial).
*/
public static ChatOrigin from(@Nullable ToolContext ctx) {
if (ctx == null) return EMPTY;
Object v = ctx.getContext().get(CTX_KEY);
return v instanceof ChatOrigin co ? co : EMPTY;
}
}

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.llm.chatmodel.ThinkingLevelHolder}.
*/
public final class ChatOriginHolder {
private static final ThreadLocal<ChatOrigin> HOLDER = new ThreadLocal<>();
private ChatOriginHolder() {
}
/** Set the origin for the current AgentService invocation. */
public static void set(ChatOrigin origin) {
HOLDER.set(origin);
}
/**
* @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
* when no entry path has supplied one (legacy callers).
*/
public static ChatOrigin get() {
ChatOrigin v = HOLDER.get();
return v != null ? v : ChatOrigin.EMPTY;
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -0,0 +1,140 @@
package vip.mate.agent.context;
/**
* Configuration for per-reasoning-loop message budgeting.
*
* <p>Used by {@link LoopMessageBudgeter} to decide when and how to trim the
* working message list that a ReAct iteration hands to the LLM. Distinct from
* the multi-turn history compression configured by
* {@link vip.mate.config.ConversationWindowProperties}: this one applies inside
* a single user turn while the ReAct loop accumulates reasoning steps and
* tool-call/tool-response pairs.
*
* <p>Field semantics:
* <ul>
* <li>{@code triggerTokens} token threshold above which budgeting kicks
* in. Compared against {@code historyTokens + reservedPrefixTokens}
* so the budgeter accounts for the full prompt the LLM will see,
* not just the message list.</li>
* <li>{@code keepTailTokens} token budget reserved for the tail (recent
* observations + the current user message). Scales with the model
* window instead of relying on a fixed count.</li>
* <li>{@code minTailMessages} floor on the kept-tail count. Prevents a
* single huge tool output from collapsing the tail to one message and
* losing recent reasoning context.</li>
* <li>{@code tailSoftCeilingRatio} multiplier applied to
* {@code keepTailTokens} when honoring the floor or pulling back to
* keep a tool pair whole. Lets the tail overshoot the hard budget by
* up to this factor before more aggressive cuts kick in.</li>
* <li>{@code reservedPrefixTokens} estimated tokens consumed by the
* non-history portion of the prompt (system prompt, skill catalog,
* runtime context, wiki injection, tool schemas, output reserve).
* Surfaces these from the caller so the budget covers the whole
* prompt, not just the message list.</li>
* <li>{@code targetMaxMessages} soft ceiling on the count fed to the
* LLM. Best-effort: the budgeter may exceed it slightly to keep a
* tool pair whole rather than orphan a call/response that case is
* reported via {@code BudgetTrace.capExceededForPairIntegrity}.</li>
* </ul>
*/
public record LoopBudgetConfig(
int triggerTokens,
int keepTailTokens,
int minTailMessages,
double tailSoftCeilingRatio,
int reservedPrefixTokens,
int targetMaxMessages) {
/** Smallest useful trigger threshold; below this budgeting is effectively disabled. */
public static final int MIN_TRIGGER_TOKENS = 1_000;
/** Smallest sensible tail budget; below this even one observation may not fit. */
public static final int MIN_TAIL_TOKENS = 2_000;
/** Floor on minTailMessages — fewer than 3 collapses recent context too aggressively. */
public static final int MIN_TAIL_MESSAGES_FLOOR = 3;
/** Floor on the soft ceiling ratio — anything below 1.0 is degenerate. */
public static final double MIN_TAIL_SOFT_CEILING_RATIO = 1.0;
/** Smallest sensible target cap; below this even a normal ReAct loop trips it. */
public static final int MIN_TARGET_MAX = 20;
public LoopBudgetConfig {
if (triggerTokens < MIN_TRIGGER_TOKENS) {
throw new IllegalArgumentException(
"triggerTokens must be >= " + MIN_TRIGGER_TOKENS + ", got " + triggerTokens);
}
if (keepTailTokens < MIN_TAIL_TOKENS) {
throw new IllegalArgumentException(
"keepTailTokens must be >= " + MIN_TAIL_TOKENS + ", got " + keepTailTokens);
}
if (minTailMessages < MIN_TAIL_MESSAGES_FLOOR) {
throw new IllegalArgumentException(
"minTailMessages must be >= " + MIN_TAIL_MESSAGES_FLOOR
+ ", got " + minTailMessages);
}
if (tailSoftCeilingRatio < MIN_TAIL_SOFT_CEILING_RATIO) {
throw new IllegalArgumentException(
"tailSoftCeilingRatio must be >= " + MIN_TAIL_SOFT_CEILING_RATIO
+ ", got " + tailSoftCeilingRatio);
}
if (reservedPrefixTokens < 0) {
throw new IllegalArgumentException(
"reservedPrefixTokens must be >= 0, got " + reservedPrefixTokens);
}
if (targetMaxMessages < MIN_TARGET_MAX) {
throw new IllegalArgumentException(
"targetMaxMessages must be >= " + MIN_TARGET_MAX
+ ", got " + targetMaxMessages);
}
if (keepTailTokens >= triggerTokens) {
throw new IllegalArgumentException(
"keepTailTokens (" + keepTailTokens + ") must be < triggerTokens ("
+ triggerTokens + ") — otherwise budgeting would never reduce anything");
}
}
/** Tail budget after applying the soft ceiling. */
public int tailSoftCeilingTokens() {
return (int) (keepTailTokens * tailSoftCeilingRatio);
}
/**
* Derive a sensible config from a model's context window. The ratios were
* chosen so the budgeter triggers well before the model's actual limit and
* leaves enough headroom for the LLM's own response.
*
* <ul>
* <li>trigger = 50% of the window same threshold the multi-turn
* compressor uses, so the two layers stay calibrated.</li>
* <li>tail budget = 30% of the window.</li>
* <li>minTailMessages = 4 at least one full reasoning/action cycle
* stays visible to the LLM no matter how big a single tool output is.</li>
* <li>tailSoftCeilingRatio = 1.5 let the tail overshoot by 50% when
* enforcing the floor or pulling back to keep a tool pair whole.</li>
* <li>reservedPrefixTokens = 0 caller should override with the real
* prefix estimate; left at 0 the budget still works but errs on
* the side of triggering later than it should.</li>
* <li>targetMaxMessages = 200 well above a normal ReAct loop's 2040
* working messages, low enough to be a meaningful guard rail.</li>
* </ul>
*/
public static LoopBudgetConfig forContext(int contextWindowTokens) {
if (contextWindowTokens <= 0) {
contextWindowTokens = 32_000;
}
int trigger = Math.max(MIN_TRIGGER_TOKENS, (int) (contextWindowTokens * 0.50));
int tail = Math.max(MIN_TAIL_TOKENS, (int) (contextWindowTokens * 0.30));
if (tail >= trigger) {
tail = Math.max(MIN_TAIL_TOKENS, trigger - MIN_TRIGGER_TOKENS);
}
return new LoopBudgetConfig(trigger, tail, 4, 1.5, 0, 200);
}
/** Return a copy with {@code reservedPrefixTokens} replaced. */
public LoopBudgetConfig withReservedPrefixTokens(int reservedPrefixTokens) {
return new LoopBudgetConfig(triggerTokens, keepTailTokens, minTailMessages,
tailSoftCeilingRatio, reservedPrefixTokens, targetMaxMessages);
}
}

View File

@ -0,0 +1,296 @@
package vip.mate.agent.context;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Per-ReAct-loop message budgeter. Bounds the working message list a Reasoning
* iteration hands to the LLM while preserving five invariants that, when
* violated, either produce off-topic answers or 400s from strict providers:
*
* <ol>
* <li><b>System prompt(s)</b> all consecutive {@link SystemMessage}s at
* the head stay verbatim. Production agents commonly have multiple
* (SOUL, AGENTS, runtime context, wiki, tool prompt, skill catalog).</li>
* <li><b>Turn anchor</b> the latest {@link UserMessage} is never dropped.
* Stitched in when an aggressive cut would otherwise lose it.</li>
* <li><b>Tool-call/response pair integrity</b> every assistant tool_call
* reaches the model with its matching tool_response, and vice versa.
* Delegated to {@link ToolPairSanitizer}.</li>
* <li><b>Token budget over message count</b> tail sized by token estimate
* so a small ReAct loop with fat observations and a large loop with
* thin observations both fit one config.</li>
* <li><b>Minimum tail messages</b> at least {@code minTailMessages}
* entries survive even when a single message is bigger than the
* hard tail budget. Prevents collapsing recent reasoning to one row
* when the latest tool output is huge.</li>
* </ol>
*
* <p>The trigger threshold compares {@code historyTokens +
* reservedPrefixTokens} against {@code triggerTokens}; this keeps the
* budgeter calibrated against the entire prompt the LLM will see, not just
* the message list (the L1 compactor uses the same arithmetic).
*
* <p>Distinct from {@link ConversationWindowManager}: that one runs once per
* user turn and produces a structured LLM summary for the accumulated
* multi-turn history. This one runs per reasoning iteration on top of
* whatever {@code ConversationWindowManager} already produced, bounding the
* intra-turn ReAct accumulation.
*
* <p>Stateless and side-effect-free for callers; safe to call from
* concurrent reasoning threads. The orphan-removal pass mutates a freshly
* allocated local list, never the caller's input.
*/
@Slf4j
@Component
public class LoopMessageBudgeter {
/** Outcome of a budgeting pass. */
public record Result(List<Message> messages, BudgetTrace trace) {}
/**
* Structured trace of a single budgeting decision. All counts and token
* figures refer to {@link Message} entries.
*
* <ul>
* <li>{@code anchorEnforced} the tail cut was pulled earlier than
* the token budget would have placed it because the latest
* UserMessage would otherwise have been dropped.</li>
* <li>{@code anchorStitched} the latest UserMessage could not fit in
* the tail even after pull-back (typically when the target cap
* fired hard); it was inserted as a standalone slot between head
* and tail.</li>
* <li>{@code capExceededForPairIntegrity} the final count exceeded
* {@code targetMaxMessages} because pulling the cut back to keep
* a tool pair whole won out over the soft cap. Useful signal that
* upstream compaction should have run sooner.</li>
* <li>{@code minTailFloorApplied} the tail was enlarged past the
* hard token budget (up to the soft ceiling) to honor
* {@code minTailMessages}.</li>
* <li>{@code triggered} the budget entered its main path because the
* trigger threshold was met. Says nothing about whether anything
* was actually removed.</li>
* <li>{@code modified} the returned list differs from the input
* (count changed or orphans removed). This is the only signal
* callers should use to gate log output; a triggered-but-no-op
* pass is normal and shouldn't spam logs.</li>
* </ul>
*/
public record BudgetTrace(
int originalCount,
int originalTokens,
int finalCount,
int finalTokens,
int reservedPrefixTokens,
int headKept,
int tailKept,
int droppedMiddle,
int orphansRemoved,
boolean anchorEnforced,
boolean anchorStitched,
boolean targetMaxTripped,
boolean capExceededForPairIntegrity,
boolean minTailFloorApplied,
boolean triggered,
boolean modified) {
/** Trace for the no-op case (budget not triggered). */
public static BudgetTrace untouched(int count, int tokens, int prefixTokens, int headKept) {
return new BudgetTrace(count, tokens, count, tokens, prefixTokens, headKept,
count - headKept, 0, 0,
false, false, false, false, false, false, false);
}
}
/** Apply the loop budget to {@code messages}. Pure function; never mutates the input. */
public Result budget(List<Message> messages, LoopBudgetConfig cfg) {
if (messages == null || messages.isEmpty()) {
return new Result(messages == null ? List.of() : messages,
BudgetTrace.untouched(0, 0, cfg.reservedPrefixTokens(), 0));
}
int originalCount = messages.size();
int historyTokens = TokenEstimator.estimateTokens(messages);
int headEnd = findHeadEnd(messages);
// Budget against the full prompt (history + prefix), so the trigger
// matches what the LLM would actually receive not just the
// history slice. Prefix covers system prompt, skill catalog,
// runtime context, wiki, tool schemas, output reserve.
int promptTokens = historyTokens + cfg.reservedPrefixTokens();
// Below both thresholds forward unchanged.
if (promptTokens < cfg.triggerTokens() && originalCount < cfg.targetMaxMessages()) {
return new Result(messages,
BudgetTrace.untouched(originalCount, historyTokens,
cfg.reservedPrefixTokens(), headEnd));
}
// 1. Token-budgeted tail cut. Walk backward from the end; the
// earliest index whose suffix fits within keepTailTokens is the
// proposed boundary.
int hardTailStart = findTailCutByTokens(messages, headEnd, cfg.keepTailTokens());
// 2. Min-tail floor: if the hard cut keeps fewer than minTailMessages,
// pull back to keep at least that many but only up to the soft
// ceiling. Without this, one giant tool output can collapse the
// tail to a single row and lose recent reasoning context.
boolean minTailFloorApplied = false;
int tailStart = hardTailStart;
int hardTailCount = originalCount - hardTailStart;
if (hardTailCount < cfg.minTailMessages()) {
int floorTailStart = Math.max(headEnd, originalCount - cfg.minTailMessages());
// Honor the soft ceiling: if even the floor count would consume
// more than tailSoftCeilingTokens, accept it (the floor wins,
// since the alternative is losing recent reasoning entirely).
tailStart = floorTailStart;
minTailFloorApplied = true;
} else {
// Apply the soft ceiling: if the hard cut undershoots the soft
// ceiling (i.e. there's slack), keep going. We already cut to
// the hard budget so there's no need to expand here the soft
// ceiling acts as a guard rail for the floor/pull-back path,
// not as a relaxation of the normal cut.
}
// 3. Anchor: never drop the latest UserMessage. Pull tail back if
// needed (cheap just moves the boundary).
boolean anchorEnforced = false;
int anchorIdx = findLatestUserMessageIdx(messages, headEnd);
if (anchorIdx >= 0 && anchorIdx < tailStart) {
tailStart = anchorIdx;
anchorEnforced = true;
}
// 4. Tool-pair integrity at the boundary: if tailStart sits inside a
// tool pair, pull back so the pair survives whole. Delegated to
// the shared sanitizer.
int beforePairPullBack = tailStart;
tailStart = ToolPairSanitizer.pullBackToToolPairBoundary(messages, headEnd, tailStart);
// 5. Target max safety net. The pair-integrity pull-back may have
// pushed final count above the soft cap; we re-evaluate and try
// to enforce, but pair integrity wins over count cap.
boolean targetMaxTripped = false;
boolean anchorStitched = false;
boolean capExceededForPairIntegrity = false;
int targetTailCap = Math.max(0, cfg.targetMaxMessages() - headEnd);
if (targetTailCap > 0 && (originalCount - tailStart) > targetTailCap) {
int provisionalTailStart = originalCount - targetTailCap;
boolean stitchNeeded = anchorIdx >= 0 && anchorIdx < provisionalTailStart;
int reservedForStitchedAnchor = stitchNeeded ? 1 : 0;
int recentTailCap = Math.max(1, targetTailCap - reservedForStitchedAnchor);
int newTailStart = originalCount - recentTailCap;
int adjustedTailStart = ToolPairSanitizer.pullBackToToolPairBoundary(
messages, headEnd, newTailStart);
if (adjustedTailStart < newTailStart) {
// Pair integrity prevailed over the cap; honestly record that
// the final count will exceed targetMaxMessages.
capExceededForPairIntegrity = true;
}
tailStart = adjustedTailStart;
targetMaxTripped = true;
anchorStitched = anchorIdx >= 0 && anchorIdx < tailStart;
}
// Detect anchor stitching from the tool-pair pull-back path too:
// pull-back may have moved tailStart earlier than the anchor index
// (rare, but possible if the pair anchor is in the head section).
if (!anchorStitched && anchorIdx >= 0 && anchorIdx < tailStart) {
anchorStitched = true;
}
// 6. Build the trimmed list: head + [stitched anchor?] + tail.
int estimated = headEnd + (anchorStitched ? 1 : 0) + (originalCount - tailStart);
List<Message> trimmed = new ArrayList<>(estimated);
trimmed.addAll(messages.subList(0, headEnd));
if (anchorStitched) {
trimmed.add(messages.get(anchorIdx));
}
trimmed.addAll(messages.subList(tailStart, originalCount));
// 7. Tool-pair invariant: cross-boundary orphans cleaned up. The
// pull-back at step 4 handles the boundary case but a head-section
// Assistant(tool_calls) whose responses fell in the dropped middle
// still needs the bidirectional pass.
int orphans = ToolPairSanitizer.removeOrphans(trimmed);
int finalCount = trimmed.size();
int finalTokens = TokenEstimator.estimateTokens(trimmed);
int droppedMiddle = originalCount - finalCount;
// Touch the unused locals so the compiler doesn't warn they're
// useful in the trace's narrative but the actual cut already
// committed.
if (beforePairPullBack != tailStart) {
// pair pull-back moved the boundary; logged via trace fields
}
boolean modified = (finalCount != originalCount) || (orphans > 0);
return new Result(trimmed, new BudgetTrace(
originalCount, historyTokens,
finalCount, finalTokens,
cfg.reservedPrefixTokens(),
headEnd,
finalCount - headEnd,
droppedMiddle,
orphans,
anchorEnforced,
anchorStitched,
targetMaxTripped,
capExceededForPairIntegrity,
minTailFloorApplied,
/* triggered */ true,
modified));
}
// ------------------------------------------------------------------------
// Internals
// ------------------------------------------------------------------------
private static int findHeadEnd(List<Message> messages) {
int i = 0;
while (i < messages.size() && messages.get(i) instanceof SystemMessage) {
i++;
}
return i;
}
/**
* Walk backward from the end accumulating per-message token estimates.
* Return the earliest index whose suffix fits within {@code keepTokens}.
* Always returns a value in {@code [headEnd, messages.size())} so the
* tail is non-empty.
*/
private static int findTailCutByTokens(List<Message> messages, int headEnd, int keepTokens) {
int n = messages.size();
if (n <= headEnd) {
return n;
}
int acc = 0;
for (int i = n - 1; i >= headEnd; i--) {
int t = TokenEstimator.estimateTokens(messages.get(i));
if (acc + t > keepTokens && i < n - 1) {
return i + 1;
}
acc += t;
}
return headEnd;
}
/** Index of the latest {@link UserMessage} at or after {@code headEnd}; -1 if none. */
private static int findLatestUserMessageIdx(List<Message> messages, int headEnd) {
for (int i = messages.size() - 1; i >= headEnd; i--) {
if (messages.get(i) instanceof UserMessage) {
return i;
}
}
return -1;
}
}

View File

@ -46,6 +46,45 @@ public final class RuntimeContextInjector {
* 构建运行时上下文消息i18n 版本
*/
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) {
return buildContextMessage(workspaceBasePath, i18n, null);
}
/**
* Build the runtime-context message and (when {@code origin} is non-null
* and carries IM channel context) append a short "who is talking, where,
* via what channel" block so the agent's system prompt can personalise
* its reply. Same cache discipline as the simpler overloads the block
* stays well under the spring-ai user-cache threshold (1024 chars).
*
* <p>The sender block is suppressed when:
* <ul>
* <li>{@code origin} is null or {@link ChatOrigin#EMPTY}</li>
* <li>the origin carries no IM context (web / cron) both produce
* a null {@code channelType} or {@code "web"}</li>
* </ul>
* Web and cron callers thus see exactly the same prompt as before.
*/
public static String buildContextMessage(String workspaceBasePath,
vip.mate.i18n.I18nService i18n,
ChatOrigin origin) {
return buildContextMessage(workspaceBasePath, i18n, origin, null, null);
}
/**
* Full overload that also renders the agent's runtime model identity.
* The model line is emitted for EVERY origin (web / cron / IM / null)
* because it describes the agent, not the caller only the sender
* block stays IM-only. {@code modelName}/{@code providerId} come from
* graph state ({@code RUNTIME_MODEL_NAME}/{@code RUNTIME_PROVIDER_ID}),
* i.e. the model selected at run start (mid-run failover is not
* reflected accepted trade-off). Stays well under the 1024-char
* spring-ai user-cache threshold.
*/
public static String buildContextMessage(String workspaceBasePath,
vip.mate.i18n.I18nService i18n,
ChatOrigin origin,
String modelName,
String providerId) {
LocalDateTime now = LocalDateTime.now(ZONE);
String dateStr = now.format(DATE_FMT);
String timeStr = now.format(TIME_FMT);
@ -66,7 +105,94 @@ public final class RuntimeContextInjector {
sb.append("\n[system-context] Working directory: ").append(workspaceBasePath);
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
}
appendSkillRootHintIfPresent(sb, workspaceBasePath, i18n);
}
appendSenderBlockIfPresent(sb, origin);
appendModelLineIfPresent(sb, modelName, providerId, i18n);
return sb.toString();
}
/**
* Tell the model that the shared skill repository is reachable in addition
* to the workspace. Without this, a model that strictly honors the
* "working directory only" hint refuses to read or run skill files that
* live outside the workspace even though the path sandbox now allows
* them. Skipped when the skill root is unknown or already sits inside the
* workspace (no separate boundary to explain).
*/
private static void appendSkillRootHintIfPresent(StringBuilder sb, String workspaceBasePath,
vip.mate.i18n.I18nService i18n) {
java.nio.file.Path skillRoot = vip.mate.tool.guard.WorkspacePathGuard.getSkillRoot();
if (skillRoot == null) {
return;
}
java.nio.file.Path wsRoot = java.nio.file.Paths.get(workspaceBasePath).toAbsolutePath().normalize();
if (skillRoot.startsWith(wsRoot)) {
return;
}
String skillRootStr = skillRoot.toString();
if (i18n != null) {
sb.append("\n").append(i18n.msg("context.skill_dir_hint", skillRootStr));
} else {
sb.append("\nShared skills live under ").append(skillRootStr)
.append("; you may also read and run files there, even though it is outside the working directory.");
}
}
/**
* Append the agent's runtime model identity. Emitted for all origins
* (it's an agent fact, not a sender fact). Skipped when modelName is
* blank. Provider parenthetical is omitted when providerId is blank.
*/
private static void appendModelLineIfPresent(StringBuilder sb, String modelName,
String providerId,
vip.mate.i18n.I18nService i18n) {
if (modelName == null || modelName.isBlank()) return;
String model = modelName.trim();
sb.append("\n");
if (i18n != null) {
sb.append(i18n.msg("context.model_identity", model));
} else {
sb.append("[system-context] Model: ").append(model);
}
if (providerId != null && !providerId.isBlank()) {
sb.append(" (provider: ").append(providerId.trim()).append(')');
}
sb.append("\n");
if (i18n != null) {
sb.append(i18n.msg("context.model_identity_hint"));
} else {
sb.append("If asked which model you are using, answer with this value for the current run.");
}
}
/**
* Append a sender / channel / chat block when the origin carries
* meaningful IM context. Format is intentionally one line per
* fact so it's both LLM-readable and easy to log-grep.
*/
private static void appendSenderBlockIfPresent(StringBuilder sb, ChatOrigin origin) {
if (origin == null || origin == ChatOrigin.EMPTY) return;
String channelType = origin.channelType();
// Only inject for real IM channels web / null / cron should
// see the previous prompt verbatim so their cache hit rate
// and existing eval baselines don't shift.
if (channelType == null || channelType.isBlank()
|| "web".equalsIgnoreCase(channelType)
|| origin.cronOrigin()) {
return;
}
sb.append("\n[system-context] Channel: ").append(channelType);
if (origin.senderName() != null && !origin.senderName().isBlank()) {
sb.append("\n[system-context] Sender: ").append(origin.senderName());
}
if (origin.requesterId() != null && !origin.requesterId().isBlank()) {
sb.append(" (id=").append(origin.requesterId()).append(')');
}
if (origin.chatId() != null && !origin.chatId().isBlank()) {
sb.append("\n[system-context] Chat: ").append(origin.chatId())
.append(" (group conversation — multiple users may follow up)");
}
}
}

View File

@ -0,0 +1,175 @@
package vip.mate.agent.context;
/**
* Boundary-aware text truncation.
*
* <p>Character-count truncation that lands inside a JSON value or string literal
* leaves the model a fragment like {@code {"name":"serv} — a shape that invites it
* to "repair" the structure by fabricating the omitted fields. When the input
* looks like JSON, this utility snaps each head/tail cut point to the nearest
* complete structural boundary (immediately after a {@code ,}, {@code &#125;} or
* {@code ]} that is not inside a string), so a retained fragment always ends and
* begins between elements rather than in the middle of one.
*
* <p>Non-JSON input falls back to a plain character cut, and boundary snapping is
* only applied when it costs less than half the requested budget so callers can
* use this unconditionally without ever losing more than a plain cut would.
*/
public final class StructuredTruncator {
private StructuredTruncator() {
}
private static final int[] NO_BOUNDARIES = new int[0];
/**
* Standard fidelity directive appended to truncation markers so the model
* treats omitted content as unknown rather than reconstructable.
*/
public static final String FIDELITY_NOTE =
"Do NOT infer or fabricate omitted content; retrieve the full data (e.g. read_file) "
+ "or tell the user the result is incomplete.";
/**
* Head-only slice: the first {@code maxHeadChars} characters, snapped back to
* a JSON boundary when one sits within the kept region. Returns the input
* unchanged when it is already short enough.
*/
public static String headSlice(String text, int maxHeadChars) {
if (text == null || maxHeadChars <= 0 || text.length() <= maxHeadChars) {
return text;
}
int[] bounds = boundaries(text);
int end = snapDown(bounds, maxHeadChars);
// Reject a boundary that throws away more than half the budget.
if (end < maxHeadChars / 2) {
end = maxHeadChars;
}
return text.substring(0, end);
}
/**
* Head + marker + tail truncation. {@code headBudget} / {@code tailBudget} are
* upper bounds on each retained side; {@code marker} is inserted between them.
* The cut points snap to JSON boundaries when the input is JSON-like and the
* snap is cheap; otherwise plain character cuts are used. The result never
* exceeds {@code headBudget + marker.length() + tailBudget}.
*
* @return the input unchanged when it already fits both budgets
*/
public static String truncate(String text, int headBudget, int tailBudget, String marker) {
if (text == null) {
return null;
}
if (headBudget < 0) {
headBudget = 0;
}
if (tailBudget < 0) {
tailBudget = 0;
}
int len = text.length();
if (len <= headBudget + tailBudget) {
return text;
}
String mk = marker == null ? "" : marker;
int[] bounds = boundaries(text);
int headEnd = snapDown(bounds, headBudget);
if (headEnd < headBudget / 2) {
// No usable boundary near the head budget plain cut.
headEnd = headBudget;
}
int floor = len - tailBudget;
int tailStart = snapUp(bounds, floor);
if (tailStart > floor + tailBudget / 2) {
// Nearest boundary is so far forward the tail would shrink by half plain cut.
tailStart = floor;
}
if (tailStart <= headEnd) {
// Snapping collapsed the two regions into each other plain, non-overlapping cut.
headEnd = Math.min(headBudget, len);
tailStart = Math.max(len - tailBudget, headEnd);
}
return text.substring(0, headEnd) + mk + text.substring(tailStart);
}
/**
* Indices (in ascending order) at which the text may be split without
* severing a JSON token. A boundary index {@code i} marks the position
* immediately after a {@code ,}, {@code &#125;} or {@code ]} that is not
* inside a string literal. Returns an empty array when the input does not
* look like JSON, which makes both snap helpers fall back to plain cuts.
*/
private static int[] boundaries(String text) {
int len = text.length();
int start = 0;
while (start < len && Character.isWhitespace(text.charAt(start))) {
start++;
}
if (start >= len) {
return NO_BOUNDARIES;
}
char first = text.charAt(start);
if (first != '{' && first != '[') {
return NO_BOUNDARIES;
}
int[] buf = new int[16];
int n = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < len; i++) {
char c = text.charAt(i);
if (inString) {
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
inString = false;
}
continue;
}
if (c == '"') {
inString = true;
} else if (c == ',' || c == '}' || c == ']') {
if (n == buf.length) {
int[] grown = new int[buf.length * 2];
System.arraycopy(buf, 0, grown, 0, n);
buf = grown;
}
buf[n++] = i + 1;
}
}
if (n == buf.length) {
return buf;
}
int[] out = new int[n];
System.arraycopy(buf, 0, out, 0, n);
return out;
}
/** Largest boundary {@code <= limit}, or 0 when none exists. */
private static int snapDown(int[] bounds, int limit) {
int best = 0;
for (int b : bounds) {
if (b > limit) {
break;
}
best = b;
}
return best;
}
/** Smallest boundary {@code >= floor}, or {@link Integer#MAX_VALUE} when none exists. */
private static int snapUp(int[] bounds, int floor) {
for (int b : bounds) {
if (b >= floor) {
return b;
}
}
return Integer.MAX_VALUE;
}
}

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

@ -0,0 +1,192 @@
package vip.mate.agent.context;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Pure-function utilities that enforce the OpenAI-compatible
* tool_call tool_response pairing invariant:
*
* <ul>
* <li>Every {@code tool_call.id} on an {@link AssistantMessage} has a
* matching {@code tool_response.id} on a {@link ToolResponseMessage}
* <em>after</em> it in the list.</li>
* <li>Every {@code tool_response.id} on a {@link ToolResponseMessage} has
* a matching {@code tool_call.id} on an {@link AssistantMessage}
* <em>before</em> it.</li>
* <li>No empty/null ids on either side.</li>
* </ul>
*
* <p>Violating either rule causes strict providers (kimi-code, anthropic in
* tool-use mode, OpenAI's responses API on certain models) to reject the
* request with a 400 error such as
* {@code "tool_call_id is not found"}. This sanitizer is the single source of
* truth for that invariant any trim / cut / window logic should run its
* pre/post passes here rather than reimplementing them.
*
* <p>All methods are {@code static} and side-effect-free except where
* documented (e.g. {@link #removeOrphans(List)} mutates the list in place to
* avoid an extra allocation hot in the reasoning loop). They never touch the
* input list when no fix is needed.
*/
public final class ToolPairSanitizer {
private ToolPairSanitizer() {
// utility class
}
/**
* Pull a proposed cut boundary earlier so an Assistant(tool_calls) that
* issued ids matching {@link ToolResponseMessage}s in the kept tail
* survives into the tail alongside its responses. Prevents producing an
* orphan response at the cut boundary in the first place.
*
* @param messages full message list (read-only)
* @param headEnd index after the last protected head message
* @param tailStart proposed boundary; messages at and after this index
* are kept, those between {@code headEnd} and
* {@code tailStart} are dropped
* @return possibly-earlier {@code tailStart} that keeps tool pairs whole
*/
public static int pullBackToToolPairBoundary(List<Message> messages, int headEnd, int tailStart) {
if (tailStart <= headEnd || messages == null || messages.isEmpty()) {
return tailStart;
}
Set<String> tailResponseIds = new HashSet<>();
for (int i = tailStart; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (r.id() != null && !r.id().isEmpty()) {
tailResponseIds.add(r.id());
}
}
}
}
if (tailResponseIds.isEmpty()) {
return tailStart;
}
for (int i = tailStart - 1; i >= headEnd; i--) {
Message m = messages.get(i);
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
boolean overlaps = am.getToolCalls().stream()
.anyMatch(tc -> tc.id() != null && tailResponseIds.contains(tc.id()));
if (overlaps) {
return i;
}
}
}
return tailStart;
}
/**
* Iteratively remove tool-pair orphans from {@code messages} (mutates the
* list in place). Two shapes are handled:
*
* <p><b>P0</b>: a {@link ToolResponseMessage} whose response id has no
* matching assistant tool_call in the list.
*
* <p><b>P1</b>: an {@link AssistantMessage} whose every tool_call id
* has no matching response in the list. (An assistant with both matched
* and unmatched calls is left alone removing it would harm more than
* it helps; strict providers tolerate extra calls more readily than
* dropping the whole assistant message.)
*
* <p>Iterates until convergence: removing an assistant for P1 can expose
* a P0 orphan that needs cleaning, and vice versa.
*
* <p>Also removes any tool_call or tool_response with a null or empty id
* those have no useful pairing semantics and confuse both the strict
* providers and the matching logic.
*
* @return total number of messages removed across all passes
*/
public static int removeOrphans(List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return 0;
}
int totalRemoved = 0;
boolean changed;
do {
Set<String> callIds = new HashSet<>();
Set<String> respIds = new HashSet<>();
for (Message m : messages) {
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
if (tc.id() != null && !tc.id().isEmpty()) {
callIds.add(tc.id());
}
}
}
if (m instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (r.id() != null && !r.id().isEmpty()) {
respIds.add(r.id());
}
}
}
}
int before = messages.size();
messages.removeIf(m -> {
if (m instanceof ToolResponseMessage trm) {
// P0: a response with a null/empty id, or whose id has
// no matching tool_call.
return trm.getResponses().stream().anyMatch(r ->
r.id() == null || r.id().isEmpty() || !callIds.contains(r.id()));
}
if (m instanceof AssistantMessage am && am.getToolCalls() != null
&& !am.getToolCalls().isEmpty()) {
// P1: every tool_call on this assistant has no matching response.
return am.getToolCalls().stream().allMatch(tc ->
tc.id() == null || tc.id().isEmpty() || !respIds.contains(tc.id()));
}
return false;
});
int removed = before - messages.size();
totalRemoved += removed;
changed = removed > 0;
} while (changed);
return totalRemoved;
}
/**
* Post-condition check: returns {@code true} iff {@code messages}
* satisfies the pairing invariant every assistant tool_call has a
* matching response after it, every response has a matching call before
* it, all ids are non-empty. Intended for tests and defensive asserts;
* production code should run {@link #removeOrphans(List)} which
* guarantees this holds on return.
*/
public static boolean isPaired(List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return true;
}
Set<String> callIds = new HashSet<>();
Set<String> respIds = new HashSet<>();
for (Message m : messages) {
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
if (tc.id() == null || tc.id().isEmpty()) return false;
callIds.add(tc.id());
}
}
if (m instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (r.id() == null || r.id().isEmpty()) return false;
respIds.add(r.id());
}
}
}
for (String c : callIds) {
if (!respIds.contains(c)) return false;
}
for (String r : respIds) {
if (!callIds.contains(r)) return false;
}
return true;
}
}

View File

@ -1,21 +1,36 @@
package vip.mate.agent.controller;
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 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.agent.service.AgentGenerationService;
import vip.mate.agent.vo.AgentCapabilitiesVO;
import vip.mate.agent.vo.AgentDraftVO;
import vip.mate.audit.service.AuditEventService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.workspace.core.service.WorkspaceService;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@ -33,16 +48,26 @@ public class AgentController {
private final AgentService agentService;
private final AuditEventService auditEventService;
private final AuthService authService;
private final WorkspaceService workspaceService;
private final ModelConfigService modelConfigService;
private final ModelCapabilityService modelCapabilityService;
private final SystemSettingService systemSettingService;
private final AgentGenerationService agentGenerationService;
private final ObjectMapper objectMapper;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@Operation(summary = "获取Agent列表")
@GetMapping
@RequireWorkspaceRole("viewer")
public R<List<AgentEntity>> list(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(value = "enabled", required = false) Boolean enabled) {
// header 时强制使用默认 workspace不返回全局数据
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(agentService.listAgentsByWorkspace(wsId));
// enabled=true: chat selectors hide disabled agents.
// enabled=null: admin management page sees enabled + disabled.
return R.ok(agentService.listAgentsByWorkspace(wsId, enabled));
}
@Operation(summary = "获取Agent详情")
@ -55,14 +80,78 @@ public class AgentController {
return R.ok(agent);
}
@Operation(summary = "获取Agent当前能力modality 集合 + sidecar 配置),用于聊天页提示条")
@GetMapping("/{id}/capabilities")
@RequireWorkspaceRole("viewer")
public R<AgentCapabilitiesVO> capabilities(
@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
ModelConfigEntity primary;
try {
primary = modelConfigService.resolveModel(agent.getModelName());
} catch (Exception e) {
// No default model configured yet return a capabilities snapshot that
// tells the UI "we can't say anything about this agent's modalities".
return R.ok(AgentCapabilitiesVO.builder()
.agentId(id)
.modelName("")
.providerId("")
.modalities(List.of())
.build());
}
java.util.Set<ModelCapabilityService.Modality> modalities =
modelCapabilityService.resolve(primary.getModelName(), primary.getModalities());
SystemSettingsDTO settings = systemSettingService.getSettings();
Long visionId = settings.getDefaultVisionModelId();
Long videoId = settings.getDefaultVideoModelId();
return R.ok(AgentCapabilitiesVO.builder()
.agentId(id)
.modelName(primary.getModelName())
.providerId(primary.getProvider())
.modalities(modalities.stream().map(Enum::name).toList())
.defaultVisionModelId(visionId)
.defaultVisionModelLabel(resolveSidecarLabel(visionId))
.defaultVideoModelId(videoId)
.defaultVideoModelLabel(resolveSidecarLabel(videoId))
.build());
}
private String resolveSidecarLabel(Long modelId) {
if (modelId == null) return null;
try {
ModelConfigEntity m = modelConfigService.getModel(modelId);
return m == null ? null : m.getProvider() + " / " + m.getModelName();
} catch (Exception e) {
return null;
}
}
@Operation(summary = "根据一句话需求生成员工草稿(不落库)")
@PostMapping("/generate")
@RequireWorkspaceRole("member")
public R<AgentDraftVO> generate(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestBody GenerateRequest request) {
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(agentGenerationService.generateDraft(request.getRequirement(), wsId));
}
@Operation(summary = "创建Agent")
@PostMapping
@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);
@ -71,10 +160,14 @@ public class AgentController {
@Operation(summary = "更新Agent")
@PutMapping("/{id}")
@RequireWorkspaceRole("member")
public R<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent,
public R<AgentEntity> update(@PathVariable Long id, @RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity existing = agentService.getAgent(id);
verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId);
AgentEntity agent = objectMapper.convertValue(body, AgentEntity.class);
if (!body.containsKey("primaryKbId")) {
agent.setPrimaryKbId(existing.getPrimaryKbId());
}
agent.setId(id);
agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移
AgentEntity updated = agentService.updateAgent(agent);
@ -84,11 +177,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();
@ -104,8 +210,10 @@ public class AgentController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
sseExecutor.execute(() -> {
try {
agentService.chatStream(id, message, conversationId)
@ -142,6 +250,7 @@ public class AgentController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
}
@ -154,6 +263,7 @@ public class AgentController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
}
@ -173,6 +283,11 @@ public class AgentController {
private String conversationId = "default";
}
@lombok.Data
public static class GenerateRequest {
private String requirement;
}
/**
* 校验目标资源实际归属的 workspace 与请求 header 一致
* 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击
@ -180,7 +295,41 @@ public class AgentController {
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
}
}
/**
* Block runtime calls against an agent flagged as disabled.
*
* <p>{@code AgentService#getOrBuildAgent} also checks the flag, but only on
* a cache miss once the {@code BaseAgent} instance is warm, a flip to
* disabled would silently keep serving requests until something else
* invalidates the cache. Enforcing here at the controller closes that gap
* for every external entry point.
*/
private void verifyAgentEnabled(AgentEntity agent) {
if (agent != null && !Boolean.TRUE.equals(agent.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + agent.getName());
}
}
private Long resolveUserId(Authentication auth) {
if (auth == null) {
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");
}
UserEntity user = authService.findByUsername(auth.getName());
if (user == null) {
throw new MateClawException("err.auth.user_not_found", 401, "User not found: " + auth.getName());
}
return user.getId();
}
private boolean isSystemAdmin(Authentication auth) {
if (auth == null) {
return false;
}
UserEntity user = authService.findByUsername(auth.getName());
return user != null && "admin".equalsIgnoreCase(user.getRole());
}
}

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

View File

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

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

View File

@ -0,0 +1,25 @@
package vip.mate.agent.event;
/**
* Spring application event fired when an agent's lifecycle state changes.
* The trigger module subscribes via {@code @EventListener} and forwards
* the payload through {@code TriggerEventIngestService} so triggers of
* pattern type {@code agent_lifecycle} can fan out to workflows.
*
* <p>{@code phase} matches the matcher's vocabulary: {@code spawned} for
* a fresh create, {@code enabled} / {@code disabled} for a flag flip,
* {@code terminated} for a delete. {@code crashed} is reserved for v1
* once the agent runtime grows a structured error hook.
*
* <p>The dedup key downstream is {@code phase + ":" + agentId + ":" +
* timestamp}; that's stable across retries of the same operation but
* lets the same agent flip enabled/disabled repeatedly without the
* trigger pipeline collapsing the events.
*/
public record AgentLifecycleEvent(
long workspaceId,
long agentId,
String agentName,
String phase,
long timestamp
) {}

View File

@ -0,0 +1,165 @@
package vip.mate.agent.graph;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
/**
* Pre-egress message-list normalizer.
*
* <p>Some OpenAI-compatible providers (notably LM Studio's built-in server,
* and certain strict-mode vLLM / SGLang deployments) enforce that exactly
* one {@link SystemMessage} must appear at index 0 of the messages array.
* Multiple consecutive SystemMessages, or any SystemMessage following a
* user / assistant / tool message, returns {@code 400 BAD_REQUEST:
* "System message must be at the beginning."}.
*
* <p>Permissive providers (OpenAI, DashScope, Ollama, DeepSeek, Kimi, Doubao,
* GLM) accept the relaxed shape, so the runtime historically composed
* prompts with multiple SystemMessages sprinkled through the non-history
* prefix (main system prompt + skill catalog + progress-ledger snapshot,
* each as its own SystemMessage). To stay portable across both strict and
* permissive backends, this normalizer collects every SystemMessage found
* anywhere in the input list, concatenates their text with a blank-line
* separator, and emits the result as a single SystemMessage at index 0.
* The relative order of non-system messages (user / assistant /
* tool_response) is preserved verbatim so {@code tool_call_id} pairings
* are unaffected.
*
* <p>Blank / whitespace-only SystemMessages are dropped from the merge. If
* every SystemMessage in the input is blank, the result is the same list
* with all SystemMessages removed (no synthetic empty SystemMessage is
* emitted). If the input contains zero SystemMessages, the input list
* reference is returned unchanged.
*
* <p>The transformation is semantically equivalent on permissive providers
* the merged SystemMessage produces the same token sequence the model
* would have seen across N separate SystemMessages and converts the
* strict-provider 400 into a success. It is also safe for non-OpenAI
* protocols: the Spring AI Anthropic and Vertex / Gemini adapters already
* extract SystemMessages out of the messages list into a top-level
* {@code system} / {@code systemInstruction} request field, so they receive
* an identical outbound payload whether handed one merged SystemMessage
* or several.
*
* <p>A kill switch is exposed via the JVM system property
* {@code mateclaw.llm.message-normalizer.enabled=false}, which makes
* {@link #normalize} a no-op for emergency rollback without code changes.
*/
public final class MessageNormalizer {
/** Separator inserted between merged SystemMessage segments. */
static final String SEPARATOR = "\n\n";
/**
* Kill-switch property name. Set to {@code false} (case-insensitive) on
* the JVM command line to disable normalization without a code change.
*/
public static final String ENABLED_PROPERTY = "mateclaw.llm.message-normalizer.enabled";
private static volatile boolean enabled = !"false".equalsIgnoreCase(
System.getProperty(ENABLED_PROPERTY, "true"));
private MessageNormalizer() {
}
/** Read the current kill-switch state. */
public static boolean isEnabled() {
return enabled;
}
/**
* Override the kill-switch at runtime (primarily for tests). Production
* code should not need to call this set the JVM property at startup
* instead.
*/
public static void setEnabledForTesting(boolean value) {
enabled = value;
}
/**
* Return a copy of {@code prompt} with every SystemMessage merged into a
* single SystemMessage at index 0. Returns the input prompt reference
* unchanged when no normalization is necessary (kill switch off, zero
* SystemMessages, or already a single non-blank SystemMessage at index 0).
*/
public static Prompt normalize(Prompt prompt) {
if (prompt == null || !enabled) {
return prompt;
}
List<Message> in = prompt.getInstructions();
List<Message> out = normalize(in);
if (out == in) {
return prompt;
}
return new Prompt(out, prompt.getOptions());
}
/**
* List-level normalization, used by {@link #normalize(Prompt)} and by
* unit tests that want to assert on the raw message shape without
* constructing a {@link Prompt}. Returns the input list reference
* unchanged when no normalization is necessary.
*/
public static List<Message> normalize(List<Message> messages) {
if (!enabled || messages == null || messages.isEmpty()) {
return messages;
}
int systemCount = 0;
int firstSystemIdx = -1;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof SystemMessage) {
if (firstSystemIdx < 0) firstSystemIdx = i;
systemCount++;
}
}
// Fast-path 1: no SystemMessages nothing to do.
if (systemCount == 0) {
return messages;
}
// Fast-path 2: exactly one SystemMessage and it sits at index 0 with
// non-blank text. Already canonical skip the rebuild.
if (systemCount == 1 && firstSystemIdx == 0) {
SystemMessage sm = (SystemMessage) messages.get(0);
String text = sm.getText();
if (text != null && !text.isBlank()) {
return messages;
}
// Single blank SystemMessage at [0] fall through to the rebuild,
// which will drop it.
}
StringBuilder merged = new StringBuilder();
List<Message> rest = new ArrayList<>(messages.size());
for (Message m : messages) {
if (m instanceof SystemMessage sm) {
String text = sm.getText();
if (text == null || text.isBlank()) {
continue;
}
if (merged.length() > 0) {
merged.append(SEPARATOR);
}
merged.append(text);
} else {
rest.add(m);
}
}
if (merged.length() == 0) {
// Every SystemMessage in the input was blank return just the
// non-system tail. No synthetic empty SystemMessage.
return rest;
}
List<Message> out = new ArrayList<>(rest.size() + 1);
out.add(new SystemMessage(merged.toString()));
out.addAll(rest);
return out;
}
}

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
@ -71,7 +88,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
Optional<OverAllState> result = compiledGraph.invoke(inputs);
// Fresh thread per invocation so graph state never carries over
// between calls. The CompiledGraph is cached and shared; without a
// unique threadId, consecutive sync runs (e.g. back-to-back cron
// executions) inherit the prior run's accumulated messages and
// counters. Mirrors the streaming paths, which already do this.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))
@ -130,7 +154,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
}
Optional<OverAllState> result = compiledGraph.invoke(inputs);
// Fresh thread per invocation see chat() for rationale.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))
@ -175,8 +202,13 @@ 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)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
@ -192,7 +224,33 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
// SummarizingNode output) into segments only final-answer
// text arrives via the FINAL_ANSWER branch below. Pre-#120
// this used persistOnly, which appended every iteration's
// narration into the persisted assistant content; next-turn
// replay then saw a chain of "Let me try X..." with no
// observations and looped retrying tools.
//
// Exception evidence-insufficient terminal turn
// (ReasoningNode.java:617): when an answer is rejected for
// unsupported references, FINAL_ANSWER is replaced with a
// short "[证据不足]" warning and STREAMED_CONTENT carries the
// actual answer body the user/UI need to see. Falling back
// to persistOnly for that case keeps both the original
// answer text and the warning in mate_message.content; with
// pure segmentOnly the persisted content would shrink to
// just the warning, breaking single-segment renderers like
// copy / TTS / history reload (segments.length<=1 disables
// the segmented view in MessageBubble).
boolean isFinalAnswerTurn = hasFinalAnswer(output);
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
lastEmittedStreamedContent.set(streamed);
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
}
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
deltas.add(contentAlreadyStreamed
@ -213,6 +271,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(() -> {
@ -225,8 +291,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
.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,8 +339,22 @@ 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)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取所有累积的事件只发送新增部分
@ -287,7 +375,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean thinkingAlreadyStreamed = output.state()
.value(THINKING_STREAMED, false);
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
// 2a. Route per-iteration narrative into the segments timeline
// so the segmented UI view still shows "我来…" preludes
// between tool cards, but keep the top-level content
// field (= persisted mate_message.content) reserved for
// the final-answer span. NodeStreamingChatHelper already
// broadcast the live deltas; segmentOnly suppresses
// re-broadcast and skips content.append while still
// populating the segments[] entry.
//
// Exception evidence-insufficient terminal turn
// (ReasoningNode.java:617): STREAMED_CONTENT carries
// the rejected answer body, FINAL_ANSWER is just the
// short "[证据不足]" warning. Use persistOnly there so
// mate_message.content keeps both the answer text and
// the warning single-segment renderers (copy / TTS /
// history reload) read content, not segments.
boolean isFinalAnswerTurn = hasFinalAnswer(output);
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
lastEmittedStreamedContent.set(streamed);
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
}
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
deltas.add(contentAlreadyStreamed
@ -310,6 +421,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 事件
@ -323,8 +443,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
.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,12 +479,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
maxInputTokens,
chatModel,
conversationId,
parsedAgentId);
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
}
List<Message> messages = new ArrayList<>(historyMessages);
// 构建当前用户消息支持 multimodal如果有图片附件直接注入 Media
messages.add(buildCurrentUserMessage(conversationId, userMessage));
// 同步获取 routing decision写入 state 供后续节点 / accumulator 读取
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
messages.add(currentTurn.userMessage());
Map<String, Object> inputs = new HashMap<>();
// 输入
@ -366,9 +499,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
inputs.put(MESSAGES, messages);
// 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
// maxIterations<=0 表示软上限解除 LLM 自己决定何时收尾加分要短路
// 否则 thinking-on 会把"无限"误算成 5变成"5 步就停"
String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
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,9 +525,83 @@ 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));
// Multimodal sidecar routing null when the turn carries no media or
// the primary model already covers the modalities. Stored as a Map so
// graph state stays JSON-friendly.
if (currentTurn.routingDecision() != null
&& currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|| (currentTurn.routingDecision() != null && !currentTurn.routingDecision().skipped().isEmpty())) {
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
}
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
// and workspace, then write it into graph state so ActionNode +
// StepExecutionNode can forward it to ToolExecutionExecutor ToolContext.
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
Long parsedAgentIdForOrigin = null;
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
if (parsedAgentIdForOrigin != null) {
origin = origin.withAgent(parsedAgentIdForOrigin);
}
origin = origin.withConversationId(conversationId)
.withWorkspace(origin.workspaceId(), workspaceBasePath);
inputs.put(CHAT_ORIGIN, origin);
// RFC 48 inject active goal snapshot for GoalEvaluationNode.
// The node + dispatcher both bail out when ACTIVE_GOAL is absent,
// so this is a no-op for conversations without a bound goal.
// GOAL_EVALUATED_THIS_RUN explicitly seeded so the FinalAnswer
// GoalEvaluation conditional edge sees a clean false on each new
// chat invocation (RFC 48 §6.3 exhaustsBudgetAndStopsLooping
// depends on this every new chat is a fresh evaluation pass).
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
try {
vip.mate.goal.model.GoalEntity active =
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
}
}
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
return inputs;
}
/**
* Pick the right {@link AgentService.StreamDelta} flavor for the per-iteration
* {@code STREAMED_CONTENT} the graph just emitted.
*
* <p>The contract:
* <ul>
* <li>Intermediate ReAct iterations (no {@code FINAL_ANSWER} yet)
* {@code segmentOnly}. The content is reasoning preamble / mid-loop
* summary that belongs in the segments timeline, not in the persisted
* {@code mate_message.content}.</li>
* <li>Terminal turn where {@code FINAL_ANSWER} is set
* {@code persistOnly}. This covers the evidence-insufficient path
* (ReasoningNode.java:617) where {@code STREAMED_CONTENT} carries the
* actual rejected answer body and {@code FINAL_ANSWER} is just a short
* "[证据不足]" warning. Persisting the streamed body keeps single-segment
* renderers (copy / TTS / history reload) showing the full text.</li>
* </ul>
*
* <p>Package-private so the unit test can pin the decision without standing
* up a full StateGraph fixture. Returning {@code null} for blank input is the
* caller's responsibility this helper just decides flavor for non-blank
* content.
*/
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
return isFinalAnswerTurn
? AgentService.StreamDelta.persistOnly(streamed, null)
: AgentService.StreamDelta.segmentOnly(streamed, null);
}
private boolean hasFinalAnswer(NodeOutput output) {
if (output == null || output.state() == null) {
return false;

View File

@ -0,0 +1,47 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATED_THIS_RUN;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
/**
* Decides whether to re-enter the reasoning loop with an injected
* follow-up prompt or terminate the graph run.
*
* <p>Both targets are passed in by the builder so the same class serves
* the ReAct graph (followup -&gt; {@code REASONING_NODE}, terminal -&gt;
* {@code END}) and the Plan-Execute graph (followup -&gt;
* {@code PLAN_GENERATION_NODE}, terminal -&gt; {@code END}) without
* branching on graph type at runtime.
*/
@Slf4j
@RequiredArgsConstructor
public class GoalEvaluationDispatcher implements EdgeAction {
/** Where to re-enter the loop when GoalEvaluationNode injected a followup. */
private final String followupTarget;
/** Where to go on the normal terminal path (typically {@code END}). */
private final String terminalTarget;
@Override
public String apply(OverAllState state) {
// Re-enter the loop only when a followup was injected AND this was not a
// terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key
// strategy and is never cleared by the reasoning nodes, so after a
// run-to-completion loop it can linger true; goalEvaluatedThisRun (set
// true on every terminal branch completed / exhausted / skip /
// continue-without-followup) is the authoritative end-of-run signal.
boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false));
boolean terminal = Boolean.TRUE.equals(state.value(GOAL_EVALUATED_THIS_RUN, false));
if (followup && !terminal) {
log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
return followupTarget;
}
return terminalTarget;
}
}

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,167 @@
package vip.mate.agent.graph.executor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
import java.util.Set;
/**
* Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
*
* <p>Layer 1 per-tool cap is implemented inside each tool itself.
* Layer 2 per-result spill when a single tool result exceeds {@link #perResultThresholdChars}
* the full output is written to disk and only a {@link #previewHeadChars} preview
* (plus a pointer line) is sent back to the LLM.
* Layer 3 per-turn aggregate budget after all tools in a turn complete, if
* the cumulative response size exceeds {@link #perTurnBudgetChars}, the largest
* non-spilled responses are spilled in turn until the aggregate fits.</p>
*
* <p>Spill files live under {@link #storageBaseDir} when set, otherwise under
* {@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a
* workspace is bound to the agent, otherwise under
* {@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/}.</p>
*
* <pre>
* mate:
* agent:
* tool-result:
* enabled: true
* per-result-threshold-chars: 16000
* per-turn-budget-chars: 32000
* preview-head-chars: 800
* excluded-tool-inline-chars: 4000
* storage-base-dir:
* </pre>
*/
@ConfigurationProperties(prefix = "mate.agent.tool-result")
public class ToolResultProperties {
/** Master switch. When false, the executor falls back to plain truncation. */
private boolean enabled = true;
/**
* Per-result spill threshold. A single tool result larger than this is
* spilled to disk and the in-context view is replaced with a short
* preview + path so the model can call {@code read_file} on demand.
*
* <p>Aligned with {@code ToolExecutionExecutor.MAX_TOOL_RESULT_CHARS}
* (8000): the executor now tries to spill the RAW result first; only
* when spilling is disabled, the tool is on {@link #excludedTools}, the
* body is under this threshold, or the disk write fails, does it fall
* back to truncating inline to 8000 chars. Keeping the threshold equal
* to the truncate cap yields a single semantic ladder above the
* threshold means "preserved on disk", at-or-below means "stays inline
* verbatim".
*
* <p>If you want to keep more text inline before spilling, raise this
* value AND raise the executor's hard cap together; otherwise the
* 8000-char fallback truncate would silently shorten anything between
* this threshold and 8000 even when spill is disabled, defeating the
* intent.
*/
private int perResultThresholdChars = 8000;
/**
* Layer 3 aggregate cap on combined response size in one tool turn.
* After all tools complete, the largest non-spilled responses are spilled
* in turn until the cumulative size fits this budget.
*/
private int perTurnBudgetChars = 32000; // was 16000 headroom for multi-tool turns
/** Number of leading characters kept inline as a preview after spilling. */
private int previewHeadChars = 800;
/**
* Retrieval-style tools are not spilled, but their inline content still must
* fit the model context. When aggregate turn budget is exceeded and only
* excluded tools remain, their results are compacted to this size.
*/
private int excludedToolInlineChars = 2500;
/**
* Optional absolute path to override the default spill location.
* When blank, falls back to {@code <workspace>/.mateclaw/tool-results/} or
* {@code ${java.io.tmpdir}/mateclaw/tool-results/}.
*/
private String storageBaseDir = "";
/**
* Tools whose results must NEVER be spilled. These are the tools the agent
* uses to <i>retrieve</i> spilled content spilling their output would
* cause infinite recursion (read spill path produces another spill
* agent reads new spill ) and starve {@code MAX_TOOL_CALLS_PER_STEP}.
*
* <p>Defaults to file-read tools that already cap their own output internally.
* Configurable so deployments can add more retrieval-style tools (e.g.,
* MCP-provided readers) without code changes.</p>
*/
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
/**
* Days to retain spill files before the scheduled cleanup deletes them.
* <p><b>Default 0 means time-based cleanup is disabled</b> spill files
* stay on disk until the owning conversation is explicitly deleted (which
* fires {@code purgeConversation} via {@code ConversationService}).
* This preserves the "recoverable" invariant: a summary or preview that
* cites a spill path will keep working for the whole life of the
* conversation, no matter how long it sits dormant.
* <p>Set to a positive value if disk pressure outweighs recoverability
* for your deployment. The scheduled sweep will then delete files whose
* mtime falls outside the retention horizon.
*/
private int retentionDays = 0;
/**
* Cron expression for the spill-cleanup task. Defaults to once a day at
* 03:00 server-local time so cleanup runs during quiet hours. Set this
* to a Spring-recognised value (six-field cron) or change the bean
* wiring to disable it entirely.
*/
private String cleanupCron = "0 0 3 * * ?";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPerResultThresholdChars() { return perResultThresholdChars; }
public void setPerResultThresholdChars(int perResultThresholdChars) {
this.perResultThresholdChars = perResultThresholdChars;
}
public int getPerTurnBudgetChars() { return perTurnBudgetChars; }
public void setPerTurnBudgetChars(int perTurnBudgetChars) {
this.perTurnBudgetChars = perTurnBudgetChars;
}
public int getPreviewHeadChars() { return previewHeadChars; }
public void setPreviewHeadChars(int previewHeadChars) {
this.previewHeadChars = previewHeadChars;
}
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
this.excludedToolInlineChars = excludedToolInlineChars;
}
public String getStorageBaseDir() { return storageBaseDir; }
public void setStorageBaseDir(String storageBaseDir) {
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
}
public List<String> getExcludedTools() { return excludedTools; }
public void setExcludedTools(List<String> excludedTools) {
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
}
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
public String getCleanupCron() { return cleanupCron; }
public void setCleanupCron(String cleanupCron) {
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
}
/** O(1) membership test for the exclusion list, used on every tool result. */
public Set<String> excludedToolsSet() {
return Set.copyOf(excludedTools);
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.agent.graph.executor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
* spill files don't accumulate forever. Kept in its own class instead of
* inlined into {@link ToolResultStorage} for two reasons:
*
* <ul>
* <li>Tests can exercise {@code cleanupExpired()} directly without
* fighting the Spring scheduler.</li>
* <li>Deployments that want to disable the schedule entirely can simply
* leave this component out of the autoconfigure path.</li>
* </ul>
*
* <p>The cron expression comes from
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
* i.e. once a day at 03:00 server-local time). The retention horizon comes
* from {@link ToolResultProperties#getRetentionDays()}.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ToolResultRetentionScheduler {
private final ToolResultStorage storage;
private final ToolResultProperties props;
/**
* Cron-fired hook. Failures are logged at WARN so they show up in
* standard log scrapes without aborting the scheduler thread losing
* a single sweep is fine, the next one will catch the same files.
*/
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
public void cleanup() {
if (props.getRetentionDays() <= 0) {
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
return;
}
try {
int deleted = storage.cleanupExpired();
if (deleted > 0) {
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
deleted, props.getRetentionDays());
}
} catch (Exception e) {
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,455 @@
package vip.mate.agent.graph.executor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Tool-result spill store implementing layers 2 and 3 of the RFC-008 Phase 3
* three-layer budget. Layer 1 (per-tool cap) lives inside individual tools.
*
* <p><b>Layer 2 per-result spill</b> ({@link #persistIfOversized}): a single
* tool result that exceeds the configured threshold is written to disk and
* the in-memory copy is replaced with a short preview plus a pointer line so
* the LLM can use {@code read_file} to retrieve the full text on demand.</p>
*
* <p><b>Layer 3 per-turn aggregate budget</b>
* ({@link #enforceTurnBudget}): after every tool in one turn has executed,
* if the combined response size still exceeds the turn budget, the largest
* non-spilled responses are spilled in turn until the aggregate fits.</p>
*
* <p>Spill files live under one of, in order:</p>
* <ol>
* <li>{@code ToolResultProperties.storageBaseDir} when explicitly set</li>
* <li>{@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a workspace is bound</li>
* <li>{@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/} as the universal fallback</li>
* </ol>
*
* <p>Failures (disk full, IO error) degrade silently: the original result is
* returned unchanged so the agent keeps working. Errors are logged at WARN.</p>
*
* <p>This class does <b>not</b> manage GC. Spill files accumulate until manually
* cleaned. A scheduled cleanup job is tracked as a Phase 3 follow-up.</p>
*/
@Slf4j
@Component
@Configuration
@EnableConfigurationProperties(ToolResultProperties.class)
public class ToolResultStorage {
/** Marker placed in the in-context preview so callers and tools can recognize spill output. */
public static final String SPILL_MARKER_PREFIX = "[mate-tool-result-spill]";
private final ToolResultProperties props;
/** Cached at construction; refreshed lazily if the underlying list mutates (rare). */
private volatile java.util.Set<String> excludedToolsSnapshot;
/** D-6: monotonically increasing spill counter for observability. */
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
/**
* Workspace roots observed during this JVM's lifetime. Populated every
* time a successful spill resolves a base directory; consulted by the
* scheduled retention sweep and by {@link #purgeConversation} so we
* don't have to query the database for every workspace path. Cross-JVM
* orphans are not covered that is documented in the cleanup javadoc.
*/
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
public ToolResultStorage(ToolResultProperties props) {
this.props = props;
this.excludedToolsSnapshot = props.excludedToolsSet();
}
/** D-6: current cumulative spill count (monotonically increasing). */
public long getSpillCount() {
return spillCount.get();
}
/**
* Returns true when {@code toolName} is in the configured exclusion list.
* Excluded tools (typically retrieval tools like {@code read_file}) are
* never spilled spilling their output would create a recursion where
* the agent reads a spill path and produces yet another spill.
*/
private boolean isExcluded(String toolName) {
if (toolName == null) return false;
java.util.Set<String> snap = excludedToolsSnapshot;
java.util.Set<String> live = props.excludedToolsSet();
if (live != snap && !live.equals(snap)) {
this.excludedToolsSnapshot = live;
snap = live;
}
return snap.contains(toolName);
}
/**
* Layer 2. If {@code result} exceeds the per-result threshold, write the full
* text to a spill file and return a preview-plus-pointer string. Otherwise
* return the original result unchanged.
*
* @param result the raw tool output (may be null)
* @param toolName used in the preview header so the LLM knows which tool produced it
* @param toolUseId unique within a conversation; becomes the spill file's basename
* @param conversationId scopes spill files by conversation
* @param workspaceBasePath agent's workspace base path; may be null/blank
*/
public String persistIfOversized(String result, String toolName, String toolUseId,
String conversationId, String workspaceBasePath) {
if (!props.isEnabled() || result == null) {
return result;
}
if (isExcluded(toolName)) {
// Retrieval-style tool never spill, would cause read-back recursion.
return result;
}
if (result.length() <= props.getPerResultThresholdChars()) {
return result;
}
Path file = spillFor(conversationId, toolUseId, workspaceBasePath);
if (file == null) {
return result;
}
try {
Files.createDirectories(file.getParent());
Files.writeString(file, result, StandardCharsets.UTF_8);
} catch (IOException ioe) {
log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original",
toolName, conversationId, ioe.getMessage());
return result;
}
long count = spillCount.incrementAndGet();
log.info("[ToolResultStorage] spill #{}: tool={} chars={} convId={}", count, toolName, result.length(), conversationId);
return buildPreview(result, toolName, file);
}
/**
* Layer 3. Walk the responses; if their aggregate length exceeds the turn
* budget, spill the largest remaining non-spilled result and recompute.
* Mutates the returned list in place by replacing oversized responses.
*/
public List<ToolResponseMessage.ToolResponse> enforceTurnBudget(
List<ToolResponseMessage.ToolResponse> responses,
String conversationId,
String workspaceBasePath) {
if (!props.isEnabled() || responses == null || responses.isEmpty()) {
return responses;
}
int budget = props.getPerTurnBudgetChars();
int aggregate = aggregateSize(responses);
if (aggregate <= budget) {
return responses;
}
log.info("[ToolResultStorage] turn budget exceeded: {} chars > {} (responses={})",
aggregate, budget, responses.size());
List<ToolResponseMessage.ToolResponse> mutable = new ArrayList<>(responses);
while (aggregate > budget) {
// Find the largest response that has not yet been spilled and is
// not produced by an excluded (retrieval-style) tool.
int targetIdx = -1;
int targetLen = -1;
for (int i = 0; i < mutable.size(); i++) {
ToolResponseMessage.ToolResponse r = mutable.get(i);
String body = r.responseData();
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
if (isExcluded(r.name())) continue; // retrieval tools must not be spilled
if (body.length() > targetLen) {
targetLen = body.length();
targetIdx = i;
}
}
if (targetIdx < 0) {
int compactedIdx = compactLargestExcludedResult(mutable);
if (compactedIdx >= 0) {
aggregate = aggregateSize(mutable);
continue;
}
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
aggregate);
break;
}
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
Path file = spillFor(conversationId, target.id(), workspaceBasePath);
if (file == null) {
break;
}
try {
Files.createDirectories(file.getParent());
Files.writeString(file, target.responseData(), StandardCharsets.UTF_8);
} catch (IOException ioe) {
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
ioe.getMessage());
break;
}
String preview = buildPreview(target.responseData(), target.name(), file);
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), preview));
aggregate = aggregateSize(mutable);
}
return mutable;
}
private int compactLargestExcludedResult(List<ToolResponseMessage.ToolResponse> mutable) {
int targetIdx = -1;
int targetLen = props.getExcludedToolInlineChars();
for (int i = 0; i < mutable.size(); i++) {
ToolResponseMessage.ToolResponse r = mutable.get(i);
String body = r.responseData();
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
if (!isExcluded(r.name())) continue;
if (body.length() > targetLen) {
targetLen = body.length();
targetIdx = i;
}
}
if (targetIdx < 0) {
return -1;
}
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
String compacted = compactInline(target.responseData(), target.name(), props.getExcludedToolInlineChars());
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), compacted));
log.info("[ToolResultStorage] compacted excluded tool result: tool={} chars={} -> {}",
target.name(), targetLen, compacted.length());
return targetIdx;
}
static String compactInline(String body, String toolName, int maxChars) {
if (body == null || body.length() <= maxChars) {
return body;
}
String marker = "\n\n... [tool result compacted for model context: tool="
+ toolName + ", original_chars=" + body.length() + ". "
+ StructuredTruncator.FIDELITY_NOTE + "] ...\n\n";
int available = Math.max(200, maxChars - marker.length());
int headLen = Math.max(100, (int) (available * 0.45));
int tailLen = Math.max(100, available - headLen);
if (headLen + tailLen >= body.length()) {
return body;
}
return StructuredTruncator.truncate(body, headLen, tailLen, marker);
}
private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) {
int sum = 0;
for (ToolResponseMessage.ToolResponse r : responses) {
if (r.responseData() != null) sum += r.responseData().length();
}
return sum;
}
private String buildPreview(String fullResult, String toolName, Path spillFile) {
// Snap the preview to a complete JSON element so the model never sees a value
// severed mid-token (which invites it to fabricate the omitted fields).
String head = StructuredTruncator.headSlice(fullResult, props.getPreviewHeadChars());
return SPILL_MARKER_PREFIX
+ " tool=" + toolName
+ " full_chars=" + fullResult.length()
+ " path=" + spillFile.toAbsolutePath()
+ "\n[Preview — first " + head.length() + " of " + fullResult.length()
+ " chars. The preview is INCOMPLETE: use read_file with the path above to "
+ "retrieve the full result. Do NOT infer or fabricate the omitted content.]\n"
+ head
+ "\n…[truncated]";
}
/**
* Resolve the spill file path for a given (conversationId, toolUseId).
* Returns {@code null} if no usable directory can be determined.
*/
private Path spillFor(String conversationId, String toolUseId, String workspaceBasePath) {
String safeConv = sanitize(conversationId);
String safeId = sanitize(toolUseId);
if (safeId.isEmpty()) {
safeId = "noid-" + System.nanoTime();
}
Path base = resolveBaseDir(workspaceBasePath);
if (base == null) return null;
return base.resolve(safeConv).resolve(safeId + ".txt");
}
private Path resolveBaseDir(String workspaceBasePath) {
Path base;
if (!props.getStorageBaseDir().isEmpty()) {
base = Paths.get(props.getStorageBaseDir());
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
} else {
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
base = Paths.get(tmp, "mateclaw", "tool-results");
}
// Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope.
observedRoots.add(base);
return base;
}
/**
* Roots currently known to this instance. Exposed package-private so the
* scheduled retention sweep and unit tests can enumerate them without
* touching the underlying set directly.
*/
java.util.Set<Path> getObservedRoots() {
return java.util.Collections.unmodifiableSet(observedRoots);
}
/**
* Best-effort: delete every spill file and per-conversation directory
* older than {@link ToolResultProperties#getRetentionDays()} across all
* roots this storage has seen, plus the configured base dir and the
* tmpdir fallback. Returns the number of files deleted.
*
* <p>Workspaces that never received a spill in this JVM's lifetime are
* not covered. Persisting an observed-roots registry across restarts
* could fix that, but is intentionally out of scope the operator-side
* remedy is to run a one-off cleanup with {@code storage-base-dir}
* pointed at the historical workspace.
*/
public int cleanupExpired() {
if (props.getRetentionDays() <= 0) {
return 0;
}
long cutoffEpochMillis = System.currentTimeMillis()
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
if (!props.getStorageBaseDir().isEmpty()) {
roots.add(Paths.get(props.getStorageBaseDir()));
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
}
int deleted = 0;
for (Path root : roots) {
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
}
if (deleted > 0) {
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
deleted, roots.size());
}
return deleted;
}
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
if (root == null || !java.nio.file.Files.isDirectory(root)) {
return 0;
}
int deleted = 0;
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
for (Path p : (Iterable<Path>) stream::iterator) {
if (p.equals(root)) continue;
if (!java.nio.file.Files.isRegularFile(p)) continue;
try {
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
if (mtime < cutoffEpochMillis) {
java.nio.file.Files.deleteIfExists(p);
deleted++;
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
return deleted;
}
// Best-effort: remove emptied per-conversation directories.
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
for (Path child : (Iterable<Path>) stream::iterator) {
if (!java.nio.file.Files.isDirectory(child)) continue;
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
if (kids.findAny().isEmpty()) {
java.nio.file.Files.deleteIfExists(child);
}
} catch (java.io.IOException ignored) {
// empty-check failure is not fatal leave the directory alone
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
}
return deleted;
}
/**
* Delete every spill file produced for {@code conversationId} across
* all observed roots, plus the configured base and tmpdir fallback.
* Called by {@code ConversationService.deleteConversation} so spill
* directories don't outlive the conversation that owns them.
*
* <p>Silently no-ops when nothing matches a conversation that never
* spilled, or one whose workspace root was never observed in this JVM,
* is simply left alone. Returns the number of files deleted.
*/
public int purgeConversation(String conversationId) {
if (conversationId == null || conversationId.isEmpty()) {
return 0;
}
String safeConv = sanitize(conversationId);
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
if (!props.getStorageBaseDir().isEmpty()) {
roots.add(Paths.get(props.getStorageBaseDir()));
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
}
int deleted = 0;
for (Path root : roots) {
Path convDir = root.resolve(safeConv);
if (!java.nio.file.Files.isDirectory(convDir)) continue;
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
for (Path p : (Iterable<Path>) stream::iterator) {
try {
if (java.nio.file.Files.isRegularFile(p)) {
java.nio.file.Files.deleteIfExists(p);
deleted++;
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
}
try {
java.nio.file.Files.deleteIfExists(convDir);
} catch (java.io.IOException ignored) {
// non-empty after deletes (another writer raced us) fine, leave it
}
}
if (deleted > 0) {
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
}
return deleted;
}
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
private static String sanitize(String s) {
if (s == null) return "";
return s.replaceAll("[^A-Za-z0-9_.-]", "_");
}
/** Test/admin helper: lexicographic ordering by length, descending. Not used at runtime. */
static Comparator<ToolResponseMessage.ToolResponse> byBodyLengthDesc() {
return (a, b) -> Integer.compare(
b.responseData() == null ? 0 : b.responseData().length(),
a.responseData() == null ? 0 : a.responseData().length());
}
}

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

@ -2,6 +2,8 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
@ -9,6 +11,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.*;
import java.util.concurrent.CancellationException;
@ -27,6 +30,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
@Slf4j
public class ActionNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** Function name of the explicit skill-load tool, mirrored from SkillLoadTool. */
private static final String LOAD_SKILL_TOOL = "load_skill";
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
private static final String ENABLE_TOOL = "enable_tool";
private final ToolExecutionExecutor executor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
@ -65,30 +76,165 @@ 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("");
}
// Pin skills the model loaded this run so the next reasoning turn's
// catalog ranks them first and the model stops re-loading the same
// skill it already pulled into message history. Tools cannot mutate
// graph state directly, so the load is detected here from the tool
// calls and merged into LOADED_SKILLS (read-merge-write, REPLACE key).
Set<String> requestedSkills = extractLoadedSkillNames(toolCalls);
if (!requestedSkills.isEmpty()) {
Set<String> merged = new LinkedHashSet<>(accessor.loadedSkills());
if (merged.addAll(requestedSkills)) {
output.loadedSkills(Set.copyOf(merged));
}
}
// Same mechanism for enable_tool: record the activated extension tools so
// ReasoningNode's next turn adds them back to the advertised callbacks.
Set<String> enabledTools = extractEnabledToolNames(toolCalls);
if (!enabledTools.isEmpty()) {
Set<String> merged = new LinkedHashSet<>(accessor.enabledExtensionTools());
if (merged.addAll(enabledTools)) {
output.enabledExtensionTools(Set.copyOf(merged));
}
}
return output.build();
}
/**
* Extract the {@code toolName} argument of every {@code enable_tool} call in
* this batch. Like {@link #extractLoadedSkillNames}, an unknown name is
* harmless: the reasoning-node split only activates names that resolve to an
* extension-tier tool actually in the agent's set.
*/
static Set<String> extractEnabledToolNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}
Set<String> names = new LinkedHashSet<>();
for (AssistantMessage.ToolCall tc : toolCalls) {
if (tc == null || !ENABLE_TOOL.equals(tc.name())) {
continue;
}
String name = parseStringArg(tc.arguments(), "toolName", "tool_name", "name");
if (name != null && !name.isBlank()) {
names.add(name.trim());
}
}
return names;
}
/**
* Extract the {@code skillName} argument of every {@code load_skill} call in
* this batch. The names are used only to bias catalog ordering, so an
* unparseable or unknown name is harmless (it simply never matches a
* visible skill) failures are swallowed rather than aborting the batch.
*/
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}
Set<String> names = new LinkedHashSet<>();
for (AssistantMessage.ToolCall tc : toolCalls) {
if (tc == null || !LOAD_SKILL_TOOL.equals(tc.name())) {
continue;
}
String name = parseStringArg(tc.arguments(), "skillName", "skill_name", "name");
if (name != null && !name.isBlank()) {
names.add(name.trim());
}
}
return names;
}
/**
* Read the first present, non-null string value among {@code keys} from a
* tool-call arguments JSON object. Returns null on malformed JSON or when
* none of the keys are present.
*/
private static String parseStringArg(String argumentsJson, String... keys) {
if (argumentsJson == null || argumentsJson.isBlank()) {
return null;
}
try {
JsonNode node = OBJECT_MAPPER.readTree(argumentsJson);
for (String key : keys) {
JsonNode value = node.get(key);
if (value != null && !value.isNull()) {
return value.asText();
}
}
return null;
} catch (Exception e) {
return null;
}
}
}

View File

@ -3,9 +3,15 @@ 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 vip.mate.common.text.MarkdownNormalizer;
import vip.mate.tool.document.GeneratedFileCache;
import java.util.List;
import java.util.Map;
/**
@ -26,6 +32,35 @@ import java.util.Map;
@Slf4j
public class FinalAnswerNode implements NodeAction {
/**
* Cache used to vet {@code /api/v1/files/generated/{id}} URLs the LLM
* may have written into the final answer. {@code null} disables the
* guard (legacy callers, narrow unit tests that don't exercise file
* outputs).
*/
private final GeneratedFileCache generatedFileCache;
/**
* Kill-switch for the deterministic Markdown cleanup applied to the answer
* body. {@code true} (default) runs {@link MarkdownNormalizer}; set to
* {@code false} to surface model output verbatim if a normalization edge
* case ever mangles a legitimate answer in production.
*/
private final boolean markdownNormalizeEnabled;
public FinalAnswerNode() {
this(null, true);
}
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
this(generatedFileCache, true);
}
public FinalAnswerNode(GeneratedFileCache generatedFileCache, boolean markdownNormalizeEnabled) {
this.generatedFileCache = generatedFileCache;
this.markdownNormalizeEnabled = markdownNormalizeEnabled;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
@ -34,9 +69,39 @@ 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 = scrubFakeUrls(assembleDirectAnswer(outputs));
String currentThinking = accessor.currentThinking();
String existingThinking = accessor.finalThinking();
String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
log.info("[FinalAnswerNode] RETURN_DIRECT — assembled final answer from {} direct " +
"tool output(s), {} chars (thinking preserved: {} chars)",
outputs.size(), assembled.length(), preservedThinking.length());
var builder = MateClawStateAccessor.output()
.finalAnswer(assembled)
.finishReason(FinishReason.RETURN_DIRECT)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.RETURN_DIRECT.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
return builder.build();
}
log.warn("[FinalAnswerNode] RETURN_DIRECT_TRIGGERED=true but DIRECT_TOOL_OUTPUTS empty; " +
"falling through to default final-answer assembly");
}
// 审批等待路径Graph AWAITING_APPROVAL 终止保留已流式推送的内容用于持久化
if (accessor.awaitingApproval()) {
String preservedContent = accessor.streamedContent();
String preservedContent = scrubFakeUrls(accessor.streamedContent());
String preservedThinking = !accessor.streamedThinking().isEmpty()
? accessor.streamedThinking() : accessor.currentThinking();
log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " +
@ -46,7 +111,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 +170,64 @@ public class FinalAnswerNode implements NodeAction {
}
}
// Scrub hallucinated `/api/v1/files/generated/{id}` URLs whose ids
// were never inserted into the cache. Done before evidence
// validation so the validator sees the user-visible warning rather
// than treating the fake link as a "reference".
finalAnswer = scrubFakeUrls(finalAnswer);
finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer);
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
// Deterministic Markdown cleanup on the model-generated answer body. LLMs
// routinely emit malformed Markdown (missing heading spaces, glued `---`,
// unaligned table pipes) that prompt rules fail to prevent; this fixes the
// mechanical defects before the answer is persisted / sent to channels.
// Verbatim tool output (RETURN_DIRECT) and approval-wait paths return early
// above and are intentionally left untouched. Gated so operators can turn
// the rewrite off (mate.agent.markdown-normalize-enabled=false).
if (markdownNormalizeEnabled) {
finalAnswer = MarkdownNormalizer.normalize(finalAnswer);
}
// Build the event list. Always carries the finish_reason event so
// downstream consumers (memory gate, channel accumulator, message
// metadata persistence) see a machine-readable status. When the
// turn ended in a non-transient error, also attach a
// feedback_event so the frontend can render retry/regenerate/
// report affordances next to the red "[错误] ..." bubble without
// this, fatal errors leave the user staring at error text with no
// way to recover short of retyping the whole prompt.
List<GraphEventPublisher.GraphEvent> events =
new java.util.ArrayList<>(2);
events.add(GraphEventPublisher.finishReason(finishReason.getValue()));
if (finishReason == FinishReason.ERROR_FALLBACK) {
events.add(GraphEventPublisher.feedback(
"ERROR_FALLBACK",
finalAnswer,
List.of("retry", "regenerate", "report")));
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
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(events);
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);
@ -115,6 +236,43 @@ 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();
}
/**
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
* with a user-visible warning. No-op when no cache is wired (legacy
* tests) or when the answer is empty.
*/
private String scrubFakeUrls(String text) {
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
return generatedFileCache.scrubMissingReferences(text);
}
private FinishReason parseFinishReason(String reason) {
if (reason == null || reason.isEmpty()) {
return FinishReason.NORMAL;

View File

@ -0,0 +1,432 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.service.GoalEvaluationService;
import vip.mate.goal.service.GoalFollowupService;
import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GraphFlavor;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
*
* <p>Evaluation runs on a settled terminal answer so upstream finishReason /
* evidence checks are already authoritative. The node:
* <ol>
* <li>Bails out for the "this turn shouldn't count" finishReasons
* (evidence_insufficient, stopped, error_fallback, return_direct,
* max_iterations_reached, plus awaiting_approval).</li>
* <li>Otherwise calls the evaluator, persists the
* agent/eval LLM-call deltas + score + gap via GoalService.</li>
* <li>Decides completed / exhausted / followup / continue. Completed
* and exhausted update {@code mate_agent_goal.status} ONLY they
* never touch FINISH_REASON, since the graph's own terminal status
* is independent of goal status.</li>
* <li>On followup, sets GOAL_FOLLOWUP_PROMPT and clears whichever
* graph-specific state would otherwise short-circuit the re-entry
* pass (clear set depends on the constructor-time GraphFlavor).</li>
* </ol>
*/
@Slf4j
public class GoalEvaluationNode implements NodeAction {
private final GoalEvaluationService evaluationService;
private final GoalFollowupService followupService;
private final GoalService goalService;
private final GoalProperties properties;
private final ConversationWindowManager windowManager; // reserved for evaluator context windowing
private final ConversationService conversationService; // reserved for evaluator context lookups
private final GraphFlavor flavor;
public GoalEvaluationNode(GoalEvaluationService evaluationService,
GoalFollowupService followupService,
GoalService goalService,
GoalProperties properties,
ConversationWindowManager windowManager,
ConversationService conversationService,
GraphFlavor flavor) {
this.evaluationService = evaluationService;
this.followupService = followupService;
this.goalService = goalService;
this.properties = properties;
this.windowManager = windowManager;
this.conversationService = conversationService;
this.flavor = flavor;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
// Master kill switch when disabled the node stays inert.
if (!properties.isEnabled()) {
return Map.of();
}
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// Resolve the active goal from the turn-start snapshot, falling back to
// a conversation lookup. The fallback is what makes a goal created
// MID-TURN (the agent calls setGoal, which only writes the DB) get
// evaluated on the very turn it was set otherwise ACTIVE_GOAL is empty
// in this run's state and the goal would sit inert until the next message.
Optional<GoalEntity> goalOpt = resolveActiveGoal(state, goalService);
if (goalOpt.isEmpty()) {
return Map.of();
}
// Re-entry guard the FinalAnswerGoalEvaluation conditional edge
// also checks this, but defence in depth pays for itself here.
if (accessor.goalEvaluatedThisRun()) {
return Map.of();
}
// Every skip path below emits a goal_evaluated event with a reason
// so the frontend can flip the breathing-halo state back off. The
// chat composable's `message_complete` handler optimistically sets
// evaluating=true; without a balancing event the ring would stay
// in that state forever after e.g. a max-iterations turn.
Long goalIdForEvents = goalOpt.get().getId();
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
// determines whether this turn counts. Plan-Execute usually doesn't
// set finishReason on the happy path, so we only enforce these
// exit conditions in REACT mode + the universal awaiting_approval
// gate that both flavors share.
// A turn that hit the ReAct iteration cap. Continuing it needs a FRESH
// iteration budget (a "hard continuation"), handled in the follow-up
// branch below; capture it here while finishReason is still authoritative.
boolean reactIterationCapReached = flavor == GraphFlavor.REACT
&& isIterationCapReached(accessor.finishReason());
if (flavor == GraphFlavor.REACT) {
String fr = accessor.finishReason();
if (isHardSkipFinishReason(fr)) {
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
.build();
}
// MAX_ITERATIONS_REACHED and EVIDENCE_INSUFFICIENT intentionally
// fall through: both mean "answer produced but the goal is likely
// unmet", which is exactly when a corrective follow-up helps.
// Max-iterations additionally needs a fresh budget (see below).
}
if (accessor.awaitingApproval()) {
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "awaiting_approval")))
.build();
}
GoalEntity goal = goalOpt.get();
String terminal = accessor.terminalAnswer();
if (terminal.isEmpty()) {
log.warn("[GoalEvaluationNode] terminalAnswer empty (flavor={}); skipping evaluation", flavor);
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goal.getId(), "empty_terminal_answer")))
.build();
}
// Build a thin recent-messages slice for the evaluator prompt.
List<Message> recent = accessor.messages();
int max = properties.getEvaluatorContextMessages();
if (recent.size() > max) {
recent = recent.subList(recent.size() - max, recent.size());
}
// Evaluator + persistence wrapped together: the just-emitted final
// answer is the user-visible thing and must NOT be lost just because
// a provider timeout or DB hiccup happens on the way to the
// bookkeeping write. On any failure we mark the run as evaluated
// (so the conditional edge above won't loop us back) and route to
// the normal terminal path the user still sees their answer; the
// goal stays in whatever state it was before this turn.
GoalEvaluationResult result;
GoalEntity refreshed;
try {
result = evaluationService.evaluate(goal, recent, terminal);
// Bill only the NEW agent LLM calls since the last accounted point.
// The run-to-completion loop evaluates multiple times per graph run
// while LLM_CALL_COUNT keeps growing, so passing the cumulative value
// raw would re-bill earlier calls on every pass and exhaust the
// goal's LLM budget prematurely. The followup branch advances the
// accounted marker; terminal branches don't (the run ends there).
int agentLlmDelta = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount());
int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
refreshed = goalService.getById(goal.getId());
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] evaluator/persist failed for goal={} — skipping this pass: {}",
goal.getId(), t.toString());
return MateClawStateAccessor.output()
.goalEvaluationResult(GoalEvaluationResult.fallback("node_exception").toMap())
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goal.getId(), "evaluator_or_persist_failed")))
.build();
}
// Decision branches. Each terminal write is wrapped so a DB hiccup
// (e.g. optimistic-lock conflict exceeding retries, memory sync
// failure on completion) does not propagate into the chat graph
// and abort the streamed answer the user already sees.
try {
// Completion is the deterministic "all criteria passed" signal the
// evaluator already folded into result.completed() no score gate.
if (result.completed()) {
GoalEntity completed = goalService.markCompleted(refreshed.getId(), result);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(completed.getId()),
"score", result.score(),
"goal", goalService.toResponse(completed)))))
.build();
}
if (goalService.isBudgetExhausted(refreshed)) {
String reason = goalService.exhaustionReason(refreshed);
GoalEntity exhausted = goalService.markExhausted(refreshed.getId(), reason);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_exhausted", Map.of(
"goalId", String.valueOf(exhausted.getId()),
"turnsUsed", exhausted.getTurnsUsed(),
"agentLlmCallsUsed", exhausted.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
"reason", reason,
"goal", goalService.toResponse(exhausted)))))
.build();
}
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
refreshed.getId(), t.toString());
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
.build();
}
int followupCountThisRun = accessor.goalFollowupCount();
int hardContinuationCount = accessor.goalHardContinuationCount();
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
GoalProperties.MAX_HARD_CONTINUATIONS_CEILING);
Optional<String> followup;
try {
followup = followupService.maybeBuildFollowup(refreshed, result);
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}",
refreshed.getId(), t.toString());
followup = Optional.empty();
}
// Per-run safety net: cap the autonomous self-continuation loop so a
// single user message can't drive an unbounded number of steps or
// approach the graph recursion limit. When the cap is hit we fall
// through to the terminal "continue, no followup" path the goal stays
// active and the cross-message turn / LLM budget (or the user) carries
// it on.
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
// A max-iterations continuation re-runs a FULL fresh ReAct segment
// (iteration budget reset), so it carries a tighter, dedicated cap on
// top of the per-run follow-up cap and is sized into the graph
// recursion ceiling. hardCap==0 keeps the legacy behaviour (a
// max-iterations turn simply ends the run).
boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap;
if (followup.isPresent() && (perRunCapReached || hardCapReached)) {
log.info("[GoalEvaluationNode] follow-up suppressed for goal={} " +
"(followups {}/{}, hardContinuations {}/{}, iterationCapReached={}); ending this run",
refreshed.getId(), followupCountThisRun, properties.getMaxFollowupsPerRun(),
hardContinuationCount, hardCap, reactIterationCapReached);
}
if (followup.isPresent() && !perRunCapReached && !hardCapReached) {
try {
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] recordFollowupInjected failed — emitting followup anyway: {}",
t.toString());
// Continue: the in-memory state-machine path still works
// even if the audit row could not be written.
}
MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalFollowupInjected(true)
.goalFollowupPrompt(followup.get())
.goalFollowupCount(followupCountThisRun + 1)
// Advance the LLM-billing marker to the current cumulative
// count so the NEXT evaluation in this run charges only its
// own delta (see agentLlmDelta above).
.goalAccountedLlmCallCount(accessor.llmCallCount())
// Deliberately NOT setting goalEvaluatedThisRun(true): leaving
// it false lets the NEXT answer be re-evaluated, turning the
// old single-step behaviour into run-to-completion. The loop
// is bounded by the per-run cap above plus the turn / LLM
// budgets; the dispatcher treats any terminal pass
// (goalEvaluatedThisRun == true) as END even if this flag
// lingers true under the REPLACE key strategy.
.needsToolCall(false)
.events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"prompt", followup.get(),
"goal", goalService.toResponse(refreshed)))));
if (flavor == GraphFlavor.REACT) {
// ReAct: append the followup as a fresh user message via the
// MESSAGES APPEND strategy. ReasoningNode picks it up on its
// next call without any followup-specific logic on its side.
out.clearFinalAnswer()
.clearFinishReason()
.messages(List.of((Message) new UserMessage(followup.get())));
if (reactIterationCapReached) {
// Hard continuation: the run's iteration budget is spent, so
// grant a brand-new ReAct segment. Reset the counter, clear
// the stale limit-exceeded draft/flag (FinalAnswerNode prefers
// the draft over a freshly reasoned answer) and any latched
// error, and advance the dedicated hard-continuation counter.
out.iterationCount(0)
.clearLimitExceededDraft()
.error("")
.goalHardContinuationCount(hardContinuationCount + 1);
log.info("[GoalEvaluationNode] hard continuation {}/{} for goal={} " +
"(fresh ReAct iteration budget after max-iterations turn)",
hardContinuationCount + 1, hardCap, refreshed.getId());
}
} else {
// Plan-Execute: wipe the wider mid-pass + terminal state.
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
// preserved the next PlanGeneration pass needs them.
out.clearFinalAnswer()
.clearFinishReason()
.clearPlanFinalSummary()
.clearPlanDirectAnswer()
.clearPlanId()
.clearPlanSteps()
.clearPlanValid()
.clearNeedsPlanning()
.clearCurrentStepIndex()
.clearCurrentStepTitle()
.clearCurrentStepResult()
.clearCompletedResults()
.clearFinalSummaryThinking()
.clearCurrentStepThinking();
}
return out.build();
}
// Continue but no follow-up just record the evaluation event.
// (helper below avoids needing a custom() factory on GraphEventPublisher.)
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_evaluated", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score(),
"gap", result.gap() == null ? "" : result.gap(),
"goal", goalService.toResponse(refreshed)))))
.build();
}
/**
* Resolve the active goal for this run: prefer the turn-start
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation
* lookup so a goal created mid-turn (via {@code setGoal}, which only writes
* the DB) is still evaluated on the turn it was set.
*
* <p>Shared by {@link #apply} and the {@code FinalAnswer/PlanSummary
* GoalEvaluation} routing edges so both agree on whether a goal is active.
* The DB fallback costs one indexed lookup per terminal turn whose snapshot
* is empty; callers should additionally gate on {@code properties.isEnabled()}
* to skip it when the feature is off.
*/
public static Optional<GoalEntity> resolveActiveGoal(OverAllState state, GoalService goalService) {
MateClawStateAccessor a = new MateClawStateAccessor(state);
Optional<Object> snapshot = a.activeGoal();
if (snapshot.isPresent() && snapshot.get() instanceof GoalEntity ge) {
return Optional.of(ge);
}
if (goalService == null) {
return Optional.empty();
}
String conversationId = a.conversationId();
if (conversationId == null || conversationId.isBlank()) {
return Optional.empty();
}
try {
return Optional.ofNullable(goalService.findActiveByConversation(conversationId));
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] active-goal fallback lookup failed for conversation={}: {}",
conversationId, t.toString());
return Optional.empty();
}
}
/**
* REACT-mode finish reasons that should neither count toward the goal nor
* trigger a continuation:
* <ul>
* <li>{@code STOPPED} the user halted the run; don't fight them.</li>
* <li>{@code RETURN_DIRECT} a tool produced the answer verbatim; this
* is not goal-progress reasoning work to evaluate or continue.</li>
* <li>{@code ERROR_FALLBACK} a fatal error already failed the turn;
* re-running immediately would just re-fail.</li>
* </ul>
* Other terminal reasons notably {@code MAX_ITERATIONS_REACHED} and
* {@code EVIDENCE_INSUFFICIENT} mean "answer produced but the goal is
* likely unmet", which is exactly when a corrective follow-up helps, so
* they are deliberately NOT skipped.
*/
static boolean isHardSkipFinishReason(String finishReason) {
return FinishReason.STOPPED.getValue().equals(finishReason)
|| FinishReason.RETURN_DIRECT.getValue().equals(finishReason)
|| FinishReason.ERROR_FALLBACK.getValue().equals(finishReason);
}
/**
* True when the terminal turn hit the ReAct iteration cap. Continuing such
* a turn requires a fresh iteration budget (a "hard continuation"), because
* the run's shared budget is already exhausted.
*/
static boolean isIterationCapReached(String finishReason) {
return FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(finishReason);
}
/** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());
}
/**
* Builds a goal_evaluated event for skip paths so the frontend can
* unconditionally flip its "evaluating" flag off after every turn that
* has an active goal even when the evaluator never ran. The reason
* field lets us tell apart "normal continue" from "skipped because of
* max iterations" in logs / future telemetry without ambiguity.
*/
private static GraphEventPublisher.GraphEvent skippedEvent(Long goalId, String reason) {
return goalEvent("goal_evaluated", Map.of(
"goalId", goalId == null ? "" : String.valueOf(goalId),
"skipped", true,
"reason", reason == null ? "" : reason));
}
}

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,43 @@ 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;
/**
* Optional ledger loader. When set, the conversation's progress snapshot
* (done / in-progress / pending) is appended to the LLM's context so the
* "graceful wrap-up" answer can be honest about which steps actually
* finished and which were still pending when the iteration cap hit.
* Null in legacy/test constructors the wrap behaves as before.
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper) {
this(chatModel, observationProcessor, streamingHelper, null, null);
}
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper, I18nService i18n) {
this(chatModel, observationProcessor, streamingHelper, i18n, null);
}
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper, I18nService i18n,
vip.mate.agent.progress.ProgressLedgerService progressLedgerService) {
this.chatModel = chatModel;
this.observationProcessor = observationProcessor;
this.streamingHelper = streamingHelper;
this.i18n = i18n;
this.progressLedgerService = progressLedgerService;
}
/**
* @deprecated Use constructor with NodeStreamingChatHelper
* @deprecated use the constructor with {@link NodeStreamingChatHelper} (and optionally {@link I18nService})
*/
@Deprecated
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
this(chatModel, observationProcessor, null);
this(chatModel, observationProcessor, null, null, null);
}
@Override
@ -88,7 +112,26 @@ 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)";
}
// Prepend the conversation's progress ledger snapshot when available
// so the wrap-up answer can be honest about partial completion ("4/10
// models researched, 6 still pending") rather than vaguely describing
// "what I tried". Without this, hitting the iteration cap on a
// 10-step task produces a useless catch-all message observed in
// round-4 of the LLM-review smoke test.
String ledgerSnapshot = null;
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
try {
ledgerSnapshot = progressLedgerService.load(conversationId).renderSnapshot();
} catch (Exception e) {
log.warn("[LimitExceededNode] Failed to load progress ledger for {}: {}",
conversationId, e.getMessage());
}
}
if (ledgerSnapshot != null) {
contextForLLM = ledgerSnapshot + "\n\n---\n\n" + contextForLLM;
}
// 构建 prompt
@ -110,8 +153,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;
@ -31,6 +32,18 @@ public class ObservationNode implements NodeAction {
private final ObservationProcessor observationProcessor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
/**
* Progressive-disclosure meta-tools that perform setup, not real work. A
* round whose entire batch is one of these is refunded its iteration (see
* {@link MateClawStateKeys#ITERATION_REFUND_COUNT}). Mirrors the authoritative
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
*/
private static final java.util.Set<String> DISCLOSURE_TOOLS =
java.util.Set.of("load_skill", "enable_tool");
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
public ObservationNode(ObservationProcessor observationProcessor) {
this(observationProcessor, null);
}
@ -55,14 +68,29 @@ public class ObservationNode implements NodeAction {
int currentIteration = accessor.iterationCount();
int maxIterations = accessor.maxIterations();
int nextIteration = currentIteration + 1;
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
// 提取最新的工具结果并处理
List<ToolResponseMessage.ToolResponse> toolResults =
state.<List<ToolResponseMessage.ToolResponse>>value(TOOL_RESULTS).orElse(List.of());
// Iteration refund: a round whose entire batch was progressive-disclosure
// setup (load_skill / enable_tool) did no real work, so don't charge it an
// iteration otherwise a tight budget loses a step to the load-then-use
// two-step. Bounded by MAX_ITERATION_REFUNDS_PER_RUN so a model that only
// ever loads skills can't dodge the budget forever.
int refundCount = accessor.iterationRefundCount();
boolean setupOnlyRound = !toolResults.isEmpty()
&& toolResults.stream().allMatch(tr -> DISCLOSURE_TOOLS.contains(tr.name()));
boolean refundIteration = setupOnlyRound && refundCount < MAX_ITERATION_REFUNDS_PER_RUN;
int nextIteration = refundIteration ? currentIteration : currentIteration + 1;
if (refundIteration) {
log.info("[ObservationNode] Iteration refunded (setup-only round, refunds {}/{}); staying at {}/{}",
refundCount + 1, MAX_ITERATION_REFUNDS_PER_RUN, nextIteration, maxIterations);
} else {
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
}
// 将每个工具结果通过 ObservationProcessor 标准化和截断
List<String> processedObservations = toolResults.stream()
.map(tr -> observationProcessor.process(tr.name(), tr.responseData()))
@ -71,7 +99,7 @@ public class ObservationNode implements NodeAction {
// 合并为单条观察记录
String combinedObservation = String.join("\n---\n", processedObservations);
// Budget Pressure WarningHermes 风格接近上限时注入警告到工具结果中
// Budget Pressure Warning接近上限时注入警告到工具结果中
// LLM 下一轮 reasoning 时能看到从而主动收束而非被硬性截断
if (maxIterations > 0) {
int progress = (int) ((double) nextIteration / maxIterations * 100);
@ -120,6 +148,21 @@ public class ObservationNode implements NodeAction {
.shouldSummarize(shouldSummarize)
.toolCallCount(newToolCallCount);
if (refundIteration) {
builder.iterationRefundCount(refundCount + 1);
}
// Close out the iteration we just observed. We use currentIteration
// (not nextIteration) so the index pairs with whatever
// iteration_start the ReasoningNode emitted at the top of this turn.
// Char totals are best-effort: ObservationNode doesn't see the LLM
// delta stream directly, so 0/0 is acceptable for now consumers
// that care fall back to summing the deltas themselves.
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
builder.events(List.of(
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
}
// 重复观察时标记错误 ObservationDispatcher 路由到 limitExceededNode
if (duplicateObservation) {
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");

View File

@ -3,13 +3,18 @@ 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.context.StructuredTruncator;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.prompt.PromptLoader;
@ -101,16 +106,23 @@ 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()) {
log.warn("[SummarizingNode] Summarization LLM call failed: {}, using raw observations as fallback",
result.errorMessage());
String fallback = observationText.length() > 500
? observationText.substring(0, 500) + "...[摘要生成失败,已截断]"
? StructuredTruncator.headSlice(observationText.toString(), 500)
+ "\n...[摘要生成失败,仅保留原始观察的前部片段;数据不完整,请勿编造、补全或重新编号缺失内容]"
: observationText.toString();
AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback);
return MateClawStateAccessor.output()
@ -173,6 +185,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 +196,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

@ -1,6 +1,7 @@
package vip.mate.agent.graph.observation;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.config.GraphObservationProperties;
import java.util.List;
@ -94,12 +95,11 @@ public class ObservationProcessor {
int headLen = (int) (available * effectiveHeadRatio);
int tailLen = available - headLen;
String head = text.substring(0, headLen);
String tail = text.substring(originalLen - tailLen);
String result = StructuredTruncator.truncate(text, headLen, tailLen, marker);
log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})",
originalLen, head.length() + tail.length(), maxLen, effectiveHeadRatio);
return head + marker + tail;
originalLen, result.length(), maxLen, effectiveHeadRatio);
return result;
}
/**

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
@ -136,7 +148,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
return compiledGraph.stream(inputs, config)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取事件只发送新增部分
@ -206,7 +218,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
@ -254,11 +266,14 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
maxInputTokens,
chatModel,
conversationId,
parsedAgentId);
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
}
List<Message> messages = new ArrayList<>(historyMessages);
messages.add(buildCurrentUserMessage(conversationId, userMessage));
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
messages.add(currentTurn.userMessage());
// 构建 working context对历史消息做受控长度摘要
String workingContext = buildWorkingContext(historyMessages, List.of());
@ -285,6 +300,43 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
if (currentTurn.routingDecision() != null
&& (currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|| !currentTurn.routingDecision().skipped().isEmpty())) {
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
}
// RFC-063r §2.5: same as ReAct path enrich and store the ChatOrigin
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
// can read it back from state.
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
Long parsedAgentIdForOrigin = null;
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
if (parsedAgentIdForOrigin != null) {
origin = origin.withAgent(parsedAgentIdForOrigin);
}
origin = origin.withConversationId(conversationId)
.withWorkspace(origin.workspaceId(), workspaceBasePath);
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
// RFC 48 inject active goal snapshot for GoalEvaluationNode.
// Mirrors StateGraphReActAgent.buildInitialState exactly.
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
try {
vip.mate.goal.model.GoalEntity active =
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
}
}
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
return inputs;
}

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

@ -30,6 +30,12 @@ public class StepProgressDispatcher implements EdgeAction {
if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
return StateGraph.END;
}
// Step-failure recovery: a failed step requested a re-plan of the
// remaining work. Route back to PlanGeneration instead of aborting;
// PLAN_REPLAN_COUNT (set by StepExecutionNode) bounds the loop.
if ("plan_replan".equals(currentPhase)) {
return PlanStateKeys.PLAN_GENERATION_NODE;
}
int currentIndex = state.value(PlanStateKeys.CURRENT_STEP_INDEX, 0);
List<String> steps = state.<List<String>>value(PlanStateKeys.PLAN_STEPS).orElse(List.of());

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,45 +2,57 @@ 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 org.springframework.util.StringUtils;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.model.AgentEntity;
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.MateClawStateKeys;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import vip.mate.planning.service.PlanningService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
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,59 +62,310 @@ public class PlanGenerationNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final AgentToolSet toolSet;
private final ObjectMapper objectMapper = new ObjectMapper();
/** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */
private final GoalService goalService;
private final GoalProperties goalProperties;
/** Optional advertise delegatable specialist agents to the planner and
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
private final AgentService agentService;
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
/** Cap the auto-derived goal title; the full request rides in the description. */
private static final int AUTO_GOAL_TITLE_MAX = 80;
/**
* Structured triage result field names use @JsonProperty to match the
* snake_case keys the LLM is instructed to produce, so no prompt changes needed.
*/
record TriageResult(
@JsonProperty("needs_planning") boolean needsPlanning,
@JsonProperty("direct_answer") String directAnswer,
@JsonProperty("plan_type") String planType,
@JsonProperty("steps") List<String> steps,
// Optional per-step delegation: agent names parallel to steps (same
// order). An empty string / missing entry means "run with the parent
// agent". Only populated when delegatable specialist agents are
// advertised to the planner; absent for backward compatibility.
@JsonProperty("step_agents") List<String> stepAgents
) {}
private static final String PLANNING_PROMPT = """
你是任务规划器不是聊天助手
你是任务分流不是聊天助手根据用户目标把请求分到三类之一并只输出一个 JSON 对象
你的输出必须满足以下规则
1. 只能返回一个 JSON 对象
2. 不允许输出任何 JSON 之外的文字
3. 不允许使用 markdown 代码块
4. 不要解释不要寒暄不要先说"我来...""我先..."
硬性规则
1. 返回一个 JSON 对象不允许 markdown 代码块不允许任何 JSON 以外的文字
2. 要解释不要寒暄不要说"我来...""我先..."
3. 判断依据是"目标是否由多个明显独立的子任务/交付物组成"而不是难度高低
单个连贯动作不要拆但目标确实分成多个部分时也不要硬压成一步
返回格式二选一
三类分流
不需要规划时
{"needs_planning": false, "direct_answer": "..."}
(A) 直接回答 简单的纯知识问答凭自身知识用一两段话即可答完不需要任何工具不需要读文件
不需要查询当前状态且目标本身不包含多个需要分别完成的子任务
注意成段的分析对比方案规划教程等通常不属于此类应走 B C
输出{"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 / 技能文件读取当成独立步骤仅当用户明确询问偏好历史决策或长期约束时才加入
- 每个步骤必须是可执行动作不写"思考一下""确认一下"之类的空话
- 多部分多阶段需要逐步推进的目标走(C)真正单一原子动作走(B)只有简单一问一答才用(A)
""";
/**
* Evidence gate action signals in the USER GOAL. When triage returns
* direct_answer (A) but the goal contains any of these, the model almost
* certainly mis-routed a tool-requiring task; accepting the direct answer
* would end the turn without ever executing a tool ("复杂任务不执行就停止").
* <p>
* The gate is deliberately biased toward executing: a false positive only
* costs one extra executor pass (which still produces the answer, with or
* without tools), whereas a false negative silently drops the whole task.
* Intentionally excludes very common bare temporal words (现在/当前/最新)
* to avoid downgrading genuine knowledge Q&A on every occurrence.
*/
private static final java.util.regex.Pattern GOAL_REQUIRES_EXECUTION = java.util.regex.Pattern.compile(
"读取|读一下|读一份|打开文件|查一下|检索|搜索|联网|下载|上传|抓取"
+ "|记住|记一下|录入|保存|写入|存储|更新|删除|新建|创建|生成|画一[张幅]|画个"
+ "|运行|执行|调用|跑一下|发送|发给|安排|提醒|预约"
+ "|我的(记忆|文件|知识库|偏好|笔记|日程|目标)"
+ "|你(现在|目前)?(挂载|加载|有哪些|支持哪些)|挂载了哪些|你的(技能|工具|MCP|插件)"
+ "|帮我(做|改|查|建|写|发|跑|算|订|定|生成|整理|安排)"
+ "|\\.(java|py|ts|js|vue|md|json|ya?ml|sql|csv|xml|txt|sh)\\b",
java.util.regex.Pattern.CASE_INSENSITIVE);
/**
* Evidence gate execution-promise phrasing in the direct answer itself.
* The model says it WILL act ("我先去读取…", "接下来调用…") rather than
* actually answering, which means the "direct answer" is really a plan
* preamble that would terminate before the action runs. Scoped to a verb
* whitelist so a normal narrative opener like "我来介绍一下杭州" is NOT caught.
*/
private static final java.util.regex.Pattern ANSWER_PROMISES_ACTION = java.util.regex.Pattern.compile(
"(我(先|这就|马上|稍后|接下来|现在)?(去|来)?|让我(先|来)?|接下来(我)?(会|要|将|需要)?|正在)"
+ "(读取|读一下|查一下|查询|检索|搜索|联网|调用|执行|运行|获取|访问|查看一下"
+ "|保存|记住|记录|写入|录入|创建|新建|生成|下载|上传|发送)");
/**
* Returns true when a triage {@code direct_answer} (A) should be overridden
* and routed through the executor as a single-step plan instead. Package-
* private and side-effect free so the gate's regex behavior is unit-testable
* without mocking the whole node.
*
* @param goal the user goal
* @param directAnswer the answer the triage model produced (may be null)
*/
static boolean shouldOverrideDirectAnswer(String goal, String directAnswer) {
String userAsk = stripInjectedContext(goal);
boolean goalNeedsExecution = userAsk != null && GOAL_REQUIRES_EXECUTION.matcher(userAsk).find();
boolean answerPromisesAction = directAnswer != null && ANSWER_PROMISES_ACTION.matcher(directAnswer).find();
return goalNeedsExecution || answerPromisesAction;
}
/**
* Strips the injected {@code <memory-context></memory-context>} wrapper that
* RuntimeContextInjector prepends to every goal, returning just the user's
* actual ask. Without this the gate matches on the injected memory/profile
* text (which contains filenames like {@code user.md} and memory keywords),
* firing on essentially every task and defeating the direct-answer fast path.
*/
static String stripInjectedContext(String goal) {
if (goal == null) {
return null;
}
int end = goal.lastIndexOf("</memory-context>");
if (end >= 0) {
return goal.substring(end + "</memory-context>".length()).trim();
}
return goal;
}
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
AgentToolSet toolSet) {
this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, null, null);
}
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
AgentToolSet toolSet,
GoalService goalService, GoalProperties goalProperties) {
this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet,
goalService, goalProperties, null);
}
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
AgentToolSet toolSet,
GoalService goalService, GoalProperties goalProperties,
AgentService agentService) {
this.chatModel = chatModel;
this.planningService = planningService;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
this.toolSet = toolSet;
this.goalService = goalService;
this.goalProperties = goalProperties;
this.agentService = agentService;
}
/**
* @deprecated Use constructor with full parameters
* @deprecated use the full-parameter constructor instead
*/
@Deprecated
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
this(chatModel, planningService, null, null, null);
this(chatModel, planningService, null, null, null, null, null);
}
/**
* Auto-derive a goal from a freshly-generated multi-step plan so the
* Plan-Execute path engages the goal subsystem (the planner / step executor
* never call {@code setGoal} themselves). The plan steps become the goal's
* acceptance criteria the plan IS the decomposition so the first
* evaluation skips the bootstrap round and judges those criteria directly.
*
* <p>Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS
* run's GoalEvaluationNode picks it up) or {@code null} when not applicable:
* feature off, fewer than {@link #MIN_STEPS_FOR_AUTO_GOAL} steps, no channel
* context, or the conversation already has an active goal. Best-effort
* any failure is swallowed so planning is never blocked by goal bookkeeping.
*/
GoalEntity maybeAutoCreateGoal(PlanStateAccessor accessor, List<String> steps) {
if (goalService == null || goalProperties == null
|| !goalProperties.isEnabled() || !goalProperties.isAutoGoalFromPlan()) {
return null;
}
if (steps == null || steps.size() < MIN_STEPS_FOR_AUTO_GOAL) {
return null;
}
ChatOrigin origin = accessor.chatOrigin();
String convId = origin.conversationId();
if (convId == null || convId.isBlank() || origin.agentId() == null) {
return null;
}
try {
if (goalService.findActiveByConversation(convId) != null) {
return null; // respect an existing goal (incl. re-plan passes)
}
String request = stripInjectedContext(accessor.goal()).strip();
GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(convId);
req.setAgentId(origin.agentId());
req.setWorkspaceId(origin.workspaceId() != null ? origin.workspaceId() : 1L);
req.setTitle(request.isEmpty() ? "多步任务"
: request.length() > AUTO_GOAL_TITLE_MAX
? request.substring(0, AUTO_GOAL_TITLE_MAX) : request);
req.setDescription(request);
List<GoalCriterion> criteria = steps.stream()
.filter(s -> s != null && !s.isBlank())
.map(s -> new GoalCriterion("", s.strip(), false, ""))
.collect(Collectors.toList());
if (!criteria.isEmpty()) {
req.setCriteria(criteria);
}
String username = origin.requesterId() != null && !origin.requesterId().isBlank()
? origin.requesterId() : "system";
GoalEntity created = goalService.create(req, username);
log.info("[PlanGeneration] Auto-derived goal {} from plan ({} criteria) for conversation {}",
created.getId(), criteria.size(), convId);
return created;
} catch (Exception e) {
log.warn("[PlanGeneration] Auto-goal-from-plan skipped (non-fatal): {}", e.toString());
return null;
}
}
/**
* Enabled agents in the given workspace, excluding the parent (plan) agent
* itself these are the agents a step can be delegated to. Empty when
* delegation is unavailable (no {@link AgentService}) or no peers exist.
*/
private List<AgentEntity> listDelegatableAgents(Long workspaceId, String parentAgentId) {
if (agentService == null || workspaceId == null) {
return List.of();
}
try {
return agentService.listAgentsByWorkspace(workspaceId, true).stream()
.filter(a -> a.getId() != null && !String.valueOf(a.getId()).equals(parentAgentId))
.collect(Collectors.toList());
} catch (Exception e) {
log.warn("[PlanGeneration] Failed to list delegatable agents (non-fatal): {}", e.toString());
return List.of();
}
}
/**
* Map the planner's {@code step_agents} (agent names, parallel to steps) to
* agent ids. Returns {@code null} when nothing is delegated so {@code createPlan}
* stays on the legacy path. Names are matched case-insensitively against the
* delegatable agents; blank / unknown / parent-agent names resolve to {@code null}
* (that step runs with the parent agent).
*/
private List<Long> resolveStepAgents(List<String> steps, List<String> stepAgents,
Long workspaceId, String parentAgentId) {
if (stepAgents == null || stepAgents.isEmpty() || steps == null || steps.isEmpty()) {
return null;
}
List<AgentEntity> delegatable = listDelegatableAgents(workspaceId, parentAgentId);
if (delegatable.isEmpty()) {
return null;
}
Map<String, Long> byName = new HashMap<>();
for (AgentEntity a : delegatable) {
if (a.getName() != null) {
byName.put(a.getName().trim().toLowerCase(), a.getId());
}
}
List<Long> ids = new ArrayList<>();
boolean any = false;
for (int i = 0; i < steps.size(); i++) {
String name = i < stepAgents.size() ? stepAgents.get(i) : null;
Long id = (name == null || name.isBlank()) ? null : byName.get(name.trim().toLowerCase());
if (id != null) {
any = true;
}
ids.add(id);
}
return any ? ids : null;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
String goal = accessor.goal();
// Goal follow-up injection: GoalEvaluationNode requested a re-plan
// pass with extra guidance. The mid-pass plan state was wiped by
// the previous node, so we run the normal planning flow but
// append the follow-up prompt to the user goal so the planner
// sees "do these original objectives + this next step the
// evaluator just asked for".
String followupPrompt = state.value(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
if (!followupPrompt.isEmpty()) {
log.info("[PlanGeneration] Goal follow-up active, augmenting goal with {} chars of guidance",
followupPrompt.length());
goal = goal + "\n\n[Follow-up guidance]\n" + followupPrompt;
}
String systemPrompt = accessor.systemPrompt();
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
// Persist plans under the real agent id (the same key StepExecutionNode
// reads), NOT the per-run trace id otherwise mate_plan.agent_id holds a
// random trace string and listByAgent never matches, leaving the Plan
// board permanently empty even after plans are generated.
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String conversationId = accessor.conversationId();
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
@ -110,7 +373,7 @@ public class PlanGenerationNode implements NodeAction {
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
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 +391,81 @@ 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)));
vip.mate.agent.context.ChatOrigin chatOrigin =
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
promptMessages.add(new UserMessage(
RuntimeContextInjector.buildContextMessage(
workspaceBasePath, null, chatOrigin, runtimeModelName, runtimeProviderId)));
// 注入可用工具名称帮助 LLM 判断用户目标是否需要工具
// Advertise available tools so the LLM can recognize when an action is possible,
// but do NOT force "any tool usage implies multi-step" single-hop tool use
// should resolve to a 1-step plan, not a multi-step decomposition.
if (toolSet != null && !toolSet.callbacks().isEmpty()) {
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对话历史摘要让规划能感知之前对话的约束和补充条件
// Advertise delegatable specialist agents so the planner can assign a
// multi-step plan's step to a dedicated agent (e.g. a test step to a
// QA agent, a UI step to a frontend agent). Only fills the step's
// step_agents slot; unassigned steps stay with the parent agent.
// Skipped entirely when no peer agents exist in the workspace.
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
String agentLines = delegatable.stream()
.map(a -> "- " + a.getName()
+ (StringUtils.hasText(a.getDescription()) ? "" + a.getDescription() : ""))
.collect(Collectors.joining("\n"));
promptMessages.add(new UserMessage(
"可委派的专职 Agent仅当某步骤明显属于其专长时才指派否则该步骤留空、由你自己执行\n"
+ agentLines
+ "\n若要委派在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
}
// Inject working context (rolling conversation summary) so triage respects
// prior constraints without re-reading full history.
String workingContext = accessor.workingContext();
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 +479,53 @@ 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;
// Evidence gate: catch a mis-routed A that actually needs tools.
// Downgrading to a single-step plan keeps tool access; the cost of
// a false positive is one extra executor pass, while a missed
// misroute drops the whole task silently.
if (shouldOverrideDirectAnswer(goal, directAnswer)) {
log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
+ "downgrading to single-step plan so tools can execute (goal: {})",
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
List<String> gatedSteps = List.of(goal);
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(gatedPlan.getId())
.planSteps(gatedSteps)
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
}
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
// 手动广播 direct_answer 文本而不是原始 JSON
streamingHelper.broadcastContent(conversationId, directAnswer);
return PlanStateAccessor.output()
@ -207,30 +539,45 @@ 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());
// Resolve any per-step agent delegation the planner asked for. Null
// when nothing is delegated, keeping createPlan on the legacy path.
List<Long> stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId);
var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
// 发布 plan_created 事件
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
return PlanStateAccessor.output()
// Auto-derive a goal from a genuine multi-step plan so the
// Plan-Execute path engages the goal subsystem. Injected into
// ACTIVE_GOAL so this same run's GoalEvaluationNode evaluates it.
GoalEntity autoGoal = maybeAutoCreateGoal(accessor, steps);
if (autoGoal != null && goalService != null) {
// Surface it to the UI exactly like the setGoal tool does
// ({goalId, conversationId, goal}) so the goal panel hydrates
// even though the user never called setGoal. Same SSE event the
// frontend goal store already listens for.
events.add(new GraphEventPublisher.GraphEvent("goal_created", Map.of(
"goalId", String.valueOf(autoGoal.getId()),
"conversationId", conversationId,
"goal", goalService.toResponse(autoGoal)), System.currentTimeMillis()));
}
PlanStateAccessor.OutputBuilder planOut = PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(steps)
@ -240,40 +587,40 @@ public class PlanGenerationNode implements NodeAction {
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
.events(events);
if (autoGoal != null) {
planOut.put(MateClawStateKeys.ACTIVE_GOAL, autoGoal);
}
return planOut.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, conversationId, goal, List.of(goal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(List.of(goal))
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
.events(events)
.build();
} catch (Exception persistErr) {
log.error("[PlanGeneration] Single-step fallback persistence also failed: {}", persistErr.getMessage());
return PlanStateAccessor.output()
.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,22 +20,34 @@ 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;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.planning.service.PlanningService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.tool.builtin.DelegateAgentTool;
import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 步骤执行节点
* <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 +65,56 @@ public class StepExecutionNode implements NodeAction {
private final ConversationWindowManager conversationWindowManager;
private final String reasoningEffort;
private final NodeStreamingChatHelper streamingHelper;
private final long stepWallClockTimeoutMs;
/**
* Renders the {@code ## Skills} catalog at runtime. Null in legacy / test
* constructors when null, no catalog segment is appended (the Plan path's
* pre-disclosure behavior of baking it into the system prompt is gone).
*/
private final SkillCatalogRenderer skillCatalogRenderer;
private static final int MAX_TOOL_CALLS_PER_STEP = 5;
/**
* Optional per-step delegation executor. Set after construction (this node is
* built by AgentGraphBuilder, not Spring) so a plan step assigned to a
* specialist agent runs on that agent. Null disables per-step delegation.
*/
private DelegateAgentTool delegateAgentTool;
public void setDelegateAgentTool(DelegateAgentTool delegateAgentTool) {
this.delegateAgentTool = delegateAgentTool;
}
/**
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
* Matching the agent-level cap means this constant is never the bottleneck
* the agent's own {@code max_iterations} (DB column) will fire first if a
* task is genuinely runaway, and a well-budgeted multi-tool step (e.g.
* web_search + browser_navigate + browser_read*N + file_write) is no longer
* cut short by an arbitrary 5-call ceiling.
*/
private static final int MAX_TOOL_CALLS_PER_STEP = 100;
/**
* Wall-clock budget per step, complementing {@link #MAX_TOOL_CALLS_PER_STEP}.
* The call-count cap doesn't help when a single LLM stream stalls or a
* concurrency-unsafe tool runs synchronously without a per-tool deadline
* (the parallel batch path enforces {@code ToolTimeoutProperties}, but the
* single-unsafe path in {@code ToolExecutionExecutor#executeSingleTool}
* currently does not). 10 minutes is generous for legitimate long steps
* (large file edits, multi-page browser flows) while still cutting off the
* pathological cases where the agent appears frozen to the user.
*/
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
/**
* Max re-plans per graph run. When a step throws, the executor re-plans the
* remaining work around the failure instead of aborting the whole plan a
* single transient tool error or one badly-scoped step no longer kills the
* task. Bounded so a step that fails every attempt can't re-plan forever;
* once exhausted the plan aborts as before. Kept small (the recursion
* ceiling already accommodates it) raise with care.
*/
private static final int MAX_REPLANS_PER_RUN = 1;
private static final ObjectMapper MAPPER = new ObjectMapper();
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
@ -64,6 +123,45 @@ public class StepExecutionNode implements NodeAction {
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager) {
this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager,
null, STEP_WALL_CLOCK_TIMEOUT_MS);
}
/** Production constructor with the runtime skill-catalog renderer. */
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
SkillCatalogRenderer skillCatalogRenderer) {
this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager,
skillCatalogRenderer, STEP_WALL_CLOCK_TIMEOUT_MS);
}
/** Test-friendly overload — production callers use the default timeout. */
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
long stepWallClockTimeoutMs) {
this(chatModel, toolSet, executor, planningService, streamTracker,
reasoningEffort, streamingHelper, conversationWindowManager,
null, stepWallClockTimeoutMs);
}
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
SkillCatalogRenderer skillCatalogRenderer,
long stepWallClockTimeoutMs) {
this.chatModel = chatModel;
this.toolSet = toolSet;
this.executor = executor;
@ -72,6 +170,8 @@ public class StepExecutionNode implements NodeAction {
this.conversationWindowManager = conversationWindowManager;
this.reasoningEffort = reasoningEffort;
this.streamingHelper = streamingHelper;
this.skillCatalogRenderer = skillCatalogRenderer;
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
}
@Override
@ -86,6 +186,14 @@ 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);
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
if (stepIndex >= steps.size()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@ -100,13 +208,36 @@ public class StepExecutionNode implements NodeAction {
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
// Iteration boundary for the plan-execute loop: each step is one
// iteration that may itself fan out to multiple LLM calls. Reason is
// "plan_step" so consumers can distinguish it from ReAct's
// "react_step" / "first_turn" markers when both stream into the
// same SSE feed.
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
// Per-step delegation: when this step is assigned to a different
// specialist agent, run it on that agent (as an isolated child) and use
// its reply as the step result, instead of executing locally with the
// parent agent's tools. assignedAgentId comes from the DB so it survives
// replay / approval-resume.
Long assignedAgentId = planningService.getStepAssignedAgent(planId, stepIndex);
if (delegateAgentTool != null && assignedAgentId != null
&& !assignedAgentId.equals(parseLongOrNull(agentId))) {
return executeDelegatedStep(accessor, stepIndex, step, planId, assignedAgentId,
conversationId, chatOrigin, events, iterationEventsOn);
}
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
}
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
planningService.updateSubPlanStatus(planId, stepIndex, "running");
// 构建消息列表
List<Message> messages = buildStepMessages(accessor, step, systemPrompt, workspaceBasePath);
List<Message> messages = buildStepMessages(accessor, step, systemPrompt, workspaceBasePath,
runtimeModelName, runtimeProviderId);
// 显式工具执行循环
String finalResult = null;
@ -117,21 +248,56 @@ 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;
// Signature-based progress detector: nudges the model to change strategy
// when a round stalls (repeated failures / identical results), and flags
// the step as stuck past a hard threshold so we re-plan instead of
// burning the whole tool-call budget and advancing with junk.
StepProgressTracker progressTracker = new StepProgressTracker();
try {
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) {
// Pass conversationId + workspaceBasePath so oversized
// older tool results can be spilled to disk instead of
// being rewritten into a lossy single-line summary.
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
@ -180,16 +346,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 +373,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 +397,40 @@ public class StepExecutionNode implements NodeAction {
if (approvalTriggered) {
break;
}
// Progress tracking: feed this round's tool results to the
// detector. A WARN-level stall injects a one-shot "change
// strategy" SystemMessage the model sees on its next call; a
// HALT-level stall stops the inner loop so the post-loop logic
// re-plans instead of spinning to the tool-call ceiling.
java.util.Map<String, String> idToArgs = new java.util.HashMap<>();
for (AssistantMessage.ToolCall tc : allToolCalls) {
if (tc != null && tc.id() != null) {
idToArgs.put(tc.id(), tc.arguments());
}
}
for (ToolResponseMessage.ToolResponse tr : toolResponses) {
var nudge = progressTracker.record(
tr.name(), idToArgs.getOrDefault(tr.id(), ""), tr.responseData());
if (nudge.isPresent()) {
messages.add(new SystemMessage(nudge.get()));
}
}
if (progressTracker.isStuck()) {
log.warn("[StepExecution] Step {} stalled ({}); stopping inner loop to re-plan",
stepIndex, progressTracker.haltReason());
break;
}
// RFC-052: returnDirect short-circuit. Any direct tool in this
// step ends the plan immediately; the dispatcher routes via
// currentPhase=plan_aborted so no further LLM call happens.
if (!stepDirectOutputs.isEmpty()) {
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
"tool output(s); aborting plan execution",
stepIndex, stepDirectOutputs.size());
break;
}
}
// 处理审批暂停
@ -239,10 +449,103 @@ public class StepExecutionNode implements NodeAction {
.build();
}
// RFC-052: direct tool short-circuit at the plan level. Treat the
// assembled direct text as the final summary and abort the plan;
// the dispatcher routes plan_aborted to END so no further LLM call
// is made. Persisting RETURN_DIRECT_TRIGGERED + DIRECT_TOOL_OUTPUTS
// lets the SSE accumulator pick up directToolNames metadata so
// history scrub (BaseAgent.isDirectToolMessage) kicks in next turn.
//
// Plan status is "completed" (not "failed"): the user got their
// answer correctly, the plan just terminated earlier than the
// model's planning stage anticipated. Marking as failed would skew
// operational dashboards and confuse plan-history readers.
if (!stepDirectOutputs.isEmpty()) {
String assembled = assembleDirectAnswerText(stepDirectOutputs);
planningService.updateSubPlanResult(planId, stepIndex, assembled);
planningService.completePlan(planId,
"Plan completed via returnDirect tool: " +
stepDirectOutputs.get(0).toolName());
events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
assembled != null ? assembled.length() : 0, 0));
}
return PlanStateAccessor.output()
.currentStepResult(assembled)
.currentStepIndex(steps.size()) // 越界 dispatcher 收束
.currentPhase("plan_aborted")
.finalSummary(assembled)
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.put(MateClawStateKeys.PROMPT_TOKENS,
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS,
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
// Step-failure recovery WITHOUT an exception: the inner loop ended
// with no usable result stalled (repeated failures / identical
// results, flagged by the progress tracker), hit the wall-clock or
// tool-call ceiling, or returned an empty answer. Re-plan the
// remaining work around it instead of advancing dependent steps with
// junk. Shares PLAN_REPLAN_COUNT with the exception path; once the
// budget is spent we fall through to the legacy "complete with a
// failure note" path below so the plan still terminates.
boolean noUsableResult = progressTracker.isStuck()
|| finalResult == null || finalResult.isBlank();
int noProgressReplanCount = accessor.replanCount();
if (noUsableResult && noProgressReplanCount < MAX_REPLANS_PER_RUN) {
String reason = progressTracker.isStuck()
? "本步骤陷入停滞(" + progressTracker.haltReason() + "),未取得有效结果"
: wallClockExceeded
? "本步骤超过最大耗时限制,未取得有效结果"
: finalResult == null
? "本步骤超过最大工具调用次数,未取得有效结果"
: "本步骤未产出有效结果";
planningService.updateSubPlanFailure(planId, stepIndex, reason);
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + "" + reason);
events.add(GraphEventPublisher.stepCompleted(stepIndex, reason));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, reason.length(), 0));
}
events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of(
"failedStepIndex", stepIndex,
"attempt", noProgressReplanCount + 1,
"maxReplans", MAX_REPLANS_PER_RUN,
"reason", reason), System.currentTimeMillis()));
log.warn("[StepExecution] Step {} produced no usable result ({}); re-planning (attempt {}/{})",
stepIndex + 1, reason, noProgressReplanCount + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.workingContext(buildReplanContext(accessor, stepIndex, reason))
.currentPhase("plan_replan")
.replanCount(noProgressReplanCount + 1)
.planId(null)
.planSteps(List.of())
.planValid(false)
.needsPlanning(true)
.currentStepIndex(0)
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
if (finalResult == null) {
if (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 +553,50 @@ 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));
}
// Step-failure recovery: rather than aborting the whole plan on a
// single failed step, re-plan the remaining work around the failure
// (up to MAX_REPLANS_PER_RUN). Completed steps are preserved in
// WORKING_CONTEXT, so the next PlanGeneration pass can skip them and
// route around (or retry) what broke. The mid-pass plan state is
// cleared so a fresh plan is derived; PLAN_REPLAN_COUNT bounds the loop.
int replanCount = accessor.replanCount();
if (replanCount < MAX_REPLANS_PER_RUN) {
String replanContext = buildReplanContext(accessor, stepIndex, shortError);
events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of(
"failedStepIndex", stepIndex,
"attempt", replanCount + 1,
"maxReplans", MAX_REPLANS_PER_RUN,
"error", shortError == null ? "" : shortError),
System.currentTimeMillis()));
log.warn("[StepExecution] Step {} failed; re-planning remaining work (attempt {}/{})",
stepIndex + 1, replanCount + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.workingContext(replanContext)
.currentPhase("plan_replan")
.replanCount(replanCount + 1)
// Wipe mid-pass plan state so PlanGenerationNode re-derives
// a fresh plan from goal + (failure-augmented) context.
.planId(null)
.planSteps(List.of())
.planValid(false)
.needsPlanning(true)
.currentStepIndex(0)
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
log.warn("[StepExecution] Step {} failed and re-plan budget exhausted ({}); aborting plan",
stepIndex + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.currentStepResult(shortError)
.currentPhase("plan_aborted")
@ -262,15 +609,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,7 +651,120 @@ public class StepExecutionNode implements NodeAction {
.build();
}
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
/**
* Execute a step by delegating it to its assigned specialist agent. The
* delegated agent runs the step description as a self-contained goal and its
* reply becomes the step result. Mirrors the success/failure bookkeeping of
* the local execution path (sub-plan status, completed-results accumulation,
* incremental working-context update) so the rest of the plan graph is
* unaffected by where the step ran.
*/
private Map<String, Object> executeDelegatedStep(
PlanStateAccessor accessor, int stepIndex, String step, Long planId,
Long assignedAgentId, String conversationId, ChatOrigin chatOrigin,
List<GraphEventPublisher.GraphEvent> events, boolean iterationEventsOn) {
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
}
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
events.add(GraphEventPublisher.phase("executing", Map.of(
"stepIndex", stepIndex, "stepTitle", step,
"delegatedAgentId", String.valueOf(assignedAgentId))));
planningService.updateSubPlanStatus(planId, stepIndex, "running");
log.info("[StepExecution] Delegating step {} to agent {}", stepIndex + 1, assignedAgentId);
// Seed the delegation context with the plan's REAL conversation id (from
// graph state) so the delegated child conversation is parented to it and
// stays hidden from the user's conversation list. The ChatOrigin in the
// plan-execute path carries no conversationId, so delegateByAgentId can't
// derive the parent on its own we provide it here.
boolean seeded = false;
if (conversationId != null && !conversationId.isBlank()
&& DelegationContext.parentConversationId() == null
&& ToolExecutionContext.conversationId() == null) {
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
seeded = true;
}
String result;
try {
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
} catch (Exception e) {
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
result = "[错误] 委派执行异常:" + e.getMessage();
} finally {
if (seeded) {
DelegationContext.exit();
}
}
String finalResult = result != null ? result : "";
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
if (failed) {
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
} else {
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
}
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, finalResult.length(), 0));
}
// Keep the rolling working-context in sync exactly like the local path
// so later steps see this delegated step's result.
String prevWorkingContext = accessor.workingContext();
String formattedNewStep = formatStepResult(stepIndex, finalResult);
String updatedWorkingContext = prevWorkingContext.isEmpty()
? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep))
: appendStepIncremental(prevWorkingContext, formattedNewStep);
return PlanStateAccessor.output()
.currentStepResult(finalResult)
.completedResults(formattedNewStep)
.currentStepIndex(stepIndex + 1)
.workingContext(updatedWorkingContext)
.currentPhase("step_completed")
.contentStreamed(true)
.events(events)
.build();
}
/** Parse a string id to Long, or null when blank / non-numeric. */
private static Long parseLongOrNull(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return null;
}
}
/**
* RFC-052: assemble the final answer text from direct tool outputs in this
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
* sees the same shape regardless of which graph (ReAct / Plan-Execute)
* produced the answer.
*/
private static String assembleDirectAnswerText(List<DirectToolOutput> outputs) {
if (outputs.size() == 1) {
return outputs.get(0).fullResult();
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < outputs.size(); i++) {
DirectToolOutput out = outputs.get(i);
if (i > 0) sb.append("\n\n");
sb.append("### ").append(out.toolName()).append("\n");
sb.append(out.fullResult());
}
return sb.toString();
}
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt,
String workspaceBasePath, String runtimeModelName, String runtimeProviderId) {
List<Message> messages = new ArrayList<>();
// Layer 1: System prompt增强指令
@ -306,8 +783,19 @@ public class StepExecutionNode implements NodeAction {
8. 每一步最多做一个必要的检查和一个必要的执行不要无意义循环
""";
messages.add(new SystemMessage(enhancedSystemPrompt));
// 注入运行时上下文当前时间 + 工作目录
messages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
// Runtime skill catalog (rendered here instead of baked into the system
// prompt). The Plan path never pins per-run loads, so render with an
// empty loaded set this reproduces the pre-disclosure DB ordering.
if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of());
if (skillCatalog != null && !skillCatalog.isBlank()) {
messages.add(new SystemMessage(skillCatalog));
}
}
// 注入运行时上下文当前时间 + 工作目录 + 发起者上下文 + 模型身份
messages.add(new UserMessage(
RuntimeContextInjector.buildContextMessage(
workspaceBasePath, null, accessor.chatOrigin(), runtimeModelName, runtimeProviderId)));
// Layer 2: Working context对话历史 + 步骤结果的受控长度摘要
String workingContext = accessor.workingContext();
@ -375,6 +863,30 @@ public class StepExecutionNode implements NodeAction {
}
}
/**
* Augment the working context with a note about the failed step so the next
* PlanGeneration pass re-plans around it. The completed-step results are
* already encoded in {@code WORKING_CONTEXT}; this appends only the failure
* so the planner can skip what's done, retry differently, or route around
* the broken step. The note is an internal LLM prompt (Chinese, matching the
* surrounding planning/execution prompts).
*/
static String buildReplanContext(PlanStateAccessor accessor, int failedStepIndex, String error) {
List<String> steps = accessor.planSteps();
String failedTitle = (failedStepIndex >= 0 && failedStepIndex < steps.size())
? steps.get(failedStepIndex) : ("步骤 " + (failedStepIndex + 1));
StringBuilder sb = new StringBuilder(accessor.workingContext());
if (sb.length() > 0) {
sb.append("\n\n");
}
sb.append("【上一轮计划执行失败】步骤 ").append(failedStepIndex + 1)
.append("").append(failedTitle).append(")执行失败:")
.append(error == null ? "未知错误" : error)
.append("\n请基于上面已完成的工作重新规划达成总目标所需的剩余步骤")
.append("绕开或换一种方式完成失败的部分,不要重复已经完成的步骤。");
return sb.toString();
}
/**
* 将异常转换为简短的错误摘要避免将完整异常体尤其是 429 JSON写入后续 prompt
* <ul>
@ -414,9 +926,51 @@ public class StepExecutionNode implements NodeAction {
}
}
/** Append helper used by the incremental working-context fast path. */
private static List<String> appendOne(List<String> previous, String item) {
List<String> out = new ArrayList<>(previous);
out.add(item);
return out;
}
/**
* 根据当前 accessor 中的会话历史消息和更新后的已完成步骤结果
* 重建 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

@ -0,0 +1,152 @@
package vip.mate.agent.graph.plan.node;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Per-step progress detector for the Plan-Execute executor.
*
* <p>A plan step runs its own inner tool-calling loop. Without progress
* tracking, a step can spin repeatedly calling the same tool, or hammering
* different variants that all fail / return nothing until it hits the
* tool-call ceiling, then "complete" with an empty result and let the plan
* plow into dependent steps that have no real input.
*
* <p>This tracker watches the tool results of each round and recognises two
* signature-based stall patterns:
* <ul>
* <li><b>repeated failure</b> the same call (tool name + canonical args)
* keeps failing, or the same tool keeps failing with different args;</li>
* <li><b>no progress</b> a call keeps returning the <em>same</em> result,
* so re-issuing it yields nothing new.</li>
* </ul>
*
* <p>Detection is graduated: at the WARN threshold it emits a one-shot nudge
* (injected back into the step's messages so the model changes strategy);
* past the HALT threshold it flags the step as stuck so the executor can stop
* the inner loop and re-plan instead of advancing with junk. Thresholds are
* deliberately low the goal is to break a stall early, before the whole
* tool-call budget is burned.
*
* <p>Not thread-safe; create one per step.
*/
public final class StepProgressTracker {
/** Same exact call (tool + args) failing: nudge / halt thresholds. */
static final int SAME_CALL_FAIL_WARN = 2;
static final int SAME_CALL_FAIL_HALT = 4;
/** Same tool failing across different args: nudge / halt thresholds. */
static final int SAME_TOOL_FAIL_WARN = 3;
static final int SAME_TOOL_FAIL_HALT = 6;
/** Same call returning identical output (no new information): nudge / halt. */
static final int NO_PROGRESS_WARN = 2;
static final int NO_PROGRESS_HALT = 4;
/**
* Lower-cased markers that identify a tool result as a failure / empty
* outcome. Kept intentionally small and language-mixed: tool errors in this
* codebase surface as English exception text, while a few common "not
* found" phrasings also appear in Chinese tool output.
*/
private static final String[] FAILURE_MARKERS = {
"execution failed", "error:", "exception", "timeout", "timed out",
"enoent", "no such file", "not found", "authentication failed",
"permission denied", "failed to", "未找到", "不存在", "没有找到", "执行失败", "无法"
};
private final Map<String, Integer> sameCallFail = new HashMap<>();
private final Map<String, Integer> sameToolFail = new HashMap<>();
private final Map<String, Integer> resultRepeat = new HashMap<>();
private final Set<String> warnedKeys = new HashSet<>();
private boolean stuck = false;
private String haltReason = null;
/**
* Record one tool result from the current round.
*
* @param toolName the invoked tool's name (never null)
* @param argsJson the raw arguments JSON (may be empty when unresolved)
* @param resultText the tool's result text (may be null/empty)
* @return a nudge to inject into the step's messages when a WARN threshold
* was freshly crossed, otherwise empty. Each distinct warning fires
* at most once.
*/
public Optional<String> record(String toolName, String argsJson, String resultText) {
String name = toolName == null ? "tool" : toolName;
String args = argsJson == null ? "" : argsJson;
String result = resultText == null ? "" : resultText;
boolean failure = looksLikeFailure(result);
String callSig = name + "::" + args.hashCode();
String resultKey = callSig + "##" + result.trim().hashCode();
// No-progress: identical result for the same call, regardless of success.
int repeats = resultRepeat.merge(resultKey, 1, Integer::sum);
if (repeats >= NO_PROGRESS_HALT) {
markStuck("no_progress:" + name);
}
Optional<String> nudge = maybeWarn(repeats >= NO_PROGRESS_WARN, "np:" + resultKey,
"工具 " + name + " 已连续 " + repeats + " 次返回相同结果。不要重复同样的调用——"
+ "改用已有结果、换查询/换工具,或直接基于现有信息给出本步骤结论。");
if (failure) {
int callFails = sameCallFail.merge(callSig, 1, Integer::sum);
int toolFails = sameToolFail.merge(name, 1, Integer::sum);
if (callFails >= SAME_CALL_FAIL_HALT || toolFails >= SAME_TOOL_FAIL_HALT) {
markStuck("repeated_failure:" + name);
}
if (nudge.isEmpty()) {
nudge = maybeWarn(callFails >= SAME_CALL_FAIL_WARN, "cf:" + callSig,
"工具 " + name + " 用相同参数已失败 " + callFails + " 次,像是死循环。"
+ "先看错误原因再换一种方式,不要原样重试。");
}
if (nudge.isEmpty()) {
nudge = maybeWarn(toolFails >= SAME_TOOL_FAIL_WARN, "tf:" + name,
"工具 " + name + " 本步骤已失败 " + toolFails + " 次。停止在同一条失败路径上重试,"
+ "换工具或换思路完成本步骤。");
}
}
return nudge;
}
/** True once a HALT threshold was crossed — the step should stop and re-plan. */
public boolean isStuck() {
return stuck;
}
/** Machine-readable reason for the halt, or null when not stuck. */
public String haltReason() {
return haltReason;
}
private Optional<String> maybeWarn(boolean crossed, String key, String message) {
if (crossed && warnedKeys.add(key)) {
return Optional.of(message);
}
return Optional.empty();
}
private void markStuck(String reason) {
if (!stuck) {
stuck = true;
haltReason = reason;
}
}
static boolean looksLikeFailure(String result) {
if (result == null || result.isBlank()) {
return true; // an empty result is no progress either
}
String lower = result.toLowerCase();
for (String marker : FAILURE_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return false;
}
}

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);
}
// ===== 步骤控制 =====
@ -70,6 +73,11 @@ public final class PlanStateAccessor {
return state.<List<String>>value(COMPLETED_RESULTS).orElse(List.of());
}
/** Re-plans already performed this run (0 at run start). */
public int replanCount() {
return state.value(PLAN_REPLAN_COUNT, 0);
}
// ===== 终止 =====
public String finalSummary() {
@ -104,6 +112,17 @@ public final class PlanStateAccessor {
return state.value(MateClawStateKeys.TRACE_ID, "");
}
/**
* The {@link vip.mate.agent.context.ChatOrigin} forwarded into graph
* state by {@code MateClawStateAccessor.OutputBuilder.chatOrigin}.
* Returns {@link vip.mate.agent.context.ChatOrigin#EMPTY} when nothing
* was injected (legacy callers / non-channel entry points).
*/
public vip.mate.agent.context.ChatOrigin chatOrigin() {
return state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
}
// ===== 会话消息复用 MateClawStateKeys.MESSAGES=====
@SuppressWarnings("unchecked")
@ -173,6 +192,10 @@ public final class PlanStateAccessor {
return put(CURRENT_STEP_INDEX, index);
}
public OutputBuilder replanCount(int count) {
return put(PLAN_REPLAN_COUNT, count);
}
public OutputBuilder currentStepTitle(String title) {
return put(CURRENT_STEP_TITLE, title);
}
@ -232,8 +255,10 @@ public final class PlanStateAccessor {
NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this;
}

View File

@ -27,6 +27,15 @@ public final class PlanStateKeys {
public static final String CURRENT_STEP_RESULT = "current_step_result";
public static final String COMPLETED_RESULTS = "completed_results"; // APPEND 策略
/**
* Number of re-plans performed in THIS graph run (REPLACE strategy). When a
* step throws, the executor re-plans the remaining work around the failure
* (carried in {@link #WORKING_CONTEXT}) instead of aborting outright, up to
* a small bound this counter enforces that bound so a pathological failure
* loop can't re-plan forever. Implicitly 0 at run start.
*/
public static final String PLAN_REPLAN_COUNT = "plan_replan_count";
// ===== 终止 =====
public static final String FINAL_SUMMARY = "final_summary";
public static final String DIRECT_ANSWER = "direct_answer"; // 简单问答的直接回答

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.*;
@ -79,6 +80,11 @@ public final class MateClawStateAccessor {
return state.value(LLM_CALL_COUNT, 0);
}
/** Iterations refunded this run for setup-only (progressive-disclosure) rounds (0 at run start). */
public int iterationRefundCount() {
return state.value(ITERATION_REFUND_COUNT, 0);
}
// ===== 观察历史 =====
@SuppressWarnings("unchecked")
@ -195,12 +201,58 @@ 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);
}
// ===== Skill progressive disclosure =====
/**
* Skills loaded via {@code load_skill} so far this run. Empty when none
* have been loaded (the common first-iteration case).
*/
@SuppressWarnings("unchecked")
public Set<String> loadedSkills() {
return state.<Set<String>>value(LOADED_SKILLS).orElse(Set.of());
}
/**
* Extension tools activated via {@code enable_tool} so far this run. Empty
* when none have been enabled (the common case).
*/
@SuppressWarnings("unchecked")
public Set<String> enabledExtensionTools() {
return state.<Set<String>>value(ENABLED_EXTENSION_TOOLS).orElse(Set.of());
}
// ===== Token Usage =====
public int promptTokens() {
@ -219,6 +271,72 @@ public final class MateClawStateAccessor {
return state.value(RUNTIME_PROVIDER_ID, "");
}
// ===== Persistent goal accessors =====
/**
* Active goal snapshot or empty. The injected object is the
* {@code vip.mate.goal.model.GoalEntity}; we reference it by Object
* here to avoid pulling the goal package into core graph state.
*/
public Optional<Object> activeGoal() {
return state.<Object>value(ACTIVE_GOAL);
}
public boolean hasActiveGoal() {
return state.<Object>value(ACTIVE_GOAL).isPresent();
}
public boolean goalEvaluatedThisRun() {
return state.value(GOAL_EVALUATED_THIS_RUN, false);
}
public boolean goalFollowupInjected() {
return state.value(GOAL_FOLLOWUP_INJECTED, false);
}
public String goalFollowupPrompt() {
return state.value(GOAL_FOLLOWUP_PROMPT, "");
}
/** Auto-followups already injected in this graph run (0 at run start). */
public int goalFollowupCount() {
return state.value(GOAL_FOLLOWUP_COUNT, 0);
}
/** Cumulative agent LLM calls already billed to the goal this run (0 at run start). */
public int goalAccountedLlmCallCount() {
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
}
/** Hard continuations (fresh-budget ReAct segments) performed this run (0 at run start). */
public int goalHardContinuationCount() {
return state.value(GOAL_HARD_CONTINUATION_COUNT, 0);
}
/**
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
* Plan-Execute writes to {@code PlanStateKeys.FINAL_SUMMARY} (long
* path) or {@code PlanStateKeys.DIRECT_ANSWER} (short path). The
* GoalEvaluationNode reads whichever is populated without having to
* know which graph it's inside.
*/
public String terminalAnswer() {
String fa = state.value(FINAL_ANSWER, "");
if (!fa.isEmpty()) {
return fa;
}
// Avoid a direct compile-time reference to PlanStateKeys (the plan
// sub-package depends on core graph state); use the string keys
// verbatim. Mismatches would surface as terminalAnswer() returning
// empty in tests the v3 TerminalAnswerTest pins exactly that.
String summary = state.value("final_summary", "");
if (!summary.isEmpty()) {
return summary;
}
return state.value("direct_answer", "");
}
// ===== 输出构建器 =====
/**
@ -260,6 +378,10 @@ public final class MateClawStateAccessor {
return put(NEEDS_TOOL_CALL, needs);
}
public OutputBuilder iterationRefundCount(int count) {
return put(ITERATION_REFUND_COUNT, count);
}
// ---- 消息 ----
public OutputBuilder messages(List<Message> msgs) {
return put(MESSAGES, msgs);
@ -373,11 +495,39 @@ 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);
}
// ---- Skill progressive disclosure ----
public OutputBuilder loadedSkills(Set<String> names) {
return put(LOADED_SKILLS, names);
}
// ---- Tool progressive disclosure ----
public OutputBuilder enabledExtensionTools(Set<String> names) {
return put(ENABLED_EXTENSION_TOOLS, names);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
@ -390,6 +540,113 @@ public final class MateClawStateAccessor {
return this;
}
// ---- Persistent goal ----
public OutputBuilder goalEvaluationResult(Map<String, Object> result) {
return put(GOAL_EVALUATION_RESULT, result);
}
public OutputBuilder goalFollowupInjected(boolean injected) {
return put(GOAL_FOLLOWUP_INJECTED, injected);
}
public OutputBuilder goalFollowupPrompt(String prompt) {
return put(GOAL_FOLLOWUP_PROMPT, prompt);
}
public OutputBuilder goalEvaluatedThisRun(boolean v) {
return put(GOAL_EVALUATED_THIS_RUN, v);
}
public OutputBuilder goalFollowupCount(int n) {
return put(GOAL_FOLLOWUP_COUNT, n);
}
public OutputBuilder goalAccountedLlmCallCount(int n) {
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
}
public OutputBuilder goalHardContinuationCount(int n) {
return put(GOAL_HARD_CONTINUATION_COUNT, n);
}
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
* immediately re-terminate via the existing final text. */
public OutputBuilder clearFinalAnswer() {
return put(FINAL_ANSWER, "");
}
/** Wipe FINISH_REASON for the same reason as clearFinalAnswer(). */
public OutputBuilder clearFinishReason() {
return put(FINISH_REASON, "");
}
/**
* Wipe the limit-exceeded draft + flag. Required before a hard
* continuation re-enters the ReAct loop: FinalAnswerNode prefers
* FINAL_ANSWER_DRAFT over a freshly reasoned answer, so a stale draft
* left by LimitExceededNode would otherwise resurface as the next
* segment's answer.
*/
public OutputBuilder clearLimitExceededDraft() {
put(FINAL_ANSWER_DRAFT, "");
return put(LIMIT_EXCEEDED, false);
}
/** Plan-Execute follow-up: clear the terminal-side plan summary so
* the next PlanGeneration pass starts clean. Identifier is the
* string literal "final_summary" to avoid a compile-time link to
* the plan sub-package from core graph state. */
public OutputBuilder clearPlanFinalSummary() {
return put("final_summary", "");
}
public OutputBuilder clearPlanDirectAnswer() {
return put("direct_answer", "");
}
/** Plan-Execute follow-up: wipe the mid-pass plan state so the next
* PlanGenerationNode pass re-derives everything from scratch. */
public OutputBuilder clearPlanId() {
return put("plan_id", null);
}
public OutputBuilder clearPlanSteps() {
return put("plan_steps", List.of());
}
public OutputBuilder clearPlanValid() {
return put("plan_valid", false);
}
public OutputBuilder clearNeedsPlanning() {
return put("needs_planning", true);
}
public OutputBuilder clearCurrentStepIndex() {
return put("current_step_index", 0);
}
public OutputBuilder clearCurrentStepTitle() {
return put("current_step_title", "");
}
public OutputBuilder clearCurrentStepResult() {
return put("current_step_result", "");
}
public OutputBuilder clearCompletedResults() {
return put("completed_results", List.of());
}
public OutputBuilder clearFinalSummaryThinking() {
return put("final_summary_thinking", "");
}
public OutputBuilder clearCurrentStepThinking() {
return put("current_step_thinking", "");
}
public Map<String, Object> build() {
return map;
}

View File

@ -30,6 +30,16 @@ public final class MateClawStateKeys {
public static final String CURRENT_ITERATION = "current_iteration";
public static final String MAX_ITERATIONS = "max_iterations";
/**
* Iterations refunded this run because a reasoning round did no real work
* its whole tool batch was progressive-disclosure setup ({@code load_skill}
* / {@code enable_tool}). ObservationNode skips the iteration increment for
* such rounds so a tight budget isn't eaten by the load-then-use two-step;
* this counter bounds the refunds so a model that only ever loads skills
* still terminates. Implicitly 0 at run start. REPLACE strategy.
*/
public static final String ITERATION_REFUND_COUNT = "iteration_refund_count";
// ===== 工具调用REPLACE 策略=====
public static final String TOOL_CALLS = "tool_calls";
public static final String TOOL_RESULTS = "tool_results";
@ -83,6 +93,15 @@ public final class MateClawStateKeys {
// ===== 事件流APPEND 策略=====
public static final String PENDING_EVENTS = "pending_events";
/**
* Multimodal routing decision for the current turn (REPLACE strategy).
* Stored as a Map ready for JSON serialization. Set by BaseAgent before
* the reasoning node runs; read back by FinalAnswerNode and (separately)
* emitted as a graph event for the SSE accumulator to write into the
* persisted message metadata under {@code metadata.routing}.
*/
public static final String ROUTING_DECISION = "routing_decision";
// ===== 阶段标记REPLACE 策略=====
public static final String CURRENT_PHASE = "current_phase";
@ -140,4 +159,141 @@ 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";
// ===== Persistent goal cross-turn objective lock-in =====
/**
* Active goal snapshot bound to the conversation; null when no goal.
* Injected by {@code buildInitialState} from {@code GoalService.findActiveByConversation}.
* Read by GoalEvaluationNode + its dispatcher.
*/
public static final String ACTIVE_GOAL = "active_goal";
/**
* Map snapshot of the latest evaluation pass (score/gap/decision/...).
* Written by GoalEvaluationNode; consumed by the SSE accumulator for
* the {@code goal_evaluated} event payload.
*/
public static final String GOAL_EVALUATION_RESULT = "goal_evaluation_result";
/**
* True when GoalEvaluationNode injected a follow-up prompt and the
* dispatcher should re-enter the reasoning loop (or PlanGeneration in
* the Plan-Execute graph) instead of terminating to END.
*/
public static final String GOAL_FOLLOWUP_INJECTED = "goal_followup_injected";
/**
* Follow-up user-message text to append to MESSAGES on graph re-entry.
* ReasoningNode (or PlanGenerationNode) reads this on its way in,
* appends to MESSAGES, then clears the value so the second pass
* cannot double-inject.
*/
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
/**
* Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets
* this true only when it ENDS the run (completed / exhausted / skip /
* continue-without-followup). The FinalAnswerNodeGoalEvaluation edge skips
* re-entering once it's true. The followup branch deliberately leaves it
* false so the self-continuation loop can re-evaluate the next answer; that
* loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus
* the goal's turn / LLM-call budgets.
*/
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
/**
* Number of auto-followups already injected in THIS graph run (one user
* turn). Bounds the self-continuation loop per single message independent
* of the goal's cross-turn turn_budget so one message can't drive an
* unbounded number of autonomous steps or exhaust the graph recursion
* limit. Implicitly 0 at the start of each graph invocation.
*/
public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count";
/**
* Cumulative agent LLM-call count already billed to the goal in THIS graph
* run. The run-to-completion loop evaluates multiple times per run while
* {@link #LLM_CALL_COUNT} keeps growing; recording only
* (current accounted) on each pass avoids re-billing earlier calls and
* exhausting the goal's LLM budget prematurely. Implicitly 0 at run start.
*/
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
/**
* Number of "hard continuations" already performed in THIS graph run a
* hard continuation is a goal follow-up that re-enters the ReAct loop with
* a FRESH iteration budget (CURRENT_ITERATION reset to 0) after a turn that
* ended in {@link FinishReason#MAX_ITERATIONS_REACHED}. Unlike a normal
* follow-up (which shares the run's single iteration budget), a hard
* continuation grants the goal a brand-new ReAct segment so a task too big
* for one budget can keep going autonomously instead of stalling until the
* user sends another message. Because each such segment costs up to a full
* {@code maxIterations} worth of node visits, it is bounded by a dedicated,
* tighter cap ({@code mateclaw.goal.max-hard-continuations-per-run}, clamped
* to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING})
* and sized into the graph recursion ceiling. Implicitly 0 at run start.
*/
public static final String GOAL_HARD_CONTINUATION_COUNT = "goal_hard_continuation_count";
/** Graph-node identifier for the GoalEvaluationNode. */
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
/**
* RFC-063r §2.5: top-level agent writes the {@code ChatOrigin} value object
* into graph state once at {@code buildInitialState}; nodes (especially
* {@code StepExecutionNode} in the Plan-Execute sub-graph) read it
* read-only when invoking {@link vip.mate.agent.graph.executor.ToolExecutionExecutor}
* so child graphs and delegated agents inherit the originating channel /
* workspace context.
*/
public static final String CHAT_ORIGIN = "chat_origin";
// ===== Skill progressive disclosure (REPLACE strategy) =====
/**
* Names of skills explicitly loaded via the {@code load_skill} tool during
* this graph run. Stored as a {@code Set<String>} and used to pin recently
* loaded skills to the top of the runtime skill catalog so a multi-iteration
* loop stops re-loading the same skill it already pulled into message
* history. ActionNode reads the prior value and writes back the merged set
* (read-merge-write under the REPLACE strategy).
* <p>
* MUST be registered in both the ReAct and Plan-Execute KeyStrategyFactory
* blocks or the framework will drop it on multi-node merges, leaving the
* catalog ranker blind to in-run loads.
*/
public static final String LOADED_SKILLS = "loaded_skills";
/**
* Function names of extension-tier tools activated via {@code enable_tool}
* during this run. Stored as a {@code Set<String>}; ReasoningNode adds these
* back to the active tool callbacks on its next turn so an enabled extension
* tool becomes callable within the same ReAct loop. ActionNode reads the
* prior value and writes back the merged set (read-merge-write under REPLACE).
* <p>
* MUST be registered in both KeyStrategyFactory blocks (see
* {@link #LOADED_SKILLS}).
*/
public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools";
}

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