Compare commits

...

7 Commits
dev ... v1.4.0

Author SHA1 Message Date
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
2076 changed files with 312895 additions and 13333 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,48 @@ JWT_SECRET=
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
MATECLAW_CORS_ALLOWED_ORIGINS=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
# ==================== 浏览器工具(可选) ====================
#
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。
# 只有在下述场景才需要 override
#
# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接:
# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222
#
# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome
# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable
#
# 3) 强制使用 Playwright channelchrome / msedge / chrome-beta …):
# MATECLAW_BROWSER_CHANNEL=chrome
MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== 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=
# ── 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

17
.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,16 @@ 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/

265
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,67 @@ 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.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) for the full story.
---
**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations.
## Contributing
@ -217,14 +232,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,42 @@ 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.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)。
---
**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。
## 参与贡献
@ -221,10 +236,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,35 @@ 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}
# 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:/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,113 @@
# 多阶段构建
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 \
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
EXPOSE 18088
EXPOSE 1455
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "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) ===== -->
@ -290,6 +344,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 +402,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 +418,57 @@
</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>
</profiles>
</project>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Minimal Maven settings for the Docker build.
Repository URLs (US Maven Central, Google CDN, Aliyun) are declared directly
in mateclaw-server/pom.xml so a single pom covers both continents — Maven
tries each repository in order and falls over on 404 / unreachable.
Historically this file also contained <mirrors> that redirected Maven Central
to Aliyun. That broke US/EU builds because <mirror> intercepts transparently
and offers no fail-over when the mirror is slow. Keeping this file empty
means pom.xml's repository list is authoritative.
If you need to force a mirror (e.g. behind a corporate proxy), add your own
mirror entries here — they will override the pom repositories.
-->
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
<mirrors/>
</settings>

View File

@ -1,6 +1,5 @@
package vip.mate;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
@ -16,13 +15,19 @@ import org.springframework.scheduling.annotation.EnableScheduling;
* @author MateClaw Team
*/
@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")
@ -33,12 +38,17 @@ public class MateClawApplication {
}
/**
* MyBatis Plus 分页插件
* MyBatis Plus pagination plugin.
*
* <p>DbType is auto-detected from the JDBC connection at runtime rather
* than hardcoded. Hardcoding H2 here meant the MySQL deployment used
* the H2 dialect for the count query, which silently returned 0
* frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

View File

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

View File

@ -0,0 +1,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,32 @@ 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.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Agent 业务服务
@ -33,9 +46,24 @@ public class AgentService {
private final AgentMapper agentMapper;
private final AgentGraphBuilder agentGraphBuilder;
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
/** 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 +76,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 +111,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());
}
}
/**
@ -91,30 +217,69 @@ public class AgentService {
// ==================== 运行时入口 ====================
public String chat(Long agentId, String message, String conversationId) {
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
}
/**
* RFC-063r §2.5: preferred entry accepts the originating
* {@link ChatOrigin} so channel binding and workspace context propagate
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chat(message, conversationId);
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();
}
}
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 +294,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 +346,20 @@ public class AgentService {
*/
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload) {
return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY);
}
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload);
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();
}
}
/**
@ -168,20 +367,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 +433,178 @@ public class AgentService {
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
}
// ==================== Lifecycle helpers ====================
/**
* Wraps a synchronous agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage as &lt;memory-context&gt; block.
* P1-4 fix: N/A for sync (no cancel/error signal issue).
*/
private String withLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
String result = invoke.apply(enrichedMessage, conversationId);
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result;
}
/**
* Wraps a streaming agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage.
* P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error.
*/
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();
return invoke.apply(enrichedMessage, conversationId)
.doOnNext(item -> {
String text = contentExtractor.apply(item);
if (text != null) {
reply.append(text);
}
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()));
}
/**
* Prepend memory-context block to user message if non-empty.
* Does not pollute build-time system prompt snapshot.
*/
private String injectMemoryContext(String message, String memoryContext) {
if (memoryContext == null || memoryContext.isBlank()) return message;
return memoryContext + "\n\n" + message;
}
// ==================== 内部方法 ====================
/**
* 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() {

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,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.service.AgentBindingService;
@ -102,6 +103,33 @@ public class AgentBindingController {
return R.ok();
}
// ==================== Provider Preferences (RFC-009 PR-3) ====================
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
@GetMapping("/provider-preferences")
@RequireWorkspaceRole("viewer")
public R<List<AgentProviderPreference>> listProviderPreferences(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
return R.ok(bindingService.listProviderPreferences(agentId));
}
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R<Void> setProviderPreferences(
@PathVariable Long agentId,
@RequestBody List<String> providerIds,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setProviderPreferences(agentId, providerIds);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
"providers=" + providerIds.size(), null);
return R.ok();
}
// ==================== Workspace Verification ====================
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
@ -111,7 +139,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,9 @@
package vip.mate.agent.binding.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.agent.binding.model.AgentProviderPreference;
@Mapper
public interface AgentProviderPreferenceMapper extends BaseMapper<AgentProviderPreference> {
}

View File

@ -3,14 +3,39 @@ package vip.mate.agent.binding.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.lifecycle.BlockedByBindingRow;
import vip.mate.skill.lifecycle.ConfirmRequiredException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@ -25,11 +50,63 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentBindingService {
public class AgentBindingService implements AgentBindingResolver {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
private final AgentProviderPreferenceMapper providerPreferenceMapper;
/**
* {@code @Lazy} SkillRuntimeService and AgentBindingService both sit
* near the agent boot path; the lazy proxy avoids a circular bean
* graph when SkillRuntimeService initializes after binding.
*/
private final SkillRuntimeService skillRuntimeService;
/**
* Source of truth for what the picker can offer (built-in + MCP). Used
* by {@link #setToolBindings} to refuse new tool names that the runtime
* couldn't resolve anyway closes the gap where a UI-disabled row
* could still be saved by hitting the API directly.
*/
private final AvailableToolService availableToolService;
/**
* Direct mapper access (instead of {@code AgentService}) to look up an
* agent's workspace before binding a skill. {@code AgentService} pulls
* in {@code AgentGraphBuilder}, which itself depends on
* {@code AgentBindingService} going through the service would create a
* boot-time cycle. The mapper has no such transitive dependency.
*/
private final AgentMapper agentMapper;
/** Same reasoning as {@link #agentMapper}: skill workspace lookup. */
private final SkillMapper skillMapper;
/**
* ACP virtual skills aren't rows in {@code mate_skill}; the bridge
* synthesizes them from {@code mate_acp_endpoint}. We need this to
* answer "what workspace does this virtual id belong to?" when an
* agent tries to bind one. MCP virtual skills don't need a bridge
* reference {@link McpSkillBridge#isVirtualMcpSkillId(Long)} is a
* static range check, and MCP servers carry no workspace today, so
* binding any MCP virtual id is allowed for any agent.
*/
private final AcpSkillBridge acpSkillBridge;
@Autowired
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
AgentToolBindingMapper toolBindingMapper,
AgentProviderPreferenceMapper providerPreferenceMapper,
@Lazy SkillRuntimeService skillRuntimeService,
AvailableToolService availableToolService,
AgentMapper agentMapper,
SkillMapper skillMapper,
AcpSkillBridge acpSkillBridge) {
this.skillBindingMapper = skillBindingMapper;
this.toolBindingMapper = toolBindingMapper;
this.providerPreferenceMapper = providerPreferenceMapper;
this.skillRuntimeService = skillRuntimeService;
this.availableToolService = availableToolService;
this.agentMapper = agentMapper;
this.skillMapper = skillMapper;
this.acpSkillBridge = acpSkillBridge;
}
// ==================== Skill Bindings ====================
@ -44,6 +121,7 @@ public class AgentBindingService {
* 获取 Agent 绑定的 enabled skill ID 集合
* 返回 null 表示该 agent 没有自定义绑定使用全局默认
*/
@Override
public Set<Long> getBoundSkillIds(Long agentId) {
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
if (bindings.isEmpty()) {
@ -56,6 +134,7 @@ public class AgentBindingService {
}
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
requireSameWorkspace(agentId, skillId);
// 检查是否已绑定
AgentSkillBinding existing = skillBindingMapper.selectOne(
new LambdaQueryWrapper<AgentSkillBinding>()
@ -85,6 +164,15 @@ public class AgentBindingService {
* 批量设置 Agent skill 绑定替换模式
*/
public void setSkillBindings(Long agentId, List<Long> skillIds) {
// Validate every incoming skill BEFORE touching the binding rows;
// a half-applied save (old bindings dropped, new set rejected
// mid-loop) would leave the agent silently un-bound from skills it
// had a moment ago.
if (skillIds != null) {
for (Long skillId : skillIds) {
requireSameWorkspace(agentId, skillId);
}
}
// 删除旧绑定
skillBindingMapper.delete(
new LambdaQueryWrapper<AgentSkillBinding>()
@ -101,6 +189,181 @@ public class AgentBindingService {
}
}
// ==================== Lifecycle curator support ====================
/**
* Skill ids explicitly bound to at least one enabled agent (binding row
* {@code enabled = true} AND agent row {@code enabled = true}). The
* lifecycle curator excludes these from its candidate set so it never
* silently undoes a user's explicit skill picks.
*/
public Set<Long> skillIdsBoundToEnabledAgents() {
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return Set.of();
}
return skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getEnabled, true))
.stream()
.filter(b -> b.getSkillId() != null && enabledAgentIds.contains(b.getAgentId()))
.map(AgentSkillBinding::getSkillId)
.collect(Collectors.toSet());
}
/**
* Binding-protected skills with the detail the lifecycle run report
* needs: {@code {skillId, name, agentIds, daysIdle}}. Hard-exempt skills
* (builtin / mcp / acp / pinned) are excluded since they would not be
* archival candidates regardless of bindings.
*/
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return List.of();
}
Map<Long, List<Long>> bySkill = new HashMap<>();
for (AgentSkillBinding b : skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getEnabled, true))) {
if (b.getSkillId() == null || !enabledAgentIds.contains(b.getAgentId())) {
continue;
}
bySkill.computeIfAbsent(b.getSkillId(), k -> new ArrayList<>()).add(b.getAgentId());
}
if (bySkill.isEmpty()) {
return List.of();
}
List<BlockedByBindingRow> rows = new ArrayList<>();
for (SkillEntity skill : skillMapper.selectBatchIds(bySkill.keySet())) {
if (Boolean.TRUE.equals(skill.getBuiltin()) || Boolean.TRUE.equals(skill.getPinned())) {
continue;
}
String type = skill.getSkillType();
if (type != null && List.of("builtin", "mcp", "acp").contains(type)) {
continue;
}
LocalDateTime anchor = skill.getLastActivityAt() != null
? skill.getLastActivityAt() : skill.getCreateTime();
long daysIdle = anchor == null ? 0L : Duration.between(anchor, now).toDays();
rows.add(new BlockedByBindingRow(skill.getId(), skill.getName(),
bySkill.get(skill.getId()), daysIdle));
}
return rows;
}
/**
* Enabled agents that explicitly bind {@code skillId}. Used by manual
* archive to list the agents an admin would affect before confirming.
*/
public List<ConfirmRequiredException.AgentRow> enabledAgentsBoundToSkill(Long skillId) {
if (skillId == null) {
return List.of();
}
Set<Long> agentIds = skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, skillId)
.eq(AgentSkillBinding::getEnabled, true))
.stream()
.map(AgentSkillBinding::getAgentId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (agentIds.isEmpty()) {
return List.of();
}
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.in(AgentEntity::getId, agentIds)
.eq(AgentEntity::getEnabled, true))
.stream()
.map(a -> new ConfirmRequiredException.AgentRow(a.getId(), a.getName()))
.collect(Collectors.toList());
}
/** Ids of every currently-enabled agent. */
private Set<Long> enabledAgentIds() {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getEnabled, true)
.select(AgentEntity::getId))
.stream()
.map(AgentEntity::getId)
.collect(Collectors.toSet());
}
/**
* Refuse to bind a skill that doesn't share the agent's workspace.
* Skills are per-workspace installable artifacts (each workspace has
* its own catalog under {@code mate_skill.workspace_id}); letting
* workspace A's agent bind workspace B's skill would leak capabilities
* and prompt content across the tenancy boundary.
*
* <p>Three skill id flavors to handle:
* <ul>
* <li><b>Real {@code mate_skill} rows</b> straight mapper lookup,
* compare {@code workspace_id} to the agent's.</li>
* <li><b>Virtual MCP-derived ids</b> ({@code >= McpSkillBridge.VIRTUAL_ID_BASE})
* pass through. MCP servers carry no workspace concept today,
* so any agent in any workspace may bind any MCP virtual skill.
* The picker (/skills/enabled) hands these out to every workspace.</li>
* <li><b>Virtual ACP-derived ids</b> ({@code AcpSkillBridge}'s range)
* resolve through the bridge so the {@link SkillEntity#getWorkspaceId()}
* comes from the backing {@code mate_acp_endpoint.workspace_id},
* then apply the same workspace comparison.</li>
* </ul>
*
* <p>Builtin skills are exempt: they are global capabilities seeded
* once into the default workspace and shared with every workspace, so
* any agent may bind them regardless of its own workspace. Only
* workspace-owned skills (dynamic / installed / synthesized) are
* tenancy-checked.
*
* @throws MateClawException 404 if the agent or skill doesn't exist;
* 403 on a workspace mismatch.
*/
private void requireSameWorkspace(Long agentId, Long skillId) {
if (agentId == null) {
throw new MateClawException("err.agent.not_found", 404, "Agent ID is required");
}
if (skillId == null) {
throw new MateClawException("err.skill.not_found", 404, "Skill ID is required");
}
AgentEntity agent = agentMapper.selectById(agentId);
if (agent == null) {
throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId);
}
// MCP virtual: no workspace on McpServerEntity globally bindable.
if (McpSkillBridge.isVirtualMcpSkillId(skillId)) {
return;
}
SkillEntity skill;
if (AcpSkillBridge.isVirtualAcpSkillId(skillId)) {
// ACP virtual: synthesize from the bridge so workspace_id flows
// through from mate_acp_endpoint. A null reply here means the
// backing endpoint was deleted or disabled between picker render
// and save same surface as a deleted real skill.
skill = acpSkillBridge.findEntityById(skillId);
if (skill == null) {
throw new MateClawException("err.skill.not_found", 404,
"ACP endpoint backing skill " + skillId + " is gone or disabled");
}
} else {
skill = skillMapper.selectById(skillId);
if (skill == null) {
throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId);
}
}
// Builtin skills are global shared across every workspace, so any
// agent in any workspace may bind them (same stance as MCP virtuals
// above). Only workspace-owned skills are tenancy-checked.
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return;
}
long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
if (agentWs != skillWs) {
throw new MateClawException("err.skill.cross_workspace_binding", 403,
"Skill " + skillId + " (workspace=" + skillWs
+ ") cannot be bound to Agent " + agentId
+ " (workspace=" + agentWs + ")");
}
}
// ==================== Tool Bindings ====================
public List<AgentToolBinding> listToolBindings(Long agentId) {
@ -125,6 +388,286 @@ public class AgentBindingService {
.collect(Collectors.toSet());
}
/**
* Single entry point that maps an agent's bindings to the set of tool
* names allowed at runtime.
*
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
* {@link #getBoundToolNames}):
* <ul>
* <li><b>{@code null} bound skills + {@code null} bound tools</b>
* returns {@code null}. Caller treats this as "no agent-level
* restriction; let the upstream {@code ToolSet} pass through
* its global default".</li>
* <li><b>at least one side non-null</b> returns the union, which
* may be empty (= "this agent is explicitly restricted to no
* tools"). The caller must distinguish empty from null.</li>
* </ul>
*
* <p>Skill expansion rules (§14.2):
* <ul>
* <li>Resolved skill found contribute
* {@code ResolvedSkill.getEffectiveAllowedTools()} only tools
* whose owning feature is READY (unavailable features stay
* hidden from the LLM, §10.2 Q8).</li>
* <li>Skill bound but unresolved (e.g. legacy or missing manifest)
* contribute nothing through this path; legacy SKILL.md prompt
* enhancement still runs separately.</li>
* </ul>
*
* <p>Auto-included on every non-null result, in addition to the bound
* tools and skill-expanded tools:
* <ul>
* <li>{@link #SYSTEM_LEVEL_TOOLS} agent-wide primitives.</li>
* <li>Every currently-bindable MCP tool ({@code source="mcp"},
* {@code available=true} in the picker) but only when the agent
* has not ticked any MCP tool itself. MCP servers are
* administrator-level capabilities, so an agent that bound merely
* a skill or a built-in tool keeps full MCP access. Once the
* operator ticks specific MCP rows, that is read as a deliberate
* per-agent scope: only the ticked MCP tools stay and the rest
* are not auto-joined, so a role can be limited to a fixed MCP
* tool set. To hide a single MCP tool from an agent that ticked
* no MCP row, use the tool-guard deny path applied upstream in
* {@code AgentGraphBuilder}.</li>
* </ul>
*/
public Set<String> getEffectiveToolNames(Long agentId) {
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
Set<String> directTools = getBoundToolNames(agentId);
// (1) null + null no restriction; defer to the global default.
if (boundSkillIds == null && directTools == null) {
return null;
}
Set<String> merged = new LinkedHashSet<>();
if (boundSkillIds != null) {
for (Long skillId : boundSkillIds) {
ResolvedSkill resolved = findResolvedSkillById(skillId);
if (resolved == null) continue;
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
// §14.2 fix: a disabled / security-blocked / setup-needed
// skill must not contribute tools to the LLM
// advertisement even if it's still bound. Without this
// guard, users see ghost tools for skills they thought
// were off.
continue;
}
Set<String> skillTools = resolved.getEffectiveAllowedTools();
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
}
}
if (directTools != null) {
// Advanced 直选的原子 tool§9.2 调整 B
merged.addAll(directTools);
}
// System-level tools that don't belong to any single skill but
// are agent-wide capabilities. Without this carve-out, binding
// any skill silently strips record_lesson / remember / structured-
// memory tools, breaking the §11 self-evolution loop entirely
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS);
// MCP tools. An agent that bound only a skill or a built-in tool
// and ticked no MCP row keeps full access to every enabled MCP
// tool: MCP servers are an administrator-enabled capability and
// must not silently vanish just because some unrelated binding
// exists. But once the operator ticks specific MCP rows, that is a
// deliberate per-agent scope only those MCP tools (already merged
// via directTools above) stay, and the rest are not auto-joined, so
// a role can be limited to a fixed MCP tool set. To instead hide a
// single MCP tool from an agent that ticked no MCP row, use the
// tool-guard deny path applied upstream in AgentGraphBuilder.
Set<String> enabledMcpTools = getEnabledMcpToolNames();
boolean agentScopedMcpExplicitly =
directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
if (!agentScopedMcpExplicitly) {
merged.addAll(enabledMcpTools);
}
return merged;
}
/**
* Names of every currently-bindable MCP tool, sourced from the same
* picker that the agent edit screen reads. Failures (picker outage,
* cache parse error) yield an empty set so the caller's allowlist is
* strictly narrower, never wider, than the picker never throws.
*/
private Set<String> getEnabledMcpToolNames() {
try {
return availableToolService.listAvailable().stream()
.filter(t -> "mcp".equals(t.getSource()))
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.filter(n -> n != null && !n.isBlank())
.collect(Collectors.toCollection(LinkedHashSet::new));
} catch (Exception e) {
log.warn("AvailableToolService unavailable while computing effective tool allowlist; "
+ "MCP tools will be excluded for this resolve cycle: {}", e.getMessage());
return Collections.emptySet();
}
}
/**
* Tools that exist outside the skill scope and must survive any
* agent-level skill binding restriction.
*
* <p>Add new entries here only after verifying the tool is genuinely
* agent-wide, not skill-specific. Tools added here bypass the
* {@link #getEffectiveToolNames} allowlist completely.
*/
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
// Structured memory primitives used by every agent regardless
// of skill bindings, otherwise the self-evolution path collapses
// (§11.3 / §11.4).
"record_lesson",
"remember",
"remember_structured",
"recall_structured",
"forget_structured",
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
// memory/YYYY-MM-DD.md). Prior versions whitelisted
// "read_workspace_file" / "write_workspace_file" /
// "list_workspace_files" those names match no @Tool bean; the
// actual function names carry the "_memory" segment, so the
// earlier carve-out was silently dead.
"list_workspace_memory_files",
"read_workspace_memory_file",
"write_workspace_memory_file",
"edit_workspace_memory_file",
// Keyword search over the same memory files. Agent-wide like the
// CRUD primitives above a skill-bound agent must still be able
// to locate a fact by keyword instead of reading whole files.
"search_workspace_memory",
// Progressive tool disclosure meta tool that activates an
// extension-tier tool for the rest of the conversation. Must be
// agent-wide so the model can always surface hidden tools.
"enable_tool",
// Skill discovery / dispatch skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts.
"load_skill",
"readSkillFile",
"runSkillScript",
"listSkillFiles",
"listAvailableSkills",
// Date / time prior whitelist had a fictional "datetime"; the
// real DateTimeTool exposes three separate methods.
"getCurrentDate",
"getCurrentDateTime",
"getCurrentTime",
// Multi-agent delegation prior whitelist had "delegate_agent",
// but DelegateAgentTool's @Tool methods are delegateToAgent /
// delegateParallel / listAvailableAgents. Same dead-name bug.
"delegateToAgent",
"delegateParallel",
// Detached async delegation spawn a sub-task that returns a
// task_id immediately, then retrieve its result in a later turn.
// Agent-wide like the synchronous delegation tools above.
"delegateAsync",
"taskOutput",
"listAvailableAgents",
// Persistent-goal management (RFC 48). These are agent-wide
// primitives the user can decide mid-conversation that this
// task is a multi-turn goal, and the assistant must be able to
// lock it in. Pre-fix, business agents like "数据分析师" with
// tight bindings rejected setGoal as "not in my toolset",
// observed during PR4 manual QA.
"setGoal",
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
// Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting
// tasks need it on every business agent, not just the planner,
// since context-window trims can otherwise let an agent forget
// what it has already produced and re-do work or stall.
"progress_update",
// Document / media generation agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever
// any skill is bound, breaking "generate a Word doc / image /
// song / video" intents on agents that happen to have a skill
// on. Regression observed 2026-05-01: a Code Reviewer agent with
// skills bound dropped renderDocx and fell back to dumping the
// markdown body for the user to copy.
"renderDocx",
"renderDocxFromFile",
"renderDocxFromFiles",
"image_generate",
"music_generate",
"video_generate",
// HTML PNG rasteriser. Closes the loop for HTML-producing skills
// (architecture-diagram, infographics, dashboards) so IM channels
// can deliver the artifact as a native image instead of a file or
// a dead markdown link.
"render_html_image",
// Universal capabilities the global system prompts (SOUL.md /
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
// available; the new gate silently hid them on any agent with
// skills bound, so the prompt promises a tool the registry then
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
// Code Reviewer agent the model called search got
// not-found gave up before ever reaching renderDocx.
"search",
"browser_use",
"read_file",
"send_file",
"write_file",
"edit_file",
"execute_shell_command",
"detect_file_type",
"extract_document_text",
"extract_pdf_text",
"extract_docx_text",
"readMateClawDoc",
// Wiki knowledge-base tools. These are agent-wide capabilities
// tied to whichever knowledge base is attached to the agent, and
// are never declared inside any skill manifest. Like the document
// and media generators above, the skill-binding allowlist would
// otherwise strip every wiki_* tool from any agent that has a
// skill bound so the agent could no longer read or write its
// own knowledge base ("save this result into the knowledge base"
// failed with a not-found / no-permission style error). Each tool
// degrades with a clear "no knowledge base" message when the
// agent has none attached, so advertising them unconditionally
// is safe.
"wiki_read_page",
"wiki_list_pages",
"wiki_search_pages",
"wiki_semantic_search",
"wiki_trace_source",
"wiki_create_page",
"wiki_compile_page",
"wiki_read_many",
"wiki_archive_page",
"wiki_unarchive_page",
"wiki_delete_page",
"wiki_related_pages",
"wiki_explain_relation",
"wiki_enrich_page",
"wiki_list_transformations",
"wiki_apply_transformation",
"wiki_apply_transformation_to_page",
"wiki_aggregate_transformation"
);
private ResolvedSkill findResolvedSkillById(Long skillId) {
if (skillId == null || skillRuntimeService == null) return null;
// resolveAllSkillsStatus returns every skill in the catalog, not
// just the active ones, so we still see READY/SETUP_NEEDED status
// for bound but partially-unsatisfied skills.
return skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(s -> s != null && skillId.equals(s.getId()))
.findFirst()
.orElse(null);
}
public AgentToolBinding bindTool(Long agentId, String toolName) {
AgentToolBinding existing = toolBindingMapper.selectOne(
new LambdaQueryWrapper<AgentToolBinding>()
@ -151,9 +694,27 @@ public class AgentBindingService {
}
/**
* 批量设置 Agent tool 绑定替换模式
* Replace the agent's tool binding set.
*
* <p>Validation rule for each incoming name:
* <ul>
* <li><b>Already in the existing binding</b> always allowed (so the
* user can keep a previously-bound tool whose upstream MCP server
* is currently stale or even removed; the client just keeps what
* it already had).</li>
* <li><b>New addition (not in existing binding)</b> must appear in
* {@link AvailableToolService#listAvailable()} with
* {@code available == true}. Names that are unknown
* (typos / legacy unprefixed MCP names / hand-crafted strings) or
* that the picker marked unavailable (hash collision, etc.) are
* rejected saving them would put a {@code mate_agent_tool} row
* in the database that the runtime can never resolve, which then
* silently drops the tool when the agent runs.</li>
* </ul>
*/
public void setToolBindings(Long agentId, List<String> toolNames) {
validateNewToolBindings(agentId, toolNames);
toolBindingMapper.delete(
new LambdaQueryWrapper<AgentToolBinding>()
.eq(AgentToolBinding::getAgentId, agentId));
@ -167,4 +728,103 @@ public class AgentBindingService {
}
}
}
/**
* Refuse the save when any *newly-added* tool name doesn't resolve to
* an {@code available=true} row in the picker. Names already in the
* existing binding are exempt so that subsequent edits (especially
* "remove this stale tool") still succeed even if upstream state has
* drifted.
*/
private void validateNewToolBindings(Long agentId, List<String> incoming) {
if (incoming == null || incoming.isEmpty()) {
return;
}
Set<String> existing = listToolBindings(agentId).stream()
.map(AgentToolBinding::getToolName)
.collect(Collectors.toSet());
Set<String> bindable;
try {
bindable = availableToolService.listAvailable().stream()
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.collect(Collectors.toSet());
} catch (Exception e) {
// The picker source briefly failing must not block the user
// from saving a binding that's still in their existing set.
// Re-validate everything against just the existing set
// strictly conservative: only allow keeps, refuse adds.
log.warn("AvailableToolService unavailable during binding validation, falling back to existing-only: {}",
e.getMessage());
bindable = Set.of();
}
List<String> rejected = new java.util.ArrayList<>();
for (String name : incoming) {
if (name == null || name.isBlank()) {
rejected.add("<blank>");
continue;
}
if (existing.contains(name)) continue; // keeps are always allowed
if (!bindable.contains(name)) rejected.add(name);
}
if (!rejected.isEmpty()) {
String preview = rejected.size() <= 5
? String.join(", ", rejected)
: String.join(", ", rejected.subList(0, 5)) + " (+" + (rejected.size() - 5) + " more)";
throw new MateClawException("err.agent.tool_binding_unbindable",
"Tool name(s) cannot be bound: " + preview
+ ". Either the name is unknown or the picker marked it unavailable "
+ "(e.g. hash collision, upstream server removed).");
}
}
// ==================== Provider Preferences ====================
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */
public List<AgentProviderPreference> listProviderPreferences(Long agentId) {
return providerPreferenceMapper.selectList(
new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId)
.orderByAsc(AgentProviderPreference::getSortOrder));
}
/**
* Ordered list of provider ids the agent prefers, lowest sort_order
* first. Disabled rows are filtered out. Empty list means "no
* preference fall back to the global chain order".
*
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
* fallback chain order per agent.</p>
*/
@Override
public List<String> getPreferredProviderIds(Long agentId) {
if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream()
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
.map(AgentProviderPreference::getProviderId)
.collect(Collectors.toList());
}
/**
* Replace the full preference list for an agent. {@code providerIds}
* is the new ordered preference (index 0 = highest preference).
* Empty / null list clears all preferences for the agent.
*/
public void setProviderPreferences(Long agentId, List<String> providerIds) {
providerPreferenceMapper.delete(
new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId));
if (providerIds == null) return;
int order = 0;
for (String providerId : providerIds) {
if (providerId == null || providerId.isBlank()) continue;
AgentProviderPreference row = new AgentProviderPreference();
row.setAgentId(agentId);
row.setProviderId(providerId.trim());
row.setSortOrder(order++);
row.setEnabled(true);
providerPreferenceMapper.insert(row);
}
}
}

View File

@ -0,0 +1,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,144 @@
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
) {
/** 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);
// ---------------- Factories per entry point ----------------
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath) {
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null, false, null, "web", null);
}
public static ChatOrigin cron(@Nullable String conversationId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable Long channelId,
@Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system",
workspaceId, workspaceBasePath, channelId, target, true, 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);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId);
}
/**
* 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);
}
// ---------------- 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

@ -46,6 +46,27 @@ 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) {
LocalDateTime now = LocalDateTime.now(ZONE);
String dateStr = now.format(DATE_FMT);
String timeStr = now.format(TIME_FMT);
@ -67,6 +88,37 @@ public final class RuntimeContextInjector {
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
}
}
appendSenderBlockIfPresent(sb, origin);
return sb.toString();
}
/**
* 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

@ -4,15 +4,26 @@ 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.vo.AgentCapabilitiesVO;
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;
@ -33,16 +44,24 @@ 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 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 +74,68 @@ 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 = "创建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);
@ -84,11 +157,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 +190,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 +230,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 +243,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()));
}
@ -180,7 +270,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

@ -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,14 @@ 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.tool.document.GeneratedFileCache;
import java.util.List;
import java.util.Map;
/**
@ -26,6 +31,22 @@ 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;
public FinalAnswerNode() {
this(null);
}
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
this.generatedFileCache = generatedFileCache;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
@ -34,9 +55,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 +97,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 +156,52 @@ 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);
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());
}
// 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 +210,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,334 @@
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>Per RFC 48 v3 §3.3, 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; // unused PR2, kept for PR5
private final ConversationService conversationService; // unused PR2, kept for PR5
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 node stays inert until PR5 flips this.
if (!properties.isEnabled()) {
return Map.of();
}
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
Optional<Object> goalOpt = accessor.activeGoal();
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() instanceof GoalEntity ge) ? ge.getId() : null;
// 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.
if (flavor == GraphFlavor.REACT) {
String fr = accessor.finishReason();
if (FinishReason.EVIDENCE_INSUFFICIENT.getValue().equals(fr)
|| FinishReason.STOPPED.getValue().equals(fr)
|| FinishReason.ERROR_FALLBACK.getValue().equals(fr)
|| FinishReason.RETURN_DIRECT.getValue().equals(fr)
|| FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(fr)) {
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
.build();
}
}
if (accessor.awaitingApproval()) {
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "awaiting_approval")))
.build();
}
Object goalObj = goalOpt.get();
if (!(goalObj instanceof GoalEntity goal)) {
log.warn("[GoalEvaluationNode] ACTIVE_GOAL is not a GoalEntity: {}", goalObj.getClass());
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(null, "non_goal_entity")))
.build();
}
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 {
if (result.completed() || result.score() >= 0.95) {
goalService.markCompleted(refreshed.getId(), result);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score()))))
.build();
}
if (goalService.isBudgetExhausted(refreshed)) {
String reason = goalService.exhaustionReason(refreshed);
goalService.markExhausted(refreshed.getId(), reason);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_exhausted", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"turnsUsed", refreshed.getTurnsUsed(),
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
"reason", reason))))
.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();
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();
if (followup.isPresent() && perRunCapReached) {
log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run",
followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId());
}
if (followup.isPresent() && !perRunCapReached) {
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()))));
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())));
} 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()))))
.build();
}
/** 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;
@ -71,7 +72,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 +121,17 @@ public class ObservationNode implements NodeAction {
.shouldSummarize(shouldSummarize)
.toolCallCount(newToolCallCount);
// Close out the iteration we just observed. We use currentIteration
// (not nextIteration) so the index pairs with whatever
// iteration_start the ReasoningNode emitted at the top of this turn.
// Char totals are best-effort: ObservationNode doesn't see the LLM
// delta stream directly, so 0/0 is acceptable for now consumers
// that care fall back to summing the deltas themselves.
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
builder.events(List.of(
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
}
// 重复观察时标记错误 ObservationDispatcher 路由到 limitExceededNode
if (duplicateObservation) {
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");

View File

@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
@ -17,13 +18,14 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.ThinkingLevelHolder;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.channel.web.ChatStreamTracker;
@ -51,18 +53,158 @@ public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
return MateClawStateAccessor.output();
}
/**
* 单次 LLM 调用的默认最大输出 token 防止退化输出无限生成
* <p>
* RFC-049 follow-up (2026-04-27): bumped 4096 16384. 4096 was hitting
* the cap when models emit large generative tool_call args (e.g. renderDocx
* with a multi-thousand-character markdown body) on top of thinking
* content for reasoning_effort=high the JSON args got truncated mid-
* stream, the tool failed to parse, the docx was never generated. 16k is
* the conservative ceiling that covers typical "write a long document"
* tool calls without enabling true runaway loops (those are bounded by
* iteration count, not per-call tokens).
*/
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384;
/**
* DashScope's native chat API caps {@code max_tokens} at 8192 and returns a
* 400 {@code InvalidParameter} ("Range of max_tokens should be [1, 8192]")
* for anything larger. The failover layer misclassifies that 400 as
* "model not found" and silently switches to a different provider, so the
* per-call ceiling must be clamped to this value for DashScope-backed
* models keeping {@link #DEFAULT_MAX_OUTPUT_TOKENS} for every other
* provider that does accept the larger budget.
*/
private static final int DASHSCOPE_MAX_OUTPUT_TOKENS = 8192;
/**
* Max times to re-prompt the model when it returns a completely empty turn
* (no tool call, no content, no thinking) before accepting termination.
* A blank turn is otherwise treated as a final answer and ends the run; on
* long multi-step tasks that surfaces as the agent quitting mid-way.
*/
private static final int MAX_EMPTY_COMPLETION_RETRIES = 2;
/**
* Number of newest tool-response messages kept verbatim in the model
* input; older ones have their bodies collapsed to a one-line "old
* output cleared" placeholder while keeping the toolCallId / tool name
* so the assistant/tool pairing remains valid. The latest few results
* are what the model is reasoning over right now beyond that, the
* content is history and re-call (or read_file on the spill path) is
* cheaper than carrying every previous body forward across iterations.
*/
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
private static final String EMPTY_COMPLETION_NUDGE =
"Your previous turn was empty. If the task is not yet complete, continue now "
+ "with the next concrete step — call a tool or write the next part. If every "
+ "required step is already done, output the final answer to the user now.";
/**
* A turn carrying no tool call, no content, and no thinking is not a usable
* answer it would route to the final-answer branch as an empty string and
* terminate the run. Fatal / prompt-too-long / partial results are handled by
* their own branches and must not be misread as "empty".
*/
static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) {
if (result == null || result.hasToolCalls() || result.hasFatalError()
|| result.isPromptTooLong() || result.partial()) {
return false;
}
boolean noContent = result.text() == null || result.text().isBlank();
boolean noThinking = result.thinking() == null || result.thinking().isBlank();
return noContent && noThinking;
}
/**
* Tool-use enforcement clause appended to every ReasoningNode
* system prompt. Treats narration ("I will now …") as a protocol violation
* to prevent the recurring failure mode where a model says it will call a
* tool but emits the description as final_answer text instead.
*/
private static final String TOOL_USE_ENFORCEMENT = "\n\n"
+ "## 工具调用纪律(必读)\n\n"
+ "- 你**必须**直接调用工具来产生结果,不允许只用文字描述\"接下来要做什么\"\n"
+ "- 当你说要执行某个动作(如生成文件、发送消息、调用接口、生成 docx\n"
+ " 你**必须**在同一条回复里**立即发出对应的 tool_call**,不允许只写文字承诺。\n"
+ "- 禁止以\"现在 / 接下来 / 我将 / 直接生成 / 我直接\"+动作描述结束本轮回复——\n"
+ " 这种叙述会让系统误判任务已完成,**实际上工具没被调用**,结果文件不会产生。\n"
+ "- 如果上一次工具调用因 args JSON 截断max_tokens 超限)失败,\n"
+ " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n"
+ "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n"
+ " 才输出无 tool_call 的纯文字回答。\n\n"
+ "## 进度跟踪(多步任务强制规则,不可绕过)\n\n"
+ "**触发条件**:用户的任务包含 ≥3 个可枚举子目标 — 比如\n"
+ "\"调研 10 个模型\"\"逐节起草报告\"\"批量生成 N 份文档\"\n"
+ "\"依次调用 N 个 API\"\"对每个文件执行同一操作\"等。\n\n"
+ "**必须做的事**\n"
+ "1. **第一轮回复就用并行 tool_calls 批量注册全部子目标为 `pending`**\n"
+ " 一条回复里 N 个 `progress_update` 同时发出(不要串行)。\n"
+ " 例:要调研 10 个模型,第一轮就发 10 个 `progress_update(stepKey=\"model_xxx\", status=\"pending\")`。\n"
+ "2. **每开始一个子目标**前发 `progress_update(同 stepKey, status=\"in_progress\")`。\n"
+ "3. **每完成一个子目标**后立即发 `progress_update(同 stepKey, status=\"done\")`。\n"
+ "4. **无法继续**时发 `progress_update(同 stepKey, status=\"blocked\", note=\"具体原因\")`。\n\n"
+ "**为什么必须**\n"
+ "- 系统在你**每一次推理前**注入一份 \"## 当前任务进度\" 快照。\n"
+ " 这是你**唯一可信**的\"已完成清单\"——比你记忆里的步骤更权威,因为上下文窗口\n"
+ " 会被裁剪,老的工具调用记录会消失,但 ledger 不会。\n"
+ "- 不维护 ledger 的后果(实测):\n"
+ " · 上下文裁剪后忘记自己做过的步骤,重复执行已完成项 → 浪费迭代预算\n"
+ " · 漏做项目 → 任务不完整 → 撞 max_iterations 还没干完\n"
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
private final ChatModel chatModel;
private final List<ToolCallback> toolCallbacks;
/**
* Full agent tool set, used for the per-turn disclosure split. Null in the
* legacy {@code (ChatModel, List)} path that path falls back to
* {@link #toolCallbacks} verbatim with no split.
*/
private final AgentToolSet toolSet;
/**
* Splits tools into core + already-enabled extensions per
* {@code ENABLED_EXTENSION_TOOLS}. Null disables the split (advertise the
* full {@link #toolCallbacks}).
*/
private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService;
private final String reasoningEffort;
/**
* PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts
* {@code reasoning_effort}. Drives the capability gate in
* {@link #resolveEffectiveReasoningEffort()} so that a front-end {@code ThinkingLevelHolder}
* override is dropped on chat-type models that cannot honor it.
*/
private final boolean supportsReasoningEffort;
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final ChatStreamTracker streamTracker;
private final int maxOutputTokens;
/** Wiki 相关性注入可选null 时跳过) */
private final vip.mate.wiki.service.WikiContextService wikiContextService;
/**
* Renders the {@code ## Skills} catalog each turn so its ordering reacts to
* skills loaded this run (load_skill pins). Null in legacy / test
* constructors when null, no catalog segment is appended.
*/
private final vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer;
/**
* Loads the per-conversation progress ledger each reasoning step so a
* compact snapshot can be injected into {@code nonHistoryPrefix}
* surviving message-window trims so the agent never loses track of
* "what is already done" on long multi-step tasks. Null in legacy /
* test constructors; when null the snapshot block is suppressed and
* the prompt is identical to pre-feature behavior.
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper,
@ -85,14 +227,96 @@ public class ReasoningNode implements NodeAction {
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService) {
// Backward-compatible delegate. Callers that have not migrated to the explicit
// supportsReasoningEffort parameter inherit the pre-PR-1 behavior: treat the bound
// model as supporting reasoning_effort iff reasoningEffort was resolved to a non-null
// value at construction time. New callers (AgentGraphBuilder) should use the
// 9-arg constructor below.
this(chatModel, toolSet, reasoningEffort, reasoningEffort != null,
streamingHelper, conversationWindowManager, streamTracker,
maxOutputTokens, wikiContextService);
}
/**
* PR-1.2 (RFC-049): Primary constructor with explicit {@code supportsReasoningEffort}
* capability flag avoids inferring capability from {@code reasoningEffort == null},
* which fails for a future "supports but not auto-enabled" scenario.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService, null);
}
/**
* Primary constructor with the runtime {@link vip.mate.skill.runtime.SkillCatalogRenderer}.
* The catalog is rendered each turn (ordered by skills loaded this run)
* instead of being baked into the system prompt, so the prompt-cache prefix
* stays stable and load_skill pins float to the top.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService,
skillCatalogRenderer, null);
}
/**
* Backward-compatible delegate for callers built before the
* {@link vip.mate.agent.progress.ProgressLedgerService} was wired in
* passes {@code null} so the progress snapshot block is suppressed.
* New call sites should use the 13-arg primary constructor below.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer,
vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, null);
}
/**
* Primary constructor with the {@link vip.mate.agent.progress.ProgressLedgerService}.
* When non-null, a compact snapshot of the conversation's progress ledger
* is appended to {@code nonHistoryPrefix} each turn so the agent retains
* its "what is already done" view across message-window trims.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer,
vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService,
vip.mate.agent.progress.ProgressLedgerService progressLedgerService) {
this.chatModel = chatModel;
this.toolSet = toolSet;
this.toolCallbacks = toolSet.callbacks();
this.toolDisclosureService = toolDisclosureService;
this.reasoningEffort = reasoningEffort;
this.supportsReasoningEffort = supportsReasoningEffort;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
this.streamTracker = streamTracker;
this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
this.wikiContextService = wikiContextService;
this.skillCatalogRenderer = skillCatalogRenderer;
this.progressLedgerService = progressLedgerService;
}
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
@ -117,13 +341,18 @@ public class ReasoningNode implements NodeAction {
@Deprecated
public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) {
this.chatModel = chatModel;
this.toolSet = null;
this.toolCallbacks = toolCallbacks;
this.toolDisclosureService = null;
this.reasoningEffort = null;
this.supportsReasoningEffort = false;
this.streamingHelper = null;
this.conversationWindowManager = null;
this.streamTracker = null;
this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
this.wikiContextService = null;
this.skillCatalogRenderer = null;
this.progressLedgerService = null;
}
@Override
@ -151,7 +380,7 @@ public class ReasoningNode implements NodeAction {
.toolCalls(List.of(toolCall))
.build();
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.toolCalls(List.of(toolCall))
.messages(List.of((Message) syntheticMsg))
@ -172,39 +401,174 @@ public class ReasoningNode implements NodeAction {
// ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt();
// Append a tool-use enforcement clause to every ReasoningNode call.
// Without it, some models (notably DeepSeek thinking and Claude Opus)
// tend to "narrate" emit a final_answer like "现在直接生成立项材料
// docx" instead of actually calling renderDocx, which makes the
// graph silently terminate at final_answer_node with the narration
// as the user-facing reply.
//
// Appended at runtime rather than woven into the AgentEntity-stored
// prompt so it stays out of the user-editable agent UI but is still
// always-on for the runtime LLM.
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
List<Message> messages = accessor.messages();
// 消息列表膨胀防护
// Guard against runaway message list growth.
//
// CRITICAL: a naive head+tail cut can break the OpenAI-compatible protocol invariant
// that requires tool_call / tool_response pairs to be complete:
//
// P0 (originally observed): AssistantMessage(tool_calls) falls into the dropped gap,
// its ToolResponseMessage lands in the kept tail provider sees an orphaned
// ToolResponseMessage kimi-code 400 "tool_call_id is not found".
//
// P1 (symmetric): AssistantMessage(tool_calls) is kept in the head at the boundary,
// its ToolResponseMessage falls into the dropped gap provider sees an assistant
// tool_call with no matching response also a 400 on strict providers.
//
// Fix: perform the normal cut, then run an iterative bidirectional integrity pass until
// the list is stable:
// Remove any ToolResponseMessage whose parent AssistantMessage.tool_calls id was
// dropped (P0).
// Remove any AssistantMessage whose tool_calls have no matching ToolResponseMessage
// (P1).
// Iterate because a P1 removal could expose a new P0 orphan (and vice versa, though that
// is pathological in practice). With 40 messages convergence is always fast.
// Dropping incomplete pairs is safe prior iterations already processed those
// observations; the LLM needs the summary context, not the raw tool I/O.
final int MAX_LOOP_MESSAGES = 40;
if (messages.size() > MAX_LOOP_MESSAGES) {
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
messages.size(), MAX_LOOP_MESSAGES, conversationId);
int headKeep = Math.min(4, messages.size());
int tailKeep = MAX_LOOP_MESSAGES - headKeep;
int tailStart = messages.size() - tailKeep;
List<Message> trimmed = new ArrayList<>(MAX_LOOP_MESSAGES);
trimmed.addAll(messages.subList(0, Math.min(4, messages.size())));
trimmed.addAll(messages.subList(messages.size() - (MAX_LOOP_MESSAGES - 4), messages.size()));
trimmed.addAll(messages.subList(0, headKeep));
trimmed.addAll(messages.subList(tailStart, messages.size()));
// Iterative bidirectional integrity pass.
int totalRemoved = 0;
boolean changed;
do {
// Snapshot current tool_call ids and response ids.
Set<String> callIds = new java.util.HashSet<>();
Set<String> respIds = new java.util.HashSet<>();
for (Message m : trimmed) {
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) callIds.add(tc.id());
}
if (m instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) respIds.add(r.id());
}
}
int before = trimmed.size();
trimmed.removeIf(m -> {
// P0: ToolResponseMessage whose parent tool_call was dropped
if (m instanceof ToolResponseMessage trm) {
return trm.getResponses().stream().anyMatch(r -> !callIds.contains(r.id()));
}
// P1: AssistantMessage whose tool_call has no ToolResponseMessage
if (m instanceof AssistantMessage am && am.getToolCalls() != null
&& !am.getToolCalls().isEmpty()) {
return am.getToolCalls().stream().anyMatch(tc -> !respIds.contains(tc.id()));
}
return false;
});
int removed = before - trimmed.size();
totalRemoved += removed;
changed = removed > 0;
} while (changed);
if (totalRemoved > 0) {
log.warn("[ReasoningNode] Removed {} message(s) with broken tool_call/response pairs "
+ "after trim (bidirectional integrity guard), conv={}", totalRemoved, conversationId);
}
messages = trimmed;
}
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt));
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
// Wiki 相关性注入根据用户消息提取相关页面摘要
if (wikiContextService != null) {
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
promptMessages.add(new UserMessage(wikiRelevant));
}
} catch (NumberFormatException ignored) {
// agentId 无法解析时跳过 wiki 注入
// Build the non-history prefix ONCE. The PTL retry branch below
// reuses this list verbatim so the retried prompt has exactly the
// same system / runtime context / wiki injection as the original
// the previous tail-only retry path silently dropped the wiki
// segment which led to "answer regressed after compaction"
// complaints on long sessions.
List<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg,
accessor.chatOrigin());
// Append the runtime-rendered skill catalog as a SEPARATE SystemMessage
// right after the skeleton system prompt. Keeping it out of the baked
// prompt keeps the stable prefix's prompt-cache hash intact, while
// re-rendering each turn lets skills loaded this run (load_skill) pin
// to the top of the catalog. Reused verbatim by the PTL retry branch.
if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
if (skillCatalog != null && !skillCatalog.isBlank()) {
nonHistoryPrefix.add(1, new SystemMessage(skillCatalog));
}
}
// Inject the conversation's progress-ledger snapshot as a separate
// SystemMessage. Sits in nonHistoryPrefix (never trimmed) so the
// agent always sees its own "what's done / what's pending" record
// even after the message-window trim above drops the tool-call
// history that produced those done entries. Suppressed when the
// ledger column is empty so short single-turn questions stay
// prompt-cache-friendly.
//
// Past iteration ~10, also emit a stale-reminder SystemMessage when
// the ledger looks abandoned (empty after many turns, or no
// progress_update in >90s). This pushes the model back to the
// ledger discipline before it drifts into the "I'm doing the work
// but never marking it" failure mode observed in round-4 of the
// LLM-review smoke test.
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
try {
vip.mate.agent.progress.ProgressLedger ledger =
progressLedgerService.load(conversationId);
String snapshot = ledger.renderSnapshot();
if (snapshot != null) {
nonHistoryPrefix.add(new SystemMessage(snapshot));
}
String staleReminder = ledger.renderStaleReminder(
accessor.iterationCount(), java.time.Instant.now());
if (staleReminder != null) {
nonHistoryPrefix.add(new SystemMessage(staleReminder));
log.info("[ReasoningNode] Injected stale-ledger reminder at iter {} for conv {}",
accessor.iterationCount(), conversationId);
}
} catch (Exception e) {
// Never let a ledger-side failure break the reasoning step.
log.warn("[ReasoningNode] Failed to load progress ledger for {}: {}",
conversationId, e.getMessage());
}
}
if (conversationWindowManager != null) {
// Age-based compaction first: drop the body of tool responses
// older than the K most recent into a one-line placeholder that
// keeps the toolCallId / tool name (so the assistant/tool pair
// stays valid) and, for spilled bodies, preserves the on-disk
// path so read_file can still recover the original. Without
// this, even spilled previews (~1-2 KB each) accumulate across
// 30+ tool calls and bloat the prompt the model sees every turn.
messages = conversationWindowManager.compactAgedToolResponses(
messages, KEEP_RECENT_TOOL_RESPONSES);
// Pass conversationId + workspaceBasePath so oversized older
// tool results can be spilled to the workspace spill directory
// (preserving the full body for read_file recovery) instead of
// being rewritten into a lossy single-line summary.
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
List<Message> promptMessages = new ArrayList<>(nonHistoryPrefix);
promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
@ -212,7 +576,15 @@ public class ReasoningNode implements NodeAction {
log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}",
ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort);
ChatOptions options = buildChatOptions(effectiveReasoning);
// Progressive disclosure: advertise only core tools plus the extensions
// enabled this run, computed fresh each turn from ENABLED_EXTENSION_TOOLS
// so an enable_tool call earlier in this loop takes effect immediately.
// Falls back to the full tool set when no disclosure service is wired.
List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null)
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks()
: toolCallbacks;
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
Prompt prompt = new Prompt(promptMessages, options);
@ -222,11 +594,22 @@ public class ReasoningNode implements NodeAction {
// PTL compact retry 会再 +1
int nextLlmCallCount = accessor.llmCallCount() + 1;
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}",
promptMessages.size(), toolCallbacks.size(),
promptMessages.size(), activeCallbacks.size(),
accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount);
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
Map.of("iteration", accessor.iterationCount()));
// Iteration boundary marker for the parent ReAct loop. Reason
// distinguishes the very first turn of the conversation from a
// mid-loop repeat for consumers grouping events into per-turn cards.
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
GraphEventPublisher.GraphEvent iterStartEvent = iterationEventsOn
? GraphEventPublisher.iterationStart(
accessor.iterationCount(),
accessor.iterationCount() == 0 ? "first_turn" : "react_step",
"parent",
null)
: null;
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
"llmCallCount", nextLlmCallCount
@ -236,19 +619,34 @@ public class ReasoningNode implements NodeAction {
try {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
// PTL 处理压缩后重试
// PTL 处理结构化压缩后重试复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把
// wiki 段一起丢掉重试后的 prompt 比原始更短少一层信息
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
log.warn("[ReasoningNode] Prompt too long, attempting STRUCTURED compaction and retry");
// MateClawStateAccessor.agentId() returns String per state
// schema; the ConversationWindowManager hook expects Long
// (nullable onPreCompress is a no-op when null).
Long agentIdLong = null;
if (!agentIdStr.isEmpty()) {
try {
agentIdLong = Long.parseLong(agentIdStr);
} catch (NumberFormatException ignored) {
// Same fallback as the non-history prefix builder above.
}
}
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
messages, chatModel, conversationId, agentIdLong);
if (compactedMessages != null && compactedMessages.size() < messages.size()) {
List<Message> retryPromptMessages = new ArrayList<>();
retryPromptMessages.add(new SystemMessage(systemPrompt));
retryPromptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
// Reuse the SAME non-history prefix wiki/runtime context preserved.
List<Message> retryPromptMessages = new ArrayList<>(nonHistoryPrefix);
retryPromptMessages.addAll(compactedMessages);
Prompt retryPrompt = new Prompt(retryPromptMessages, options);
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
messages.size(), compactedMessages.size());
// compact retry 是第 2 LLM 调用先递增再调用
nextLlmCallCount++;
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
@ -260,13 +658,35 @@ public class ReasoningNode implements NodeAction {
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
}
}
// Empty-completion guard: a turn with no tool call, no content, and
// no thinking is not a real answer. Under heavy message-window
// trimming on long multi-step tasks the model occasionally emits a
// blank turn; the final-answer branch would then treat it as "done"
// (finalAnswer="") and end the run prematurely (observed: a 10-item
// research task stopping at item 2). Re-prompt it to continue
// bounded, so a model that genuinely has nothing left still
// terminates cleanly through the normal empty-answer path below.
int emptyRetries = 0;
while (emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && isEmptyCompletion(result)) {
emptyRetries++;
log.warn("[ReasoningNode] Empty LLM completion (no tool call / content / thinking); "
+ "nudging to continue (retry {}/{}), conv={}",
emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId);
List<Message> nudgedMessages = new ArrayList<>(promptMessages);
nudgedMessages.add(new UserMessage(EMPTY_COMPLETION_NUDGE));
Prompt nudgePrompt = new Prompt(nudgedMessages, options);
nextLlmCallCount++;
result = streamingHelper.streamCall(
chatModel, nudgePrompt, conversationId, "reasoning_empty_retry");
}
} catch (CancellationException ce) {
// "调用已发出但尚未产出内容时用户停止" streamHelper CancellationException
// 返回空 finalAnswer + STOPPED FinalAnswerNode STOPPED 语义处理
// 必须显式清零 needsToolCall/shouldSummarize防止前一轮残留标志导致误路由
log.info("[ReasoningNode] CancellationException during LLM call (user stopped before first token), " +
"returning empty answer with STOPPED, llmCallCount={}", nextLlmCallCount);
return MateClawStateAccessor.output()
return reasonOutput()
.finalAnswer("")
.needsToolCall(false)
.shouldSummarize(false)
@ -285,7 +705,7 @@ public class ReasoningNode implements NodeAction {
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), flushing as final answer",
partialText.length(), partialThinking.length());
var builder = MateClawStateAccessor.output()
var builder = reasonOutput()
.finalAnswer(partialText)
.needsToolCall(false)
.shouldSummarize(false)
@ -300,13 +720,86 @@ public class ReasoningNode implements NodeAction {
return builder.build();
}
// Order matters: the partial-truncation branch MUST sit before
// hasFatalError(). hasFatalError() is "no text + no tool calls + non-
// null errorMessage", which is also the shape of a thinking-only cap
// result (text is empty by definition). Without this ordering the
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
// INCOMPLETE semantics.
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
// Soft thinking-only loop: the helper disposed the upstream stream
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
// reasoning_content without emitting any visible content or tool
// calls. Treat as INCOMPLETE rather than fatal the thinking text
// has already been streamed and is preserved for the UI's collapse
// panel; the user gets a short fallback line they can retry from.
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
"INCOMPLETE",
partialThinking.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
.contentStreamed(false)
.thinkingStreamed(true)
.mergeUsage(state, result);
if (!partialThinking.isEmpty()) {
builder.finalThinking(partialThinking);
}
return builder.build();
}
if (result.partial() && "content_repetition".equals(result.errorMessage())) {
// Reasoning loop: the helper disposed the stream because the
// model emitted the same paragraph 4+ times in a row (qwen3.6
// / deepseek-r1 self-arguing pattern). The streamed text
// already showed the duplicates to the user we can't unsend
// SSE chunks but the persisted finalAnswer should be ONE
// clean copy so the IM channel reply and any page-reload
// history don't show the wall of repetition. Skip
// FinalAnswerNode's evidence validation: the answer is
// already truncated, applying validateAnswer on top would
// double-stamp warnings on something the user already knows
// is incomplete.
String rawContent = result.text() != null ? result.text() : "";
String dedupedAnswer = NodeStreamingChatHelper.dedupTrailingRepeats(
rawContent,
NodeStreamingChatHelper.CONTENT_REPEAT_MIN_PERIOD,
NodeStreamingChatHelper.CONTENT_REPEAT_MAX_PERIOD);
log.warn("[ReasoningNode] Content-repetition cap hit (raw={} chars → deduped={} chars); " +
"INCOMPLETE",
rawContent.length(), dedupedAnswer.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(dedupedAnswer.isEmpty()
? "(模型反复输出同一段内容,已自动截断。请尝试重新生成或换个问法。)"
: dedupedAnswer)
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
// contentStreamed=true because the user already saw
// the looping text in their bubble; persisting again
// via streamedContent would replay it.
.contentStreamed(true)
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
.mergeUsage(state, result);
if (result.thinking() != null && !result.thinking().isEmpty()) {
builder.finalThinking(result.thinking());
}
return builder.build();
}
// Fatal error直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK
// 不走 LimitExceededNode后者会再发一次 LLM 调用语义不对且对认证/配额错误会再失败
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize finalAnswerNode
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用finishReason 保持 ERROR_FALLBACK
if (result.hasFatalError()) {
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("[错误] " + result.errorMessage())
@ -319,7 +812,8 @@ public class ReasoningNode implements NodeAction {
}
if (result.partial()) {
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
}
if (result.hasToolCalls()) {
@ -331,7 +825,7 @@ public class ReasoningNode implements NodeAction {
"toolCount", result.toolCalls().size()
));
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.shouldSummarize(false)
.toolCalls(result.toolCalls())
@ -344,7 +838,7 @@ public class ReasoningNode implements NodeAction {
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.events(buildEvents(phaseEvent, iterStartEvent))
.build();
} else {
String content = result.text();
@ -353,23 +847,49 @@ public class ReasoningNode implements NodeAction {
"iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0
));
SourceEvidenceLedger.Validation validation =
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
boolean evidenceInsufficient = !validation.valid();
String finalAnswer = evidenceInsufficient
? evidenceWarning(validation.unsupportedReferences())
: (content != null ? content : "");
if (evidenceInsufficient) {
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
return MateClawStateAccessor.output()
// Final-answer path: iteration ends in this same node because
// ReAct never re-enters the loop afterwards.
GraphEventPublisher.GraphEvent iterEndEvent = iterationEventsOn
? GraphEventPublisher.iterationEnd(accessor.iterationCount(),
"parent", null,
content != null ? content.length() : 0,
result.thinking() != null ? result.thinking().length() : 0)
: null;
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(content != null ? content : "")
.finalAnswer(finalAnswer)
.finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.contentStreamed(true)
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
.contentStreamed(!evidenceInsufficient)
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.events(buildEvents(phaseEvent, iterStartEvent, iterEndEvent))
.build();
}
}
private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
}
private AssistantMessage.ToolCall deserializeToolCall(String json) {
try {
@SuppressWarnings("unchecked")
@ -386,6 +906,69 @@ public class ReasoningNode implements NodeAction {
}
}
/**
* Compose the per-call event list, dropping any null entries so the
* iteration-boundary toggle ({@code mateclaw.stream.iteration-events})
* works without forcing every caller into branching code.
*/
private static List<GraphEventPublisher.GraphEvent> buildEvents(GraphEventPublisher.GraphEvent... events) {
List<GraphEventPublisher.GraphEvent> out = new ArrayList<>(events.length);
for (GraphEventPublisher.GraphEvent ev : events) {
if (ev != null) out.add(ev);
}
return out;
}
/**
* Build the part of the Prompt that does not depend on history messages:
* system prompt, workspace runtime context, and (when wiring permits) the
* wiki relevant-pages snippet. Extracted so the initial Prompt assembly
* and the PTL retry path can share one source of truth historically
* these were two parallel code paths and the retry one silently dropped
* the wiki injection.
* <p>
* {@code systemPrompt} is consumed as-is; the upstream callsite has
* already appended the tool-use enforcement clause, so this helper must
* NOT re-append it (doing so would duplicate the clause on every retry).
*
* @param systemPrompt Fully-built system prompt (with tool-use
* enforcement already appended upstream).
* @param workspaceBasePath Active workspace directory; passed to
* {@link RuntimeContextInjector}.
* @param agentIdStr Agent ID as carried in graph state parsed
* to {@code Long} only when non-empty and
* numeric; otherwise the wiki segment is
* skipped (matches the pre-refactor behavior).
* @param userMsg Current user message used by
* {@code WikiContextService} to score
* relevance.
*/
// Package-private so ReasoningNodePtlPromptTest can directly assert on
// the wiki / runtime-context layout; the production callsites inside
// this class call it via {@code this.buildNonHistoryPrefix(...)} so
// narrowing the visibility doesn't change behavior.
List<Message> buildNonHistoryPrefix(String systemPrompt,
String workspaceBasePath,
String agentIdStr,
String userMsg,
vip.mate.agent.context.ChatOrigin chatOrigin) {
List<Message> prefix = new ArrayList<>();
prefix.add(new SystemMessage(systemPrompt));
prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
if (wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
prefix.add(new UserMessage(wikiRelevant));
}
} catch (NumberFormatException ignored) {
// agentId not numeric skip wiki injection (matches prior behavior).
}
}
return prefix;
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
return;
@ -399,12 +982,12 @@ public class ReasoningNode implements NodeAction {
* - AnthropicChatModel AnthropicChatOptions支持 extended thinking
* - 其他OpenAI/DashScope OpenAiChatOptions支持 reasoningEffort
*/
private ChatOptions buildChatOptions(String effectiveReasoning) {
private ChatOptions buildChatOptions(String effectiveReasoning, List<ToolCallback> activeCallbacks) {
// Anthropic 协议模型AnthropicChatModelMiniMax 也用此协议但不支持 thinking
if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) {
org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder =
org.springframework.ai.anthropic.AnthropicChatOptions.builder()
.toolCallbacks(toolCallbacks)
.toolCallbacks(activeCallbacks)
.internalToolExecutionEnabled(false);
// 仅对真正的 Claude 模型启用 extended thinkingMiniMax 等走 Anthropic 协议但不支持
@ -438,9 +1021,19 @@ public class ReasoningNode implements NodeAction {
// 始终使用 OpenAiChatOptions而非 ToolCallingChatOptions
// 因为 ToolCallingChatOptions 会丢失 OpenAI 特有参数streamUsage
// 导致 Kimi OpenAI 兼容 API 响应异常或提前截断
// DashScope rejects max_tokens above its 8192 ceiling with a 400 that
// the failover layer misreads as "model not found"; clamp so a
// DashScope-backed model never overflows the provider limit.
int effectiveMaxTokens = maxOutputTokens;
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model",
effectiveMaxTokens, DASHSCOPE_MAX_OUTPUT_TOKENS);
effectiveMaxTokens = DASHSCOPE_MAX_OUTPUT_TOKENS;
}
OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbacks)
.maxTokens(maxOutputTokens);
.toolCallbacks(activeCallbacks)
.maxTokens(effectiveMaxTokens);
if (StringUtils.hasText(effectiveReasoning)) {
oaiBuilder.reasoningEffort(effectiveReasoning);
}
@ -454,6 +1047,13 @@ public class ReasoningNode implements NodeAction {
* 解析有效的 reasoningEffort
* 优先级ThinkingLevelHolder请求级 > 构造时的 reasoningEffortAgent/模型默认
* "off" 会清除 reasoningEffort返回 null
*
* <p>PR-1.2 (RFC-049 L1-B): If the bound model's family does not support
* {@code reasoning_effort} (as declared via {@link #supportsReasoningEffort} at
* construction time), the front-end thinking-level override is ignored.
* Chat-type models like {@code deepseek-chat} must not be forced into thinking mode
* just because the user ticked "deep thinking" in the UI this is a product
* contract, not a runtime option.
*/
private String resolveEffectiveReasoningEffort() {
String requestLevel = ThinkingLevelHolder.get();
@ -461,6 +1061,11 @@ public class ReasoningNode implements NodeAction {
if ("off".equalsIgnoreCase(requestLevel)) {
return null;
}
if (!this.supportsReasoningEffort) {
log.debug("[ReasoningNode] Ignoring thinkingLevel='{}' — bound model family does not support reasoning_effort",
requestLevel);
return null;
}
// thinkingLevel reasoningEffort 映射
return switch (requestLevel.toLowerCase()) {
case "low" -> "low";

View File

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

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

View File

@ -2,14 +2,14 @@ package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
@ -26,21 +26,23 @@ import java.util.Map;
import java.util.stream.Collectors;
/**
* 计划生成节点
* Task triage node for the Plan-Execute graph.
* <p>
* 职责
* <ol>
* <li>判断是否需要规划简单问答快速退出</li>
* <li>如需规划生成计划 JSON解析校验</li>
* <li> PlanningService.createPlan() 持久化</li>
* <li>发布 plan_created 事件</li>
* </ol>
* Decides one of three routes for the user's goal and emits a JSON directive:
* <ul>
* <li>{@code direct_answer} pure knowledge question, no tools, no planning</li>
* <li>single-step plan needs tools but a single coherent action (steps=1)</li>
* <li>multi-step plan genuinely independent subtasks (26 steps)</li>
* </ul>
* When {@code needs_planning} is false the node streams the direct answer
* through {@link NodeStreamingChatHelper} and the graph exits via
* {@code DirectAnswerNode}. Otherwise a plan is persisted via
* {@link PlanningService} and {@code step_execution} takes over.
* <p>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用
* 即便最终返回 JSON也允许模型的 planning 输出以流式产生最终再聚合解析
* 直接回答路径也通过流式 helper 实时输出给前端
*
* @author MateClaw Team
* The previous version forced {@code needs_planning=true} whenever any tool
* was required, producing multi-step plans for trivial single-hop tasks.
* The revised prompt collapses single-hop tool use into a 1-step plan so the
* executor can handle it with one ReAct-style iteration (see RFC-008).
*/
@Slf4j
public class PlanGenerationNode implements NodeAction {
@ -50,32 +52,44 @@ public class PlanGenerationNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final AgentToolSet toolSet;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Structured triage result field names use @JsonProperty to match the
* snake_case keys the LLM is instructed to produce, so no prompt changes needed.
*/
record TriageResult(
@JsonProperty("needs_planning") boolean needsPlanning,
@JsonProperty("direct_answer") String directAnswer,
@JsonProperty("plan_type") String planType,
@JsonProperty("steps") List<String> steps
) {}
private static final String PLANNING_PROMPT = """
你是任务规划器不是聊天助手
你是任务分流不是聊天助手根据用户目标把请求分到三类之一并只输出一个 JSON 对象
你的输出必须满足以下规则
1. 只能返回一个 JSON 对象
2. 不允许输出任何 JSON 之外的文字
3. 不允许使用 markdown 代码块
4. 不要解释不要寒暄不要先说"我来...""我先..."
硬性规则
1. 只返回一个 JSON 对象不允许 markdown 代码块不允许任何 JSON 以外的文字
2. 不要解释不要寒暄不要说"我来...""我先..."
3. 不确定时优先选择"单步"而不是拆成多步
返回格式二选一
三类分流
不需要规划时
{"needs_planning": false, "direct_answer": "..."}
(A) 直接回答 纯知识问答模型凭自身知识即可回答不需要任何工具不需要读文件不需要查询当前状态
输出{"needs_planning": false, "direct_answer": "<你的回答>"}
需要规划时
{"needs_planning": true, "steps": ["步骤1", "步骤2", "步骤3"]}
(B) 单步任务 需要工具但本质是一个连贯动作一次文件读取 / 一次搜索 / 一次命令 / 一次记忆读写 / 一次计算
执行器会在这一步内部迭代调用多次工具**不要**提前拆分
输出{"needs_planning": true, "steps": ["<将用户目标复述为一句清晰可执行的指令>"]}
要求
- steps 数量 2 6
- 每个步骤必须是可执行动作不要写空话
- 默认不要把 MEMORY.mdPROFILE.md记忆文件当成独立步骤但如果用户目标明显依赖历史偏好长期约束过往决策或持续上下文可以加入必要的记忆读取步骤
- 不要把技能文件当成独立步骤除非用户任务明确要求
- 如果用户目标需要调用任何工具才能完成包括记忆读写文件操作搜索命令执行等必须返回 needs_planning: true只有纯知识问答不需要调用任何工具的简单问题才返回 needs_planning: false
- 如果无法确定也必须返回合法 JSON不能输出自然语言
(C) 多步任务 用户目标包含 2 个及以上明显独立必须先后完成的子任务例如"先调研 A 再调研 B 然后对比"
"读配置、迁移数据、验证结果"子任务之间如果可以合并应当合并
输出{"needs_planning": true, "steps": ["步骤1", "步骤2", ...]}2 6 个步骤
关键原则
- 单工具调用绝对不拆成多步"读 A 文件并总结" 是单步B不是两步
- 默认不要把 MEMORY.md / PROFILE.md / 技能文件读取当成独立步骤仅当用户明确询问偏好历史决策或长期约束时才加入
- 每个步骤必须是可执行动作不写"思考一下""确认一下"之类的空话
- 解析不出来时视作(B) 单步宁愿单步也不要无脑拆分
""";
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
@ -90,7 +104,7 @@ public class PlanGenerationNode implements NodeAction {
}
/**
* @deprecated Use constructor with full parameters
* @deprecated use the full-parameter constructor instead
*/
@Deprecated
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
@ -101,6 +115,20 @@ public class PlanGenerationNode implements NodeAction {
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");
String conversationId = accessor.conversationId();
@ -110,7 +138,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 +156,60 @@ 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);
promptMessages.add(new UserMessage(
RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
// 注入可用工具名称帮助 LLM 判断用户目标是否需要工具
// Advertise available tools so the LLM can recognize when an action is possible,
// but do NOT force "any tool usage implies multi-step" single-hop tool use
// should resolve to a 1-step plan, not a multi-step decomposition.
if (toolSet != null && !toolSet.callbacks().isEmpty()) {
String toolNames = toolSet.callbacks().stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.joining(", "));
promptMessages.add(new UserMessage(
"以使以下工具:" + toolNames
+ "\n如果用户目标需要调用任何工具才能完成,必须返回 needs_planning: true"));
"可用工具:" + toolNames
+ "\n单次工具调用应归为单步B不要拆成多步"));
}
// 注入 working context对话历史摘要让规划能感知之前对话的约束和补充条件
// Inject working context (rolling conversation summary) so triage respects
// prior constraints without re-reading full history.
String workingContext = accessor.workingContext();
if (!workingContext.isEmpty()) {
promptMessages.add(new UserMessage(
"以下是此前对话中用户提出的约束、说明和上下文,请在规划时充分考\n\n"
"以下是此前对话中用户提出的约束、说明和上下文,请在流时参考:\n\n"
+ workingContext));
}
promptMessages.add(new UserMessage("用户目标:" + goal));
// Append JSON schema hint generated by BeanOutputConverter so the LLM
// knows the exact expected structure (replaces hand-written schema in PLANNING_PROMPT).
BeanOutputConverter<TriageResult> converter = new BeanOutputConverter<>(TriageResult.class);
promptMessages.add(new UserMessage(converter.getFormat()));
Prompt prompt = new Prompt(promptMessages);
// 静默流式调用 LLM 返回结构化 JSON不直接推送给前端
// Broadcast a lightweight progress token so the frontend shows activity
// during the silent triage call (typically 1-3 s).
if (streamingHelper != null) {
streamingHelper.broadcastProgress(conversationId, "分析中...");
}
// Silent streaming call structured JSON is parsed below; tokens are not forwarded to the client.
long triageStartMs = System.currentTimeMillis();
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCallSilent(
chatModel, prompt, conversationId, "plan_generation");
// PTL 处理压缩后重试
// Prompt-too-long handling: compact the conversation window and retry once.
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[PlanGeneration] Prompt too long, attempting compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
@ -177,23 +223,28 @@ public class PlanGenerationNode implements NodeAction {
}
}
long triageMs = System.currentTimeMillis() - triageStartMs;
String llmResponse = result.text();
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
// 清理 markdown 代码块标记
String cleanedJson = cleanJsonResponse(llmResponse);
// D-6: emit triage perf summary
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
"triage_ms", triageMs,
"prompt_tokens", result.promptTokens(),
"completion_tokens", result.completionTokens()
)));
// 解析 JSON
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
TriageResult triage = converter.convert(llmResponse);
boolean needsPlanning = triage != null && triage.needsPlanning();
if (!needsPlanning) {
// 简单问答快速退出 解析出 direct_answer 后手动推送给前端
String directAnswer = parsed.get("direct_answer") != null
? parsed.get("direct_answer").toString() : llmResponse;
log.info("[PlanGeneration] Simple question detected, returning direct answer");
// Category (A): direct answer push to client and terminate via DirectAnswerNode.
String directAnswer = triage != null && triage.directAnswer() != null
? triage.directAnswer() : llmResponse;
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
// 手动广播 direct_answer 文本而不是原始 JSON
streamingHelper.broadcastContent(conversationId, directAnswer);
return PlanStateAccessor.output()
@ -207,27 +258,21 @@ public class PlanGenerationNode implements NodeAction {
.build();
}
// 需要规划提取步骤
@SuppressWarnings("unchecked")
List<String> steps = (List<String>) parsed.get("steps");
// Categories (B) single-step or (C) multi-step: extract steps.
List<String> steps = triage != null ? triage.steps() : null;
if (steps == null || steps.isEmpty()) {
log.warn("[PlanGeneration] LLM returned needs_planning=true but empty steps, falling back to direct answer");
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(llmResponse)
.currentPhase("direct_answer")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
// LLM asked for planning but produced no steps fall back to a
// synthetic 1-step plan using the user's goal so the executor
// can still reach the tools. (Previous behavior dropped back to
// direct_answer, which silently stripped tool capability.)
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
steps = List.of(goal);
}
// 持久化计划
var plan = planningService.createPlan(agentId, goal, steps);
log.info("[PlanGeneration] Plan created: id={}, steps={}", plan.getId(), steps.size());
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
// 发布 plan_created 事件
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
return PlanStateAccessor.output()
@ -244,36 +289,33 @@ public class PlanGenerationNode implements NodeAction {
.build();
} catch (Exception e) {
log.error("[PlanGeneration] Failed to generate plan: {}", e.getMessage(), e);
// 降级作为简单问答处理不向前端暴露内部异常细节
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);
// When the triage LLM fails or returns unparseable output we now fall back to
// a single-step plan (the user's goal verbatim) instead of a direct text
// answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks.
try {
var plan = planningService.createPlan(agentId, goal, List.of(goal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(List.of(goal))
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
.events(events)
.build();
} catch (Exception persistErr) {
log.error("[PlanGeneration] Single-step fallback persistence also failed: {}", persistErr.getMessage());
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer("抱歉,我暂时无法完成规划,请重试或换一种方式描述任务。")
.directAnswer("抱歉,我暂时无法完成任务分流,请重试或换一种方式描述任务。")
.currentPhase("direct_answer")
.events(events)
.build();
}
}
}
/**
* 清理 LLM 返回的 JSON移除可能的 markdown 代码块标记
* 若响应中不包含合法的 JSON 对象抛出异常让调用方走降级路径
*/
private String cleanJsonResponse(String response) {
if (response == null) {
throw new IllegalArgumentException("LLM returned null response");
}
String cleaned = response.trim();
if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("```json?\\n?", "").replaceAll("```", "").trim();
}
// 找到第一个 { 和最后一个 }
int start = cleaned.indexOf('{');
int end = cleaned.lastIndexOf('}');
if (start < 0 || end <= start) {
throw new IllegalArgumentException(
"LLM response does not contain a valid JSON object: " + cleaned.substring(0, Math.min(80, cleaned.length())));
}
return cleaned.substring(start, end + 1);
}
}

View File

@ -11,7 +11,6 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.JsonNode;
@ -21,12 +20,14 @@ 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.skill.runtime.SkillCatalogRenderer;
import java.util.ArrayList;
import java.util.List;
@ -36,7 +37,12 @@ import java.util.Map;
* 步骤执行节点
* <p>
* 执行当前步骤使用显式工具执行循环internalToolExecutionEnabled=false
* 单步最大工具调用次数限制为 5 防止无限循环
* 单步最大工具调用次数限制为 {@link #MAX_TOOL_CALLS_PER_STEP}
* {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING} 对齐因此实际生效的上限
* 永远是 agent {@code max_iterations}DB 单步本身不会先于 agent
* 的整体预算被打掉早期 5 次的硬限制对"查新闻 + 整理 Word"这种合理多
* 工具任务过紧 LimitExceededNode 提前拦截后用户看到的是冷冰冰的
* "工具调用次数超出最大限制"
* <p>
* 支持 NEEDS_APPROVAL 审批流程对需要审批的工具调用创建 pending
* 发出 SSE 事件后立即返回审批提示非阻塞审批通过后通过 replay 重新执行
@ -54,8 +60,35 @@ 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;
/**
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
* Matching the agent-level cap means this constant is never the bottleneck
* the agent's own {@code max_iterations} (DB column) will fire first if a
* task is genuinely runaway, and a well-budgeted multi-tool step (e.g.
* web_search + browser_navigate + browser_read*N + file_write) is no longer
* cut short by an arbitrary 5-call ceiling.
*/
private static final int MAX_TOOL_CALLS_PER_STEP = 100;
/**
* Wall-clock budget per step, complementing {@link #MAX_TOOL_CALLS_PER_STEP}.
* The call-count cap doesn't help when a single LLM stream stalls or a
* concurrency-unsafe tool runs synchronously without a per-tool deadline
* (the parallel batch path enforces {@code ToolTimeoutProperties}, but the
* single-unsafe path in {@code ToolExecutionExecutor#executeSingleTool}
* currently does not). 10 minutes is generous for legitimate long steps
* (large file edits, multi-page browser flows) while still cutting off the
* pathological cases where the agent appears frozen to the user.
*/
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
private static final ObjectMapper MAPPER = new ObjectMapper();
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
@ -64,6 +97,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 +144,8 @@ public class StepExecutionNode implements NodeAction {
this.conversationWindowManager = conversationWindowManager;
this.reasoningEffort = reasoningEffort;
this.streamingHelper = streamingHelper;
this.skillCatalogRenderer = skillCatalogRenderer;
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
}
@Override
@ -86,6 +160,12 @@ public class StepExecutionNode implements NodeAction {
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
// RFC-063r §2.5: read parent ChatOrigin from graph state so tools in
// this step (and any DelegateAgentTool sub-graphs) inherit channel /
// workspace / requester context.
vip.mate.agent.context.ChatOrigin chatOrigin =
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
if (stepIndex >= steps.size()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@ -100,6 +180,15 @@ public class StepExecutionNode implements NodeAction {
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
// Iteration boundary for the plan-execute loop: each step is one
// iteration that may itself fan out to multiple LLM calls. Reason is
// "plan_step" so consumers can distinguish it from ReAct's
// "react_step" / "first_turn" markers when both stream into the
// same SSE feed.
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
}
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
@ -117,21 +206,50 @@ public class StepExecutionNode implements NodeAction {
int stepPromptTokens = 0;
int stepCompletionTokens = 0;
// RFC-052: any returnDirect tool that fires inside this step must
// short-circuit the entire plan (not just this step). We accumulate
// outputs across the inner loop and break out as soon as one appears.
List<DirectToolOutput> stepDirectOutputs = new ArrayList<>();
// Wall-clock budget guards against a single hung LLM stream or
// synchronous unsafe-tool call (the per-tool timeout is enforced only
// in the parallel batch path of ToolExecutionExecutor). Checked at the
// top of each iteration so we never issue another LLM call after the
// budget is gone.
long stepStartedAtMs = System.currentTimeMillis();
boolean wallClockExceeded = false;
try {
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
ChatOptions options;
if (StringUtils.hasText(reasoningEffort)) {
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
if (elapsedMs > stepWallClockTimeoutMs) {
log.warn("[StepExecution] Step {} exceeded wall-clock budget " +
"({} ms > {} ms) after {} tool round(s); aborting step",
stepIndex, elapsedMs, stepWallClockTimeoutMs, toolCallCount);
wallClockExceeded = true;
break;
}
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
// producer in NodeStreamingChatHelper.doStreamCall can attach the
// user-token. Using ToolCallingChatOptions when reasoningEffort is
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
// would 400 again.
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.reasoningEffort(reasoningEffort)
.build();
if (StringUtils.hasText(reasoningEffort)) {
oaiOpts.setReasoningEffort(reasoningEffort);
}
oaiOpts.setInternalToolExecutionEnabled(false);
options = oaiOpts;
} else {
options = ToolCallingChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.internalToolExecutionEnabled(false)
.build();
ChatOptions options = oaiOpts;
if (conversationWindowManager != null) {
// 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 +298,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 +325,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 +349,16 @@ public class StepExecutionNode implements NodeAction {
if (approvalTriggered) {
break;
}
// RFC-052: returnDirect short-circuit. Any direct tool in this
// step ends the plan immediately; the dispatcher routes via
// currentPhase=plan_aborted so no further LLM call happens.
if (!stepDirectOutputs.isEmpty()) {
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
"tool output(s); aborting plan execution",
stepIndex, stepDirectOutputs.size());
break;
}
}
// 处理审批暂停
@ -239,10 +377,53 @@ public class StepExecutionNode implements NodeAction {
.build();
}
// RFC-052: direct tool short-circuit at the plan level. Treat the
// assembled direct text as the final summary and abort the plan;
// the dispatcher routes plan_aborted to END so no further LLM call
// is made. Persisting RETURN_DIRECT_TRIGGERED + DIRECT_TOOL_OUTPUTS
// lets the SSE accumulator pick up directToolNames metadata so
// history scrub (BaseAgent.isDirectToolMessage) kicks in next turn.
//
// Plan status is "completed" (not "failed"): the user got their
// answer correctly, the plan just terminated earlier than the
// model's planning stage anticipated. Marking as failed would skew
// operational dashboards and confuse plan-history readers.
if (!stepDirectOutputs.isEmpty()) {
String assembled = assembleDirectAnswerText(stepDirectOutputs);
planningService.updateSubPlanResult(planId, stepIndex, assembled);
planningService.completePlan(planId,
"Plan completed via returnDirect tool: " +
stepDirectOutputs.get(0).toolName());
events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
assembled != null ? assembled.length() : 0, 0));
}
return PlanStateAccessor.output()
.currentStepResult(assembled)
.currentStepIndex(steps.size()) // 越界 dispatcher 收束
.currentPhase("plan_aborted")
.finalSummary(assembled)
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.put(MateClawStateKeys.PROMPT_TOKENS,
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS,
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
if (finalResult == null) {
if (wallClockExceeded) {
finalResult = "步骤执行超过最大耗时限制("
+ (stepWallClockTimeoutMs / 1000) + "秒),已中止本步骤";
} else {
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
}
}
} catch (Exception e) {
log.error("[StepExecution] Step {} execution failed: {}", stepIndex, e.getMessage(), e);
@ -250,6 +431,10 @@ public class StepExecutionNode implements NodeAction {
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
shortError != null ? shortError.length() : 0, 0));
}
return PlanStateAccessor.output()
.currentStepResult(shortError)
.currentPhase("plan_aborted")
@ -262,15 +447,32 @@ public class StepExecutionNode implements NodeAction {
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
finalResult != null ? finalResult.length() : 0,
stepThinking != null ? stepThinking.length() : 0));
}
log.info("[StepExecution] Step {}/{} completed: {}",
stepIndex + 1, steps.size(),
finalResult.length() > 100 ? finalResult.substring(0, 100) + "..." : finalResult);
// 更新 working context将最新完成的步骤结果纳入摘要
List<String> allCompleted = new ArrayList<>(accessor.completedResults());
allCompleted.add(formatStepResult(stepIndex, finalResult));
String updatedWorkingContext = rebuildWorkingContext(accessor, allCompleted);
// RFC-008 P4.2: incremental working-context update.
// Previous behavior rebuilt the entire context from history + every
// completed result on every step (O(N) per step). On long plans this
// re-walks the same conversation history each iteration. Now we take
// the previous context as-is (which already encodes earlier history
// and earlier completed steps) and append just the freshly-completed
// step, then trim from the head if the running total exceeds the cap.
// For first-step calls where prior context is empty, fall through to
// the original rebuild path so the conversation history seed is still
// captured.
String prevWorkingContext = accessor.workingContext();
String formattedNewStep = formatStepResult(stepIndex, finalResult);
String updatedWorkingContext = prevWorkingContext.isEmpty()
? rebuildWorkingContext(accessor,
appendOne(accessor.completedResults(), formattedNewStep))
: appendStepIncremental(prevWorkingContext, formattedNewStep);
return PlanStateAccessor.output()
.currentStepResult(finalResult)
@ -287,6 +489,26 @@ public class StepExecutionNode implements NodeAction {
.build();
}
/**
* RFC-052: assemble the final answer text from direct tool outputs in this
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
* sees the same shape regardless of which graph (ReAct / Plan-Execute)
* produced the answer.
*/
private static String assembleDirectAnswerText(List<DirectToolOutput> outputs) {
if (outputs.size() == 1) {
return outputs.get(0).fullResult();
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < outputs.size(); i++) {
DirectToolOutput out = outputs.get(i);
if (i > 0) sb.append("\n\n");
sb.append("### ").append(out.toolName()).append("\n");
sb.append(out.fullResult());
}
return sb.toString();
}
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
List<Message> messages = new ArrayList<>();
@ -306,8 +528,18 @@ 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())));
// Layer 2: Working context对话历史 + 步骤结果的受控长度摘要
String workingContext = accessor.workingContext();
@ -414,9 +646,51 @@ public class StepExecutionNode implements NodeAction {
}
}
/** Append helper used by the incremental working-context fast path. */
private static List<String> appendOne(List<String> previous, String item) {
List<String> out = new ArrayList<>(previous);
out.add(item);
return out;
}
/**
* 根据当前 accessor 中的会话历史消息和更新后的已完成步骤结果
* 重建 working context复用与 StateGraphPlanExecuteAgent.buildWorkingContext 相同的逻辑
* Incrementally extend the previous working context with one new step
* result. Cheap O(1) path used for steps 2..N: avoids walking the full
* conversation history again. The result is trimmed from the head if it
* exceeds the same overall cap that {@link #rebuildWorkingContext}
* enforces, so the budget invariant is preserved.
*
* <p>Per-step truncation: a single step result longer than 800 chars is
* abbreviated before append, mirroring the per-step caps in
* {@code rebuildWorkingContext}.</p>
*/
private static String appendStepIncremental(String previousContext, String formattedStepResult) {
final int OVERALL_CAP = 6000;
final int PER_STEP_CAP = 800;
String stepLine = formattedStepResult.length() > PER_STEP_CAP
? formattedStepResult.substring(0, PER_STEP_CAP) + ""
: formattedStepResult;
String combined = previousContext + "\n" + stepLine + "\n";
if (combined.length() <= OVERALL_CAP) {
return combined;
}
// Drop oldest content from the head until we fit. Cut on a newline
// boundary so we don't truncate mid-line.
int overshoot = combined.length() - OVERALL_CAP;
int cutFrom = combined.indexOf('\n', overshoot);
if (cutFrom < 0 || cutFrom >= combined.length() - 1) {
cutFrom = overshoot;
} else {
cutFrom += 1; // skip the newline itself
}
return "…(earlier context truncated)\n" + combined.substring(cutFrom);
}
/**
* Full rebuild of working context from conversation history plus all
* completed step results. Reused on the cold path (first step, or when
* the incremental path can't be applied). Mirrors
* {@code StateGraphPlanExecuteAgent.buildWorkingContext}.
*/
private static String rebuildWorkingContext(PlanStateAccessor accessor, List<String> allCompletedResults) {
List<Message> messages = accessor.messages();

View File

@ -48,7 +48,10 @@ public final class PlanStateAccessor {
}
public boolean needsPlanning() {
return state.value(NEEDS_PLANNING, true);
// Default to false: an unset triage flag means the request was not
// classified as requiring a plan. See PlanGenerationDispatcher for the
// rationale and RFC-008 for the full discussion.
return state.value(NEEDS_PLANNING, false);
}
// ===== 步骤控制 =====
@ -104,6 +107,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")
@ -232,8 +246,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

@ -0,0 +1,24 @@
package vip.mate.agent.graph.state;
/**
* RFC-052: full-text result of a tool call that declared {@code returnDirect=true}.
*
* <p>The result is delivered to the user (and persisted to {@code mate_message})
* verbatim, but is intentionally <em>not</em> placed into any subsequent LLM
* prompt see {@link MateClawStateKeys#DIRECT_TOOL_OUTPUTS} and
* {@code FinalAnswerNode}'s direct branch.
*
* @param toolCallId the tool call id from the originating LLM response
* @param toolName the resolved tool name
* @param fullResult the complete tool result, never truncated or spilled
* @param executedAtMs epoch milliseconds when the tool returned
*
* @author MateClaw Team
*/
public record DirectToolOutput(
String toolCallId,
String toolName,
String fullResult,
long executedAtMs
) {
}

View File

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

View File

@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import java.util.*;
@ -195,12 +196,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 +266,67 @@ 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);
}
/**
* 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", "");
}
// ===== 输出构建器 =====
/**
@ -373,11 +481,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 +526,97 @@ 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);
}
/** 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, "");
}
/** 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

@ -83,6 +83,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 +149,125 @@ 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";
/** 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";
}

View File

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

View File

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

View File

@ -21,8 +21,34 @@ public class TemplateDTO {
private String agentType;
private String tags;
private Integer maxIterations;
/**
* Optional pre-rendered system prompt seeded into the new agent. Templates
* use H2 sections (## Role / ## Goal / ## Backstory / ## Additional
* Instructions) so the editor UI can split the prompt into structured
* fields and derive a one-line tagline for the agent card.
*/
private String systemPrompt;
private List<WorkspaceFileTemplate> workspaceFiles;
/**
* Skill slugs (matching {@code mate_skill.name}) to pre-bind to the newly
* hired agent. Resolved against the target workspace at apply time; any
* slug whose row is missing in that workspace is logged and skipped so a
* partially-installed environment can still hire the agent. Templates ship
* with classpath-stable slugs, not numeric IDs, because skill ids vary per
* install.
*/
private List<String> defaultSkillSlugs;
/**
* Tool names to pre-bind directly (bypassing the skill layer). Filtered
* against {@code AvailableToolService.listAvailable()} at apply time
* names the picker can't resolve are dropped with a warning rather than
* aborting the hire. Use for capabilities that aren't owned by any skill,
* not for system-level tools that are already universally available.
*/
private List<String> defaultToolNames;
@Data
public static class WorkspaceFileTemplate {
private String filename;

View File

@ -0,0 +1,35 @@
package vip.mate.agent.progress;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* A single step inside a conversation's {@link ProgressLedger}.
*
* <p>{@code key} is the stable identifier the agent picks (e.g. {@code
* "model_gpt55"} for "research GPT-5.5" or {@code "step_pptx"} for "generate
* the slide deck"). The same key on subsequent updates overwrites the entry
* in place so the model can advance one step from {@code PENDING}
* {@code IN_PROGRESS} {@code DONE} without producing duplicates.
*
* <p>{@code note} is optional and capped at a few hundred characters when
* rendered into the snapshot; the field itself isn't length-limited because
* the underlying column is LONGTEXT and a model that wants to dump rich
* context shouldn't be silently truncated at the schema layer.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProgressEntry {
private String key;
private String label;
private ProgressStatus status;
private String note;
private Instant updatedAt;
}

View File

@ -0,0 +1,187 @@
package vip.mate.agent.progress;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Read-only view over a conversation's progress entries with a renderer that
* turns the map into a compact markdown snapshot for system-prompt injection.
*
* <p>The snapshot is grouped by status (done in-progress pending
* blocked) and stays short on purpose: the agent reads it on every turn, so
* spending more than ~200 tokens on it would defeat the very context
* pressure this ledger exists to relieve.
*/
public final class ProgressLedger {
/** Hard cap on the snapshot's note suffix so a rambling note can't bloat every turn. */
private static final int NOTE_PREVIEW_CHARS = 120;
private final Map<String, ProgressEntry> entries;
public ProgressLedger(Map<String, ProgressEntry> entries) {
this.entries = entries != null ? entries : new LinkedHashMap<>();
}
public static ProgressLedger empty() {
return new ProgressLedger(new LinkedHashMap<>());
}
public boolean isEmpty() {
return entries.isEmpty();
}
public int size() {
return entries.size();
}
public Map<String, ProgressEntry> asMap() {
return entries;
}
/**
* @return the most recent {@code updatedAt} across all entries, or empty
* when the ledger is empty / all entries lack a timestamp.
*/
public Optional<Instant> mostRecentUpdate() {
Instant max = null;
for (ProgressEntry e : entries.values()) {
Instant t = e.getUpdatedAt();
if (t != null && (max == null || t.isAfter(max))) {
max = t;
}
}
return Optional.ofNullable(max);
}
/** Iteration before which no stale reminder is ever issued — too early to judge. */
private static final int STALE_WARMUP_ITERATIONS = 10;
/** Iteration past which an empty ledger triggers a "you should register steps" reminder. */
private static final int EMPTY_LEDGER_NUDGE_ITERATIONS = 15;
/** Wall-clock gap that flips a non-empty ledger from "fresh" to "stale". */
private static final long STALE_GAP_SECONDS = 90;
/**
* Build a stale-reminder string for injection into the model's context
* when the ledger appears to be falling behind the actual reasoning
* progress. Returns {@code null} when the ledger is being maintained
* normally so the caller can skip the injection.
*
* <p>Trigger heuristics derived from round-4 of the LLM-review smoke
* test, where the model stopped calling {@code progress_update} after
* the first 30s and silently fell out of the ledger discipline:
*
* <ul>
* <li><strong>Warm-up</strong>: {@code currentIteration < 10} never
* remind, the model is still setting up the task.</li>
* <li><strong>Empty ledger</strong>: {@code currentIteration 15} and
* no entries at all likely a multi-step task being executed
* without any ledger discipline.</li>
* <li><strong>Stale updates</strong>: ledger has entries, but the
* most recent {@code updatedAt} is &gt; 90 s ago ledger is no
* longer tracking the real work.</li>
* </ul>
*
* @param currentIteration the agent's current ReAct iteration count
* @param now the reference instant for staleness ("now");
* injected for testability
*/
public String renderStaleReminder(int currentIteration, Instant now) {
if (currentIteration < STALE_WARMUP_ITERATIONS) {
return null;
}
if (entries.isEmpty()) {
if (currentIteration < EMPTY_LEDGER_NUDGE_ITERATIONS) {
return null;
}
return "## ⚠️ 进度账本是空的(已运行 " + currentIteration + " 轮)\n\n"
+ "你正在进行一个看起来需要拆解的多步任务,但还没有调用 `progress_update`。\n"
+ "**立即用一条并行 tool_calls 回复批量注册所有 pending 步骤**,否则上下文\n"
+ "窗口被裁剪后,你会忘记自己做过的工作并重复执行。";
}
Optional<Instant> lastUpdate = mostRecentUpdate();
if (lastUpdate.isEmpty()) {
return null;
}
long gap = java.time.Duration.between(lastUpdate.get(), now).getSeconds();
if (gap < STALE_GAP_SECONDS) {
return null;
}
int done = (int) entries.values().stream()
.filter(e -> e.getStatus() == ProgressStatus.DONE).count();
int inProgress = (int) entries.values().stream()
.filter(e -> e.getStatus() == ProgressStatus.IN_PROGRESS).count();
return "## ⚠️ 进度账本已 " + gap + " 秒未更新\n\n"
+ "你已运行 " + currentIteration + " 轮,但 progress_update 已经 "
+ gap + " 秒(约 " + (gap / 60) + " 分钟)没被调用过。\n"
+ "当前账本:" + done + " done / " + inProgress + " in_progress / "
+ (entries.size() - done - inProgress) + " pending。\n\n"
+ "**立即做以下一件事**(不要再 read_file 或 browser_use先更新账本\n"
+ "- 把已经完成的子步骤切到 `done`(如果你能看到工作区文件已生成)\n"
+ "- 把正在做的步骤切到 `in_progress`\n"
+ "- 有阻塞切到 `blocked` + 写明原因\n"
+ "不维护账本会导致重复工作 / 漏做项目 / 撞迭代上限。";
}
/**
* @return a compact, model-readable progress snapshot, or {@code null}
* when the ledger is empty so the caller can skip injection
* entirely (no "(empty)" placeholder noise).
*/
public String renderSnapshot() {
if (entries.isEmpty()) {
return null;
}
List<ProgressEntry> done = bucket(ProgressStatus.DONE);
List<ProgressEntry> inProgress = bucket(ProgressStatus.IN_PROGRESS);
List<ProgressEntry> pending = bucket(ProgressStatus.PENDING);
List<ProgressEntry> blocked = bucket(ProgressStatus.BLOCKED);
StringBuilder sb = new StringBuilder(256);
sb.append("## 当前任务进度(执行参考,权威记录)\n\n");
appendBucket(sb, "✅ 已完成", done);
appendBucket(sb, "🔄 进行中", inProgress);
appendBucket(sb, "⏳ 待办", pending);
appendBucket(sb, "⛔ 受阻", blocked);
sb.append("\n请基于此进度继续推进已完成的步骤不要重复执行。")
.append("完成新步骤后调用 `progress_update` 工具更新本账本。");
return sb.toString();
}
private List<ProgressEntry> bucket(ProgressStatus status) {
List<ProgressEntry> out = new ArrayList<>();
for (ProgressEntry e : entries.values()) {
if (e.getStatus() == status) {
out.add(e);
}
}
return out;
}
private void appendBucket(StringBuilder sb, String header, Collection<ProgressEntry> items) {
if (items.isEmpty()) {
return;
}
sb.append(header).append(" (").append(items.size()).append("):\n");
for (ProgressEntry e : items) {
String label = e.getLabel() != null && !e.getLabel().isBlank() ? e.getLabel() : e.getKey();
sb.append("- ").append(label).append(" [`").append(e.getKey()).append("`]");
String note = e.getNote();
if (note != null && !note.isBlank()) {
String trimmed = note.length() > NOTE_PREVIEW_CHARS
? note.substring(0, NOTE_PREVIEW_CHARS) + ""
: note;
sb.append("").append(trimmed);
}
sb.append('\n');
}
sb.append('\n');
}
}

View File

@ -0,0 +1,157 @@
package vip.mate.agent.progress;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loader / writer for the per-conversation progress ledger persisted as a
* JSON blob on {@code mate_conversation.progress_ledger} (see V100 migration).
*
* <p>The service is the only component that touches the JSON column directly.
* Callers above it work with {@link ProgressLedger} (immutable view) or plain
* {@code Map<String, ProgressEntry>}.
*
* <p>Failure mode: a malformed JSON value never throws back at the caller
* the runtime would rather render no snapshot than crash the reasoning loop
* over a corrupted ledger column. Parse failures are logged at warn level so
* the operator notices on a long-running deployment.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ProgressLedgerService {
/** Map<stepKey, ProgressEntry> — LinkedHashMap preserves insertion order in the rendered snapshot. */
private static final TypeReference<LinkedHashMap<String, ProgressEntry>> LEDGER_TYPE =
new TypeReference<>() {};
/**
* Per-conversation mutex for the load-mutate-save sequence inside
* {@link #upsert}. Without this guard, a single agent turn that issues
* N parallel {@code progress_update} tool calls (observed: 12 calls in
* one batch when the model pre-registered every step at task start)
* collapses to last-writer-wins, losing every entry but one defeating
* the whole point of the ledger. Different conversations stay
* uncontended; only intra-conversation writes serialise.
*
* <p>Entries are computed on demand and never explicitly removed; even
* with thousands of long-running conversations the map stays bounded by
* the active conversation set, and any leak is a {@code Object} per
* conversation id small enough to ignore relative to the rest of the
* per-conv state already held in memory.
*/
private final ConcurrentHashMap<String, Object> upsertLocks = new ConcurrentHashMap<>();
private final ConversationMapper conversationMapper;
private final ObjectMapper objectMapper;
/**
* @return the conversation's ledger, never null an empty map when the
* column is NULL or unparseable.
*/
public ProgressLedger load(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return ProgressLedger.empty();
}
return parse(loadLedgerJson(conversationId));
}
/**
* Read the raw JSON column for one conversation, or {@code null} when
* the row or column is empty. Protected so concurrency tests can
* subclass and back the service with an in-memory map without having
* to mock the Mybatis-Plus wrapper internals.
*/
protected String loadLedgerJson(String conversationId) {
ConversationEntity row = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.select(ConversationEntity::getProgressLedger));
return row != null ? row.getProgressLedger() : null;
}
/**
* Write the raw JSON column for one conversation. Protected for the
* same reason as {@link #loadLedgerJson}.
*/
protected void saveLedgerJson(String conversationId, String json) {
conversationMapper.update(null,
new LambdaUpdateWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.set(ConversationEntity::getProgressLedger, json));
}
/**
* Upsert one entry on the ledger atomically (load mutate save).
*
* @return the updated ledger so callers can render a fresh snapshot
* without a second DB roundtrip.
*/
public ProgressLedger upsert(String conversationId, String key, String label,
ProgressStatus status, String note) {
if (conversationId == null || conversationId.isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("step key is required");
}
if (status == null) {
throw new IllegalArgumentException("status is required");
}
// Serialise the load-mutate-save sequence per conversation. Without
// this, two parallel @Tool calls on the same conversation race: both
// read the same starting state, each adds its own entry, and the
// last save() drops the other's entry. Observed in production: a
// 12-entry pre-registration collapsed to 8 because four sibling
// tool calls landed in the same window.
Object mutex = upsertLocks.computeIfAbsent(conversationId, k -> new Object());
synchronized (mutex) {
ProgressLedger ledger = load(conversationId);
Map<String, ProgressEntry> map = ledger.asMap();
ProgressEntry existing = map.get(key);
String effectiveLabel = (label != null && !label.isBlank())
? label
: (existing != null ? existing.getLabel() : key);
map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now()));
persist(conversationId, map);
return new ProgressLedger(map);
}
}
private ProgressLedger parse(String json) {
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return ProgressLedger.empty();
}
try {
LinkedHashMap<String, ProgressEntry> map = objectMapper.readValue(json, LEDGER_TYPE);
return new ProgressLedger(map);
} catch (Exception e) {
log.warn("Failed to parse progress ledger JSON, treating as empty: {}", e.getMessage());
return ProgressLedger.empty();
}
}
private void persist(String conversationId, Map<String, ProgressEntry> map) {
try {
String json = objectMapper.writeValueAsString(map);
saveLedgerJson(conversationId, json);
} catch (Exception e) {
// Surface to caller so the tool can return an error message to
// the LLM rather than silently dropping the update.
throw new IllegalStateException(
"Failed to persist progress ledger for " + conversationId + ": " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,52 @@
package vip.mate.agent.progress;
import java.util.Locale;
/**
* Status of a single step in the conversation-scoped progress ledger.
*
* <p>Kept deliberately small four states cover the workflow patterns we
* see in long multi-step agent tasks (research one item at a time, draft a
* document section by section, etc.) without inviting bikeshedding on
* intermediate states. The wire form is the lowercase enum name; the tool's
* {@code status} parameter accepts case-insensitive input.
*/
public enum ProgressStatus {
/** Step is known to be needed but not yet started. */
PENDING,
/** Currently being worked on. */
IN_PROGRESS,
/** Finished and verified by the agent. */
DONE,
/** Cannot continue — note must explain why so the user / next pass can intervene. */
BLOCKED;
public String wireValue() {
return name().toLowerCase(Locale.ROOT);
}
/**
* Parse a model-supplied status string. Tolerates case differences,
* hyphens, and spaces (the model often writes "in progress" or
* "in-progress" both map to {@link #IN_PROGRESS}).
*
* @return the matching status, or {@code null} when no match is found so
* the caller can surface a structured error back to the LLM.
*/
public static ProgressStatus parse(String raw) {
if (raw == null) {
return null;
}
String normalised = raw.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_');
for (ProgressStatus s : values()) {
if (s.name().equals(normalised)) {
return s;
}
}
return null;
}
}

View File

@ -9,14 +9,14 @@ import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
/**
* Prompt 文件加载器
* <p>
* classpath:/prompts/ 目录加载 .txt 文件使用 ConcurrentHashMap 做线程安全的懒加载缓存
* <p>
* 未来扩展点可在 loadPrompt() 中增加"先查数据库覆盖 → 再读 resource → 最后代码兜底"的优先级链
* 但本次只实现 resource 读取
* Loads prompt text files from {@code classpath:/prompts/} with a thread-safe
* lazy cache.
*
* @author MateClaw Team
* <p>Single-language by design: prompts are written in the system's default
* language and the LLM is trusted to follow the user's input language for
* its output. The previous {@code loadPrompt(name, locale)} overload and
* {@code prompts/{locale}/...} fallback chain were never wired up by any
* caller and have been removed.</p>
*/
@Slf4j
public final class PromptLoader {
@ -28,44 +28,19 @@ public final class PromptLoader {
private PromptLoader() {}
/**
* 加载 prompt 文件内容默认语言
* Load a prompt file's contents.
*
* @param promptName 文件名不含路径前缀和 .txt 后缀例如 "graph/summarize-system"
* @return 文件文本内容
* @throws RuntimeException 文件不存在或读取失败时抛出不会静默返回空字符串
* @param promptName file name without the {@code prompts/} prefix or {@code .txt} suffix
* (e.g. {@code "graph/summarize-system"})
* @return file text content
* @throws RuntimeException when the file is missing or unreadable; the loader never
* silently returns an empty string
*/
public static String loadPrompt(String promptName) {
return promptCache.computeIfAbsent(promptName, name -> readPromptFile(name, null));
return promptCache.computeIfAbsent(promptName, PromptLoader::readPromptFile);
}
/**
* 加载指定语言的 prompt 文件内容
* <p>
* 查找顺序{@code prompts/{locale}/{name}.txt} {@code prompts/{name}.txt}
*
* @param promptName 文件名不含路径前缀和 .txt 后缀
* @param locale 语言标识 "en""zh" null "zh" 时使用默认文件
* @return 文件文本内容
*/
public static String loadPrompt(String promptName, String locale) {
if (locale == null || locale.isBlank() || "zh".equals(locale)) {
return loadPrompt(promptName);
}
String cacheKey = locale + ":" + promptName;
return promptCache.computeIfAbsent(cacheKey, key -> readPromptFile(promptName, locale));
}
private static String readPromptFile(String name, String locale) {
// 优先尝试 locale 目录
if (locale != null && !locale.isBlank()) {
String localeFileName = PROMPT_PATH_PREFIX + locale + "/" + name + ".txt";
try (InputStream is = PromptLoader.class.getClassLoader().getResourceAsStream(localeFileName)) {
if (is != null) {
return StreamUtils.copyToString(is, StandardCharsets.UTF_8);
}
} catch (IOException ignored) {}
}
// 回退到默认目录
private static String readPromptFile(String name) {
String fileName = PROMPT_PATH_PREFIX + name + ".txt";
try (InputStream inputStream = PromptLoader.class.getClassLoader().getResourceAsStream(fileName)) {
if (inputStream == null) {
@ -78,18 +53,12 @@ public final class PromptLoader {
}
}
/**
* 清空缓存
*/
/** Drop the entire cache. Useful for tests and hot-reload tooling. */
public static void clearCache() {
promptCache.clear();
}
/**
* 获取缓存大小
*
* @return 已缓存的 prompt 数量
*/
/** Number of prompts currently cached. */
public static int getCacheSize() {
return promptCache.size();
}

View File

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

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