Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d994be3d04 | ||
|
|
9ada305b8a |
28
.dockerignore
Normal file
@ -0,0 +1,28 @@
|
||||
# Git and IDE
|
||||
.git
|
||||
.idea
|
||||
*.iml
|
||||
|
||||
# Node artifacts
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.nuxt
|
||||
**/.output
|
||||
|
||||
# Maven build output (will be rebuilt in Docker)
|
||||
**/target
|
||||
|
||||
# Desktop / webchat (not needed for server or sites build)
|
||||
mateclaw-desktop
|
||||
|
||||
# Data and logs
|
||||
data
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.env
|
||||
*.md
|
||||
!docs/**/*.md
|
||||
!matevip-sites/**/*.md
|
||||
!mateclaw-plugin-api/**
|
||||
!mateclaw-server/**
|
||||
38
.env.example
@ -1,18 +1,10 @@
|
||||
# MateClaw 环境变量配置
|
||||
# 复制此文件为 .env 并填写实际值:cp .env.example .env
|
||||
#
|
||||
# LLM API Key(DashScope、OpenAI 等)无需在此配置,启动后在管理界面「模型管理」中添加。
|
||||
#
|
||||
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
|
||||
|
||||
# ==================== LLM / 搜索 ====================
|
||||
|
||||
# 阿里云 DashScope API Key(必填)
|
||||
# 申请地址:https://dashscope.aliyun.com/
|
||||
DASHSCOPE_API_KEY=your-dashscope-api-key-here
|
||||
|
||||
# Serper 网页搜索 API Key(可选,用于 WebSearch 工具)
|
||||
# 申请地址:https://serper.dev/
|
||||
SERPER_API_KEY=
|
||||
|
||||
# ==================== 数据库(Docker 模式必填) ====================
|
||||
|
||||
DB_HOST=localhost
|
||||
@ -36,3 +28,29 @@ JWT_SECRET=
|
||||
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。
|
||||
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
|
||||
MATECLAW_CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
|
||||
# openssl rand -hex 32
|
||||
SEARXNG_SECRET=
|
||||
|
||||
# ==================== 浏览器工具(可选) ====================
|
||||
#
|
||||
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。
|
||||
# 只有在下述场景才需要 override:
|
||||
#
|
||||
# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接:
|
||||
# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222
|
||||
#
|
||||
# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome):
|
||||
# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable
|
||||
#
|
||||
# 3) 强制使用 Playwright channel(chrome / msedge / chrome-beta …):
|
||||
# MATECLAW_BROWSER_CHANNEL=chrome
|
||||
MATECLAW_BROWSER_CDP_URL=
|
||||
MATECLAW_BROWSER_CHROME_PATH=
|
||||
MATECLAW_BROWSER_CHANNEL=
|
||||
|
||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||
#MAVEN_FLAGS=-Paliyun-first
|
||||
|
||||
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
name: "🐛 Bug Report (English)"
|
||||
description: Report something that's broken. Three required fields — fill them and submit.
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this. Three things, that's it — any less and we can't locate it; any more wastes your time.
|
||||
|
||||
> **Issues without a screenshot, log, or repro steps will be closed.** Not because we don't care — we genuinely can't fix what we can't reproduce.
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What broke? (required, attach screenshot)
|
||||
description: |
|
||||
One sentence describing the symptom + at least one screenshot (drag it into the text box).
|
||||
If it's a backend error, paste the stack trace here too (wrapped in ```).
|
||||
placeholder: |
|
||||
Example: As a `member`-role user in ws-b, I clicked "Create from template". The new Agent appeared in the default workspace instead of ws-b.
|
||||
|
||||
[drag in screenshot / screen recording]
|
||||
|
||||
[paste backend stack trace or frontend console error]
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: How to reproduce? (required, numbered steps)
|
||||
description: |
|
||||
Steps that someone with zero context can follow. **A symptom you can't reproduce is a guess, not a bug.**
|
||||
placeholder: |
|
||||
1. Log in as admin / admin123, create workspace ws-b
|
||||
2. Add user bob as ws-b member
|
||||
3. Log out, log back in as bob, switch UI to ws-b
|
||||
4. Go to Agents → "Create from template" → pick assistant → apply
|
||||
5. Switch to default workspace — the Agent shows up here
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: module
|
||||
attributes:
|
||||
label: Affected module (optional, multi-select)
|
||||
description: Which part of the system? Skip if unsure — helps maintainers triage.
|
||||
multiple: true
|
||||
options:
|
||||
- Backend / 后端
|
||||
- Frontend (admin UI) / 前端
|
||||
- Desktop / 桌面端
|
||||
- Webchat embed widget
|
||||
- Channel (DingTalk / Feishu / Telegram / Discord / QQ / Slack ...)
|
||||
- Tool / 工具
|
||||
- Skill / 技能
|
||||
- Wiki / 知识库
|
||||
- Memory / 记忆
|
||||
- Agent / StateGraph runtime
|
||||
- Auth / Workspace permission
|
||||
- Deployment / DB migration
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: env
|
||||
attributes:
|
||||
label: Environment (required, one line)
|
||||
description: version / workspace role / browser or client. One line.
|
||||
placeholder: "v0.x.y / member / Chrome 130 on macOS 14.5"
|
||||
validations:
|
||||
required: true
|
||||
74
.github/ISSUE_TEMPLATE/bug-zh.yml
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
name: "🐛 Bug 报告(中文)"
|
||||
description: 报告一个不工作的功能。三个必填项,写完就交。
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢花时间反馈。三件事,写完就好——少一件我们没法定位,多一件浪费你时间。
|
||||
|
||||
> **没截图、没日志、没步骤的 issue 我们会直接关掉**,不是不在乎,是真的修不了。
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: 出了什么问题?(必填,附截图)
|
||||
description: |
|
||||
一句话说清现象 + 至少一张截图(直接拖进文本框即可)。
|
||||
如果是后端报错,把后端日志也贴这里(用 ``` 包起来)。
|
||||
placeholder: |
|
||||
例:作为 member 角色用户,在 ws-b 工作区点「从模板创建」,新建出来的 Agent 出现在了默认工作区,不在 ws-b。
|
||||
|
||||
[拖入截图 / 录屏]
|
||||
|
||||
[贴出后端 stack trace 或前端 console error]
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: 怎么复现?(必填,编号步骤)
|
||||
description: |
|
||||
让一个完全不知情的人能按步骤复现。**说不出步骤的现象不是 bug,是猜想。**
|
||||
placeholder: |
|
||||
1. 用 admin / admin123 登录,新建工作区 ws-b
|
||||
2. 添加用户 bob 为 ws-b 的 member
|
||||
3. 注销,用 bob 登录,前端切到 ws-b
|
||||
4. 点 Agents 页面 → 「从模板创建」 → 选 assistant → 应用
|
||||
5. 切回默认工作区,看到 Agent 出现在了这里
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: module
|
||||
attributes:
|
||||
label: 影响模块(选填,多选)
|
||||
description: 大致是哪一块?不确定就空着,方便维护者打 label。
|
||||
multiple: true
|
||||
options:
|
||||
- 后端 / Backend
|
||||
- 前端 / Frontend (admin UI)
|
||||
- 桌面端 / Desktop
|
||||
- Webchat 嵌入组件
|
||||
- Channel(钉钉/飞书/Telegram/Discord/QQ/Slack...)
|
||||
- Tool / 工具
|
||||
- Skill / 技能
|
||||
- Wiki / 知识库
|
||||
- Memory / 记忆
|
||||
- Agent / StateGraph 运行时
|
||||
- Auth / 工作区权限
|
||||
- 部署 / 数据库迁移
|
||||
- 其它
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: env
|
||||
attributes:
|
||||
label: 环境(必填,一行)
|
||||
description: 版本 / 工作区角色 / 浏览器或客户端。一行写完。
|
||||
placeholder: "v0.x.y / member / Chrome 130 macOS 14.5"
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 使用问题先看文档 / Check the docs first
|
||||
url: https://claw.mate.vip/docs
|
||||
about: 安装、配置、用法问题文档里大多有答案 / Install, config, and usage questions are usually answered in the docs.
|
||||
- name: 🔒 安全漏洞私下报告 / Report security issues privately
|
||||
url: https://github.com/matevip/mateclaw/security/advisories/new
|
||||
about: 安全相关问题请走 Security Advisory,不要开公开 issue / Please use Security Advisory for security-related issues, don't open a public issue.
|
||||
41
.github/ISSUE_TEMPLATE/feature-en.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: "✨ Feature Request (English)"
|
||||
description: Propose a new feature or improvement. Start with why, then what.
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
The key to a new feature is not "what it is" but "**who suffers without it, and how**".
|
||||
|
||||
If you can't articulate who would use it and why, the feature probably shouldn't be built.
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: What problem are you solving? (required)
|
||||
description: |
|
||||
Describe a real scenario. **Don't jump to "add an XX button"** — first explain why you need that button, and what hurts without it.
|
||||
placeholder: |
|
||||
Example: I switch the default model for 5 different Agents every day, and each switch takes 3 clicks in the settings page.
|
||||
A global "quick switch default model" menu would save me 30 clicks a day.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: How should it work? (required)
|
||||
description: |
|
||||
A paragraph or a few bullets. If you can sketch it or share a mockup, even better (drag in images).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you've tried? (optional)
|
||||
description: |
|
||||
If you can't think of any, leave it blank. **Don't invent content just to fill the field.**
|
||||
validations:
|
||||
required: false
|
||||
41
.github/ISSUE_TEMPLATE/feature-zh.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: "✨ 功能建议(中文)"
|
||||
description: 提一个新功能或改进。先讲为什么,再讲是什么。
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
新功能的关键不是"它是什么",是"**没有它,谁在受什么苦**"。
|
||||
|
||||
如果你说不清谁会用、为什么用,这个功能大概率不该做。
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: 你在解决什么问题?(必填)
|
||||
description: |
|
||||
描述真实场景。**不要直接写"应该加一个 XX 按钮"** —— 先说为什么要这个按钮、不加会怎样。
|
||||
placeholder: |
|
||||
例:我每天要给 5 个不同的 Agent 切换默认模型,每次都要进设置页改 3 处。
|
||||
如果有一个"快速切换默认模型"的全局菜单,我每天能少点 30 次鼠标。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: 你期望它怎么工作?(必填)
|
||||
description: |
|
||||
一段话或几个 bullet。如果你能画个草图、贴个 mockup,更好(直接拖图)。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 你试过的替代方案?(选填)
|
||||
description: |
|
||||
如果想不到替代方案,就空着。**不要为了填字段而瞎写。**
|
||||
validations:
|
||||
required: false
|
||||
7
.gitignore
vendored
@ -92,5 +92,12 @@ deploy/nginx/ssl/*.pem
|
||||
deploy/.env
|
||||
|
||||
# Claude Code local settings
|
||||
CLAUDE.md
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
|
||||
# Codex CLI local artifacts
|
||||
.codex/
|
||||
|
||||
# Sync tooling local state (generated each run; report is intentionally tracked)
|
||||
scripts/.*-sync-state.json
|
||||
|
||||
259
README.md
@ -6,13 +6,13 @@
|
||||
|
||||
# MateClaw
|
||||
|
||||
<p align="center"><b>Build AI that thinks, acts, remembers, and ships.</b></p>
|
||||
<p align="center"><b>Your second brain</b></p>
|
||||
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://claw.mate.vip/docs)
|
||||
[](https://claw-demo.mate.vip)
|
||||
[](https://claw.mate.vip)
|
||||
[](https://adoptium.net/)
|
||||
[](https://adoptium.net/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
@ -28,123 +28,117 @@
|
||||
|
||||
---
|
||||
|
||||
An AI agent. A knowledge engine. A memory system. A tool runtime. A multi-channel presence.
|
||||
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
|
||||
>
|
||||
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
|
||||
|
||||
**One product. The whole widget.**
|
||||
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
||||
|
||||
MateClaw is a personal AI operating system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). It's not a chatbox, not a workflow builder, not just another coding assistant. It's the entire system — from reasoning to remembering to shipping — in one deployment.
|
||||
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
|
||||
|
||||
Three things make it insanely different:
|
||||
---
|
||||
|
||||
1. **Agents do work, not just talk** — ReAct + Plan-and-Execute. Not one-shot answers — iterative reasoning that actually completes tasks
|
||||
2. **Knowledge is shaped, not just stored** — An LLM Wiki that digests raw material into structured, linked pages. The difference between a warehouse and a library
|
||||
3. **End-to-end, no compromises** — Web console, desktop app, 7 IM channels, tool guardrails, enterprise auth. One team, one deployment, one experience
|
||||
## Three things that make it different
|
||||
|
||||
### 1 · Your AI doesn't die when a model does
|
||||
|
||||
Primary key expired. Vendor returns 401. Network blip. Quota drained.
|
||||
|
||||
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
||||
|
||||
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
|
||||
|
||||
### 2 · Knowledge that links itself
|
||||
|
||||
Upload a PDF, a batch of markdown, a scraped page — raw material in.
|
||||
|
||||
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
|
||||
|
||||
This is the difference between a warehouse and a library.
|
||||
|
||||
### 3 · One product, five surfaces
|
||||
|
||||
| Surface | What it is |
|
||||
|---|---|
|
||||
| **Web Console** | Full admin — digital employees, models, skills, knowledge, security, cron, **runtime console** (see what every employee is doing, force-recycle in one click) |
|
||||
| **Desktop** | Electron app with a bundled JRE 21. Double-click, run. No Java install |
|
||||
| **Webchat Widget** | One `<script>` tag embed. Drop it on any site |
|
||||
| **IM Channels** | DingTalk · Feishu · WeChat Work · WeChat · Telegram · Discord · QQ · Slack |
|
||||
| **Plugin SDK** | Java module for third-party capability packs |
|
||||
|
||||
Same brain. Same memory. Same tools. Different doors.
|
||||
|
||||
<p align="center"><b>$0 · No tokens metered. No seats billed. Your server. Your data. Your keys.</b></p>
|
||||
|
||||
---
|
||||
|
||||
## What's in the box
|
||||
|
||||
### Digital employees, not chatbots
|
||||
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
|
||||
|
||||
### Knowledge & memory
|
||||
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt
|
||||
- **Workspace memory** — `AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
|
||||
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows
|
||||
|
||||
### Skills · MCP · ACP — three ways to extend capability
|
||||
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
|
||||
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server
|
||||
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
|
||||
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
|
||||
|
||||
### You see what every employee is doing
|
||||
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
|
||||
|
||||
### Multimodal creation
|
||||
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons.
|
||||
|
||||
### Enterprise-ready
|
||||
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
|
||||
|
||||
---
|
||||
|
||||
## AI is becoming infrastructure
|
||||
|
||||
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
|
||||
|
||||
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
|
||||
|
||||
**MateClaw is that layer — built the Spring Boot way.**
|
||||
|
||||
---
|
||||
|
||||
## Why MateClaw
|
||||
|
||||
Most AI tools do one thing well. MateClaw does the whole thing.
|
||||
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||
| **Multi-vendor failover** | **Chain + health tracker + cooldown** | Swap providers via config | Orchestration w/ retry | Anthropic only | One model |
|
||||
| **Knowledge digestion** | **LLM Wiki + page-level citations** | Canvas + memory | Skills Hub + memory | — | Code index |
|
||||
| **Multi-user admin** | **RBAC + approval + audit + runtime console** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
|
||||
| **Capability extension** | **Skills (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
|
||||
| **Surfaces** | Web admin + Desktop + Widget + SDK + 8 IM | 25+ chat channels | 15+ channels (CLI-led) | 3 IM preview | IDE only |
|
||||
| **Stack** | **Java (Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||
| **License / Price** | **Apache 2.0 · Free** | MIT · Free | MIT · Free | Proprietary · $20–200/mo | Proprietary · $0–200/mo |
|
||||
|
||||
| Capability | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [CoPaw](https://github.com/agentscope-ai/CoPaw) | [QClaw](https://cntechpost.com/2026/03/20/tencent-opens-qclaw-public-testing-amid-fierce-ai-rivalry/) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) | [Windsurf](https://windsurf.com) |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| Agent Orchestration | **ReAct + Plan-Execute** | Multi-agent teams | Multi-agent collab | Specialist agents | Agent Teams + subagents | Background Agents (cloud VM) | Cascade engine |
|
||||
| Knowledge System | **LLM Wiki (digestion)** | Intelligence Mode + Wiki | Personal KB | Knowledge graph | CLAUDE.md (no RAG) | Codebase indexing | No |
|
||||
| Memory | **Extract + Consolidate + Dream** | SQLite + Dreaming + Wiki | ReMe (hybrid retrieval) | 3-layer memory | 3-layer (CLAUDE.md + auto + files) | No persistent memory | Memories (~48h learning) |
|
||||
| Tool Guard & Approval | **RBAC + approval flow** | HITL + risk levels | No | No | Permissions + Sandbox + Hooks | No | Turbo Mode (auto-approve) |
|
||||
| Multi-Channel IM | **7 channels** | 25+ channels | 7 channels | 5 channels | 3 channels (preview) | IDE only | IDE only |
|
||||
| Web Management UI | **Full admin dashboard** | Control UI | Console UI | Dashboard | Enterprise dashboard | No | No |
|
||||
| Desktop App | **Electron + bundled JRE** | macOS menu bar | Electron (Beta) | Win/Mac app | Claude Desktop (Mac/Win) | VS Code fork | VS Code fork |
|
||||
| Multimodal Creation | **TTS/STT/Img/Music/Video** | TTS/Video/Music/Image | Vision input | No | Vision input only | No | No |
|
||||
| Skill Ecosystem | **ClawHub marketplace** | ClawHub registry | Python skills | Templates | 340+ plugins, 1300+ skills | MCP marketplace | MCP one-click |
|
||||
| Enterprise Auth | **RBAC + JWT** | Basic (password) | Basic auth | No | SSO/SCIM/RBAC | SSO + Teams | Teams plan |
|
||||
| Open Source | **Apache 2.0** | MIT | Apache 2.0 | Partial | No (source-available) | No | No |
|
||||
| Pricing | **Free** | Free | Free | Free (beta) | $20–200/mo | $0–200/mo | $0–200/mo |
|
||||
| Tech Stack | **Java + Vue 3** | TypeScript | Python + TS | OpenClaw fork | TypeScript | Electron (VS Code) | Electron (VS Code) |
|
||||
**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
|
||||
cd mateclaw-server
|
||||
mvn spring-boot:run # http://localhost:18088
|
||||
mvn spring-boot:run # http://localhost:18088
|
||||
|
||||
# Frontend
|
||||
cd mateclaw-ui
|
||||
pnpm install && pnpm dev # http://localhost:5173
|
||||
pnpm install && pnpm dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Login: `admin` / `admin123`
|
||||
@ -156,55 +150,64 @@ cp .env.example .env
|
||||
docker compose up -d # http://localhost:18080
|
||||
```
|
||||
|
||||
### Desktop App
|
||||
### Desktop
|
||||
|
||||
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21 — no Java needed.
|
||||
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
## Architecture
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
|
||||
| Agent | StateGraph Runtime |
|
||||
| Database | H2 (dev) / MySQL 8.0+ (prod) |
|
||||
| ORM | MyBatis Plus 3.5 |
|
||||
| Auth | Spring Security + JWT |
|
||||
| Frontend | Vue 3 · TypeScript · Vite |
|
||||
| UI | Element Plus · TailwindCSS 4 |
|
||||
| Desktop | Electron · electron-updater |
|
||||
<p align="center">
|
||||
<img src="assets/architecture-biz-en.svg" alt="Business Architecture" width="800">
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary><b>Technical architecture</b></summary>
|
||||
<p align="center">
|
||||
<img src="assets/architecture-tech-en.svg" alt="Technical Architecture" width="800">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
## Project structure
|
||||
|
||||
```
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot backend
|
||||
├── mateclaw-ui/ Vue 3 SPA frontend
|
||||
├── mateclaw-desktop/ Electron desktop app
|
||||
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
|
||||
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
||||
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
||||
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
||||
├── mateclaw-plugin-sample/ Reference plugin implementation
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
|
||||
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP) · ACP bridge (Claude Code / Codex) |
|
||||
| Database | H2 (dev) · MySQL 8.0+ (prod) |
|
||||
| Auth | Spring Security + JWT |
|
||||
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
||||
| Widget | Vite library mode · UMD + ES bundles |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
|
||||
|
||||
---
|
||||
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, architecture, each subsystem, API reference.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Richer multi-agent collaboration
|
||||
- Smarter model routing
|
||||
- Deeper multimodal understanding
|
||||
- Stronger long-term memory
|
||||
- Richer ClawHub ecosystem
|
||||
|
||||
---
|
||||
Sharper multi-employee collaboration · Smarter model routing · Deeper multimodal understanding · Longer-lived memory · A richer ClawHub · More ACP upstream integrations.
|
||||
|
||||
## Contributing
|
||||
|
||||
@ -217,14 +220,12 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||
|
||||
---
|
||||
|
||||
## Why The Name
|
||||
## Why the name
|
||||
|
||||
**Mate** is companion. **Claw** is capability.
|
||||
|
||||
A system that stays with you, and a system that grabs work and moves it.
|
||||
|
||||
---
|
||||
Something that stays with you — and grabs work and moves it.
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
[Apache License 2.0](LICENSE). No asterisks.
|
||||
|
||||
277
README_zh.md
@ -4,15 +4,15 @@
|
||||
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
|
||||
</p>
|
||||
|
||||
# MateClaw
|
||||
# 太一(MateClaw)
|
||||
|
||||
<p align="center"><b>让 AI 真正去思考、行动、记忆,并把结果交付出来。</b></p>
|
||||
<p align="center"><b>你的超级大脑</b></p>
|
||||
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://claw.mate.vip/docs)
|
||||
[](https://claw-demo.mate.vip)
|
||||
[](https://claw.mate.vip)
|
||||
[](https://adoptium.net/)
|
||||
[](https://adoptium.net/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
@ -28,50 +28,131 @@
|
||||
|
||||
---
|
||||
|
||||
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
|
||||
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
||||
>
|
||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
|
||||
|
||||
**一个产品。完整交付。**
|
||||
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||
|
||||
MateClaw 是基于 **Java + Vue 3** 构建的个人 AI 操作系统,由 [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba) 驱动。它不是聊天框,不是工作流编排器,不只是又一个编码助手。它是从推理到记忆到交付的完整系统——一次部署,全部搞定。
|
||||
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完。
|
||||
|
||||
三件事让它截然不同:
|
||||
---
|
||||
|
||||
1. **智能体做事,不只聊天** — ReAct + 计划执行。不是一问一答——是迭代推理,直到任务真正完成
|
||||
2. **知识被塑造,而非仅仅被存储** — LLM Wiki 把原始资料消化成结构化的链接页面。仓库和图书馆的区别
|
||||
3. **端到端,不妥协** — Web 控制台、桌面端、7 个 IM 渠道、工具防护、企业认证。一个团队、一次部署、一个体验
|
||||
## 三件让它与众不同的事
|
||||
|
||||
### 1 · 模型挂了,AI 不挂
|
||||
|
||||
Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
||||
|
||||
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX,共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
|
||||
|
||||
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
|
||||
|
||||
### 2 · 知识会自己长出链接
|
||||
|
||||
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
|
||||
|
||||
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题,得到的页面是从对应片段拼出来的——带可核对的出处。
|
||||
|
||||
这是**仓库**和**图书馆**的区别。
|
||||
|
||||
### 3 · 一个产品,五个入口
|
||||
|
||||
| 入口 | 它是什么 |
|
||||
|---|---|
|
||||
| **Web 控制台** | 完整的管理后台——数字员工、模型、技能、知识、安全、定时任务、**运行时控制台**(看见每位员工正在干什么、一键回收) |
|
||||
| **桌面端** | Electron + 内嵌 JRE 21,双击即用,无需装 Java |
|
||||
| **网页嵌入式聊天** | 一个 `<script>` 标签就能嵌进任何网站 |
|
||||
| **IM 渠道** | 钉钉 · 飞书 · 企业微信 · 微信 · Telegram · Discord · QQ · Slack |
|
||||
| **插件 SDK** | Java 模块,供第三方扩展能力包 |
|
||||
|
||||
同一个大脑。同一份记忆。同一套工具。不同的门。
|
||||
|
||||
<p align="center"><b>$0 · 无 token 计费。无座位收费。你的服务器,你的数据,你的 Key。</b></p>
|
||||
|
||||
---
|
||||
|
||||
## 盒子里有什么
|
||||
|
||||
### 数字员工,不是聊天机器人
|
||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
|
||||
|
||||
### 知识与记忆
|
||||
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt
|
||||
- **工作区记忆** — `AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
|
||||
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流
|
||||
|
||||
### 技能 · MCP · ACP — 三种"接外部能力"的方式
|
||||
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
|
||||
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器
|
||||
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
|
||||
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
|
||||
|
||||
### 你看得见每位员工正在干什么
|
||||
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示(思考 / 工具 / 回答),SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
|
||||
|
||||
### 多模态创作
|
||||
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。
|
||||
|
||||
### 企业就绪
|
||||
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema,升级时自愈。一个 JAR 交付。生产用 MySQL,开发用 H2,代码零改动。
|
||||
|
||||
---
|
||||
|
||||
## AI 正在变成基础设施
|
||||
|
||||
2026 年 3 月 2 日,Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次,**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
|
||||
|
||||
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
|
||||
|
||||
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
||||
|
||||
---
|
||||
|
||||
## 为什么选 MateClaw
|
||||
|
||||
大多数 AI 工具只做好一件事。MateClaw 做好整件事。
|
||||
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||
| **多厂商失败转移** | **Chain + 健康追踪 + 冷却** | 切换供应商(改配置) | 内置编排重试 | 仅 Anthropic | 单模型 |
|
||||
| **知识消化式加工** | **Wiki + 页面级引用溯源** | Canvas + 记忆 | Skills Hub + 记忆 | — | 代码索引 |
|
||||
| **多用户管理** | **RBAC + 审批流 + 审计 + 运行时控制台** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
|
||||
| **能力扩展接口** | **技能 (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
|
||||
| **用户触点** | Web 管理台 + 桌面 + 嵌入 + SDK + 8 IM | 25+ 聊天渠道 | 15+ 渠道(CLI 为主) | 3 IM(预览) | 仅 IDE |
|
||||
| **技术栈** | **Java(Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20–200/月 | 闭源 · $0–200/月 |
|
||||
|
||||
| 能力 | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [CoPaw](https://github.com/agentscope-ai/CoPaw) | [QClaw](https://cntechpost.com/2026/03/20/tencent-opens-qclaw-public-testing-amid-fierce-ai-rivalry/) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) | [Windsurf](https://windsurf.com) |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| 智能体编排 | **ReAct + 计划执行** | 多智能体团队 | 多智能体协作 | 专家智能体 | Agent Teams + 子智能体 | 后台 Agent(云端 VM) | Cascade 引擎 |
|
||||
| 知识系统 | **LLM Wiki(消化式)** | Intelligence Mode + Wiki | 个人知识库 | 知识图谱 | CLAUDE.md(无 RAG) | 代码库索引 | 无 |
|
||||
| 记忆 | **提取 + 整理 + 涌现** | SQLite + Dreaming + Wiki | ReMe(混合检索) | 三层记忆 | 三层(CLAUDE.md + 自动 + 文件) | 无持久记忆 | Memories(~48h 学习) |
|
||||
| 工具防护与审批 | **RBAC + 审批流** | HITL + 风险等级 | 无 | 无 | 权限 + 沙箱 + Hooks | 无 | Turbo Mode(自动放行) |
|
||||
| 多渠道 IM | **7 个渠道** | 25+ 渠道 | 7 个渠道 | 5 个渠道 | 3 个渠道(预览) | 仅 IDE | 仅 IDE |
|
||||
| Web 管理界面 | **完整管理仪表盘** | Control UI | Console UI | 控制面板 | 企业版仪表盘 | 无 | 无 |
|
||||
| 桌面端 | **Electron + 内嵌 JRE** | macOS 菜单栏 | Electron(Beta) | Win/Mac 应用 | Claude Desktop(Mac/Win) | VS Code 分支 | VS Code 分支 |
|
||||
| 多模态创作 | **TTS/STT/图/音乐/视频** | TTS/视频/音乐/图片 | 视觉输入 | 无 | 仅视觉输入 | 无 | 无 |
|
||||
| 技能生态 | **ClawHub 市场** | ClawHub 注册表 | Python 技能 | 模板 | 340+ 插件, 1300+ 技能 | MCP 市场 | MCP 一键集成 |
|
||||
| 企业认证 | **RBAC + JWT** | 基础(密码) | 基础认证 | 无 | SSO/SCIM/RBAC | SSO + 团队版 | 团队版 |
|
||||
| 开源 | **Apache 2.0** | MIT | Apache 2.0 | 部分 | 否(源码可见) | 否 | 否 |
|
||||
| 定价 | **免费** | 免费 | 免费 | 免费(公测) | $20–200/月 | $0–200/月 | $0–200/月 |
|
||||
| 技术栈 | **Java + Vue 3** | TypeScript | Python + TS | OpenClaw 衍生 | TypeScript | Electron (VS Code) | Electron (VS Code) |
|
||||
**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/月
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd mateclaw-server
|
||||
mvn spring-boot:run # http://localhost:18088
|
||||
|
||||
# 前端
|
||||
cd mateclaw-ui
|
||||
pnpm install && pnpm dev # http://localhost:5173
|
||||
```
|
||||
|
||||
默认登录:`admin` / `admin123`
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d # http://localhost:18080
|
||||
```
|
||||
|
||||
### 桌面端
|
||||
|
||||
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
||||
|
||||
---
|
||||
|
||||
@ -90,121 +171,43 @@ MateClaw 是基于 **Java + Vue 3** 构建的个人 AI 操作系统,由 [Sprin
|
||||
|
||||
---
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 智能体引擎
|
||||
|
||||
- **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
|
||||
mvn spring-boot:run # http://localhost:18088
|
||||
|
||||
# 前端
|
||||
cd mateclaw-ui
|
||||
pnpm install && pnpm dev # http://localhost:5173
|
||||
```
|
||||
|
||||
默认登录:`admin` / `admin123`
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d # http://localhost:18080
|
||||
```
|
||||
|
||||
### 桌面端
|
||||
|
||||
从 [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 |
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot 后端
|
||||
├── mateclaw-ui/ Vue 3 SPA 前端
|
||||
├── mateclaw-desktop/ Electron 桌面端
|
||||
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时)
|
||||
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
||||
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
||||
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
||||
├── mateclaw-plugin-sample/ 参考插件实现
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层次 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
|
||||
| 能力扩展 | SKILL.md 包 · MCP 协议(stdio / SSE / HTTP)· ACP 桥接(Claude Code / Codex) |
|
||||
| 数据库 | H2(开发)· MySQL 8.0+(生产)|
|
||||
| 认证 | Spring Security + JWT |
|
||||
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
||||
| Webchat | Vite library 模式 · UMD + ES bundle |
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档请访问 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
|
||||
|
||||
---
|
||||
完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
|
||||
|
||||
## 路线图
|
||||
|
||||
- 更丰富的多智能体协作
|
||||
- 更智能的模型路由
|
||||
- 更深度的多模态理解
|
||||
- 更强的长期记忆
|
||||
- 更丰富的 ClawHub 生态
|
||||
|
||||
---
|
||||
更强的多员工协作 · 更智能的模型路由 · 更深度的多模态理解 · 更长久的记忆 · 更繁荣的 ClawHub · 更多 ACP 上游集成。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
@ -221,10 +224,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||
|
||||
**Mate** 是陪伴。**Claw** 是能力。
|
||||
|
||||
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。
|
||||
|
||||
---
|
||||
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
[Apache License 2.0](LICENSE)。没有星号。
|
||||
|
||||
@ -32,18 +32,19 @@
|
||||
<!-- ===== Center: Agent Core ===== -->
|
||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Agent</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Reason · Plan · Execute</text>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
|
||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||
|
||||
<!-- ===== Top: User Surfaces ===== -->
|
||||
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/>
|
||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
|
||||
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">User Surfaces</text>
|
||||
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
|
||||
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
|
||||
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM Channels</text>
|
||||
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
|
||||
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
|
||||
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
|
||||
<text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM (8)</text>
|
||||
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
||||
|
||||
@ -52,32 +53,38 @@
|
||||
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
||||
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Knowledge</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki KB</text>
|
||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured Digestion</text>
|
||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Memory Extraction</text>
|
||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">Workspace Context</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">Shape it, don't just store it</text>
|
||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured + Backlinks</text>
|
||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Citations + Soft Archive</text>
|
||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">Lazy ingest · On-demand</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">A library, not a vector store</text>
|
||||
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Right: Tools & Skills ===== -->
|
||||
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/>
|
||||
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Tools & 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 & Approval</text>
|
||||
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + Approval Flow</text>
|
||||
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">Agentic, not autonomous</text>
|
||||
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
|
||||
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Memory</text>
|
||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">Short-term Context</text>
|
||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Extraction + Consolidation</text>
|
||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">Memory should compound</text>
|
||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Memory · Dreaming</text>
|
||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">Short-term + Extraction</text>
|
||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Nightly consolidation</text>
|
||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">It works while you sleep</text>
|
||||
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
||||
|
||||
@ -91,10 +98,11 @@
|
||||
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
||||
|
||||
<!-- ===== Bottom Center: Models ===== -->
|
||||
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
|
||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
||||
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Model Layer</text>
|
||||
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ Providers</text>
|
||||
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Provider Pool · Failover</text>
|
||||
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ providers</text>
|
||||
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
|
||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.3 KiB |
@ -33,18 +33,19 @@
|
||||
<!-- ===== Center: Agent Core ===== -->
|
||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">智能体</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">推理 · 规划 · 执行</text>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
|
||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||
|
||||
<!-- ===== Top: User Surfaces ===== -->
|
||||
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/>
|
||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
|
||||
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">用户触点</text>
|
||||
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
||||
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
||||
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
|
||||
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
||||
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
||||
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
|
||||
<text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
|
||||
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||
<!-- Arrow down -->
|
||||
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
||||
@ -53,35 +54,42 @@
|
||||
<rect x="40" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
||||
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">知识系统</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">Wiki 知识库</text>
|
||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 链接</text>
|
||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">记忆提取与整理</text>
|
||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">工作区上下文文件</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">知识不是存储,是塑造</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki 知识库</text>
|
||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 双向链接</text>
|
||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">引用溯源 + 软归档</text>
|
||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">lazy 入库 · 按需出页</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">是图书馆,不是向量库</text>
|
||||
<!-- Arrow right -->
|
||||
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Right: Tools & Skills ===== -->
|
||||
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/>
|
||||
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">工具与技能</text>
|
||||
<text x="820" y="264" text-anchor="middle" font-size="10" fill="#665245">内置工具集</text>
|
||||
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP 协议扩展</text>
|
||||
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">技能包 + ClawHub</text>
|
||||
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">安全审批与防护</text>
|
||||
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">能力需要边界</text>
|
||||
<!-- Arrow left -->
|
||||
<line x1="552" y1="280" x2="720" y2="280" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,275 546,280 554,285" fill="#d96d46" opacity="0.6"/>
|
||||
<!-- ===== Right Top: Tools & Skills ===== -->
|
||||
<rect x="720" y="210" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="210" width="4" height="65" rx="2" fill="url(#primary)"/>
|
||||
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">技能 · MCP · ACP</text>
|
||||
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
|
||||
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Claude Code 也来当员工</text>
|
||||
<!-- Arrow to Tools -->
|
||||
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Bottom Left: Memory ===== -->
|
||||
<!-- ===== Right Bottom: Security & Approval ===== -->
|
||||
<rect x="720" y="285" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="285" width="4" height="65" rx="2" fill="url(#accent)"/>
|
||||
<text x="820" y="307" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">安全与审批</text>
|
||||
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + 审批流</text>
|
||||
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">会动手,不擅自动手</text>
|
||||
<!-- Arrow to Security -->
|
||||
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
|
||||
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆层</text>
|
||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文管理</text>
|
||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">长期提取 + 定时整理</text>
|
||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">记忆应该积累而非消散</text>
|
||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆 · Dreaming</text>
|
||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文 + 长期提取</text>
|
||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">夜里整合,早上接着</text>
|
||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">你睡了它在工作</text>
|
||||
<!-- Arrow up-right -->
|
||||
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
||||
@ -97,11 +105,12 @@
|
||||
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
||||
|
||||
<!-- ===== Bottom Center: Models ===== -->
|
||||
<!-- ===== Bottom Center: Models + Failover ===== -->
|
||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
||||
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型供应</text>
|
||||
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
||||
<!-- Arrow up -->
|
||||
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
|
||||
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
||||
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">健康追踪 · 自动切换</text>
|
||||
<!-- Arrow up to agent -->
|
||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 8.5 KiB |
@ -56,19 +56,21 @@
|
||||
</g>
|
||||
<g transform="translate(632, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Channel Adapters</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">DingTalk / Feishu / WeCom</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">China IM (5)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">DingTalk · Feishu</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">WeCom · WeChat · QQ</text>
|
||||
</g>
|
||||
<g transform="translate(776, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhooks</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Telegram / Discord / QQ</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Global IM (3)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram · Discord</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT ENGINE</text>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
|
||||
|
||||
<g transform="translate(56, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -84,9 +86,9 @@
|
||||
</g>
|
||||
<g transform="translate(432, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool System</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in + MCP + Skills</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval + Guard Rules</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
|
||||
</g>
|
||||
<g transform="translate(620, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -132,9 +134,9 @@
|
||||
|
||||
<g transform="translate(516, 356)">
|
||||
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Unified Abstraction</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Provider Pool</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI · Failover</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Health Tracker · Cooldown</text>
|
||||
</g>
|
||||
<g transform="translate(650, 356)">
|
||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -176,8 +178,9 @@
|
||||
</g>
|
||||
<g transform="translate(632, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron Scheduler</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Task Automation</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron → Channel</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Ambient AI</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Proactive Delivery</text>
|
||||
</g>
|
||||
<g transform="translate(776, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
@ -191,7 +194,7 @@
|
||||
<line x1="250" y1="438" x2="250" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
||||
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@ -59,19 +59,21 @@
|
||||
</g>
|
||||
<g transform="translate(632, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">多渠道适配器</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">钉钉 / 飞书 / 企微 / TG</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">国内 IM (5)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">钉钉·飞书·企微</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">微信·QQ</text>
|
||||
</g>
|
||||
<g transform="translate(776, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhook</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Discord / QQ</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">海外 IM (3)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram·Discord</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">智能体引擎</text>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
|
||||
|
||||
<g transform="translate(56, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -87,15 +89,15 @@
|
||||
</g>
|
||||
<g transform="translate(432, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具系统</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 + MCP + 技能包</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">安全审批 + 防护规则</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
|
||||
</g>
|
||||
<g transform="translate(620, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆系统</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">定时整理 + 记忆涌现</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
|
||||
</g>
|
||||
<g transform="translate(808, 222)">
|
||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -135,9 +137,9 @@
|
||||
|
||||
<g transform="translate(516, 356)">
|
||||
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">统一模型抽象</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">模型池 · Failover</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI 统一抽象</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">健康追踪 · 自动切换</text>
|
||||
</g>
|
||||
<g transform="translate(650, 356)">
|
||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -179,8 +181,9 @@
|
||||
</g>
|
||||
<g transform="translate(632, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">定时任务</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Cron 调度引擎</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron → 渠道</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Ambient AI</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付</text>
|
||||
</g>
|
||||
<g transform="translate(776, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
@ -195,7 +198,7 @@
|
||||
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
||||
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 897 KiB |
@ -21,8 +21,9 @@ services:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
|
||||
# Schema and seed data are managed by Flyway on application startup.
|
||||
# Do NOT mount legacy schema.sql / data.sql here — Flyway creates all
|
||||
# tables from V1 baseline and applies incremental migrations automatically.
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
@ -30,18 +31,26 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# SearXNG 搜索引擎(keyless 搜索 provider,零配置可用)
|
||||
# SearXNG 搜索引擎(keyless 搜索 provider)
|
||||
#
|
||||
# The custom image at docker/searxng/ bakes in settings.yml so the sidecar
|
||||
# works out of the box (upstream image ships JSON disabled + Limiter enabled,
|
||||
# both of which silently break mateclaw's SearXNGSearchProvider).
|
||||
# No host bind-mount — edit docker/searxng/settings.yml and rebuild.
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
build:
|
||||
context: ./docker/searxng
|
||||
container_name: mateclaw-searxng
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://searxng:8080
|
||||
volumes:
|
||||
- searxng_data:/etc/searxng
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
|
||||
- UWSGI_WORKERS=2
|
||||
- UWSGI_THREADS=4
|
||||
ports:
|
||||
- "8088:8080"
|
||||
healthcheck:
|
||||
# Healthz needs json format, so this also doubles as an integration check.
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
@ -50,8 +59,10 @@ services:
|
||||
# MateClaw 后端服务
|
||||
mateclaw-server:
|
||||
build:
|
||||
context: ./mateclaw-server
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: mateclaw-server/Dockerfile
|
||||
args:
|
||||
MAVEN_FLAGS: ${MAVEN_FLAGS:-}
|
||||
container_name: mateclaw-server
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@ -66,16 +77,27 @@ services:
|
||||
DB_NAME: ${DB_NAME:-mateclaw}
|
||||
DB_USERNAME: ${DB_USERNAME:-mateclaw}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
|
||||
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?DASHSCOPE_API_KEY is required in .env}
|
||||
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-}
|
||||
SERPER_API_KEY: ${SERPER_API_KEY:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
|
||||
# SearXNG: tell the app where to reach the sidecar container
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080}
|
||||
# Browser automation: the runtime image (mcr.microsoft.com/playwright:*)
|
||||
# bakes Chromium + system libs + fonts in, so the tool works out of the box.
|
||||
# Override these if you want to attach to an external Chrome (CDP sidecar):
|
||||
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
|
||||
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
|
||||
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
|
||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||
# recommendation for Playwright / headless chrome.
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- "18080:18080"
|
||||
- "18080:18088" # host:container — app listens on 18088 inside the container
|
||||
volumes:
|
||||
- server_data:/app/data
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
server_data:
|
||||
searxng_data:
|
||||
|
||||
9
docker/searxng/Dockerfile
Normal file
@ -0,0 +1,9 @@
|
||||
# Custom SearXNG image for MateClaw.
|
||||
#
|
||||
# Bakes our settings.yml into /etc/searxng/settings.yml so the sidecar works
|
||||
# out of the box with no host bind-mount. The upstream image ships JSON output
|
||||
# disabled and the Limiter plugin enabled — both silently break mateclaw's
|
||||
# SearXNGSearchProvider, so this override is required.
|
||||
FROM searxng/searxng:latest
|
||||
|
||||
COPY settings.yml /etc/searxng/settings.yml
|
||||
61
docker/searxng/settings.yml
Normal file
@ -0,0 +1,61 @@
|
||||
# SearXNG config for MateClaw's bundled search sidecar.
|
||||
#
|
||||
# Two things differ from the upstream default:
|
||||
# 1. JSON output format is enabled — mateclaw's SearXNGSearchProvider
|
||||
# queries /search?format=json and fails silently without this.
|
||||
# 2. The anti-bot Limiter plugin is disabled — it otherwise rejects
|
||||
# server-side HTTP calls (no JS, no cookies) with HTTP 429.
|
||||
#
|
||||
# This file is baked into the custom image via docker/searxng/Dockerfile —
|
||||
# do NOT bind-mount it from the host (prior host bind-mount broke deploys
|
||||
# where the host directory did not exist and Docker auto-created an empty
|
||||
# dir over the path).
|
||||
#
|
||||
# See https://docs.searxng.org/admin/settings/ for all knobs.
|
||||
|
||||
use_default_settings: true
|
||||
|
||||
general:
|
||||
# Cosmetic only; shown in the UI title.
|
||||
instance_name: "MateClaw Search"
|
||||
# Keep this private — no outbound metrics.
|
||||
donation_url: false
|
||||
contact_url: false
|
||||
enable_metrics: false
|
||||
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: ""
|
||||
default_lang: "auto"
|
||||
formats:
|
||||
- html
|
||||
- json # REQUIRED for mateclaw integration
|
||||
|
||||
server:
|
||||
# Override the default dev secret; docker-compose passes SEARXNG_SECRET in.
|
||||
secret_key: "${SEARXNG_SECRET:-please-change-me-to-a-random-32-char-string}"
|
||||
# Trust Docker's internal network — the reverse-proxy / rate-limit plugin
|
||||
# uses this to know the caller's real IP.
|
||||
limiter: false
|
||||
image_proxy: false
|
||||
# Bind address matches the container default.
|
||||
bind_address: "0.0.0.0"
|
||||
port: 8080
|
||||
|
||||
ui:
|
||||
static_use_hash: true
|
||||
|
||||
# The default engine list is huge; keep a tight set of reliable ones.
|
||||
engines:
|
||||
- name: duckduckgo
|
||||
disabled: false
|
||||
- name: bing
|
||||
disabled: false
|
||||
- name: brave
|
||||
disabled: false
|
||||
- name: wikipedia
|
||||
disabled: false
|
||||
- name: google
|
||||
disabled: false
|
||||
- name: startpage
|
||||
disabled: false
|
||||
@ -1,13 +1,99 @@
|
||||
# 多阶段构建
|
||||
FROM maven:3.9-eclipse-temurin-21 AS builder
|
||||
WORKDIR /build
|
||||
COPY pom.xml .
|
||||
RUN mvn dependency:go-offline -q
|
||||
COPY src ./src
|
||||
RUN mvn package -DskipTests -q
|
||||
# Multi-stage build
|
||||
#
|
||||
# Stage 1 — Frontend (Node / pnpm)
|
||||
# Builds the Vue 3 admin SPA and emits static files to /static inside the
|
||||
# build container. These files are later copied into the JAR's classpath so
|
||||
# Spring Boot serves the SPA at the root URL.
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
RUN npm install -g pnpm --silent
|
||||
WORKDIR /frontend
|
||||
# Install dependencies first (layer cache)
|
||||
COPY mateclaw-ui/package.json mateclaw-ui/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Copy source and build
|
||||
COPY mateclaw-ui/ ./
|
||||
# Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which
|
||||
# is outside this container; call vite directly to control --outDir.
|
||||
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
|
||||
# the production Docker image build.
|
||||
RUN pnpm exec vite build --outDir /static --emptyOutDir
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# Stage 2 — Backend (Maven)
|
||||
FROM maven:3.9-eclipse-temurin-21 AS builder
|
||||
|
||||
# Optional Maven extra flags passed at build time.
|
||||
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun
|
||||
# repos first — speeds up builds dramatically inside mainland China.
|
||||
ARG MAVEN_FLAGS=""
|
||||
|
||||
# Inject mirror settings to avoid Maven Central timeouts in restricted networks
|
||||
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
|
||||
|
||||
# Build and install plugin-api into the local Maven cache first
|
||||
WORKDIR /plugin-api
|
||||
COPY mateclaw-plugin-api/pom.xml ./pom.xml
|
||||
COPY mateclaw-plugin-api/src ./src
|
||||
RUN mvn install -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
|
||||
# Pre-fetch mateclaw-server dependencies (uses mirror, so this won't hang)
|
||||
WORKDIR /build
|
||||
COPY mateclaw-server/pom.xml .
|
||||
RUN mvn dependency:go-offline -q ${MAVEN_FLAGS}
|
||||
|
||||
# Copy backend source and inject pre-built frontend into the right classpath location
|
||||
COPY mateclaw-server/src ./src
|
||||
COPY --from=frontend-builder /static ./src/main/resources/static
|
||||
|
||||
RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
|
||||
# Stage 3 — Runtime
|
||||
#
|
||||
# Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three
|
||||
# browsers (Chromium / Firefox / WebKit) and every system library Chromium needs
|
||||
# pre-installed. This avoids the `playwright install` step and the Alpine/musl
|
||||
# incompatibility that blocks browser_use on minimal images.
|
||||
#
|
||||
# We pin to the exact Playwright version declared in pom.xml (1.52.0). If you
|
||||
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
||||
# tag with the matching driver, so mismatched versions cause the java driver to
|
||||
# re-download browsers at runtime (defeating the whole point of this image).
|
||||
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
WORKDIR /app
|
||||
|
||||
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
||||
# Install openjdk-21 explicitly and add CJK fonts so Chinese pages render
|
||||
# correctly in screenshots and snapshots.
|
||||
#
|
||||
# PDF extraction toolchain — DocumentExtractTool tries pdftotext first, then
|
||||
# Python pdfplumber/pypdf, then falls through to a naive Java parser that
|
||||
# reads bytes as ISO_8859_1 (mojibake for CJK). Without poppler-utils the
|
||||
# Docker image always hits the naive path and feeds garbled text to the
|
||||
# Wiki pipeline.
|
||||
#
|
||||
# We install poppler-utils (backend 1) and tesseract (backend 4), which
|
||||
# together cover the vast majority of PDFs including scanned docs. The
|
||||
# Python backend is intentionally skipped — pip install against aliyun
|
||||
# mirrors in CN networks hits transient hash-mismatch failures on cffi /
|
||||
# cryptography transitive deps, and RFC-051 PR-1c will replace the Python
|
||||
# hop with JVM-native Tika extraction anyway. Leaving it out keeps the
|
||||
# image ~200 MB smaller and the build reproducible.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
openjdk-21-jre-headless \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji \
|
||||
poppler-utils \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-chi-sim \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Tell Playwright Java where Microsoft's image stored the browsers.
|
||||
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||
TZ=Asia/Shanghai \
|
||||
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
|
||||
|
||||
COPY --from=builder /build/target/*.jar app.jar
|
||||
EXPOSE 18088
|
||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>vip.mate</groupId>
|
||||
<artifactId>mateclaw-server</artifactId>
|
||||
<version>1.1.0</version>
|
||||
<version>1.2.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MateClaw Server</name>
|
||||
@ -15,15 +15,15 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.13</version>
|
||||
<version>3.5.14</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- Spring AI 1.1.4 正式版 -->
|
||||
<spring-ai.version>1.1.4</spring-ai.version>
|
||||
<!-- Spring AI 1.1.5 正式版(patch upgrade from 1.1.4) -->
|
||||
<spring-ai.version>1.1.5</spring-ai.version>
|
||||
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x) -->
|
||||
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
|
||||
<mybatis-plus.version>3.5.16</mybatis-plus.version>
|
||||
@ -67,6 +67,12 @@
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Actuator — exposes Spring AI observation metrics (gen_ai.*) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring AI Alibaba DashScope ===== -->
|
||||
<!--
|
||||
1.1.2.2 需单独指定版本,不在 BOM 中
|
||||
@ -189,14 +195,14 @@
|
||||
<dependency>
|
||||
<groupId>com.dingtalk.open</groupId>
|
||||
<artifactId>dingtalk-stream</artifactId>
|
||||
<version>1.3.5</version>
|
||||
<version>1.3.12</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||
<dependency>
|
||||
<groupId>com.larksuite.oapi</groupId>
|
||||
<artifactId>oapi-sdk</artifactId>
|
||||
<version>2.5.3</version>
|
||||
<version>2.6.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
||||
@ -274,6 +280,77 @@
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Apache POI (in-process .docx generation) ===== -->
|
||||
<!--
|
||||
Used by DocxRenderTool to render Markdown into a .docx in-JVM,
|
||||
replacing the Node.js docx-js subprocess (3-5 min cold install).
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
|
||||
<!--
|
||||
Used by MarkdownDocxRenderer to convert  image references
|
||||
into PNG bytes that POI can embed via XWPFRun.addPicture(). Without this,
|
||||
agents that produce architecture diagrams as inline SVG cannot get them
|
||||
into the final .docx. Rasterization runs in-JVM (no rsvg-convert / cairo
|
||||
dependency on the host).
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.apache.xmlgraphics</groupId>
|
||||
<artifactId>batik-transcoder</artifactId>
|
||||
<version>1.18</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.xmlgraphics</groupId>
|
||||
<artifactId>batik-codec</artifactId>
|
||||
<version>1.18</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== jsoup (HTML cleanup for Wiki ingest, RFC-051 PR-1c) ===== -->
|
||||
<!--
|
||||
Used by WikiContentNormalizer to strip nav/footer/script/style/aside
|
||||
and ad-class nodes from URL/HTML uploads before chunking. Small
|
||||
(~430KB), no transitive deps, JVM-only — safe for the desktop bundle.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.18.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Apache Tika (RFC-051 PR-?: Java-side last-resort extractor) ===== -->
|
||||
<!--
|
||||
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
|
||||
chains, after every system command + Python + POI-based path has failed.
|
||||
Used in production primarily by Windows users without Python or poppler
|
||||
installed; otherwise idle.
|
||||
|
||||
Pinned to the precise format modules called out in RFC-051 §5.2 — we
|
||||
deliberately avoid `tika-parsers-standard-package`, which pulls in mail,
|
||||
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
|
||||
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
|
||||
tika-parser-microsoft-module (POI-scratchpad ~10MB) ≈ 16MB.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-parser-pdf-module</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-parser-microsoft-module</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Database Migration (Flyway) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
@ -290,6 +367,52 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 architecture invariants) =====
|
||||
test-scope only — guards:
|
||||
- every ToolCallback implementation overrides call(String, ToolContext)
|
||||
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
|
||||
- CronJobRunner (introduced in PR-3) must not carry @Transactional
|
||||
(would silently fail under self-invocation; see RFC §5.2)
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ShedLock: distributed lock for the cron scheduler so a
|
||||
multi-instance deployment doesn't fire the same job N times.
|
||||
JDBC mode reuses the existing DataSource — no Redis dependency
|
||||
on the desktop / single-node footprint. -->
|
||||
<dependency>
|
||||
<groupId>net.javacrumbs.shedlock</groupId>
|
||||
<artifactId>shedlock-spring</artifactId>
|
||||
<version>5.16.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.javacrumbs.shedlock</groupId>
|
||||
<artifactId>shedlock-provider-jdbc-template</artifactId>
|
||||
<version>5.16.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Graph algorithms (community detection, shortest path, centrality)
|
||||
used by the wiki page-to-page relevance and insights features. -->
|
||||
<dependency>
|
||||
<groupId>org.jgrapht</groupId>
|
||||
<artifactId>jgrapht-core</artifactId>
|
||||
<version>1.5.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
|
||||
Used to walk PDPage resources and pull out PDImageXObject instances
|
||||
for downstream captioning. -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
<version>3.0.3</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@ -306,6 +429,143 @@
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- Populate ${net.bytebuddy:byte-buddy-agent:jar} from the test
|
||||
classpath so maven-surefire-plugin can attach it statically
|
||||
(Mockito inline mock maker on JDK 21+ can no longer self-attach). -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>resolve-test-classpath-properties</id>
|
||||
<goals>
|
||||
<goal>properties</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Static agent attach for Mockito on JDK 21+. Without this, dynamic
|
||||
agent loading raises ByteBuddyAgent.AttachmentTypeEvaluator errors
|
||||
depending on the JVM's startup hardening, making tests pass on one
|
||||
machine and fail on another. byte-buddy-agent rides in transitively
|
||||
via mockito-core. -->
|
||||
<argLine>-javaagent:${net.bytebuddy:byte-buddy-agent:jar}</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<!--
|
||||
Dependency repositories, with US + CN mirrors listed side by side so builds
|
||||
are reasonable on either continent. Maven tries repositories in the order
|
||||
they are declared — the first one that resolves an artifact wins.
|
||||
|
||||
IDs are deliberately distinct from the super-POM's `central` id so that
|
||||
mirror rules in settings.xml (if any) don't silently redirect them. Keep
|
||||
the fastest-by-default first; switch order via a local ~/.m2/settings.xml
|
||||
or pass `-Paliyun-first` when building from inside China.
|
||||
-->
|
||||
<repositories>
|
||||
<!-- Primary: Maven Central direct — fast from US/EU backbones. -->
|
||||
<repository>
|
||||
<id>maven-central</id>
|
||||
<name>Maven Central</name>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<!-- Fallback 1: Google Cloud's Maven Central mirror (global CDN edge). -->
|
||||
<repository>
|
||||
<id>google-maven-central</id>
|
||||
<name>Google Maven Central Mirror</name>
|
||||
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<!-- Fallback 2: Aliyun public — fast from China, full Central mirror. -->
|
||||
<repository>
|
||||
<id>aliyun-public</id>
|
||||
<name>Aliyun Public</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<!-- Spring milestones / snapshots — direct from Spring (US). -->
|
||||
<repository>
|
||||
<id>spring-milestones</id>
|
||||
<name>Spring Milestones</name>
|
||||
<url>https://repo.spring.io/milestone</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<!-- Aliyun Spring mirror — fallback for CN builds. -->
|
||||
<repository>
|
||||
<id>aliyun-spring</id>
|
||||
<name>Aliyun Spring Mirror</name>
|
||||
<url>https://maven.aliyun.com/repository/spring</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<!-- Plugin lookups follow the same multi-region fallback. -->
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>maven-central</id>
|
||||
<name>Maven Central</name>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
<pluginRepository>
|
||||
<id>google-maven-central</id>
|
||||
<name>Google Maven Central Mirror</name>
|
||||
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
<pluginRepository>
|
||||
<id>aliyun-public</id>
|
||||
<name>Aliyun Public</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
|
||||
<!--
|
||||
Profile: swap the primary repo order when building from China so Aliyun
|
||||
is tried first. Activate with `mvn -Paliyun-first ...`.
|
||||
-->
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>aliyun-first</id>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>aliyun-public-first</id>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>aliyun-spring-first</id>
|
||||
<url>https://maven.aliyun.com/repository/spring</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>aliyun-public-first</id>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
||||
22
mateclaw-server/settings.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Minimal Maven settings for the Docker build.
|
||||
|
||||
Repository URLs (US Maven Central, Google CDN, Aliyun) are declared directly
|
||||
in mateclaw-server/pom.xml so a single pom covers both continents — Maven
|
||||
tries each repository in order and falls over on 404 / unreachable.
|
||||
|
||||
Historically this file also contained <mirrors> that redirected Maven Central
|
||||
to Aliyun. That broke US/EU builds because <mirror> intercepts transparently
|
||||
and offers no fail-over when the mirror is slow. Keeping this file empty
|
||||
means pom.xml's repository list is authoritative.
|
||||
|
||||
If you need to force a mirror (e.g. behind a corporate proxy), add your own
|
||||
mirror entries here — they will override the pom repositories.
|
||||
-->
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
|
||||
http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||
<mirrors/>
|
||||
</settings>
|
||||
@ -1,6 +1,5 @@
|
||||
package vip.mate;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
@ -33,12 +32,17 @@ public class MateClawApplication {
|
||||
}
|
||||
|
||||
/**
|
||||
* MyBatis Plus 分页插件
|
||||
* MyBatis Plus pagination plugin.
|
||||
*
|
||||
* <p>DbType is auto-detected from the JDBC connection at runtime rather
|
||||
* than hardcoded. Hardcoding H2 here meant the MySQL deployment used
|
||||
* the H2 dialect for the count query, which silently returned 0 —
|
||||
* frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<>();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.acp.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.service.AcpConnectionTester;
|
||||
import vip.mate.acp.service.AcpEndpointService;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — REST surface for managing ACP endpoints.
|
||||
*
|
||||
* <p>Mirrors the McpServers controller so the frontend page can be a
|
||||
* close cousin of {@code McpServers.vue}.
|
||||
*/
|
||||
@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acp/endpoints")
|
||||
@RequiredArgsConstructor
|
||||
public class AcpEndpointController {
|
||||
|
||||
private final AcpEndpointService service;
|
||||
private final AcpConnectionTester tester;
|
||||
|
||||
@Operation(summary = "List ACP endpoints")
|
||||
@GetMapping
|
||||
public R<List<AcpEndpointEntity>> list() {
|
||||
return R.ok(service.list());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get ACP endpoint by id")
|
||||
@GetMapping("/{id}")
|
||||
public R<AcpEndpointEntity> get(@PathVariable Long id) {
|
||||
return R.ok(service.get(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Create a custom ACP endpoint")
|
||||
@PostMapping
|
||||
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.create(body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Update an ACP endpoint")
|
||||
@PutMapping("/{id}")
|
||||
public R<AcpEndpointEntity> update(@PathVariable Long id,
|
||||
@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.update(id, body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
|
||||
@DeleteMapping("/{id}")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "Enable / disable an ACP endpoint")
|
||||
@PutMapping("/{id}/toggle")
|
||||
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
|
||||
@RequestParam boolean enabled) {
|
||||
return R.ok(service.toggle(id, enabled));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the configured CLI, run {@code initialize} + {@code
|
||||
* session/new}, persist the outcome, and return diagnostics.
|
||||
*/
|
||||
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
|
||||
@PostMapping("/{id}/test")
|
||||
public R<Map<String, Object>> test(@PathVariable Long id) {
|
||||
AcpEndpointEntity endpoint = service.get(id);
|
||||
return R.ok(tester.testEndpoint(endpoint));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package vip.mate.activity;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.audit.model.AuditEventEntity;
|
||||
import vip.mate.audit.repository.AuditEventMapper;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 §4.5 / §7 — unified Activity feed.
|
||||
*
|
||||
* <p>Merges three sources into one chronologically-ordered stream:
|
||||
* <ul>
|
||||
* <li>{@code audit_event} — CRUD-style events on agents / channels /
|
||||
* skills / wiki / workspace (the existing audit log)</li>
|
||||
* <li>{@code tool_approval} — approval requests + their resolution
|
||||
* (granted / denied / expired). Ties tool gating decisions
|
||||
* directly to the audit timeline.</li>
|
||||
* <li>Successful tool calls — RFC §4.5 mentions these, but the
|
||||
* runtime doesn't yet persist a row per successful call.
|
||||
* Returning an empty bucket keeps the API contract stable so
|
||||
* the UI can light up automatically once a future commit adds
|
||||
* persistence.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Pagination is best-effort: each source is paged from index 0
|
||||
* up to {@code size * 2}, then the merged list is trimmed and offset
|
||||
* in-memory. For workspaces with >>1k events / day a follow-up should
|
||||
* push merging into SQL; this is good enough for v1.
|
||||
*/
|
||||
@Tag(name = "Activity Feed (RFC-090)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/activity")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityFeedController {
|
||||
|
||||
private final AuditEventService auditEventService;
|
||||
private final AuditEventMapper auditEventMapper;
|
||||
private final ToolApprovalMapper toolApprovalMapper;
|
||||
|
||||
/**
|
||||
* RFC-090 §4.5 — paginated activity feed.
|
||||
*
|
||||
* <p>Pagination strategy:
|
||||
* <ul>
|
||||
* <li><b>Single-source filter</b> (source=audit | approval) →
|
||||
* direct {@code BaseMapper.selectPage(...)} on the matching
|
||||
* table. Both total and records are SQL-accurate.</li>
|
||||
* <li><b>Combined feed</b> (source unset) → fetch
|
||||
* {@code page*size} rows from each side, merge by time-desc,
|
||||
* slice to the requested window. {@code total} is the sum
|
||||
* of {@code selectCount} across both tables — exact for
|
||||
* count, best-effort for time-merge ordering at very deep
|
||||
* page numbers (the merge buffer is bounded but typical
|
||||
* use stays within a few hundred rows).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Caps: {@code size} clamped to [1, 200]; {@code page} ≥ 1.
|
||||
*/
|
||||
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
|
||||
@GetMapping("/feed")
|
||||
public R<Map<String, Object>> feed(
|
||||
@RequestParam(required = false) Long workspaceId,
|
||||
@RequestParam(required = false) String source,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
if (size <= 0) size = 20;
|
||||
if (size > 200) size = 200;
|
||||
if (page <= 0) page = 1;
|
||||
|
||||
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
|
||||
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
|
||||
|
||||
// ───── Single-source path: direct SQL pagination ─────
|
||||
if (wantAudit && !wantApproval) {
|
||||
return R.ok(pageAuditOnly(workspaceId, page, size));
|
||||
}
|
||||
if (wantApproval && !wantAudit) {
|
||||
return R.ok(pageApprovalOnly(page, size));
|
||||
}
|
||||
|
||||
// ───── Combined path: per-source paginate + merge ─────
|
||||
// Fetch page*size from each side so the merged window contains
|
||||
// the requested slice even in the worst case where one source
|
||||
// dominates the timeline. This is wasteful at very deep pages
|
||||
// but bounded — a follow-up can push merging into SQL via a
|
||||
// UNION ALL view if event volume gets into 10k+/day territory.
|
||||
int bufferSize = Math.max(size * page, 50);
|
||||
|
||||
LambdaQueryWrapper<AuditEventEntity> auditQ = new LambdaQueryWrapper<AuditEventEntity>()
|
||||
.orderByDesc(AuditEventEntity::getCreateTime);
|
||||
if (workspaceId != null) auditQ.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||
IPage<AuditEventEntity> auditPage = auditEventMapper.selectPage(new Page<>(1, bufferSize), auditQ);
|
||||
|
||||
LambdaQueryWrapper<ToolApprovalEntity> approvalQ = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||
IPage<ToolApprovalEntity> approvalPage = toolApprovalMapper.selectPage(new Page<>(1, bufferSize), approvalQ);
|
||||
|
||||
List<ActivityRow> rows = new ArrayList<>();
|
||||
for (AuditEventEntity ev : auditPage.getRecords()) rows.add(fromAuditEvent(ev));
|
||||
for (ToolApprovalEntity ap : approvalPage.getRecords()) rows.add(fromApproval(ap));
|
||||
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
|
||||
long total = auditPage.getTotal() + approvalPage.getTotal();
|
||||
int from = Math.min((page - 1) * size, rows.size());
|
||||
int to = Math.min(from + size, rows.size());
|
||||
List<ActivityRow> sliced = rows.subList(from, to);
|
||||
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", total);
|
||||
resp.put("records", sliced);
|
||||
return R.ok(resp);
|
||||
}
|
||||
|
||||
/** Pure SQL pagination on the audit_event table; total + records both
|
||||
* come from the underlying {@link Page} object. */
|
||||
private Map<String, Object> pageAuditOnly(Long workspaceId, int page, int size) {
|
||||
LambdaQueryWrapper<AuditEventEntity> q = new LambdaQueryWrapper<AuditEventEntity>()
|
||||
.orderByDesc(AuditEventEntity::getCreateTime);
|
||||
if (workspaceId != null) q.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||
IPage<AuditEventEntity> p = auditEventMapper.selectPage(new Page<>(page, size), q);
|
||||
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
|
||||
for (AuditEventEntity ev : p.getRecords()) records.add(fromAuditEvent(ev));
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", p.getTotal());
|
||||
resp.put("records", records);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Pure SQL pagination on the tool_approval table. */
|
||||
private Map<String, Object> pageApprovalOnly(int page, int size) {
|
||||
LambdaQueryWrapper<ToolApprovalEntity> q = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||
IPage<ToolApprovalEntity> p = toolApprovalMapper.selectPage(new Page<>(page, size), q);
|
||||
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
|
||||
for (ToolApprovalEntity ap : p.getRecords()) records.add(fromApproval(ap));
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", p.getTotal());
|
||||
resp.put("records", records);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("detailJson", ev.getDetailJson());
|
||||
detail.put("userAgent", ev.getUserAgent());
|
||||
detail.put("workspaceId", ev.getWorkspaceId());
|
||||
return new ActivityRow(
|
||||
"audit-" + ev.getId(),
|
||||
"audit",
|
||||
ev.getCreateTime(),
|
||||
ev.getUsername(),
|
||||
ev.getAction(),
|
||||
ev.getResourceType(),
|
||||
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
|
||||
ev.getIpAddress(),
|
||||
detail);
|
||||
}
|
||||
|
||||
private ActivityRow fromApproval(ToolApprovalEntity ap) {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("toolArguments", ap.getToolArguments());
|
||||
detail.put("summary", ap.getSummary());
|
||||
detail.put("maxSeverity", ap.getMaxSeverity());
|
||||
detail.put("status", ap.getStatus());
|
||||
detail.put("resolvedAt", ap.getResolvedAt());
|
||||
// Map approval status onto an audit-style action so the UI's
|
||||
// existing action coloring (CREATE / DELETE / etc.) keeps
|
||||
// working without a special case.
|
||||
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
|
||||
return new ActivityRow(
|
||||
"approval-" + ap.getId(),
|
||||
"approval",
|
||||
ap.getCreatedAt(),
|
||||
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
|
||||
action,
|
||||
"TOOL_APPROVAL",
|
||||
ap.getToolName(),
|
||||
null,
|
||||
detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format row. Public record so Jackson serializes it directly
|
||||
* without needing a separate DTO.
|
||||
*/
|
||||
public record ActivityRow(
|
||||
String id,
|
||||
String source,
|
||||
LocalDateTime time,
|
||||
String username,
|
||||
String action,
|
||||
String resourceType,
|
||||
String resourceName,
|
||||
String ipAddress,
|
||||
Map<String, Object> detail
|
||||
) {}
|
||||
}
|
||||
@ -7,15 +7,22 @@ import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
|
||||
import vip.mate.memory.lifecycle.TurnContext;
|
||||
import vip.mate.memory.service.MemoryRecallTracker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Agent 业务服务
|
||||
@ -33,6 +40,8 @@ public class AgentService {
|
||||
private final AgentMapper agentMapper;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
private final MemoryLifecycleMediator lifecycleMediator;
|
||||
private final MemoryProperties memoryProperties;
|
||||
|
||||
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
||||
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
||||
@ -91,28 +100,67 @@ public class AgentService {
|
||||
// ==================== 运行时入口 ====================
|
||||
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: preferred entry — accepts the originating
|
||||
* {@link ChatOrigin} so channel binding and workspace context propagate
|
||||
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
|
||||
*/
|
||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chat(message, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chat(msg, convId));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatStream(message, conversationId);
|
||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId),
|
||||
chunk -> chunk);
|
||||
}).doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
||||
return chatStructuredStream(agentId, message, conversationId, "", null);
|
||||
return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId) {
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null);
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId, ChatOrigin origin) {
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null, origin);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId, String thinkingLevel) {
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, thinkingLevel,
|
||||
ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId, String thinkingLevel,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
|
||||
@ -129,22 +177,45 @@ public class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
if (agent instanceof StructuredStreamCapable capable) {
|
||||
return capable.chatStructuredStream(message, conversationId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear());
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
||||
ThinkingLevelHolder.clear();
|
||||
return agent.chatStream(message, conversationId)
|
||||
.map(chunk -> new StreamDelta(chunk, null));
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId)
|
||||
.map(chunk -> new StreamDelta(chunk, null)),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId) {
|
||||
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.execute(goal, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, goal, conversationId,
|
||||
(msg, convId) -> agent.execute(msg, convId));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,9 +229,20 @@ public class AgentService {
|
||||
*/
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload) {
|
||||
return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -168,15 +250,29 @@ public class AgentService {
|
||||
*/
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload) {
|
||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "");
|
||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId) {
|
||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, requesterId,
|
||||
ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
|
||||
requesterId != null ? requesterId : "");
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, userMessage, conversationId,
|
||||
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
|
||||
requesterId != null ? requesterId : ""),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public AgentState getAgentState(Long agentId) {
|
||||
@ -208,6 +304,66 @@ public class AgentService {
|
||||
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
|
||||
}
|
||||
|
||||
// ==================== Lifecycle helpers ====================
|
||||
|
||||
/**
|
||||
* Wraps a synchronous agent call with lifecycle mediator hooks.
|
||||
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
|
||||
*
|
||||
* P1-1 fix: prefetchAll result is now prepended to userMessage as <memory-context> block.
|
||||
* P1-4 fix: N/A for sync (no cancel/error signal issue).
|
||||
*/
|
||||
private String withLifecycleSync(Long agentId, String message, String conversationId,
|
||||
java.util.function.BiFunction<String, String, String> invoke) {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return invoke.apply(message, conversationId);
|
||||
}
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
|
||||
// Inject memory context into the user message (RFC-037 §3.3)
|
||||
String enrichedMessage = injectMemoryContext(message, memoryContext);
|
||||
String result = invoke.apply(enrichedMessage, conversationId);
|
||||
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a streaming agent call with lifecycle mediator hooks.
|
||||
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
|
||||
*
|
||||
* P1-1 fix: prefetchAll result is now prepended to userMessage.
|
||||
* P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error.
|
||||
*/
|
||||
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
|
||||
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
||||
Function<T, String> contentExtractor) {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return invoke.apply(message, conversationId);
|
||||
}
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
|
||||
String enrichedMessage = injectMemoryContext(message, memoryContext);
|
||||
StringBuilder reply = new StringBuilder();
|
||||
return invoke.apply(enrichedMessage, conversationId)
|
||||
.doOnNext(item -> {
|
||||
String text = contentExtractor.apply(item);
|
||||
if (text != null) {
|
||||
reply.append(text);
|
||||
}
|
||||
})
|
||||
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
|
||||
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend memory-context block to user message if non-empty.
|
||||
* Does not pollute build-time system prompt snapshot.
|
||||
*/
|
||||
private String injectMemoryContext(String message, String memoryContext) {
|
||||
if (memoryContext == null || memoryContext.isBlank()) return message;
|
||||
return memoryContext + "\n\n" + message;
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
private BaseAgent getOrBuildAgent(Long agentId) {
|
||||
|
||||
@ -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 Bean、ToolCallbackProvider、MCP server 暴露的 tool callbacks
|
||||
* 统一收集为一致的 ToolCallback 列表,供 StateGraph 节点使用。
|
||||
*
|
||||
* <h3>Alias index — why one tool has multiple names</h3>
|
||||
* Each tool can be referenced by several equivalent identifiers:
|
||||
* <ul>
|
||||
* <li>{@code @Tool} function name (the runtime truth: {@code cb.getToolDefinition().name()},
|
||||
* e.g. {@code browser_use})</li>
|
||||
* <li>Spring bean name (e.g. {@code browserUseTool})</li>
|
||||
* <li>Java class simple name (e.g. {@code BrowserUseTool} — what the seed data and
|
||||
* legacy {@code mate_agent_tool.tool_name} bindings happen to store)</li>
|
||||
* </ul>
|
||||
* Filtering operations ({@link #withAllowedToolsOnly}, {@link #withDeniedToolsFiltered},
|
||||
* {@link #excluding}) accept any of these aliases, so callers don't need to know which
|
||||
* naming convention the persistence layer happens to use. This is the same pattern Spring's
|
||||
* {@code BeanFactory} uses for bean names + aliases.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public class AgentToolSet {
|
||||
@ -21,26 +35,69 @@ public class AgentToolSet {
|
||||
private final List<Object> toolBeans;
|
||||
private final List<ToolCallback> callbacks;
|
||||
private final Map<String, ToolCallback> callbackByName;
|
||||
/**
|
||||
* Alias → callbacks. One alias may resolve to multiple callbacks
|
||||
* (e.g. a Spring bean name pointing at a class that exposes several {@code @Tool} methods),
|
||||
* which is why values are sets.
|
||||
*/
|
||||
private final Map<String, Set<ToolCallback>> aliasIndex;
|
||||
|
||||
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks) {
|
||||
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
|
||||
Function<Object, String> beanNameResolver) {
|
||||
this.toolBeans = List.copyOf(toolBeans);
|
||||
// 按工具名去重:内置工具在前(先添加),MCP 工具在后,同名时保留内置工具
|
||||
// 使用 LinkedHashMap 保证插入顺序,确保内置工具始终排在 MCP 工具前面(影响 LLM 工具选择倾向)
|
||||
this.callbackByName = callbacks.stream()
|
||||
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
|
||||
.collect(Collectors.toMap(
|
||||
cb -> cb.getToolDefinition().name(),
|
||||
cb -> cb,
|
||||
(a, b) -> a,
|
||||
LinkedHashMap::new));
|
||||
this.callbackByName = byName;
|
||||
// callbacks 列表也使用去重后的结果,避免 Spring AI ToolCallingChatOptions 校验重名报错
|
||||
this.callbacks = List.copyOf(callbackByName.values());
|
||||
this.callbacks = List.copyOf(byName.values());
|
||||
this.aliasIndex = buildAliasIndex(this.toolBeans, byName, beanNameResolver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal constructor for {@link #rebuild} — preserves a pre-filtered alias index
|
||||
* so we don't need {@code beanNameResolver} on every {@code with*} call.
|
||||
*/
|
||||
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
|
||||
Map<String, Set<ToolCallback>> precomputedAliasIndex) {
|
||||
this.toolBeans = List.copyOf(toolBeans);
|
||||
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
|
||||
.collect(Collectors.toMap(
|
||||
cb -> cb.getToolDefinition().name(),
|
||||
cb -> cb,
|
||||
(a, b) -> a,
|
||||
LinkedHashMap::new));
|
||||
this.callbackByName = byName;
|
||||
this.callbacks = List.copyOf(byName.values());
|
||||
this.aliasIndex = Map.copyOf(precomputedAliasIndex);
|
||||
}
|
||||
|
||||
/** No-op resolver for callers that don't have access to Spring bean names. */
|
||||
private static final Function<Object, String> NO_BEAN_NAMES = bean -> null;
|
||||
|
||||
/**
|
||||
* 从预构建的 ToolCallback 列表构建工具集(用于 i18n 等需要包装 callback 的场景)
|
||||
*/
|
||||
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks) {
|
||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks);
|
||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, NO_BEAN_NAMES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #fromCallbacks(List, List)} but additionally indexes each tool bean by
|
||||
* its Spring bean name and Java simple class name, so {@link #withAllowedToolsOnly} accepts
|
||||
* any of those identifiers (in addition to the {@code @Tool} function name).
|
||||
*
|
||||
* @param beanNameResolver lookup from a tool bean instance to its Spring bean name;
|
||||
* may return {@code null} if the bean has no registered name
|
||||
*/
|
||||
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks,
|
||||
Function<Object, String> beanNameResolver) {
|
||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, beanNameResolver);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -67,36 +124,45 @@ public class AgentToolSet {
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks);
|
||||
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks, NO_BEAN_NAMES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤掉 denied 工具后返回新的 AgentToolSet。
|
||||
* denied 工具不会暴露给模型,模型完全不知道它们的存在。
|
||||
*
|
||||
* @param deniedTools denied 工具名集合(为空或 null 时直接返回 this)
|
||||
* @param deniedTools denied 工具名集合(接受 function name / bean name / class simple name;
|
||||
* 为空或 null 时直接返回 this)
|
||||
*/
|
||||
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
|
||||
if (deniedTools == null || deniedTools.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
List<ToolCallback> filtered = new ArrayList<>(callbacks);
|
||||
filtered.removeIf(cb -> deniedTools.contains(cb.getToolDefinition().name()));
|
||||
return new AgentToolSet(toolBeans, filtered);
|
||||
Set<ToolCallback> denied = resolveAliases(deniedTools);
|
||||
if (denied.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
List<ToolCallback> filtered = callbacks.stream()
|
||||
.filter(cb -> !denied.contains(cb))
|
||||
.toList();
|
||||
return rebuild(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅保留指定名称的工具(白名单模式,用于 per-agent 绑定)
|
||||
*
|
||||
* @param allowedTools 允许的工具名集合(为 null 时直接返回 this,表示使用全局默认)
|
||||
* @param allowedTools 允许的工具名集合(接受 function name / Spring bean name / Java class simple name;
|
||||
* 为 null 时直接返回 this,表示使用全局默认)
|
||||
*/
|
||||
public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) {
|
||||
if (allowedTools == null) {
|
||||
return this; // null = 无绑定,使用全局默认
|
||||
}
|
||||
List<ToolCallback> filtered = new ArrayList<>(callbacks);
|
||||
filtered.removeIf(cb -> !allowedTools.contains(cb.getToolDefinition().name()));
|
||||
return new AgentToolSet(toolBeans, filtered);
|
||||
Set<ToolCallback> allowed = resolveAliases(allowedTools);
|
||||
List<ToolCallback> filtered = callbacks.stream()
|
||||
.filter(allowed::contains)
|
||||
.toList();
|
||||
return rebuild(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -122,15 +188,21 @@ public class AgentToolSet {
|
||||
|
||||
/**
|
||||
* 返回排除指定工具名后的新 AgentToolSet
|
||||
*
|
||||
* @param toolNames 要排除的工具名集合(接受 function name / bean name / class simple name)
|
||||
*/
|
||||
public AgentToolSet excluding(Set<String> toolNames) {
|
||||
if (toolNames == null || toolNames.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
Set<ToolCallback> excluded = resolveAliases(toolNames);
|
||||
if (excluded.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
List<ToolCallback> filtered = callbacks.stream()
|
||||
.filter(cb -> !toolNames.contains(cb.getToolDefinition().name()))
|
||||
.filter(cb -> !excluded.contains(cb))
|
||||
.toList();
|
||||
return new AgentToolSet(toolBeans, filtered);
|
||||
return rebuild(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -146,4 +218,107 @@ public class AgentToolSet {
|
||||
public int size() {
|
||||
return callbacks.size();
|
||||
}
|
||||
|
||||
// ==================== Internals ====================
|
||||
|
||||
/**
|
||||
* Resolve a set of aliases (any mix of function name / bean name / class simple name)
|
||||
* into the set of {@link ToolCallback} instances they refer to. Unknown aliases are
|
||||
* silently dropped — the caller is expected to be tolerant of stale persistence data.
|
||||
*/
|
||||
private Set<ToolCallback> resolveAliases(Set<String> aliases) {
|
||||
Set<ToolCallback> resolved = new LinkedHashSet<>();
|
||||
for (String alias : aliases) {
|
||||
Set<ToolCallback> hits = aliasIndex.get(alias);
|
||||
if (hits != null) {
|
||||
resolved.addAll(hits);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a new {@code AgentToolSet} after filtering callbacks, carrying forward
|
||||
* only the alias entries whose targets survived. This avoids re-running
|
||||
* {@link ToolCallbacks#from(Object)} reflection on every {@code with*} call.
|
||||
*/
|
||||
private AgentToolSet rebuild(List<ToolCallback> filteredCallbacks) {
|
||||
Set<ToolCallback> survivors = new HashSet<>(filteredCallbacks);
|
||||
Map<String, Set<ToolCallback>> filteredAliases = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Set<ToolCallback>> e : aliasIndex.entrySet()) {
|
||||
Set<ToolCallback> kept = new LinkedHashSet<>();
|
||||
for (ToolCallback cb : e.getValue()) {
|
||||
if (survivors.contains(cb)) {
|
||||
kept.add(cb);
|
||||
}
|
||||
}
|
||||
if (!kept.isEmpty()) {
|
||||
filteredAliases.put(e.getKey(), Set.copyOf(kept));
|
||||
}
|
||||
}
|
||||
return new AgentToolSet(toolBeans, filteredCallbacks, filteredAliases);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the alias index. Function names are always indexed (they are the runtime truth);
|
||||
* bean names and class simple names are indexed when {@code beanNameResolver} is provided
|
||||
* — typically only the production registry has the {@link org.springframework.context.ApplicationContext}
|
||||
* needed to map bean instances to names. Unit tests that pass empty {@code toolBeans}
|
||||
* naturally get a function-name-only index.
|
||||
*/
|
||||
private static Map<String, Set<ToolCallback>> buildAliasIndex(
|
||||
List<Object> toolBeans,
|
||||
Map<String, ToolCallback> callbackByName,
|
||||
Function<Object, String> beanNameResolver) {
|
||||
|
||||
Map<String, Set<ToolCallback>> aliases = new LinkedHashMap<>();
|
||||
|
||||
// 1. Always index by function name (the runtime identifier)
|
||||
for (Map.Entry<String, ToolCallback> e : callbackByName.entrySet()) {
|
||||
aliases.computeIfAbsent(e.getKey(), k -> new LinkedHashSet<>()).add(e.getValue());
|
||||
}
|
||||
|
||||
// 2. If we have bean info, also index by Spring bean name and Java class simple name.
|
||||
// A single bean may expose multiple @Tool methods → the alias maps to a set.
|
||||
if (beanNameResolver != null) {
|
||||
for (Object bean : toolBeans) {
|
||||
String beanName = beanNameResolver.apply(bean);
|
||||
String simpleName = bean.getClass().getSimpleName();
|
||||
|
||||
// Find which callbacks belong to this bean, looking them up in the
|
||||
// (possibly i18n-wrapped) callbackByName so we point at the same
|
||||
// instances the rest of the set uses.
|
||||
Set<ToolCallback> beanCallbacks = new LinkedHashSet<>();
|
||||
ToolCallback[] rawCallbacks;
|
||||
try {
|
||||
rawCallbacks = ToolCallbacks.from(bean);
|
||||
} catch (Exception ignored) {
|
||||
// Defensive: a misbehaving bean shouldn't break the whole tool set
|
||||
continue;
|
||||
}
|
||||
for (ToolCallback raw : rawCallbacks) {
|
||||
ToolCallback wrapped = callbackByName.get(raw.getToolDefinition().name());
|
||||
if (wrapped != null) {
|
||||
beanCallbacks.add(wrapped);
|
||||
}
|
||||
}
|
||||
if (beanCallbacks.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (beanName != null && !beanName.isBlank()) {
|
||||
aliases.computeIfAbsent(beanName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
|
||||
}
|
||||
if (simpleName != null && !simpleName.isBlank()) {
|
||||
aliases.computeIfAbsent(simpleName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze inner sets
|
||||
Map<String, Set<ToolCallback>> frozen = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Set<ToolCallback>> e : aliases.entrySet()) {
|
||||
frozen.put(e.getKey(), Set.copyOf(e.getValue()));
|
||||
}
|
||||
return Map.copyOf(frozen);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Relays per-request assistant {@code reasoning_content} from the producer
|
||||
* ({@code NodeStreamingChatHelper}, which sees {@code AssistantMessage.metadata})
|
||||
* to the consumer ({@code AgentGraphBuilder.patchReasoningContent}, which rebuilds
|
||||
* the outbound {@code ChatCompletionRequest}).
|
||||
*
|
||||
* <p>Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to
|
||||
* {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the
|
||||
* caller does not propagate across the producer/consumer boundary. The relay
|
||||
* token travels inside the request object itself
|
||||
* ({@code OpenAiApi.ChatCompletionRequest.user}), which survives scheduler hops
|
||||
* without needing Reactor context propagation config.
|
||||
*
|
||||
* <p>The {@link RelayEntry} carries both the per-assistant thinking list and the
|
||||
* caller's <em>original</em> {@code user} field — the producer overwrites
|
||||
* {@code OpenAiChatOptions.user} with the relay token before handing the
|
||||
* {@code Prompt} to Spring AI, so by the time the consumer runs,
|
||||
* {@code request.user()} only contains the token. The consumer restores the
|
||||
* caller's original value from the entry when rebuilding the outbound request.
|
||||
* The internal token is never sent to the provider.
|
||||
*
|
||||
* <p>Ownership: the producer is responsible for calling {@link #discard(String)}
|
||||
* in a {@code finally} block as a belt-and-suspenders cleanup. The consumer's
|
||||
* {@link #take(String)} already removes the entry on the happy path, so
|
||||
* {@code discard} is a no-op in that case; it becomes the only cleanup when the
|
||||
* consumer never runs (e.g., a Reactor error before the request is dispatched).
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class AssistantThinkingRelay {
|
||||
|
||||
/**
|
||||
* Per-request relay payload.
|
||||
*
|
||||
* @param thinkings per-assistant {@code reasoning_content} in message order;
|
||||
* empty string means "this assistant had no thinking"
|
||||
* @param originalUser the caller's original {@code OpenAiChatOptions.user} value
|
||||
* before the producer overwrote it with the relay token;
|
||||
* may be {@code null}
|
||||
*/
|
||||
public record RelayEntry(List<String> thinkings, String originalUser) {
|
||||
public RelayEntry {
|
||||
thinkings = List.copyOf(thinkings);
|
||||
}
|
||||
}
|
||||
|
||||
private static final ConcurrentHashMap<String, RelayEntry> MAP = new ConcurrentHashMap<>();
|
||||
|
||||
/** Prefix must be distinctive enough that a caller-provided {@code user} value
|
||||
* can never collide with a relay token. */
|
||||
public static final String TOKEN_PREFIX = "__mc_thinking_";
|
||||
|
||||
private AssistantThinkingRelay() {}
|
||||
|
||||
/**
|
||||
* Stash per-assistant thinking (in message order) plus the caller's original
|
||||
* {@code user} field. Returns the token to embed in
|
||||
* {@code OpenAiChatOptions.user}.
|
||||
*/
|
||||
public static String stash(List<String> thinkingsInOrder, String originalUser) {
|
||||
String token = TOKEN_PREFIX + UUID.randomUUID();
|
||||
MAP.put(token, new RelayEntry(thinkingsInOrder, originalUser));
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Consume and remove entry. Returns {@code null} if {@code user} is not a
|
||||
* relay token or the entry was already taken. */
|
||||
public static RelayEntry take(String user) {
|
||||
if (!isToken(user)) return null;
|
||||
return MAP.remove(user);
|
||||
}
|
||||
|
||||
/** Whether the given {@code user} field value is a relay token produced by
|
||||
* {@link #stash(List, String)}. */
|
||||
public static boolean isToken(String user) {
|
||||
return user != null && user.startsWith(TOKEN_PREFIX);
|
||||
}
|
||||
|
||||
/** Defensive cleanup; idempotent — safe to call even after {@link #take}. */
|
||||
public static void discard(String token) {
|
||||
if (token != null) MAP.remove(token);
|
||||
}
|
||||
|
||||
// ---------- test hooks ----------
|
||||
|
||||
/** Visible for tests: current map size. Production code must not use. */
|
||||
static int size() {
|
||||
return MAP.size();
|
||||
}
|
||||
|
||||
/** Visible for tests: clear all entries. Production code must not use. */
|
||||
static void clearAll() {
|
||||
MAP.clear();
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.util.MimeType;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.approval.ApprovalPlaceholderUtil;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
@ -18,7 +19,9 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
@ -43,8 +46,13 @@ public abstract class BaseAgent {
|
||||
/** 系统提示词 */
|
||||
protected String systemPrompt;
|
||||
|
||||
/** 最大工具调用迭代次数 */
|
||||
protected int maxIterations = 25;
|
||||
/**
|
||||
* Max ReAct iterations (one reasoning + action + observation step counts as one).
|
||||
* Default 100, hard ceiling 100 (enforced in AgentGraphBuilder so per-agent DB
|
||||
* overrides cannot exceed it).
|
||||
*/
|
||||
public static final int MAX_ITERATIONS_HARD_CEILING = 100;
|
||||
protected int maxIterations = 100;
|
||||
|
||||
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */
|
||||
protected String workspaceBasePath;
|
||||
@ -52,6 +60,13 @@ public abstract class BaseAgent {
|
||||
/** 模型名称 */
|
||||
protected String modelName;
|
||||
|
||||
/**
|
||||
* Modalities the chat model can natively consume (resolved at agent build time
|
||||
* by {@link vip.mate.llm.service.ModelCapabilityService}). Empty set = unknown model,
|
||||
* fall back to text-only behavior. See issue #44.
|
||||
*/
|
||||
protected Set<ModelCapabilityService.Modality> modelCapabilities = EnumSet.noneOf(ModelCapabilityService.Modality.class);
|
||||
|
||||
/** 采样温度 */
|
||||
protected Double temperature;
|
||||
|
||||
@ -217,20 +232,124 @@ public abstract class BaseAgent {
|
||||
|
||||
List<Message> messages = new ArrayList<>(limit);
|
||||
for (int i = 0; i < limit; i += 1) {
|
||||
MessageEntity entity = history.get(i);
|
||||
// 过滤审批占位消息,确保 LLM 上下文不包含审批残留
|
||||
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
|
||||
log.debug("[{}] Filtering approval placeholder from history: msgId={}", agentName, entity.getId());
|
||||
continue;
|
||||
}
|
||||
Message springMessage = toSpringMessage(entity);
|
||||
Message springMessage = sanitizeForLlm(history.get(i));
|
||||
if (springMessage != null) {
|
||||
messages.add(springMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Tail guard — orphan-user strip (issue #47).
|
||||
//
|
||||
// Invariant: every caller of buildConversationHistory appends the
|
||||
// current user message AFTER this history (BaseAgent.buildClient
|
||||
// via .user(), StateGraphReActAgent / StateGraphPlanExecuteAgent
|
||||
// via messages.add(buildCurrentUserMessage)). So the final prompt is
|
||||
// [system, ...history, current_user]
|
||||
// and is *always* terminated by a user message. That means a trailing
|
||||
// assistant in history is FINE for every provider we support — it
|
||||
// produces the correct [..., user, assistant, current_user] alternation
|
||||
// (OpenAI, Anthropic, DeepSeek regular/thinking, Gemini, Qwen, …).
|
||||
//
|
||||
// The actual hazard is the opposite: a trailing USER in history.
|
||||
// That happens when the immediately-prior turn's assistant message
|
||||
// was dropped by Stage 1 (approval placeholder) or Stage 1.5 (errored
|
||||
// turn / "[错误] " row), or never persisted at all (turn interrupted
|
||||
// before doOnComplete saved the assistant). In that case the history
|
||||
// ends with an orphan unanswered user, and appending the current user
|
||||
// produces TWO consecutive user messages. Most providers concatenate
|
||||
// those and answer both — leaking the orphan question's answer
|
||||
// alongside the current answer. (This was the symptom reported in
|
||||
// issue #47, originally caused by a tail guard that stripped trailing
|
||||
// ASSISTANT messages instead of trailing USER ones — a direction-
|
||||
// reversed version of this loop.)
|
||||
//
|
||||
// Stripping orphan users is safe: the user re-asked or asked a new
|
||||
// question; the orphan turn produced no answer the model can build
|
||||
// on. We lose a small amount of conversational context in exchange
|
||||
// for clean alternation across every provider.
|
||||
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) {
|
||||
messages.remove(messages.size() - 1);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* History sanitization entry point. Encapsulates *all* steps applied to a
|
||||
* persisted message before it reaches an LLM prompt. Returns {@code null}
|
||||
* to drop the message, or a Spring AI {@link Message} (possibly with
|
||||
* rewritten content) to keep it.
|
||||
*
|
||||
* <p>Design philosophy (OpenClaw-inspired): keep the conversion + every
|
||||
* sanitization stage centralized here so future steps (RFC-052 §9 PII
|
||||
* field-level redaction, RFC-049 thinking-block replay strategy, image
|
||||
* compression for vision models, etc.) plug in as additional inline
|
||||
* stages with clear ordering rather than scattering across the loop.
|
||||
*
|
||||
* <p>Current stages (in order):
|
||||
* <ol>
|
||||
* <li><b>Drop approval placeholders</b> — assistant messages whose
|
||||
* content is a "[等待审批]" stub from the approval flow are removed
|
||||
* entirely so they don't pollute the LLM context.</li>
|
||||
* <li><b>Render content</b> — convert {@code MessageEntity} to a string
|
||||
* via {@link ConversationService#renderMessageContent}.</li>
|
||||
* <li><b>Direct-tool scrub (RFC-052)</b> — assistant messages produced
|
||||
* by a returnDirect tool path get their content replaced with a
|
||||
* tool-named placeholder; the original DB content is unchanged.</li>
|
||||
* <li><b>Type dispatch</b> — wrap into {@code AssistantMessage},
|
||||
* {@code SystemMessage}, or {@code UserMessage} (with multimodal
|
||||
* Media for image/video parts).</li>
|
||||
* </ol>
|
||||
*/
|
||||
private Message sanitizeForLlm(MessageEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 0: drop cron-run header rows (system role + "📋 " prefix)
|
||||
// inserted by CronJobLifecycleService.startRun. These are UI dividers
|
||||
// for the unified tasks_<wsId> view and the IM channel-session
|
||||
// mirror — they carry no semantic context for the LLM. Without this
|
||||
// skip, every subsequent IM turn would feed the model unsolicited
|
||||
// SystemMessage rows like "📋 每日新闻 · 定时触发 · 2026-04-30T10:55"
|
||||
// and bloat the prompt with scheduler metadata.
|
||||
if ("system".equals(entity.getRole())
|
||||
&& entity.getContent() != null
|
||||
&& entity.getContent().startsWith("📋 ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 1: drop approval-placeholder assistant messages
|
||||
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
|
||||
log.debug("[{}] Filtering approval placeholder from history: msgId={}",
|
||||
agentName, entity.getId());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 1.5: drop typed-error assistant messages. These are persisted
|
||||
// by ChatController.doOnComplete with status='error' (or carry the
|
||||
// "[错误] " prefix injected by NodeStreamingChatHelper for legacy
|
||||
// rows). Re-sending them as multi-turn context drives a self-replicating
|
||||
// failure loop:
|
||||
// - DeepSeek thinking mode → 400 "reasoning_content must be passed back"
|
||||
// (we never captured a real reasoning_content for the failed turn)
|
||||
// - Anthropic Claude → 400 "does not support assistant message prefill"
|
||||
// (the trailing-user-dedup at the call site can leave an assistant
|
||||
// tail when the prior turn errored)
|
||||
// Both providers' 400 then re-persist a fresh "[错误] " row, repeat.
|
||||
if ("assistant".equals(entity.getRole())
|
||||
&& ("error".equals(entity.getStatus())
|
||||
|| (entity.getContent() != null && entity.getContent().startsWith("[错误] ")))) {
|
||||
log.debug("[{}] Filtering error assistant message from history: msgId={} status={}",
|
||||
agentName, entity.getId(), entity.getStatus());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delegate stages 2-4 to toSpringMessage; the stage 3 scrub is applied
|
||||
// there so the rendered content is replaced before the typed Message
|
||||
// wrapper is constructed.
|
||||
return toSpringMessage(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断消息是否为持久化的压缩摘要。
|
||||
*/
|
||||
@ -256,6 +375,86 @@ public abstract class BaseAgent {
|
||||
return ApprovalPlaceholderUtil.isApprovalPlaceholder(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-052: regex matching {@code "directToolNames":["a","b",...]} in the
|
||||
* metadata JSON and capturing every tool name in group(1) iterations. The
|
||||
* {@code \\s*} guards keep us robust to pretty-printed JSON.
|
||||
*
|
||||
* <p>Design note (OpenClaw-inspired): rather than a one-shot "is this a
|
||||
* direct turn?" boolean we extract the actual tool names and weave them
|
||||
* into the placeholder, so the next LLM turn can reason about *which* tool
|
||||
* answered (e.g. "the user just asked their salary; you used
|
||||
* query_employee_salary; if they ask follow-up questions, call it again").
|
||||
* This preserves conversational continuity that a generic placeholder
|
||||
* destroys.
|
||||
*/
|
||||
private static final java.util.regex.Pattern DIRECT_TOOL_NAMES_ARRAY =
|
||||
java.util.regex.Pattern.compile(
|
||||
"\"directToolNames\"\\s*:\\s*\\[(\\s*\"[^\"]*\"\\s*(?:,\\s*\"[^\"]*\"\\s*)*)\\]");
|
||||
private static final java.util.regex.Pattern DIRECT_TOOL_NAMES_INNER =
|
||||
java.util.regex.Pattern.compile("\"([^\"]+)\"");
|
||||
|
||||
/**
|
||||
* RFC-052: returns the list of returnDirect tool names recorded in the
|
||||
* persisted assistant message's metadata. Empty list means this is NOT a
|
||||
* direct-tool message and the content is safe for the LLM.
|
||||
*
|
||||
* <p>Allocates only when a non-empty {@code directToolNames} array is
|
||||
* actually present (the common case — normal assistant turns — exits at
|
||||
* the first {@code contains} check with zero allocations).
|
||||
*/
|
||||
static List<String> directToolNamesIn(MessageEntity msg) {
|
||||
if (msg == null) return List.of();
|
||||
String metadata = msg.getMetadata();
|
||||
if (metadata == null || metadata.isEmpty()) return List.of();
|
||||
if (!metadata.contains("\"directToolNames\"")) return List.of();
|
||||
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
|
||||
if (!arrayMatcher.find()) return List.of();
|
||||
String inner = arrayMatcher.group(1);
|
||||
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
||||
List<String> names = new ArrayList<>(2);
|
||||
while (nameMatcher.find()) {
|
||||
names.add(nameMatcher.group(1));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper preserved for callers that only need the boolean.
|
||||
* Keeps the original test surface stable.
|
||||
*/
|
||||
static boolean isDirectToolMessage(MessageEntity msg) {
|
||||
return !directToolNamesIn(msg).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-052: build the placeholder text used to replace a direct-tool
|
||||
* assistant message in next-turn prompts. Includes the originating tool
|
||||
* names so the model retains conversational structure (it knows *why*
|
||||
* the content is redacted and *which* tool would re-fetch it). The
|
||||
* original message stays unchanged in {@code mate_message.content}.
|
||||
*
|
||||
* <p>Worded as a neutral status line, not as a faux assistant utterance —
|
||||
* the model treats it as a system-level note, not as previous output to
|
||||
* be continued.
|
||||
*/
|
||||
static String directToolHistoryPlaceholder(List<String> toolNames) {
|
||||
if (toolNames == null || toolNames.isEmpty()) {
|
||||
return "[Previous answer was tool data returned directly to the user. " +
|
||||
"Content withheld from model context per tool policy.]";
|
||||
}
|
||||
String joined = toolNames.size() == 1
|
||||
? "'" + toolNames.get(0) + "'"
|
||||
: toolNames.stream()
|
||||
.map(n -> "'" + n + "'")
|
||||
.reduce((a, b) -> a + ", " + b)
|
||||
.orElse("");
|
||||
return "[Previous turn used direct-return tool(s) " + joined + " to deliver " +
|
||||
"data straight to the user. Content withheld from model context per tool " +
|
||||
"policy. If the user asks a follow-up that requires that data, call the " +
|
||||
"tool again.]";
|
||||
}
|
||||
|
||||
private Message toSpringMessage(MessageEntity message) {
|
||||
if (message == null) {
|
||||
return null;
|
||||
@ -264,10 +463,33 @@ public abstract class BaseAgent {
|
||||
if (renderedContent == null || renderedContent.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// RFC-052: scrub direct-tool content from any subsequent LLM prompt.
|
||||
// The DB content stays unchanged; only the in-memory Message handed to
|
||||
// the model gets replaced. This is MateClaw's persistence-aware analog
|
||||
// of joyagent-jdgenie's Memory.clearToolContext (purely in-memory) and
|
||||
// OpenClaw's stripToolResultDetails (structural strip per replay).
|
||||
//
|
||||
// Unlike a generic "withheld" placeholder, we name the originating
|
||||
// tool(s) so the model retains the dialog structure: it knows what
|
||||
// kind of data was withheld and which tool would fetch it again. This
|
||||
// preserves multi-turn coherence without leaking the payload itself.
|
||||
if ("assistant".equals(message.getRole())) {
|
||||
List<String> directNames = directToolNamesIn(message);
|
||||
if (!directNames.isEmpty()) {
|
||||
log.debug("[{}] Scrubbing direct-tool content from history msgId={} tools={} (RFC-052)",
|
||||
agentName, message.getId(), directNames);
|
||||
renderedContent = directToolHistoryPlaceholder(directNames);
|
||||
}
|
||||
}
|
||||
return switch (message.getRole()) {
|
||||
case "assistant" -> new AssistantMessage(renderedContent);
|
||||
case "system" -> new SystemMessage(renderedContent);
|
||||
case "user" -> buildUserMessage(message, renderedContent);
|
||||
// History user messages: text only. Re-injecting Media on every replay
|
||||
// accumulates attachments across turns — many providers cap at 1 video
|
||||
// per request (e.g. Zhipu GLM-5V returns code 1210). The current turn
|
||||
// gets Media via buildCurrentUserMessage, which is the only path that
|
||||
// should send raw bytes to the model.
|
||||
case "user" -> buildUserMessage(message, renderedContent, false);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@ -276,15 +498,15 @@ public abstract class BaseAgent {
|
||||
|
||||
/**
|
||||
* 判断当前模型是否支持视频输入。
|
||||
* 仅已知支持视频分析的视觉模型(Qwen-VL、GPT-4o、Gemini 等)才注入视频 Media。
|
||||
* 由 {@link ModelCapabilityService} 在 agent 构建时解析并注入到
|
||||
* {@link #modelCapabilities},per-model 粒度(区分如 glm-4v vs glm-4v-plus)。
|
||||
*/
|
||||
private boolean modelSupportsVideo() {
|
||||
if (modelName == null) return false;
|
||||
String n = modelName.toLowerCase();
|
||||
return (n.contains("qwen") && n.contains("vl"))
|
||||
|| n.contains("gpt-4o")
|
||||
|| n.contains("gemini")
|
||||
|| (n.contains("glm") && n.contains("v"));
|
||||
return modelCapabilities.contains(ModelCapabilityService.Modality.VIDEO);
|
||||
}
|
||||
|
||||
private boolean modelSupportsVision() {
|
||||
return modelCapabilities.contains(ModelCapabilityService.Modality.VISION);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -292,9 +514,27 @@ public abstract class BaseAgent {
|
||||
* 让模型在 prompt 中直接看到媒体内容,不需要再调 MCP read_media_file 工具。
|
||||
*/
|
||||
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent) {
|
||||
return buildUserMessage(message, renderedContent, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param injectMedia when {@code false} (history replay), skip the Media-loading
|
||||
* branch entirely and return text-only — providers like Zhipu
|
||||
* GLM-5V cap at 1 video per request, so re-injecting historical
|
||||
* attachments on every turn breaks the call.
|
||||
*/
|
||||
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent, boolean injectMedia) {
|
||||
if (!injectMedia) {
|
||||
return new UserMessage(renderedContent == null ? "" : renderedContent);
|
||||
}
|
||||
List<MessageContentPart> parts = conversationService.parseMessageParts(message);
|
||||
List<Media> mediaList = new ArrayList<>();
|
||||
// Reasons for attachments that the model cannot consume — surfaced to the agent
|
||||
// via the user message text so it does not hallucinate a tool call to read them.
|
||||
// See issue #44.
|
||||
List<String> skippedAttachments = new ArrayList<>();
|
||||
boolean videoSupported = modelSupportsVideo();
|
||||
boolean visionSupported = modelSupportsVision();
|
||||
|
||||
for (MessageContentPart part : parts) {
|
||||
if (part == null) continue;
|
||||
@ -315,6 +555,15 @@ public abstract class BaseAgent {
|
||||
if (isImage && contentType.contains("svg")) {
|
||||
log.debug("[{}] Skipping SVG attachment (not supported by multimodal API): {}",
|
||||
agentName, part.getFileName());
|
||||
skippedAttachments.add(part.getFileName() + "(SVG 格式,多模态 API 不支持)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 图片仅在模型支持视觉时注入;纯文本模型(如 GLM-5-Turbo / DeepSeek-V3)会被跳过
|
||||
if (isImage && !visionSupported) {
|
||||
log.debug("[{}] Skipping image attachment (model '{}' does not support vision): {}",
|
||||
agentName, modelName, part.getFileName());
|
||||
skippedAttachments.add(part.getFileName() + "(当前模型 " + modelName + " 不支持图片输入)");
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -322,6 +571,7 @@ public abstract class BaseAgent {
|
||||
if (isVideo && !videoSupported) {
|
||||
log.debug("[{}] Skipping video attachment (model '{}' does not support video): {}",
|
||||
agentName, modelName, part.getFileName());
|
||||
skippedAttachments.add(part.getFileName() + "(当前模型 " + modelName + " 不支持视频输入)");
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -329,6 +579,7 @@ public abstract class BaseAgent {
|
||||
if (isVideo && part.getFileSize() != null && part.getFileSize() > MAX_VIDEO_SIZE_BYTES) {
|
||||
log.warn("[{}] Skipping oversized video attachment ({}MB > 20MB): {}",
|
||||
agentName, part.getFileSize() / (1024 * 1024), part.getFileName());
|
||||
skippedAttachments.add(part.getFileName() + "(视频超过 20MB 大小限制)");
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -340,6 +591,7 @@ public abstract class BaseAgent {
|
||||
if (mediaPath == null) {
|
||||
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
|
||||
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
|
||||
skippedAttachments.add(part.getFileName() + "(文件未找到)");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@ -351,14 +603,23 @@ public abstract class BaseAgent {
|
||||
} catch (Exception e) {
|
||||
log.warn("[{}] Failed to create Media for {} {}: {}",
|
||||
agentName, isVideo ? "video" : "image", part.getFileName(), e.getMessage());
|
||||
skippedAttachments.add(part.getFileName() + "(媒体加载失败)");
|
||||
}
|
||||
}
|
||||
|
||||
String finalText = renderedContent;
|
||||
if (!skippedAttachments.isEmpty()) {
|
||||
finalText = (renderedContent == null ? "" : renderedContent)
|
||||
+ "\n\n[系统提示] 以下附件未能传入当前模型:" + String.join("、", skippedAttachments)
|
||||
+ "。\n请用对话语言清晰、友好地告诉用户:当前模型无法处理这类附件,建议切换到具备相应能力的多模态模型(图片需视觉模型,视频需视频理解模型)后重新上传。"
|
||||
+ "不要调用任何工具(包括 ffmpeg、浏览器、文件读取等)尝试解析这些附件。";
|
||||
}
|
||||
|
||||
if (mediaList.isEmpty()) {
|
||||
return new UserMessage(renderedContent);
|
||||
return new UserMessage(finalText);
|
||||
}
|
||||
return UserMessage.builder()
|
||||
.text(renderedContent)
|
||||
.text(finalText)
|
||||
.media(mediaList)
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -27,6 +27,23 @@ public final class GraphEventPublisher {
|
||||
public static final String EVENT_STEP_STARTED = "plan_step_started";
|
||||
public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
|
||||
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
|
||||
/** RFC-06 D-6: lightweight performance summary emitted per-phase. */
|
||||
public static final String EVENT_PERF_SUMMARY = "perf_summary";
|
||||
/**
|
||||
* RFC-052: a tool with returnDirect=true completed; its full result is
|
||||
* carried in the payload and is intended to be rendered as part of the
|
||||
* assistant message (renderAs=assistant_message), bypassing the LLM.
|
||||
*/
|
||||
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
|
||||
|
||||
/**
|
||||
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
|
||||
* emitted at FinalAnswerNode so channel-side accumulators can persist it
|
||||
* into message metadata. Downstream filters (e.g. memory promotion gate)
|
||||
* branch on this structured value instead of doing brittle text matching
|
||||
* on the assistant content.
|
||||
*/
|
||||
public static final String EVENT_FINISH_REASON = "finish_reason";
|
||||
|
||||
/**
|
||||
* 事件记录
|
||||
@ -44,8 +61,23 @@ public final class GraphEventPublisher {
|
||||
}
|
||||
|
||||
public static GraphEvent toolStart(String toolName, String arguments) {
|
||||
return toolStart(null, toolName, arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a tool_call_started event with the LLM-provided tool_call.id so the
|
||||
* frontend can match start/complete pairs precisely. Without the id, the
|
||||
* UI uses toolName + status="running" + findLast() to pair completes back
|
||||
* to the original card; when the LLM fires multiple calls of the same tool
|
||||
* (e.g. several execute_shell_command in a row) the matching collapses to
|
||||
* "the most recent running" and earlier cards get stranded with a
|
||||
* permanent spinner. Pass the id whenever it's available; null is OK for
|
||||
* legacy callers.
|
||||
*/
|
||||
public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) {
|
||||
long ts = System.currentTimeMillis();
|
||||
return new GraphEvent(EVENT_TOOL_START, Map.of(
|
||||
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||
"toolName", toolName,
|
||||
"arguments", arguments != null ? arguments : "",
|
||||
"timestamp", ts
|
||||
@ -53,10 +85,20 @@ public final class GraphEventPublisher {
|
||||
}
|
||||
|
||||
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
|
||||
return toolComplete(null, toolName, result, success);
|
||||
}
|
||||
|
||||
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Carry the full tool result; transport-layer chunking lives in
|
||||
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
|
||||
// into ordered tool_result_chunk events when they exceed the 8 KB
|
||||
// single-event budget. The previous unconditional 500-char truncation
|
||||
// here destroyed data that the front-end could otherwise render in full.
|
||||
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
||||
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||
"toolName", toolName,
|
||||
"result", result != null ? truncateResult(result) : "",
|
||||
"result", result != null ? result : "",
|
||||
"success", success,
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
@ -82,9 +124,11 @@ public final class GraphEventPublisher {
|
||||
|
||||
public static GraphEvent stepCompleted(int index, String result) {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Full step result; broadcastChunked splits at the transport layer
|
||||
// when the payload exceeds the per-event size budget.
|
||||
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
|
||||
"index", index,
|
||||
"result", result != null ? truncateResult(result) : "",
|
||||
"result", result != null ? result : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
}
|
||||
@ -95,7 +139,7 @@ public final class GraphEventPublisher {
|
||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
|
||||
"pendingId", pendingId,
|
||||
"toolName", toolName != null ? toolName : "",
|
||||
"arguments", arguments != null ? truncateResult(arguments) : "",
|
||||
"arguments", arguments != null ? arguments : "",
|
||||
"reason", reason != null ? reason : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
@ -112,7 +156,7 @@ public final class GraphEventPublisher {
|
||||
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("pendingId", pendingId);
|
||||
data.put("toolName", toolName != null ? toolName : "");
|
||||
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
|
||||
data.put("arguments", arguments != null ? arguments : "");
|
||||
data.put("reason", reason != null ? reason : "");
|
||||
data.put("summary", summary);
|
||||
data.put("maxSeverity", maxSeverity);
|
||||
@ -121,6 +165,58 @@ public final class GraphEventPublisher {
|
||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-06 D-6: emit a lightweight performance summary for a phase.
|
||||
* Consumers (dashboard, audit, _usage_final) can aggregate these
|
||||
* to reconstruct per-turn latency profiles without full tracing.
|
||||
*
|
||||
* @param phase e.g. "triage", "reasoning", "tool_execution"
|
||||
* @param metrics arbitrary key-value pairs (e.g. "retry_count", "backoff_wait_ms")
|
||||
*/
|
||||
/**
|
||||
* RFC-052: emit a tool result that was produced by a returnDirect tool.
|
||||
* The full text is carried verbatim and the {@code renderAs="assistant_message"}
|
||||
* hint instructs the SSE consumer (front-end / accumulator) to fold the
|
||||
* payload into the assistant bubble rather than into a tool card.
|
||||
*/
|
||||
public static GraphEvent toolDirectResult(String toolCallId, String toolName, String fullResult) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("toolCallId", toolCallId != null ? toolCallId : "");
|
||||
data.put("toolName", toolName != null ? toolName : "");
|
||||
data.put("result", fullResult != null ? fullResult : "");
|
||||
data.put("renderAs", "assistant_message");
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_TOOL_DIRECT_RESULT, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
public static GraphEvent perfSummary(String phase, Map<String, Object> metrics) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.HashMap<>(metrics);
|
||||
data.put("phase", phase);
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_PERF_SUMMARY, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal {@code finish_reason} event. Emitted from FinalAnswerNode so it
|
||||
* rides through the same PENDING_EVENTS → StreamDelta pipeline that
|
||||
* channel-side accumulators consume — a sibling SSE-only broadcast would
|
||||
* bypass {@code ChatController.StreamAccumulator.accept(...)} and fail to
|
||||
* persist the reason into message metadata.
|
||||
*
|
||||
* @param reason {@link vip.mate.agent.graph.state.FinishReason#getValue()}
|
||||
* (e.g. {@code "incomplete"}, {@code "stopped"},
|
||||
* {@code "evidence_insufficient"}, {@code "normal"}).
|
||||
*/
|
||||
public static GraphEvent finishReason(String reason) {
|
||||
long ts = System.currentTimeMillis();
|
||||
return new GraphEvent(EVENT_FINISH_REASON, Map.of(
|
||||
"reason", reason != null ? reason : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
}
|
||||
|
||||
// ===== 提取方法 =====
|
||||
|
||||
/**
|
||||
@ -135,14 +231,90 @@ public final class GraphEventPublisher {
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through; preserved for source/binary compatibility with older callers.
|
||||
* Truncation at the SSE transport layer is now handled by {@code
|
||||
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
|
||||
* ordered chunk events instead of dropping bytes. Logs a one-time
|
||||
* deprecation hint when invoked.
|
||||
*
|
||||
* @deprecated callers should pass full payloads and let the transport layer
|
||||
* decide whether to chunk.
|
||||
*/
|
||||
@Deprecated
|
||||
private static String truncateResult(String result) {
|
||||
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
|
||||
warnTruncateDeprecation();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串用于直推广播(公共方法,供 Node 直接构造广播数据时使用)
|
||||
* Pass-through, kept for source compatibility with code that built broadcast
|
||||
* payloads directly. Same deprecation reason as {@link #truncateResult}.
|
||||
*
|
||||
* @deprecated callers should pass full payloads.
|
||||
*/
|
||||
@Deprecated
|
||||
public static String truncateForBroadcast(String text) {
|
||||
return truncateResult(text);
|
||||
warnTruncateDeprecation();
|
||||
return text;
|
||||
}
|
||||
|
||||
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
|
||||
private static void warnTruncateDeprecation() {
|
||||
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
|
||||
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
|
||||
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
|
||||
"no-op pass-throughs; payloads are no longer truncated here. " +
|
||||
"Move callers to send the full string and rely on " +
|
||||
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Iteration lifecycle events =====
|
||||
|
||||
public static final String EVENT_ITERATION_START = "iteration_start";
|
||||
public static final String EVENT_ITERATION_END = "iteration_end";
|
||||
|
||||
/**
|
||||
* Marks the entry of an iteration boundary so consumers can group later
|
||||
* tool / content / thinking events under a single logical step. The
|
||||
* {@code scope} field distinguishes the parent agent ("parent") from a
|
||||
* delegated sub-agent ("subagent"); when scope is "subagent" the
|
||||
* {@code subagentId} payload field is populated by the producer.
|
||||
*/
|
||||
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("index", index);
|
||||
data.put("reason", reason != null ? reason : "");
|
||||
data.put("scope", scope != null ? scope : "parent");
|
||||
if (subagentId != null && !subagentId.isEmpty()) {
|
||||
data.put("subagentId", subagentId);
|
||||
}
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
|
||||
* and {@code thinkingChars} let consumers render a compact "this turn
|
||||
* produced X content / Y thinking" header without re-aggregating the
|
||||
* underlying delta events.
|
||||
*/
|
||||
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
|
||||
int contentChars, int thinkingChars) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("index", index);
|
||||
data.put("scope", scope != null ? scope : "parent");
|
||||
if (subagentId != null && !subagentId.isEmpty()) {
|
||||
data.put("subagentId", subagentId);
|
||||
}
|
||||
data.put("contentChars", contentChars);
|
||||
data.put("thinkingChars", thinkingChars);
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
package vip.mate.agent.binding.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* RFC-009 Phase 4 PR-3 — agent → preferred provider routing hint.
|
||||
*
|
||||
* <p>An agent with zero rows here uses the global fallback chain order
|
||||
* (no behavior change from pre-PR-3 deployments). When rows exist,
|
||||
* {@code AgentGraphBuilder.buildFallbackChain} sorts those provider ids
|
||||
* to the front by ascending {@code sortOrder}; non-listed providers
|
||||
* follow in their global priority order.</p>
|
||||
*
|
||||
* <p>Pool/cooldown gating still applies: a preferred provider that is
|
||||
* HARD-removed or cooling down is still skipped by the runtime walker.</p>
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_agent_provider_preference")
|
||||
public class AgentProviderPreference {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long agentId;
|
||||
|
||||
/** Provider id (matches {@code mate_model_provider.provider_id}). */
|
||||
private String providerId;
|
||||
|
||||
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
|
||||
private Integer sortOrder;
|
||||
|
||||
private Boolean enabled;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -17,6 +17,5 @@ public class AgentSkillBinding {
|
||||
private LocalDateTime createTime;
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
|
||||
@ -16,6 +16,5 @@ public class AgentToolBinding {
|
||||
private LocalDateTime createTime;
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.agent.binding.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.agent.binding.model.AgentProviderPreference;
|
||||
|
||||
@Mapper
|
||||
public interface AgentProviderPreferenceMapper extends BaseMapper<AgentProviderPreference> {
|
||||
}
|
||||
@ -3,13 +3,20 @@ package vip.mate.agent.binding.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.binding.model.AgentProviderPreference;
|
||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@ -25,11 +32,28 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentBindingService {
|
||||
|
||||
private final AgentSkillBindingMapper skillBindingMapper;
|
||||
private final AgentToolBindingMapper toolBindingMapper;
|
||||
private final AgentProviderPreferenceMapper providerPreferenceMapper;
|
||||
/**
|
||||
* {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit
|
||||
* near the agent boot path; the lazy proxy avoids a circular bean
|
||||
* graph when SkillRuntimeService initializes after binding.
|
||||
*/
|
||||
private final SkillRuntimeService skillRuntimeService;
|
||||
|
||||
@Autowired
|
||||
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
|
||||
AgentToolBindingMapper toolBindingMapper,
|
||||
AgentProviderPreferenceMapper providerPreferenceMapper,
|
||||
@Lazy SkillRuntimeService skillRuntimeService) {
|
||||
this.skillBindingMapper = skillBindingMapper;
|
||||
this.toolBindingMapper = toolBindingMapper;
|
||||
this.providerPreferenceMapper = providerPreferenceMapper;
|
||||
this.skillRuntimeService = skillRuntimeService;
|
||||
}
|
||||
|
||||
// ==================== Skill Bindings ====================
|
||||
|
||||
@ -125,6 +149,166 @@ public class AgentBindingService {
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 §14.2 — single entry point that maps an agent's bindings to
|
||||
* the set of tool names allowed at runtime.
|
||||
*
|
||||
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
|
||||
* {@link #getBoundToolNames}):
|
||||
* <ul>
|
||||
* <li><b>{@code null} bound skills + {@code null} bound tools</b> →
|
||||
* returns {@code null}. Caller treats this as "no agent-level
|
||||
* restriction; let the upstream {@code ToolSet} pass through
|
||||
* its global default".</li>
|
||||
* <li><b>at least one side non-null</b> → returns the union, which
|
||||
* may be empty (= "this agent is explicitly restricted to no
|
||||
* tools"). The caller must distinguish empty from null.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Skill expansion rules (§14.2):
|
||||
* <ul>
|
||||
* <li>Resolved skill found → contribute
|
||||
* {@code ResolvedSkill.getEffectiveAllowedTools()} — only tools
|
||||
* whose owning feature is READY (unavailable features stay
|
||||
* hidden from the LLM, §10.2 Q8).</li>
|
||||
* <li>Skill bound but unresolved (e.g. legacy or missing manifest)
|
||||
* → contribute nothing through this path; legacy SKILL.md prompt
|
||||
* enhancement still runs separately.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public Set<String> getEffectiveToolNames(Long agentId) {
|
||||
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
|
||||
Set<String> directTools = getBoundToolNames(agentId);
|
||||
|
||||
// (1) null + null → no restriction; defer to the global default.
|
||||
if (boundSkillIds == null && directTools == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> merged = new LinkedHashSet<>();
|
||||
|
||||
if (boundSkillIds != null) {
|
||||
for (Long skillId : boundSkillIds) {
|
||||
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
||||
if (resolved == null) continue;
|
||||
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
|
||||
// §14.2 fix: a disabled / security-blocked / setup-needed
|
||||
// skill must not contribute tools to the LLM
|
||||
// advertisement even if it's still bound. Without this
|
||||
// guard, users see ghost tools for skills they thought
|
||||
// were off.
|
||||
continue;
|
||||
}
|
||||
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
||||
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
|
||||
}
|
||||
}
|
||||
|
||||
if (directTools != null) {
|
||||
// ∪ Advanced 直选的原子 tool(§9.2 调整 B)
|
||||
merged.addAll(directTools);
|
||||
}
|
||||
|
||||
// System-level tools that don't belong to any single skill but
|
||||
// are agent-wide capabilities. Without this carve-out, binding
|
||||
// any skill silently strips record_lesson / remember / structured-
|
||||
// memory tools, breaking the §11 self-evolution loop entirely
|
||||
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
|
||||
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 §11 — tools that exist outside the skill scope and must
|
||||
* survive any agent-level skill binding restriction.
|
||||
*
|
||||
* <p>Add new entries here only after verifying the tool is genuinely
|
||||
* agent-wide, not skill-specific. Tools added here bypass the
|
||||
* {@link #getEffectiveToolNames} allowlist completely.
|
||||
*/
|
||||
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
|
||||
// Structured memory primitives — used by every agent regardless
|
||||
// of skill bindings, otherwise the self-evolution path collapses
|
||||
// (§11.3 / §11.4).
|
||||
"record_lesson",
|
||||
"remember",
|
||||
"remember_structured",
|
||||
"recall_structured",
|
||||
"forget_structured",
|
||||
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
|
||||
// memory/YYYY-MM-DD.md). Prior versions whitelisted
|
||||
// "read_workspace_file" / "write_workspace_file" /
|
||||
// "list_workspace_files" — those names match no @Tool bean; the
|
||||
// actual function names carry the "_memory" segment, so the
|
||||
// earlier carve-out was silently dead.
|
||||
"list_workspace_memory_files",
|
||||
"read_workspace_memory_file",
|
||||
"write_workspace_memory_file",
|
||||
"edit_workspace_memory_file",
|
||||
// Skill discovery / dispatch — skills are docs, not callables;
|
||||
// these helpers let the LLM read SKILL.md / run scripts.
|
||||
"readSkillFile",
|
||||
"runSkillScript",
|
||||
"listSkillFiles",
|
||||
"listAvailableSkills",
|
||||
// Date / time — prior whitelist had a fictional "datetime"; the
|
||||
// real DateTimeTool exposes three separate methods.
|
||||
"getCurrentDate",
|
||||
"getCurrentDateTime",
|
||||
"getCurrentTime",
|
||||
// Multi-agent delegation — prior whitelist had "delegate_agent",
|
||||
// but DelegateAgentTool's @Tool methods are delegateToAgent /
|
||||
// delegateParallel / listAvailableAgents. Same dead-name bug.
|
||||
"delegateToAgent",
|
||||
"delegateParallel",
|
||||
"listAvailableAgents",
|
||||
// Document / media generation — agent-wide capabilities, never
|
||||
// declared inside any skill manifest. Pre-Phase-2b these were
|
||||
// universally visible; the new gate silently strips them whenever
|
||||
// any skill is bound, breaking "generate a Word doc / image /
|
||||
// song / video" intents on agents that happen to have a skill
|
||||
// on. Regression observed 2026-05-01: a Code Reviewer agent with
|
||||
// skills bound dropped renderDocx and fell back to dumping the
|
||||
// markdown body for the user to copy.
|
||||
"renderDocx",
|
||||
"renderDocxFromFile",
|
||||
"renderDocxFromFiles",
|
||||
"image_generate",
|
||||
"music_generate",
|
||||
"video_generate",
|
||||
// Universal capabilities the global system prompts (SOUL.md /
|
||||
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
|
||||
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
|
||||
// available; the new gate silently hid them on any agent with
|
||||
// skills bound, so the prompt promises a tool the registry then
|
||||
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
|
||||
// Code Reviewer agent — the model called search → got
|
||||
// not-found → gave up before ever reaching renderDocx.
|
||||
"search",
|
||||
"browser_use",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"execute_shell_command",
|
||||
"detect_file_type",
|
||||
"extract_document_text",
|
||||
"extract_pdf_text",
|
||||
"extract_docx_text",
|
||||
"readMateClawDoc"
|
||||
);
|
||||
|
||||
private ResolvedSkill findResolvedSkillById(Long skillId) {
|
||||
if (skillId == null || skillRuntimeService == null) return null;
|
||||
// resolveAllSkillsStatus returns every skill in the catalog, not
|
||||
// just the active ones, so we still see READY/SETUP_NEEDED status
|
||||
// for bound but partially-unsatisfied skills.
|
||||
return skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||
.filter(s -> s != null && skillId.equals(s.getId()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public AgentToolBinding bindTool(Long agentId, String toolName) {
|
||||
AgentToolBinding existing = toolBindingMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
@ -167,4 +351,52 @@ public class AgentBindingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
||||
|
||||
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */
|
||||
public List<AgentProviderPreference> listProviderPreferences(Long agentId) {
|
||||
return providerPreferenceMapper.selectList(
|
||||
new LambdaQueryWrapper<AgentProviderPreference>()
|
||||
.eq(AgentProviderPreference::getAgentId, agentId)
|
||||
.orderByAsc(AgentProviderPreference::getSortOrder));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of provider ids the agent prefers, lowest sort_order
|
||||
* first. Disabled rows are filtered out. Empty list means "no
|
||||
* preference — fall back to the global chain order".
|
||||
*
|
||||
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
|
||||
* fallback chain order per agent.</p>
|
||||
*/
|
||||
public List<String> getPreferredProviderIds(Long agentId) {
|
||||
if (agentId == null) return Collections.emptyList();
|
||||
return listProviderPreferences(agentId).stream()
|
||||
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
|
||||
.map(AgentProviderPreference::getProviderId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the full preference list for an agent. {@code providerIds}
|
||||
* is the new ordered preference (index 0 = highest preference).
|
||||
* Empty / null list clears all preferences for the agent.
|
||||
*/
|
||||
public void setProviderPreferences(Long agentId, List<String> providerIds) {
|
||||
providerPreferenceMapper.delete(
|
||||
new LambdaQueryWrapper<AgentProviderPreference>()
|
||||
.eq(AgentProviderPreference::getAgentId, agentId));
|
||||
if (providerIds == null) return;
|
||||
int order = 0;
|
||||
for (String providerId : providerIds) {
|
||||
if (providerId == null || providerId.isBlank()) continue;
|
||||
AgentProviderPreference row = new AgentProviderPreference();
|
||||
row.setAgentId(agentId);
|
||||
row.setProviderId(providerId.trim());
|
||||
row.setSortOrder(order++);
|
||||
row.setEnabled(true);
|
||||
providerPreferenceMapper.insert(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,250 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.cache.AnthropicCacheOptionsFactory;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_MESSAGES}.
|
||||
*
|
||||
* <p>Owns the full Anthropic construction logic — API client + chat options
|
||||
* including the extended-thinking budget mapping (low/medium/high/max →
|
||||
* 4k/8k/16k/32k thinking tokens) and prompt-cache options. PR-0b moved this
|
||||
* out of {@code AgentGraphBuilder}.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
private final AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
|
||||
|
||||
public AgentAnthropicChatModelBuilder(
|
||||
ModelProviderService modelProviderService,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider,
|
||||
AnthropicCacheOptionsFactory anthropicCacheOptionsFactory) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
this.anthropicCacheOptionsFactory = anthropicCacheOptionsFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProtocol supportedProtocol() {
|
||||
return ModelProtocol.ANTHROPIC_MESSAGES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
AnthropicApi api = buildAnthropicApi(provider, model.getRequestTimeoutSeconds());
|
||||
AnthropicChatOptions options = buildAnthropicOptions(model);
|
||||
return AnthropicChatModel.builder()
|
||||
.anthropicApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retry)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
}
|
||||
|
||||
AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
|
||||
return buildAnthropicApi(provider, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — accepts a per-model read-timeout override
|
||||
* (seconds). Null falls back to the default 180s.
|
||||
*/
|
||||
AnthropicApi buildAnthropicApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
|
||||
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||||
throw new MateClawException("err.agent.anthropic_not_configured",
|
||||
"Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||||
}
|
||||
String apiKey = provider.getApiKey();
|
||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.anthropic_key_invalid",
|
||||
"Anthropic API Key 未配置或无效: " + provider.getProviderId());
|
||||
}
|
||||
String baseUrl = provider.getBaseUrl();
|
||||
RestClient.Builder restClientBuilder = applyHttpTimeouts(
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
|
||||
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
|
||||
|
||||
AnthropicApi.Builder builder = AnthropicApi.builder()
|
||||
.apiKey(apiKey.trim())
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder);
|
||||
if (StringUtils.hasText(baseUrl)) {
|
||||
builder.baseUrl(baseUrl.trim());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Substrings used to detect Claude 4.7 model variants. Reference:
|
||||
* hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
|
||||
* Claude 4.7 returns HTTP 400 if any of {@code temperature}, {@code top_p},
|
||||
* or {@code top_k} are set to non-default values, AND introduces an
|
||||
* "xhigh" thinking effort level between high and max.
|
||||
*/
|
||||
static boolean isClaude47(String modelName) {
|
||||
if (modelName == null) return false;
|
||||
String lower = modelName.toLowerCase();
|
||||
// Require the "claude" token to avoid false positives like "gpt-4-7"
|
||||
// matching. Match both hyphenated (claude-opus-4-7, anthropic/claude-opus-4-7)
|
||||
// and dotted (claude-opus-4.7, anthropic/claude-opus-4.7 via OpenRouter)
|
||||
// forms. Also tolerates date-stamped variants (claude-opus-4-7-20260415).
|
||||
if (!lower.contains("claude")) return false;
|
||||
return lower.contains("4-7") || lower.contains("4.7");
|
||||
}
|
||||
|
||||
AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||||
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
|
||||
String modelName = runtimeModel.getModelName();
|
||||
if (StringUtils.hasText(modelName)) {
|
||||
builder.model(modelName);
|
||||
}
|
||||
boolean isClaude47 = isClaude47(modelName);
|
||||
|
||||
// Extended thinking — request-level depth from ThinkingLevelHolder
|
||||
String thinkingLevel = ThinkingLevelHolder.get();
|
||||
boolean thinkingEnabled = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||
|
||||
if (thinkingEnabled) {
|
||||
// Anthropic thinking-mode constraints (pre-4.7): temperature MUST be 1,
|
||||
// top_p forbidden, max_tokens must accommodate budget_tokens + buffer.
|
||||
// Claude 4.7 forbids temperature/top_p/top_k entirely (any non-null value
|
||||
// → HTTP 400) and adds an "xhigh" budget tier between high and max.
|
||||
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
|
||||
case "low" -> 4096;
|
||||
case "medium" -> 8192;
|
||||
case "high" -> 16384;
|
||||
case "xhigh" -> 24576; // 4.7 only — between high (16k) and max (32k)
|
||||
case "max" -> 32768;
|
||||
default -> 16384;
|
||||
};
|
||||
builder.thinking(AnthropicApi.ThinkingType.ENABLED, budgetTokens);
|
||||
builder.maxTokens(Math.max(budgetTokens + 4096,
|
||||
runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192));
|
||||
// Claude 4.7: omit temperature entirely. Pre-4.7 thinking mode requires
|
||||
// temperature=1 (Anthropic-mandated default for thinking).
|
||||
if (!isClaude47) {
|
||||
builder.temperature(1.0);
|
||||
}
|
||||
} else {
|
||||
// Non-thinking path.
|
||||
// - Pre-4.7: Anthropic accepts EITHER temperature OR top_p (not both).
|
||||
// - 4.7+: rejects all of temperature/top_p/top_k unless null/default. We
|
||||
// omit them entirely so operators with legacy configs don't 400.
|
||||
if (!isClaude47) {
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.temperature(runtimeModel.getTemperature());
|
||||
} else if (runtimeModel.getTopP() != null) {
|
||||
builder.topP(runtimeModel.getTopP());
|
||||
}
|
||||
} else if (runtimeModel.getTemperature() != null || runtimeModel.getTopP() != null) {
|
||||
log.debug("Ignoring temperature/top_p for Claude 4.7 model {} (API rejects sampling params)",
|
||||
modelName);
|
||||
}
|
||||
// RFC-025: Anthropic rejects non-positive maxTokens — clamp here so a bad config
|
||||
// surfaces as a logged warning instead of an opaque API 400 mid-conversation.
|
||||
Integer configuredMax = runtimeModel.getMaxTokens();
|
||||
if (configuredMax != null && configuredMax > 0) {
|
||||
builder.maxTokens(configuredMax);
|
||||
} else {
|
||||
if (configuredMax != null) {
|
||||
log.warn("Ignoring non-positive Anthropic maxTokens={} for model {}; falling back to 4096",
|
||||
configuredMax, modelName);
|
||||
}
|
||||
builder.maxTokens(4096);
|
||||
}
|
||||
}
|
||||
// RFC-014: prompt cache (system / tools / conversation history) — Spring AI 1.1.4+ first-class.
|
||||
builder.cacheOptions(anthropicCacheOptionsFactory.build());
|
||||
|
||||
return builder.internalToolExecutionEnabled(false).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply 10s connect / 180s read timeouts. The 180s read covers the case
|
||||
* where nginx caps the gateway at 60s but a real long thinking response
|
||||
* needs more — the upper retry layer takes over once we time out.
|
||||
*
|
||||
* <p>Package-private + static so {@code AgentClaudeCodeChatModelBuilder}
|
||||
* (RFC-062) can apply the same timeouts to its OAuth RestClient without
|
||||
* duplicating the snippet.</p>
|
||||
*/
|
||||
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
|
||||
return applyHttpTimeouts(builder, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — accepts a per-model read-timeout override
|
||||
* (seconds). Null / zero / negative falls back to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_READ_TIMEOUT}
|
||||
* so unset model configs keep the historical 180s.
|
||||
*/
|
||||
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
|
||||
.build();
|
||||
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
|
||||
rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
|
||||
return builder.requestFactory(rf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming counterpart of {@link #applyHttpTimeouts(RestClient.Builder)}.
|
||||
* Without this, Spring AI's AnthropicApi would back its streaming chat
|
||||
* call by a default WebClient with neither connect nor read timeout — a
|
||||
* stalled provider could hang the agent thread indefinitely while the
|
||||
* failover chain idles (no exception = no signal).
|
||||
* <p>
|
||||
* Mirrors AgentGraphBuilder.applyHttpTimeoutsToWebClient: same JDK
|
||||
* HttpClient + JdkClientHttpConnector path, so the dependency surface
|
||||
* doesn't pull in reactor-netty (excluded by this project's pom).
|
||||
*/
|
||||
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
|
||||
return applyHttpTimeoutsToWebClient(builder, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — same per-model override semantics as
|
||||
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
|
||||
*/
|
||||
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
|
||||
.build();
|
||||
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
|
||||
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
|
||||
connector.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
|
||||
return builder.clientConnector(connector);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.model.NoopApiKey;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
/**
|
||||
* RFC-062: Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
|
||||
*
|
||||
* <p>Sends Anthropic Messages API requests authenticated with the user's
|
||||
* Claude Code OAuth subscription token instead of an API key — letting users
|
||||
* with a Claude Pro/Max plan run MateClaw against their existing entitlement.
|
||||
*
|
||||
* <h2>How OAuth changes the wire format</h2>
|
||||
* <ol>
|
||||
* <li>{@code Authorization: Bearer <oauth-token>} replaces {@code x-api-key}.
|
||||
* Spring AI's {@link AnthropicApi} only sets {@code x-api-key} when the
|
||||
* supplied {@code ApiKey.getValue()} returns a non-blank string, so we
|
||||
* pass a {@link NoopApiKey} to satisfy the non-null assertion without
|
||||
* leaking a key header.</li>
|
||||
* <li>{@code anthropic-beta} must include {@code claude-code-20250219} and
|
||||
* {@code oauth-2025-04-20} or Anthropic's edge intermittently 500s.
|
||||
* We push these via {@link AnthropicApi.Builder#anthropicBetaFeatures}
|
||||
* so Spring AI's existing header-merging logic still applies.</li>
|
||||
* <li>{@code User-Agent: claude-cli/<ver>} (bare — no suffix) and
|
||||
* {@code x-app: cli} masquerade as the Claude Code CLI. Suffix variants
|
||||
* like {@code (external, cli)} are anti-abuse fingerprints; see
|
||||
* {@link ClaudeCodeApiHeaders#userAgent()}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Token lifecycle</h2>
|
||||
* <p>Each {@link #build} call asks {@link ClaudeCodeOAuthService} for a valid
|
||||
* access token. The service auto-refreshes when within 60s of expiry and
|
||||
* persists the fresh credential back to whichever source (Keychain / JSON
|
||||
* file) it originally read from. The constructed {@link AnthropicApi} pins
|
||||
* the token at build time — for a multi-hour session this is fine because
|
||||
* tokens last hours and Spring AI's call-site retry covers the rare case
|
||||
* where a token rolls mid-call (next request rebuilds with a fresh token).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final AgentAnthropicChatModelBuilder anthropicBuilder;
|
||||
private final ClaudeCodeOAuthService oauthService;
|
||||
private final ClaudeCodeApiHeaders apiHeaders;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AgentClaudeCodeChatModelBuilder(
|
||||
AgentAnthropicChatModelBuilder anthropicBuilder,
|
||||
ClaudeCodeOAuthService oauthService,
|
||||
ClaudeCodeApiHeaders apiHeaders,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider,
|
||||
ObjectMapper objectMapper) {
|
||||
this.anthropicBuilder = anthropicBuilder;
|
||||
this.oauthService = oauthService;
|
||||
this.apiHeaders = apiHeaders;
|
||||
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProtocol supportedProtocol() {
|
||||
return ModelProtocol.ANTHROPIC_CLAUDE_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
// 1) Pull a fresh access token (auto-refreshes when near expiry; throws
|
||||
// err.anthropic.no_claude_code or err.anthropic.token_expired_no_refresh
|
||||
// so the UI / global handler can present an actionable message).
|
||||
String accessToken = oauthService.getValidToken();
|
||||
|
||||
// 2) Build the Anthropic API client wired with OAuth headers.
|
||||
AnthropicApi api = buildOauthAnthropicApi(accessToken, model.getRequestTimeoutSeconds());
|
||||
|
||||
// 3) Reuse the canonical Anthropic options builder — same Claude 4.7
|
||||
// sampling-params handling, thinking-budget mapping, prompt cache.
|
||||
AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model);
|
||||
|
||||
AnthropicChatModel raw = AnthropicChatModel.builder()
|
||||
.anthropicApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retry)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
|
||||
// 4) Wrap with the OAuth identity decorator. Anthropic's edge rate-limits /
|
||||
// 5xxs requests that don't claim Claude Code identity in the system
|
||||
// prompt — symptom: 429 rate_limit_error with body "Error" on quiet
|
||||
// accounts. See ClaudeCodeIdentityChatModelDecorator javadoc.
|
||||
return new ClaudeCodeIdentityChatModelDecorator(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an {@link AnthropicApi} whose underlying RestClient + WebClient
|
||||
* are pre-stamped with OAuth-mode headers. Package-private so unit tests
|
||||
* can verify header composition without spinning up a chat model.
|
||||
*/
|
||||
AnthropicApi buildOauthAnthropicApi(String accessToken) {
|
||||
return buildOauthAnthropicApi(accessToken, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — same OAuth-stamped Anthropic client, with a
|
||||
* per-model read-timeout override threaded through to the underlying
|
||||
* RestClient + WebClient timeouts.
|
||||
*/
|
||||
AnthropicApi buildOauthAnthropicApi(String accessToken, Integer readTimeoutOverride) {
|
||||
String authHeader = apiHeaders.bearerAuth(accessToken);
|
||||
String userAgent = apiHeaders.userAgent();
|
||||
String xApp = apiHeaders.xApp();
|
||||
String betas = apiHeaders.allBetas();
|
||||
|
||||
// Real Claude Code is an Electron + Node app that uses the official
|
||||
// Anthropic JS SDK. The SDK auto-sets `accept: application/json` and
|
||||
// `anthropic-dangerous-direct-browser-access: true` on every request.
|
||||
// Spring AI's Java client doesn't, so Anthropic's edge fingerprint
|
||||
// sees the missing headers and treats the traffic as suspicious —
|
||||
// rate-limited harder than spec'd. Reference: openclaw
|
||||
// anthropic-transport-stream.ts:567-574.
|
||||
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride)
|
||||
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
|
||||
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
|
||||
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
|
||||
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
|
||||
.defaultHeader("x-app", xApp)
|
||||
// Rewrite system string → array before the request hits the wire.
|
||||
// Anthropic's OAuth anti-abuse gate requires system to be an array;
|
||||
// see ClaudeCodeSystemArrayInterceptor for the full explanation.
|
||||
.requestInterceptor(new ClaudeCodeSystemArrayInterceptor(objectMapper))
|
||||
// Diagnostic: log Anthropic's rate-limit headers on 429 so we
|
||||
// can tell apart "5h Pro quota exhausted" (tokens-remaining=0,
|
||||
// retry-after huge) from "anti-abuse gate" (tokens-remaining
|
||||
// large, retry-after small) from "burst limit hit" without
|
||||
// staring at SDK internals.
|
||||
.requestInterceptor(new RateLimitDiagnosticInterceptor());
|
||||
|
||||
WebClient.Builder webClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
|
||||
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
|
||||
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
|
||||
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
|
||||
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
|
||||
.defaultHeader("x-app", xApp)
|
||||
// Rewrite system string → array (streaming path counterpart).
|
||||
.filter(new ClaudeCodeSystemArrayExchangeFilter(objectMapper))
|
||||
.filter(new RateLimitDiagnosticExchangeFilter());
|
||||
|
||||
// NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing
|
||||
// skips x-api-key. The Builder.build() Assert.notNull on apiKey still
|
||||
// passes because the object is non-null.
|
||||
return AnthropicApi.builder()
|
||||
.apiKey(new NoopApiKey())
|
||||
.anthropicBetaFeatures(betas)
|
||||
.restClientBuilder(restClientBuilder)
|
||||
.webClientBuilder(webClientBuilder)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Strategy implementation for {@link ModelProtocol#DASHSCOPE_NATIVE}.
|
||||
*
|
||||
* <p>Owns all DashScope-specific construction logic (api + options) plus the
|
||||
* fallback-chain helpers for resolving API key / Base URL when the provider
|
||||
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
|
||||
* so the agent package no longer carries any DashScope schema knowledge.</p>
|
||||
*
|
||||
* <p>DashScopeChatModel is injected via ObjectProvider so that the builder
|
||||
* degrades gracefully when DashScope auto-configuration is disabled or the
|
||||
* dependency is absent, rather than failing the entire application context.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
|
||||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||
private final ModelProviderService modelProviderService;
|
||||
|
||||
public AgentDashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
|
||||
DashScopeConnectionProperties dashScopeConnectionProperties,
|
||||
ModelProviderService modelProviderService) {
|
||||
this.dashScopeChatModelProvider = dashScopeChatModelProvider;
|
||||
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
|
||||
this.modelProviderService = modelProviderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProtocol supportedProtocol() {
|
||||
return ModelProtocol.DASHSCOPE_NATIVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
|
||||
if (defaultModel == null) {
|
||||
throw new MateClawException("err.agent.dashscope_unavailable",
|
||||
"DashScope 自动配置未激活(可能缺少依赖或被排除),无法构建 DashScope 模型");
|
||||
}
|
||||
DashScopeApi api = buildDashScopeApi(provider);
|
||||
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
|
||||
return defaultModel.mutate()
|
||||
.dashScopeApi(api)
|
||||
.defaultOptions(options)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bailian's built-in web search is only accepted by a subset of models;
|
||||
* sending {@code enable_search} to a model that doesn't support it returns
|
||||
* 400 InvalidParameter and the failover layer then evicts the entire
|
||||
* provider as MODEL_NOT_FOUND. Per the public docs, only Qwen-Plus,
|
||||
* Qwen-Max, Qwen-Turbo and the Qwen3-Max series accept the parameter.
|
||||
*
|
||||
* <p>Resolution order:</p>
|
||||
* <ol>
|
||||
* <li>Explicit {@code enableSearch} in the model row (per-model toggle)</li>
|
||||
* <li>Explicit {@code enableSearch} in provider kwargs (admin-level toggle)</li>
|
||||
* <li>Default: enabled only when the model name matches a known-supporting
|
||||
* prefix; disabled for everything else (coder / thinking / DeepSeek /
|
||||
* Long / Vision)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Public so {@code AgentGraphBuilder.build()} can surface the
|
||||
* "built-in search active" log once per agent.</p>
|
||||
*/
|
||||
public boolean isBuiltinSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
if (runtimeModel != null && runtimeModel.getEnableSearch() != null) {
|
||||
return Boolean.TRUE.equals(runtimeModel.getEnableSearch());
|
||||
}
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
Object kwargsSearch = kwargs.get("enableSearch");
|
||||
if (kwargsSearch != null) {
|
||||
return Boolean.TRUE.equals(kwargsSearch);
|
||||
}
|
||||
return modelSupportsBuiltinSearch(runtimeModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model id prefixes that the Bailian text-generation endpoint documents as
|
||||
* accepting {@code enable_search}. Anything else (qwen-coder-*, qwen-long,
|
||||
* qwen3-*-thinking-*, deepseek-*, qwen*-vl-*) returns 400 InvalidParameter
|
||||
* when the parameter is sent.
|
||||
*/
|
||||
private static final java.util.List<String> BUILTIN_SEARCH_SUPPORTED_PREFIXES = java.util.List.of(
|
||||
"qwen-plus",
|
||||
"qwen-max",
|
||||
"qwen-turbo",
|
||||
"qwen3-max",
|
||||
"qwen3.5-flash",
|
||||
"qwen3.6-flash"
|
||||
);
|
||||
|
||||
private static boolean modelSupportsBuiltinSearch(ModelConfigEntity model) {
|
||||
if (model == null) return false;
|
||||
String name = model.getModelName();
|
||||
if (!StringUtils.hasText(name)) return false;
|
||||
String lower = name.trim().toLowerCase();
|
||||
for (String prefix : BUILTIN_SEARCH_SUPPORTED_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
|
||||
if (StringUtils.hasText(runtimeModel.getModelName())) {
|
||||
builder.withModel(runtimeModel.getModelName());
|
||||
}
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.withTemperature(runtimeModel.getTemperature());
|
||||
}
|
||||
if (runtimeModel.getMaxTokens() != null) {
|
||||
builder.withMaxToken(runtimeModel.getMaxTokens());
|
||||
}
|
||||
if (runtimeModel.getTopP() != null) {
|
||||
builder.withTopP(runtimeModel.getTopP());
|
||||
}
|
||||
if (isBuiltinSearchEnabled(runtimeModel, provider)) {
|
||||
builder.withEnableSearch(true);
|
||||
String strategy = runtimeModel.getSearchStrategy();
|
||||
if (!StringUtils.hasText(strategy)) {
|
||||
strategy = (String) kwargs.get("searchStrategy");
|
||||
}
|
||||
if (StringUtils.hasText(strategy)) {
|
||||
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
|
||||
.searchStrategy(strategy)
|
||||
.enableSource(true)
|
||||
.enableCitation(true)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
|
||||
DashScopeApi.Builder builder = DashScopeApi.builder();
|
||||
|
||||
// API Key fallback chain: provider UI config → env / application.yml → default bean reflection
|
||||
String apiKey = provider != null ? provider.getApiKey() : null;
|
||||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = dashScopeConnectionProperties.getApiKey();
|
||||
}
|
||||
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = readApiKeyFromDefaultChatModel();
|
||||
}
|
||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.dashscope_key_missing",
|
||||
"DashScope API Key 未配置,请在模型设置中填写 dashscope 的 API Key,或设置 DASHSCOPE_API_KEY 环境变量");
|
||||
}
|
||||
builder.apiKey(apiKey.trim());
|
||||
|
||||
// Base URL fallback chain — same priority as API Key
|
||||
String baseUrl = provider != null ? provider.getBaseUrl() : null;
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = dashScopeConnectionProperties.getBaseUrl();
|
||||
}
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = readBaseUrlFromDefaultChatModel();
|
||||
}
|
||||
String normalizedBaseUrl = normalizeDashScopeBaseUrl(baseUrl);
|
||||
if (StringUtils.hasText(normalizedBaseUrl)) {
|
||||
builder.baseUrl(normalizedBaseUrl);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the OpenAI compatible-mode path off any user-supplied URL
|
||||
* (common when migrating from compat-mode), trim trailing slash, and
|
||||
* return null when the result is the SDK default — letting Spring AI's
|
||||
* built-in default win avoids path-concat surprises.
|
||||
*/
|
||||
private String normalizeDashScopeBaseUrl(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = baseUrl.trim();
|
||||
int compatibleIndex = normalized.indexOf("/compatible-mode/");
|
||||
if (compatibleIndex >= 0) {
|
||||
normalized = normalized.substring(0, compatibleIndex);
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
if ("https://dashscope.aliyuncs.com".equals(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reflection helpers — read whatever the auto-configured default
|
||||
// DashScopeChatModel was built with, as a final fallback when no
|
||||
// explicit credentials reach us.
|
||||
// ============================================================
|
||||
|
||||
private String readApiKeyFromDefaultChatModel() {
|
||||
try {
|
||||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||
if (api == null) return null;
|
||||
Field apiKeyField = DashScopeApi.class.getDeclaredField("apiKey");
|
||||
apiKeyField.setAccessible(true);
|
||||
Object apiKey = apiKeyField.get(api);
|
||||
if (apiKey instanceof org.springframework.ai.model.ApiKey key) {
|
||||
return key.getValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read API key from default DashScopeChatModel: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readBaseUrlFromDefaultChatModel() {
|
||||
try {
|
||||
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
|
||||
if (api == null) return null;
|
||||
Field baseUrlField = DashScopeApi.class.getDeclaredField("baseUrl");
|
||||
baseUrlField.setAccessible(true);
|
||||
Object baseUrl = baseUrlField.get(api);
|
||||
return baseUrl instanceof String value ? value : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read baseUrl from default DashScopeChatModel: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
||||
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
|
||||
if (defaultModel == null) {
|
||||
return null;
|
||||
}
|
||||
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
||||
apiField.setAccessible(true);
|
||||
Object api = apiField.get(defaultModel);
|
||||
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
/**
|
||||
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
|
||||
* Delegates to {@link AgentGraphBuilder}'s helpers; see
|
||||
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
|
||||
* pattern and the {@code @Lazy} cycle break.
|
||||
*/
|
||||
@Component
|
||||
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
|
||||
public AgentOpenAiCompatibleChatModelBuilder(
|
||||
@Lazy AgentGraphBuilder agentGraphBuilder,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
this.agentGraphBuilder = agentGraphBuilder;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProtocol supportedProtocol() {
|
||||
return ModelProtocol.OPENAI_COMPATIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
// RFC-03 Lane B1 — pass model.requestTimeoutSeconds so providers /
|
||||
// models with extended-thinking p99s don't false-positive on the
|
||||
// hardcoded 180s read timeout.
|
||||
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
|
||||
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
|
||||
ChatModel raw = OpenAiChatModel.builder()
|
||||
.openAiApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retry)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
|
||||
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
|
||||
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
|
||||
// those directly — wrap with a per-request payload patcher. See
|
||||
// DeepSeekV4ThinkingDecorator javadoc.
|
||||
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
|
||||
return new DeepSeekV4ThinkingDecorator(raw);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,324 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.DefaultToolDefinition;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* RFC-062: Claude Code OAuth identity transform applied to every Anthropic
|
||||
* request when the underlying auth is a Claude Code OAuth token.
|
||||
*
|
||||
* <p>Anthropic's OAuth edge enforces an anti-abuse path that rate-limits
|
||||
* (and intermittently 5xxs) requests claiming Claude Code identity but
|
||||
* shaped differently from real Claude Code traffic. Symptoms:
|
||||
*
|
||||
* <ul>
|
||||
* <li>HTTP 429 with {@code rate_limit_error} on quiet accounts that haven't
|
||||
* come close to their token budget — give-away is a body of just
|
||||
* {@code {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}}
|
||||
* (genuine quota exhaustion carries a descriptive message).</li>
|
||||
* <li>Sporadic 500s on the first call after a long idle period.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request}
|
||||
* lines 1571-1607 — same transforms applied unconditionally on
|
||||
* {@code is_oauth=True} requests.
|
||||
*
|
||||
* <h2>Transforms applied per call</h2>
|
||||
* <ol>
|
||||
* <li><b>System prompt prefix</b>: prepend
|
||||
* {@code "You are Claude Code, Anthropic's official CLI for Claude."}.
|
||||
* Insert a new SystemMessage if none exists.</li>
|
||||
* <li><b>Brand scrub</b>: replace {@code "MateClaw"}/{@code "mateclaw"}
|
||||
* in system text with their Claude Code equivalents — Anthropic's
|
||||
* content filter flags identity contradictions.</li>
|
||||
* <li><b>Tool {@code mcp_} prefix (outgoing)</b>: every tool definition
|
||||
* sent to Anthropic is renamed {@code mcp_<orig>} — Claude Code
|
||||
* runs all tools through MCP servers, so real Claude Code traffic
|
||||
* always has the prefix. Mismatch trips anti-abuse.</li>
|
||||
* <li><b>History tool_use prefix</b>: previously-issued tool calls in
|
||||
* AssistantMessage history get the prefix re-applied (we strip on
|
||||
* response, so they're stored unprefixed).</li>
|
||||
* <li><b>Tool {@code mcp_} prefix (incoming)</b>: ChatResponse tool_use
|
||||
* names are stripped of the {@code mcp_} prefix so MateClaw's tool
|
||||
* registry can resolve them.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
|
||||
|
||||
/** Magic identity prefix Anthropic's OAuth edge requires in the system prompt. */
|
||||
static final String CLAUDE_CODE_SYSTEM_PREFIX =
|
||||
"You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
|
||||
/** Tool-name prefix Claude Code uses for all MCP-routed tools. */
|
||||
static final String MCP_TOOL_PREFIX = "mcp_";
|
||||
|
||||
private final ChatModel delegate;
|
||||
|
||||
public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
return stripToolPrefixes(delegate.call(transform(prompt)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||
return delegate.stream(transform(prompt)).map(this::stripToolPrefixes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatOptions getDefaultOptions() {
|
||||
return delegate.getDefaultOptions();
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* Outbound transform: Prompt → Prompt with identity + tool prefix */
|
||||
/* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Build a new {@link Prompt} with the OAuth identity transforms applied.
|
||||
* Package-private for unit tests.
|
||||
*/
|
||||
Prompt transform(Prompt original) {
|
||||
if (original == null) {
|
||||
return null;
|
||||
}
|
||||
List<Message> source = original.getInstructions();
|
||||
List<Message> rewritten = new ArrayList<>(source.size() + 1);
|
||||
|
||||
boolean systemSeen = false;
|
||||
for (Message msg : source) {
|
||||
if (msg instanceof SystemMessage sm && !systemSeen) {
|
||||
// Emit identity as its own block so Spring AI serialises system as an
|
||||
// array. Anthropic's OAuth anti-abuse gate 429s when the identity prefix
|
||||
// and additional content are merged into a single string, but accepts
|
||||
// them as separate array elements (verified 2026-04-25).
|
||||
rewritten.add(new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
|
||||
String sanitized = sanitizeBranding(sm.getText());
|
||||
if (sanitized != null && !sanitized.isBlank()) {
|
||||
rewritten.add(new SystemMessage(sanitized));
|
||||
}
|
||||
systemSeen = true;
|
||||
} else if (msg instanceof AssistantMessage am && am.hasToolCalls()) {
|
||||
// Re-prefix tool_use names in history. We strip on response, so
|
||||
// by the time MateClaw stores the AssistantMessage the names
|
||||
// are unprefixed — must put the prefix back when echoing the
|
||||
// history to Anthropic for it to match its own prior turn.
|
||||
rewritten.add(rebuildAssistantMessage(am, true));
|
||||
} else {
|
||||
rewritten.add(msg);
|
||||
}
|
||||
}
|
||||
if (!systemSeen) {
|
||||
rewritten.add(0, new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
|
||||
}
|
||||
|
||||
ChatOptions transformedOptions = transformOptions(original.getOptions());
|
||||
return new Prompt(rewritten, transformedOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap each tool callback in the options so its {@code getToolDefinition().name()}
|
||||
* returns {@code mcp_<orig>}. Spring AI sends those names verbatim to Anthropic.
|
||||
* Other tool fields (description, schema) untouched. Returns {@code null} for
|
||||
* non-Anthropic options so we don't accidentally drop them on a custom subclass.
|
||||
*/
|
||||
private ChatOptions transformOptions(ChatOptions options) {
|
||||
if (!(options instanceof AnthropicChatOptions anthropicOpts)) {
|
||||
return options;
|
||||
}
|
||||
List<ToolCallback> originalCallbacks = anthropicOpts.getToolCallbacks();
|
||||
Set<String> originalToolNames = anthropicOpts.getToolNames();
|
||||
|
||||
boolean hasCallbacks = originalCallbacks != null && !originalCallbacks.isEmpty();
|
||||
boolean hasToolNames = originalToolNames != null && !originalToolNames.isEmpty();
|
||||
if (!hasCallbacks && !hasToolNames) {
|
||||
return options;
|
||||
}
|
||||
|
||||
AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(anthropicOpts);
|
||||
if (hasCallbacks) {
|
||||
List<ToolCallback> wrapped = new ArrayList<>(originalCallbacks.size());
|
||||
for (ToolCallback cb : originalCallbacks) {
|
||||
wrapped.add(cb instanceof PrefixedToolCallback ? cb : new PrefixedToolCallback(cb));
|
||||
}
|
||||
copy.setToolCallbacks(wrapped);
|
||||
}
|
||||
if (hasToolNames) {
|
||||
// toolNames is a set used by Spring AI's tool resolver to filter from
|
||||
// a wider registry. If MateClaw populates it (most paths use callbacks
|
||||
// directly so this is rare), prefix the names so they line up with
|
||||
// the wrapped callbacks above.
|
||||
Set<String> prefixed = new LinkedHashSet<>(originalToolNames.size());
|
||||
for (String n : originalToolNames) {
|
||||
prefixed.add(n.startsWith(MCP_TOOL_PREFIX) ? n : MCP_TOOL_PREFIX + n);
|
||||
}
|
||||
copy.setToolNames(prefixed);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* Inbound transform: ChatResponse → strip tool prefix */
|
||||
/* ====================================================================== */
|
||||
|
||||
ChatResponse stripToolPrefixes(ChatResponse response) {
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
List<Generation> origGens = response.getResults();
|
||||
if (origGens == null || origGens.isEmpty()) {
|
||||
return response;
|
||||
}
|
||||
List<Generation> rewritten = null;
|
||||
for (int i = 0; i < origGens.size(); i++) {
|
||||
Generation g = origGens.get(i);
|
||||
AssistantMessage am = g.getOutput();
|
||||
if (am == null || !am.hasToolCalls()) continue;
|
||||
boolean changed = false;
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||
if (tc.name() != null && tc.name().startsWith(MCP_TOOL_PREFIX)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) continue;
|
||||
|
||||
if (rewritten == null) {
|
||||
rewritten = new ArrayList<>(origGens);
|
||||
}
|
||||
AssistantMessage stripped = rebuildAssistantMessage(am, false);
|
||||
ChatGenerationMetadata meta = g.getMetadata();
|
||||
rewritten.set(i, new Generation(stripped, meta));
|
||||
}
|
||||
if (rewritten == null) {
|
||||
return response; // no tool_use blocks needed rewriting
|
||||
}
|
||||
return new ChatResponse(rewritten, response.getMetadata());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an AssistantMessage with tool_call names prefixed (when
|
||||
* {@code prefix=true}) or stripped (when {@code prefix=false}).
|
||||
*/
|
||||
private AssistantMessage rebuildAssistantMessage(AssistantMessage original, boolean prefix) {
|
||||
List<AssistantMessage.ToolCall> rebuilt = new ArrayList<>(original.getToolCalls().size());
|
||||
for (AssistantMessage.ToolCall tc : original.getToolCalls()) {
|
||||
String name = tc.name();
|
||||
String newName;
|
||||
if (prefix) {
|
||||
newName = (name == null || name.startsWith(MCP_TOOL_PREFIX)) ? name : MCP_TOOL_PREFIX + name;
|
||||
} else {
|
||||
newName = (name != null && name.startsWith(MCP_TOOL_PREFIX))
|
||||
? name.substring(MCP_TOOL_PREFIX.length()) : name;
|
||||
}
|
||||
rebuilt.add(new AssistantMessage.ToolCall(tc.id(), tc.type(), newName, tc.arguments()));
|
||||
}
|
||||
return AssistantMessage.builder()
|
||||
.content(original.getText())
|
||||
.properties(original.getMetadata())
|
||||
.toolCalls(rebuilt)
|
||||
.media(original.getMedia())
|
||||
.build();
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* String helpers (system prompt + branding) */
|
||||
/* ====================================================================== */
|
||||
|
||||
private static String prependIdentity(String existingSystem) {
|
||||
if (existingSystem == null || existingSystem.isBlank()) {
|
||||
return CLAUDE_CODE_SYSTEM_PREFIX;
|
||||
}
|
||||
if (existingSystem.startsWith(CLAUDE_CODE_SYSTEM_PREFIX)) {
|
||||
return existingSystem;
|
||||
}
|
||||
return CLAUDE_CODE_SYSTEM_PREFIX + "\n\n" + existingSystem;
|
||||
}
|
||||
|
||||
static String sanitizeBranding(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
return text
|
||||
.replace("MateClaw", "Claude Code")
|
||||
.replace("mateclaw", "claude-code")
|
||||
.replace("Mate Claw", "Claude Code");
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
/* PrefixedToolCallback — wraps a ToolCallback to expose the mcp_ name */
|
||||
/* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Wraps a {@link ToolCallback} so its {@code getToolDefinition().name()}
|
||||
* returns {@code mcp_<orig>}, while {@code call(...)} forwards verbatim
|
||||
* to the underlying tool. Anthropic sees the prefixed name on the wire;
|
||||
* MateClaw's tool implementation never sees the prefix.
|
||||
*/
|
||||
static final class PrefixedToolCallback implements ToolCallback {
|
||||
|
||||
private final ToolCallback delegate;
|
||||
private final ToolDefinition prefixedDefinition;
|
||||
|
||||
PrefixedToolCallback(ToolCallback delegate) {
|
||||
this.delegate = delegate;
|
||||
ToolDefinition orig = delegate.getToolDefinition();
|
||||
String origName = orig.name();
|
||||
String prefixed = (origName != null && origName.startsWith(MCP_TOOL_PREFIX))
|
||||
? origName : MCP_TOOL_PREFIX + origName;
|
||||
this.prefixedDefinition = DefaultToolDefinition.builder()
|
||||
.name(prefixed)
|
||||
.description(orig.description())
|
||||
.inputSchema(orig.inputSchema())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return prefixedDefinition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolMetadata getToolMetadata() {
|
||||
return delegate.getToolMetadata();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String input) {
|
||||
return delegate.call(input);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String input, ToolContext context) {
|
||||
return delegate.call(input, context);
|
||||
}
|
||||
|
||||
ToolCallback unwrap() {
|
||||
return delegate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
|
||||
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFunction;
|
||||
import org.reactivestreams.Publisher;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* WebClient (streaming) counterpart of {@link ClaudeCodeSystemArrayInterceptor}.
|
||||
*
|
||||
* <p>Collects the full request body via {@code DataBufferUtils.join}, delegates
|
||||
* the rewrite to {@link ClaudeCodeSystemArrayInterceptor#rewriteSystemField}, and
|
||||
* emits the modified bytes as a single new {@link DataBuffer}.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
class ClaudeCodeSystemArrayExchangeFilter implements ExchangeFilterFunction {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
|
||||
ClientRequest intercepted = ClientRequest.from(request)
|
||||
.body((outputMessage, context) -> request.body().insert(
|
||||
new ClientHttpRequestDecorator(outputMessage) {
|
||||
@Override
|
||||
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
|
||||
return DataBufferUtils.join(Flux.from(body))
|
||||
.flatMap(joined -> {
|
||||
byte[] original = new byte[joined.readableByteCount()];
|
||||
joined.read(original);
|
||||
DataBufferUtils.release(joined);
|
||||
|
||||
byte[] rewritten = ClaudeCodeSystemArrayInterceptor
|
||||
.rewriteSystemField(original, objectMapper);
|
||||
|
||||
long declared = getHeaders().getContentLength();
|
||||
if (declared > 0 && declared != rewritten.length) {
|
||||
getHeaders().setContentLength(rewritten.length);
|
||||
}
|
||||
|
||||
return super.writeWith(Mono.just(
|
||||
outputMessage.bufferFactory().wrap(rewritten)));
|
||||
});
|
||||
}
|
||||
}, context))
|
||||
.build();
|
||||
|
||||
return next.exchange(intercepted);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* RestClient interceptor that rewrites the Anthropic {@code system} field from
|
||||
* a plain string to a two-element content-block array before the request hits
|
||||
* the wire.
|
||||
*
|
||||
* <p>Anthropic's OAuth anti-abuse gate accepts the Claude Code identity prefix
|
||||
* as a string ONLY when it is the sole content. Appending any additional text
|
||||
* triggers a 429; two separate array elements always pass (verified 2026-04-25).
|
||||
*
|
||||
* <p>Spring AI's native array path is guarded by {@code @JsonIgnore cacheOptions}
|
||||
* which {@code ModelOptionsUtils.copyToTarget} drops before our settings can
|
||||
* reach {@code buildSystemContent}. This interceptor bypasses that by rewriting
|
||||
* at the HTTP transport layer.
|
||||
*
|
||||
* <p>Sync (RestClient) variant; the WebFlux equivalent is
|
||||
* {@link ClaudeCodeSystemArrayExchangeFilter}.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
class ClaudeCodeSystemArrayInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
return execution.execute(request, rewriteSystemField(body, objectMapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@code body} is a JSON object whose {@code system} field is a string,
|
||||
* replace it with a two-element content-block array:
|
||||
* <pre>
|
||||
* [ {"type":"text","text":"You are Claude Code..."}, {"type":"text","text":"<rest>"} ]
|
||||
* </pre>
|
||||
* Returns {@code body} unchanged on any error or if rewrite is not needed.
|
||||
* Package-private static so {@link ClaudeCodeSystemArrayExchangeFilter} can reuse.
|
||||
*/
|
||||
static byte[] rewriteSystemField(byte[] body, ObjectMapper mapper) {
|
||||
if (body == null || body.length == 0) return body;
|
||||
try {
|
||||
JsonNode root = mapper.readTree(body);
|
||||
if (!root.isObject()) return body;
|
||||
JsonNode systemNode = root.get("system");
|
||||
if (systemNode == null || !systemNode.isTextual()) return body;
|
||||
byte[] rewritten = mapper.writeValueAsBytes(buildRewritten((ObjectNode) root, systemNode.asText()));
|
||||
log.debug("[ClaudeCodeSystem] rewrote system field to array ({} → {} bytes)",
|
||||
body.length, rewritten.length);
|
||||
return rewritten;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ClaudeCodeSystem] body rewrite failed, sending original: {}", e.getMessage());
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
static ObjectNode buildRewritten(ObjectNode root, String systemText) {
|
||||
String identity = ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX;
|
||||
ArrayNode arr = root.arrayNode();
|
||||
|
||||
ObjectNode identityBlock = arr.objectNode();
|
||||
identityBlock.put("type", "text");
|
||||
identityBlock.put("text", identity);
|
||||
arr.add(identityBlock);
|
||||
|
||||
if (!systemText.equals(identity) && systemText.startsWith(identity)) {
|
||||
String rest = systemText.substring(identity.length()).replaceFirst("^\n+", "");
|
||||
if (!rest.isBlank()) {
|
||||
ObjectNode contentBlock = arr.objectNode();
|
||||
contentBlock.put("type", "text");
|
||||
contentBlock.put("text", rest);
|
||||
arr.add(contentBlock);
|
||||
}
|
||||
}
|
||||
|
||||
ObjectNode copy = root.deepCopy();
|
||||
copy.set("system", arr);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC: DeepSeek V4 thinking-mode payload patcher applied to every
|
||||
* {@code deepseek-v4-flash} / {@code deepseek-v4-pro} request.
|
||||
*
|
||||
* <p>DeepSeek V4 extends OpenAI's chat-completions wire format with two
|
||||
* non-standard request fields that the base Spring AI {@link OpenAiChatOptions}
|
||||
* has no first-class support for:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code thinking: {"type": "enabled" | "disabled"}} — toggles V4's
|
||||
* step-by-step reasoning channel.</li>
|
||||
* <li>{@code reasoning_effort: "low" | "medium" | "high"} — only meaningful
|
||||
* when {@code thinking.type == "enabled"}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>It also has a strict replay contract: when thinking is enabled and the
|
||||
* conversation contains prior assistant tool-calls, every such tool-call
|
||||
* message must carry a {@code reasoning_content} string (empty allowed) or
|
||||
* the API rejects with an obscure 400. When thinking is disabled, any prior
|
||||
* {@code reasoning_content} must be stripped or DeepSeek echoes the old
|
||||
* thinking back into the response.
|
||||
*
|
||||
* <p>Reference: openclaw {@code plugin-sdk/provider-stream-shared.ts}
|
||||
* lines 185-213 ({@code createDeepSeekV4OpenAICompatibleThinkingWrapper}).
|
||||
*
|
||||
* <h2>Pipeline (per request)</h2>
|
||||
* <ol>
|
||||
* <li>Read {@link ThinkingLevelHolder} for the current request's thinking
|
||||
* level (set by AgentService before the call).</li>
|
||||
* <li>Clone {@link OpenAiChatOptions} and patch its {@code extraBody} +
|
||||
* {@code reasoningEffort} fields. Spring AI sends {@code extraBody}
|
||||
* verbatim in the JSON body, so the {@code thinking} key lands where
|
||||
* DeepSeek expects it.</li>
|
||||
* <li>Walk message history: when disabled, strip {@code reasoning_content}
|
||||
* from {@link AssistantMessage} metadata; when enabled, ensure each
|
||||
* tool-call message has a (possibly empty) {@code reasoning_content}
|
||||
* entry to satisfy V4's replay contract.</li>
|
||||
* <li>Delegate to the wrapped {@link ChatModel}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Spring AI 1.1.4's {@link OpenAiChatOptions} exposes a public
|
||||
* {@code extraBody: Map<String, Object>} (verified via {@code javap}). No
|
||||
* byte-level body patching needed — the simple path works.
|
||||
*/
|
||||
@Slf4j
|
||||
public class DeepSeekV4ThinkingDecorator implements ChatModel {
|
||||
|
||||
/** Metadata key under which we stash {@code reasoning_content} on AssistantMessage. */
|
||||
static final String REASONING_CONTENT_KEY = "reasoning_content";
|
||||
|
||||
/** Request-body field DeepSeek V4 reads to toggle thinking mode. */
|
||||
static final String THINKING_FIELD = "thinking";
|
||||
|
||||
private final ChatModel delegate;
|
||||
|
||||
public DeepSeekV4ThinkingDecorator(ChatModel delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
return delegate.call(transform(prompt));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||
return delegate.stream(transform(prompt));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatOptions getDefaultOptions() {
|
||||
return delegate.getDefaultOptions();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Outbound transform */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/** Build a new Prompt with thinking + reasoning_content patched. Package-private for tests. */
|
||||
Prompt transform(Prompt original) {
|
||||
if (original == null) {
|
||||
return null;
|
||||
}
|
||||
boolean thinkingEnabled = isThinkingEnabled();
|
||||
ChatOptions patchedOptions = patchOptions(original.getOptions(), thinkingEnabled);
|
||||
List<Message> patchedMessages = patchMessages(original.getInstructions(), thinkingEnabled);
|
||||
return new Prompt(patchedMessages, patchedOptions);
|
||||
}
|
||||
|
||||
private static boolean isThinkingEnabled() {
|
||||
String level = ThinkingLevelHolder.get();
|
||||
// null/empty → fall back to enabled (V4's default behavior is reasoning-on);
|
||||
// explicit "off" → disabled.
|
||||
return level == null || level.isBlank() || !"off".equalsIgnoreCase(level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map MateClaw's thinking levels (off/low/medium/high/max) to DeepSeek's
|
||||
* accepted reasoning_effort values. Aligns with openclaw
|
||||
* {@code resolveDeepSeekV4ReasoningEffort}: max collapses into high since
|
||||
* DeepSeek doesn't expose a "max" tier on V4.
|
||||
*/
|
||||
static String mapEffort(String level) {
|
||||
if (level == null || level.isBlank()) return "medium";
|
||||
return switch (level.toLowerCase()) {
|
||||
case "low" -> "low";
|
||||
case "medium" -> "medium";
|
||||
case "high", "max" -> "high";
|
||||
default -> "medium";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone {@link OpenAiChatOptions} and inject extraBody.thinking + reasoning_effort.
|
||||
* Returns the input unchanged for non-OpenAI options (defensive — should
|
||||
* never happen for V4, but skips ahead-of-binding work in tests that pass
|
||||
* vanilla {@link ChatOptions}).
|
||||
*/
|
||||
private static ChatOptions patchOptions(ChatOptions original, boolean enabled) {
|
||||
if (!(original instanceof OpenAiChatOptions oai)) {
|
||||
return original;
|
||||
}
|
||||
OpenAiChatOptions copy = OpenAiChatOptions.fromOptions(oai);
|
||||
Map<String, Object> extra = copy.getExtraBody();
|
||||
Map<String, Object> patched = (extra == null) ? new LinkedHashMap<>() : new LinkedHashMap<>(extra);
|
||||
|
||||
if (enabled) {
|
||||
patched.put(THINKING_FIELD, Map.of("type", "enabled"));
|
||||
// reasoning_effort is a first-class OpenAiChatOptions field → set via setter.
|
||||
String level = ThinkingLevelHolder.get();
|
||||
copy.setReasoningEffort(mapEffort(level));
|
||||
} else {
|
||||
patched.put(THINKING_FIELD, Map.of("type", "disabled"));
|
||||
// Drop reasoning_effort — DeepSeek 400s if both are present with thinking disabled.
|
||||
copy.setReasoningEffort(null);
|
||||
}
|
||||
copy.setExtraBody(patched);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk message history and patch reasoning_content per V4's contract:
|
||||
* <ul>
|
||||
* <li><b>enabled</b>: every assistant tool-call message must carry a
|
||||
* (possibly empty) {@code reasoning_content} entry in its metadata.</li>
|
||||
* <li><b>disabled</b>: strip any {@code reasoning_content} from prior
|
||||
* messages so DeepSeek doesn't echo stale reasoning back.</li>
|
||||
* </ul>
|
||||
*/
|
||||
static List<Message> patchMessages(List<Message> source, boolean enabled) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return source;
|
||||
}
|
||||
List<Message> out = new ArrayList<>(source.size());
|
||||
for (Message msg : source) {
|
||||
if (msg.getMessageType() == MessageType.ASSISTANT && msg instanceof AssistantMessage am) {
|
||||
out.add(rewriteAssistant(am, enabled));
|
||||
} else {
|
||||
out.add(msg);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static AssistantMessage rewriteAssistant(AssistantMessage am, boolean enabled) {
|
||||
Map<String, Object> meta = am.getMetadata();
|
||||
boolean hasTools = am.hasToolCalls();
|
||||
boolean hasReasoning = meta != null && meta.containsKey(REASONING_CONTENT_KEY);
|
||||
|
||||
// Fast path: no rewrite needed.
|
||||
if (enabled && (!hasTools || hasReasoning)) {
|
||||
return am;
|
||||
}
|
||||
if (!enabled && !hasReasoning) {
|
||||
return am;
|
||||
}
|
||||
|
||||
Map<String, Object> newMeta = (meta == null) ? new HashMap<>() : new HashMap<>(meta);
|
||||
if (enabled) {
|
||||
// Tool-call messages need reasoning_content present (empty OK) for replay.
|
||||
newMeta.putIfAbsent(REASONING_CONTENT_KEY, "");
|
||||
} else {
|
||||
// Drop reasoning_content entirely — DeepSeek mirrors back stale thinking otherwise.
|
||||
newMeta.remove(REASONING_CONTENT_KEY);
|
||||
}
|
||||
return AssistantMessage.builder()
|
||||
.content(am.getText())
|
||||
.properties(newMeta)
|
||||
.toolCalls(am.getToolCalls())
|
||||
.media(am.getMedia())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
|
||||
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFunction;
|
||||
import org.reactivestreams.Publisher;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* WebClient (streaming) counterpart of {@link RateLimitDiagnosticInterceptor}.
|
||||
*
|
||||
* <p>On 429, logs outgoing request headers (sanitized), a body preview captured
|
||||
* non-destructively via {@link DataBuffer#toByteBuffer(int, int)}, and the
|
||||
* {@code anthropic-ratelimit-*} response headers. Delegates constant and
|
||||
* formatting logic to the shared statics on {@link RateLimitDiagnosticInterceptor}.
|
||||
*/
|
||||
@Slf4j
|
||||
class RateLimitDiagnosticExchangeFilter implements ExchangeFilterFunction {
|
||||
|
||||
@Override
|
||||
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
|
||||
AtomicReference<String> capturedBody = new AtomicReference<>();
|
||||
|
||||
ClientRequest intercepted = ClientRequest.from(request)
|
||||
.body((outputMessage, context) -> request.body().insert(
|
||||
new ClientHttpRequestDecorator(outputMessage) {
|
||||
@Override
|
||||
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
|
||||
return super.writeWith(
|
||||
Flux.from(body).doOnNext(buf -> {
|
||||
if (capturedBody.get() == null) {
|
||||
int len = Math.min(buf.readableByteCount(),
|
||||
RateLimitDiagnosticInterceptor.BODY_LOG_LIMIT);
|
||||
ByteBuffer view = buf.toByteBuffer(buf.readPosition(), len);
|
||||
byte[] bytes = new byte[len];
|
||||
view.get(bytes);
|
||||
capturedBody.compareAndSet(null,
|
||||
new String(bytes, StandardCharsets.UTF_8));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}, context))
|
||||
.build();
|
||||
|
||||
return next.exchange(intercepted).doOnNext(response -> {
|
||||
if (response.statusCode().value() == 429) {
|
||||
RateLimitDiagnosticInterceptor.logRequestHeaders(request.headers());
|
||||
String preview = capturedBody.get();
|
||||
log.warn("[Anthropic 429] request body preview: {}",
|
||||
preview != null ? preview : "(not captured)");
|
||||
RateLimitDiagnosticInterceptor.logResponseHeaders(
|
||||
response.headers().asHttpHeaders());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* On 429, logs the outgoing request headers (sanitized), the request body
|
||||
* (first {@link #BODY_LOG_LIMIT} bytes), and the {@code anthropic-ratelimit-*}
|
||||
* response headers to distinguish three failure modes:
|
||||
*
|
||||
* <table>
|
||||
* <caption>How to read the response headers</caption>
|
||||
* <tr><th>Failure mode</th><th>tokens-remaining</th><th>retry-after</th></tr>
|
||||
* <tr><td>5h Pro/Max quota exhausted</td><td>0</td><td>thousands of seconds</td></tr>
|
||||
* <tr><td>Anti-abuse fingerprint gate</td><td>(absent)</td><td>(absent)</td></tr>
|
||||
* <tr><td>Per-minute burst limit</td><td>large</td><td>single-digit seconds</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>Sync (RestClient) variant; the WebFlux equivalent is
|
||||
* {@link RateLimitDiagnosticExchangeFilter}.
|
||||
*/
|
||||
@Slf4j
|
||||
class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
static final int BODY_LOG_LIMIT = 16384;
|
||||
|
||||
static final List<String> RATE_LIMIT_HEADERS = List.of(
|
||||
"anthropic-ratelimit-requests-limit",
|
||||
"anthropic-ratelimit-requests-remaining",
|
||||
"anthropic-ratelimit-requests-reset",
|
||||
"anthropic-ratelimit-tokens-limit",
|
||||
"anthropic-ratelimit-tokens-remaining",
|
||||
"anthropic-ratelimit-tokens-reset",
|
||||
"anthropic-ratelimit-input-tokens-limit",
|
||||
"anthropic-ratelimit-input-tokens-remaining",
|
||||
"anthropic-ratelimit-input-tokens-reset",
|
||||
"anthropic-ratelimit-output-tokens-limit",
|
||||
"anthropic-ratelimit-output-tokens-remaining",
|
||||
"anthropic-ratelimit-output-tokens-reset",
|
||||
"retry-after");
|
||||
|
||||
static final List<String> REQUEST_HEADERS_TO_LOG = List.of(
|
||||
"authorization",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"x-app",
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
"anthropic-dangerous-direct-browser-access");
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
if (response.getStatusCode().value() == 429) {
|
||||
logRequestHeaders(request.getHeaders());
|
||||
logRequestBody(body);
|
||||
logResponseHeaders(response.getHeaders());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
static void logRequestHeaders(HttpHeaders headers) {
|
||||
StringBuilder sb = new StringBuilder("[Anthropic 429] outgoing request headers (sanitized): ");
|
||||
boolean first = true;
|
||||
for (String name : REQUEST_HEADERS_TO_LOG) {
|
||||
String value = headers.getFirst(name);
|
||||
if (value == null) continue;
|
||||
if (!first) sb.append(", ");
|
||||
first = false;
|
||||
if ("authorization".equalsIgnoreCase(name) && value.startsWith("Bearer ")) {
|
||||
sb.append(name).append("=Bearer <redacted>");
|
||||
} else {
|
||||
sb.append(name).append('=').append(value);
|
||||
}
|
||||
}
|
||||
log.warn(sb.toString());
|
||||
}
|
||||
|
||||
static void logRequestBody(byte[] body) {
|
||||
if (body == null || body.length == 0) {
|
||||
log.warn("[Anthropic 429] request body: (empty)");
|
||||
return;
|
||||
}
|
||||
int len = Math.min(body.length, BODY_LOG_LIMIT);
|
||||
log.warn("[Anthropic 429] request body (first {} of {} bytes): {}",
|
||||
len, body.length, new String(body, 0, len, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static void logResponseHeaders(HttpHeaders headers) {
|
||||
StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit response headers: ");
|
||||
boolean any = false;
|
||||
for (String name : RATE_LIMIT_HEADERS) {
|
||||
String value = headers.getFirst(name);
|
||||
if (value != null) {
|
||||
if (any) sb.append(", ");
|
||||
sb.append(name).append('=').append(value);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) {
|
||||
log.warn("[Anthropic 429] no rate-limit response headers — likely anti-abuse gate, not real quota");
|
||||
} else {
|
||||
log.warn(sb.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
* Channel-bound target identity used when an agent's response must be delivered
|
||||
* back to a specific external channel (cron, IM relay, etc.).
|
||||
*
|
||||
* <p>Kept as a sub-VO of {@link ChatOrigin} so future channel-related fields do
|
||||
* not pollute the top-level origin record.
|
||||
*
|
||||
* <p>Field evolution rule: only-add, do-not-rename, deprecate-for-90-days before
|
||||
* physical removal — see {@link ChatOrigin}'s class doc.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ChannelTarget(
|
||||
@Nullable String targetId,
|
||||
@Nullable String threadId,
|
||||
@Nullable String accountId
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Immutable value object that travels alongside an agent invocation describing
|
||||
* <em>where the request came from</em> — channel, conversation, requester,
|
||||
* workspace, and optional delivery target.
|
||||
*
|
||||
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
|
||||
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
|
||||
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
|
||||
*
|
||||
* <h2>Field evolution rule</h2>
|
||||
* <ul>
|
||||
* <li>Only add — never delete; deprecate at least 90 days (covers approval TTL)
|
||||
* before physical removal.</li>
|
||||
* <li>Never rename — add a new field plus deprecate-old-field, double-write
|
||||
* during the migration window.</li>
|
||||
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
|
||||
* compatibility when older approval rows are deserialized after upgrades.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ChatOrigin(
|
||||
@Nullable Long agentId,
|
||||
@Nullable String conversationId,
|
||||
@Nullable String requesterId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget channelTarget
|
||||
) {
|
||||
|
||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||
|
||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||
public static final ChatOrigin EMPTY =
|
||||
new ChatOrigin(null, null, "", null, null, null, null);
|
||||
|
||||
// ---------------- Factories per entry point ----------------
|
||||
|
||||
public static ChatOrigin web(@Nullable String conversationId,
|
||||
@Nullable String requesterId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath) {
|
||||
return new ChatOrigin(null, conversationId,
|
||||
requesterId != null ? requesterId : "",
|
||||
workspaceId, workspaceBasePath, null, null);
|
||||
}
|
||||
|
||||
public static ChatOrigin cron(@Nullable String conversationId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget target) {
|
||||
return new ChatOrigin(null, conversationId, "system",
|
||||
workspaceId, workspaceBasePath, channelId, target);
|
||||
}
|
||||
|
||||
// ---------------- Wither-style updates ----------------
|
||||
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||
}
|
||||
|
||||
// ---------------- Spring AI ToolContext interop ----------------
|
||||
|
||||
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
|
||||
public ToolContext toToolContext() {
|
||||
return new ToolContext(Map.of(CTX_KEY, this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
|
||||
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
|
||||
* no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
|
||||
* callers safe even if wiring is partial).
|
||||
*/
|
||||
public static ChatOrigin from(@Nullable ToolContext ctx) {
|
||||
if (ctx == null) return EMPTY;
|
||||
Object v = ctx.getContext().get(CTX_KEY);
|
||||
return v instanceof ChatOrigin co ? co : EMPTY;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
/**
|
||||
* Request-scoped {@link ChatOrigin} bridge between {@code AgentService}'s
|
||||
* public entry points and the StateGraph's {@code buildInitialState}.
|
||||
*
|
||||
* <p>RFC-063r §2.5 carries the origin end-to-end via Spring AI {@code ToolContext}
|
||||
* once it lands in graph state. This holder is the small bridge that gets the
|
||||
* origin from the AgentService method invocation into the graph's initial
|
||||
* state map — the holder lifecycle is bounded by the AgentService method
|
||||
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
|
||||
* the rest of the runtime reads via the typed accessor — no further ThreadLocal
|
||||
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
|
||||
*/
|
||||
public final class ChatOriginHolder {
|
||||
|
||||
private static final ThreadLocal<ChatOrigin> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ChatOriginHolder() {
|
||||
}
|
||||
|
||||
/** Set the origin for the current AgentService invocation. */
|
||||
public static void set(ChatOrigin origin) {
|
||||
HOLDER.set(origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
|
||||
* when no entry path has supplied one (legacy callers).
|
||||
*/
|
||||
public static ChatOrigin get() {
|
||||
ChatOrigin v = HOLDER.get();
|
||||
return v != null ? v : ChatOrigin.EMPTY;
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
@ -71,6 +72,21 @@ public class ConversationWindowManager {
|
||||
private static final int CONTENT_MAX = 6000;
|
||||
private static final int CONTENT_HEAD = 4000;
|
||||
private static final int CONTENT_TAIL = 1500;
|
||||
private static final int OLD_TOOL_RESULT_SUMMARY_THRESHOLD = 500;
|
||||
|
||||
/**
|
||||
* Tool names whose results must never be compacted into a one-line
|
||||
* summary. Sub-agent delegations are irreplaceable: the child runs an
|
||||
* independent LLM session that the parent cannot reproduce, so dropping
|
||||
* earlier batches forces the parent to re-dispatch the same children to
|
||||
* recover what was lost. Every other tool (read_file, shell, search,
|
||||
* memory) can be re-invoked cheaply if the parent decides it needs
|
||||
* the data again.
|
||||
*/
|
||||
private static final java.util.Set<String> PRUNE_EXEMPT_TOOLS = java.util.Set.of(
|
||||
"delegateToAgent",
|
||||
"delegateParallel"
|
||||
);
|
||||
|
||||
// ==================== 冷却机制 ====================
|
||||
|
||||
@ -116,9 +132,27 @@ public class ConversationWindowManager {
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId, Long agentId) {
|
||||
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||
maxInputTokens, chatModel, conversationId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as the 7-arg overload but additionally accounts for the tool
|
||||
* definitions sent on every LLM call. Without {@code toolCallbacks},
|
||||
* the budget calculation underestimates the actual request size by the
|
||||
* full size of the tools schema (often several thousand tokens for
|
||||
* agents bound to multiple MCP servers), making compression fire too
|
||||
* late and producing HTTP 400 once the request hits the model.
|
||||
*/
|
||||
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId, Long agentId,
|
||||
java.util.Collection<ToolCallback> toolCallbacks) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
messages = pruneOldToolResultsForModelInput(messages);
|
||||
|
||||
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
|
||||
? maxInputTokens : properties.getDefaultMaxInputTokens();
|
||||
@ -127,20 +161,21 @@ public class ConversationWindowManager {
|
||||
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
|
||||
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
|
||||
int historyTokens = TokenEstimator.estimateTokens(messages);
|
||||
int totalTokens = systemTokens + currentMsgTokens + historyTokens;
|
||||
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
|
||||
int totalTokens = systemTokens + currentMsgTokens + historyTokens + toolsTokens;
|
||||
|
||||
if (totalTokens <= triggerThreshold) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conv={}",
|
||||
totalTokens, systemTokens, currentMsgTokens, historyTokens,
|
||||
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}, tools={}) > {} 触发阈值 (max={}), conv={}",
|
||||
totalTokens, systemTokens, currentMsgTokens, historyTokens, toolsTokens,
|
||||
triggerThreshold, effectiveMax, conversationId);
|
||||
|
||||
evictExpiredEntries();
|
||||
|
||||
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
|
||||
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
|
||||
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
|
||||
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
|
||||
// RFC-025 Change 1: reserve 硬封顶到 effectiveMax 的 50%。
|
||||
// 小上下文模型(Ollama 16K、本地 8K)下,systemTokens + currentMsgTokens 很容易
|
||||
// 接近或超过 effectiveMax,不封顶会让 historyBudget 变负数导致死循环压缩
|
||||
@ -339,6 +374,85 @@ public class ConversationWindowManager {
|
||||
|
||||
// ==================== 工具结果处理 ====================
|
||||
|
||||
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
|
||||
int latestToolResponseIndex = -1;
|
||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage) {
|
||||
latestToolResponseIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (latestToolResponseIndex <= 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
List<Message> pruned = new ArrayList<>(messages);
|
||||
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
|
||||
int changed = 0;
|
||||
for (int i = pruned.size() - 1; i >= 0; i--) {
|
||||
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
|
||||
continue;
|
||||
}
|
||||
boolean keepFull = i == latestToolResponseIndex;
|
||||
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
|
||||
boolean messageChanged = false;
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
String data = r.responseData();
|
||||
boolean exempt = r.name() != null && PRUNE_EXEMPT_TOOLS.contains(r.name());
|
||||
if (keepFull || exempt || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
|
||||
newResponses.add(r);
|
||||
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
|
||||
seenLargeOutputs.add(data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String replacement;
|
||||
if (seenLargeOutputs.contains(data)) {
|
||||
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
|
||||
} else {
|
||||
replacement = summarizeToolResponse(r.name(), data);
|
||||
seenLargeOutputs.add(data);
|
||||
}
|
||||
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
|
||||
messageChanged = true;
|
||||
}
|
||||
if (messageChanged) {
|
||||
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (changed > 0) {
|
||||
log.info("[ConversationWindow] Pruned {} older tool response message(s) before model request", changed);
|
||||
}
|
||||
return changed > 0 ? pruned : messages;
|
||||
}
|
||||
|
||||
private static String summarizeToolResponse(String toolName, String data) {
|
||||
int chars = data.length();
|
||||
int lines = data.isBlank() ? 0 : data.split("\\R", -1).length;
|
||||
String firstLine = firstNonBlankLine(data);
|
||||
if (firstLine.length() > 160) {
|
||||
firstLine = firstLine.substring(0, 160) + "...";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append('[').append(toolName).append("] previous tool output summarized for model context: ")
|
||||
.append(chars).append(" chars, ").append(lines).append(" lines");
|
||||
if (!firstLine.isBlank()) {
|
||||
sb.append(". First line: ").append(firstLine);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String firstNonBlankLine(String data) {
|
||||
for (String line : data.split("\\R")) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isBlank()) {
|
||||
return trimmed.replace('|', '/');
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
*/
|
||||
@ -435,19 +549,20 @@ public class ConversationWindowManager {
|
||||
String systemPrompt;
|
||||
String userPrompt;
|
||||
|
||||
// System prompt always carries the budget directive; both branches
|
||||
// must replace the placeholder. The previous code applied the
|
||||
// replace only on the first-compression branch, so iterative-mode
|
||||
// calls leaked the literal "{summary_budget}" string to the LLM.
|
||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
|
||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
||||
if (previousSummary != null) {
|
||||
// 迭代更新模式:旧摘要 + 新轮次
|
||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM;
|
||||
// Iterative update: previous summary + new turns.
|
||||
userPrompt = STRUCTURED_SUMMARY_UPDATE
|
||||
.replace("{previous_summary}", previousSummary)
|
||||
.replace("{conversation}", conversationText)
|
||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
||||
.replace("{conversation}", conversationText);
|
||||
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
|
||||
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
|
||||
} else {
|
||||
// 首次压缩
|
||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
|
||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
||||
userPrompt = STRUCTURED_SUMMARY_USER
|
||||
.replace("{conversation}", conversationText);
|
||||
log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId);
|
||||
|
||||
@ -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 {
|
||||
/** 每条消息的固定开销 token(role 标记、分隔符等) */
|
||||
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 字符(中日韩统一表意文字 + 常用标点)
|
||||
*/
|
||||
|
||||
@ -4,15 +4,20 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import vip.mate.channel.web.Utf8SseEmitter;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@ -33,6 +38,8 @@ public class AgentController {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final AuthService authService;
|
||||
private final WorkspaceService workspaceService;
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@Operation(summary = "获取Agent列表")
|
||||
@ -60,9 +67,12 @@ public class AgentController {
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentEntity> create(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestBody AgentEntity agent) {
|
||||
@RequestBody AgentEntity agent,
|
||||
Authentication auth) {
|
||||
// 始终注入 workspace_id,无 header 时使用默认
|
||||
agent.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
|
||||
// RFC-077 §4.4: 记录创建者,让 member 后续可删除自建 Agent
|
||||
agent.setCreatorUserId(resolveUserId(auth));
|
||||
AgentEntity created = agentService.createAgent(agent);
|
||||
auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null);
|
||||
return R.ok(created);
|
||||
@ -84,11 +94,24 @@ public class AgentController {
|
||||
|
||||
@Operation(summary = "删除Agent")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> delete(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
|
||||
|
||||
// RFC-077 §4.4: 三选一鉴权 — 系统 admin / workspace admin+ / 创建者本人
|
||||
Long userId = resolveUserId(auth);
|
||||
boolean systemAdmin = isSystemAdmin(auth);
|
||||
boolean workspaceAdmin = !systemAdmin
|
||||
&& workspaceService.hasPermission(agent.getWorkspaceId(), userId, "admin");
|
||||
boolean isCreator = userId.equals(agent.getCreatorUserId());
|
||||
if (!systemAdmin && !workspaceAdmin && !isCreator) {
|
||||
throw new MateClawException("err.agent.delete_forbidden", 403,
|
||||
"Only the creator or a workspace admin can delete this Agent");
|
||||
}
|
||||
|
||||
agentService.deleteAgent(id);
|
||||
auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent.getName(), null);
|
||||
return R.ok();
|
||||
@ -105,7 +128,8 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
|
||||
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
|
||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
||||
sseExecutor.execute(() -> {
|
||||
try {
|
||||
agentService.chatStream(id, message, conversationId)
|
||||
@ -183,4 +207,23 @@ public class AgentController {
|
||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveUserId(Authentication auth) {
|
||||
if (auth == null) {
|
||||
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");
|
||||
}
|
||||
UserEntity user = authService.findByUsername(auth.getName());
|
||||
if (user == null) {
|
||||
throw new MateClawException("err.auth.user_not_found", 401, "User not found: " + auth.getName());
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private boolean isSystemAdmin(Authentication auth) {
|
||||
if (auth == null) {
|
||||
return false;
|
||||
}
|
||||
UserEntity user = authService.findByUsername(auth.getName());
|
||||
return user != null && "admin".equalsIgnoreCase(user.getRole());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* REST surface for managing live sub-agents:
|
||||
* <ul>
|
||||
* <li>{@code POST /interrupt} — stop a running sub-agent.</li>
|
||||
* <li>{@code POST /spawn-pause} — toggle the per-parent spawn-pause flag.</li>
|
||||
* <li>{@code GET /active} — list sub-agents under one parent conversation.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Every endpoint authorizes the caller against the parent conversation's
|
||||
* owner before mutating or revealing anything; the {@code parentConversationId}
|
||||
* query parameter on {@code /active} is mandatory so the route cannot be used
|
||||
* to enumerate cross-tenant sub-agents.
|
||||
*
|
||||
* <p>Authorization mirrors the {@link vip.mate.workspace.conversation.ConversationService#isConversationOwner}
|
||||
* pattern used by the chat stop / fork routes — usernames are the principal
|
||||
* identity carried on {@link Authentication#getName()}, and shared "system"
|
||||
* conversations are accessible to all logged-in users (matches the existing
|
||||
* cron-job convention).
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Sub-agents")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/subagents")
|
||||
@RequiredArgsConstructor
|
||||
public class SubagentController {
|
||||
|
||||
private final SubagentRegistry registry;
|
||||
private final ConversationService conversationService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Resolve the record and verify the caller owns its parent conversation.
|
||||
* Throws a 403-coded exception when ownership fails so the global handler
|
||||
* can render a uniform JSON error envelope.
|
||||
*/
|
||||
private SubagentRegistry.SubagentRecord requireOwnership(String subagentId, Authentication auth) {
|
||||
Optional<SubagentRegistry.SubagentRecord> opt = registry.get(subagentId);
|
||||
if (opt.isEmpty()) {
|
||||
throw new MateClawException(404, "subagent " + subagentId + " not found");
|
||||
}
|
||||
SubagentRegistry.SubagentRecord rec = opt.get();
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(rec.parentConversationId(), username)) {
|
||||
// Audit denial separately from the operation itself so admins can
|
||||
// see what cross-tenant attempts hit the registry. Best-effort
|
||||
// serialization — the audit insert is async on the service side.
|
||||
auditEventService.record("subagent.interrupt.denied", "subagent",
|
||||
subagentId, rec.subagentId(),
|
||||
safeJson(Map.of(
|
||||
"callerUsername", username,
|
||||
"parent", rec.parentConversationId(),
|
||||
"agentId", rec.agentId() == null ? -1L : rec.agentId()
|
||||
)));
|
||||
throw new MateClawException(403, "not the owner of subagent's parent conversation");
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running sub-agent. The registry flips status to {@code interrupted}
|
||||
* and disposes the streaming subscription if one was registered. Returns
|
||||
* the {@code interrupted} flag so the caller can distinguish "we did stop
|
||||
* something" from "the subagent was already finished" (404 case is handled
|
||||
* separately by {@link #requireOwnership}).
|
||||
*/
|
||||
@Operation(summary = "Interrupt a running sub-agent")
|
||||
@PostMapping("/{subagentId}/interrupt")
|
||||
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
|
||||
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
|
||||
boolean ok = registry.interrupt(subagentId);
|
||||
auditEventService.record("subagent.interrupt", "subagent",
|
||||
subagentId, rec.subagentId(),
|
||||
safeJson(Map.of(
|
||||
"by", currentUsername(auth),
|
||||
"parent", rec.parentConversationId(),
|
||||
"result", ok
|
||||
)));
|
||||
return R.ok(Map.of("interrupted", ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether new sub-agent spawns are accepted under a parent
|
||||
* conversation. Used by the operator UI to halt runaway parent agents
|
||||
* mid-turn without killing the parent's own LLM call.
|
||||
*/
|
||||
@Operation(summary = "Set sub-agent spawn-pause for a conversation")
|
||||
@PostMapping("/spawn-pause")
|
||||
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
|
||||
Object parentObj = body == null ? null : body.get("parentConversationId");
|
||||
String parent = parentObj == null ? null : parentObj.toString();
|
||||
if (parent == null || parent.isBlank()) {
|
||||
throw new MateClawException(400, "parentConversationId required");
|
||||
}
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(parent, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parent);
|
||||
}
|
||||
boolean paused = Boolean.TRUE.equals(body.get("paused"));
|
||||
registry.setSpawnPaused(parent, paused);
|
||||
auditEventService.record("subagent.spawn-pause", "conversation",
|
||||
parent, parent,
|
||||
safeJson(Map.of(
|
||||
"paused", paused,
|
||||
"by", username
|
||||
)));
|
||||
return R.ok(Map.of("paused", paused));
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sub-agents currently active under {@code parentConversationId}.
|
||||
* The query parameter is mandatory: returning all subagents process-wide
|
||||
* would let any logged-in user enumerate other tenants' delegation trees.
|
||||
*/
|
||||
@Operation(summary = "List active sub-agents under a parent conversation")
|
||||
@GetMapping("/active")
|
||||
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
|
||||
Authentication auth) {
|
||||
if (parentConversationId == null || parentConversationId.isBlank()) {
|
||||
throw new MateClawException(400, "parentConversationId required");
|
||||
}
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(parentConversationId, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
|
||||
}
|
||||
List<Map<String, Object>> snapshot = registry.snapshot(parentConversationId).stream()
|
||||
.map(this::toResponseDto)
|
||||
.toList();
|
||||
return R.ok(Map.of("subagents", snapshot));
|
||||
}
|
||||
|
||||
/** Username from auth context; falls back to "anonymous" only when null. */
|
||||
private String currentUsername(Authentication auth) {
|
||||
return auth != null ? auth.getName() : "anonymous";
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO projection that drops the {@link reactor.core.Disposable} (not
|
||||
* serializable to the wire) and exposes only the user-facing fields.
|
||||
*/
|
||||
private Map<String, Object> toResponseDto(SubagentRegistry.SubagentRecord rec) {
|
||||
Map<String, Object> dto = new LinkedHashMap<>();
|
||||
dto.put("subagentId", rec.subagentId());
|
||||
dto.put("parentConversationId", rec.parentConversationId());
|
||||
dto.put("childConversationId", rec.childConversationId());
|
||||
dto.put("agentId", rec.agentId());
|
||||
dto.put("goal", rec.goal());
|
||||
dto.put("startedAt", rec.startedAt());
|
||||
dto.put("status", rec.status().get());
|
||||
dto.put("toolCount", rec.toolCount().get());
|
||||
dto.put("lastTool", rec.lastTool().get());
|
||||
dto.put("currentPhase", rec.currentPhase().get());
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON serialization for audit detail. Falling back to a
|
||||
* marker string keeps the audit row insertable when payload contains
|
||||
* a non-serializable value — the alternative (throwing) would lose the
|
||||
* audit record entirely.
|
||||
*/
|
||||
private String safeJson(Map<String, Object> payload) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{\"error\":\"audit_serialization_failed\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Periodic watchdog that flips a sub-agent's status to {@code stale} when its
|
||||
* child stream stops making observable progress.
|
||||
*
|
||||
* <p>Progress is probed via {@link ChatStreamTracker#getRunningToolName} and
|
||||
* {@link ChatStreamTracker#getCurrentPhase}. When neither has changed across
|
||||
* the configured number of cycles, the record is marked stale and a
|
||||
* {@code subagent_stale} event is broadcast on the parent conversation so the
|
||||
* UI can surface the issue. Cycle count uses two separate thresholds — one
|
||||
* for idle children and one for children mid-tool — because legitimately slow
|
||||
* tools (large file scans, slow LLM calls) need a longer window than an idle
|
||||
* model that has simply gone quiet.
|
||||
*
|
||||
* <p>The runtime tool name + phase combination is a deliberately coarse
|
||||
* progress signal: it does not require introspecting LLM token deltas, which
|
||||
* keeps the watchdog cheap and avoids racing with the streaming hot path.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SubagentHeartbeat {
|
||||
|
||||
private final SubagentRegistry registry;
|
||||
private final SubagentHeartbeatConfig cfg;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
|
||||
/**
|
||||
* Scheduled tick. Defaults to every 30 s; controlled by
|
||||
* {@code mateclaw.delegation.heartbeat.intervalSec}.
|
||||
*/
|
||||
@Scheduled(fixedRateString = "#{@subagentHeartbeatConfig.intervalSec * 1000L}")
|
||||
public void check() {
|
||||
for (var rec : registry.allActive()) {
|
||||
if (!"running".equals(rec.status().get())) {
|
||||
continue;
|
||||
}
|
||||
evaluate(rec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible for testing — apply one heartbeat tick to a single record so
|
||||
* tests can drive the watchdog deterministically without scheduling.
|
||||
*/
|
||||
void evaluate(SubagentRegistry.SubagentRecord rec) {
|
||||
// Probe child progress. We use (currentTool, currentPhase) as the
|
||||
// monotonic-progress signal: any change in either implies the child
|
||||
// advanced at least one observable step. We deliberately do NOT
|
||||
// depend on a private apiCallCount field — the RunState does not
|
||||
// expose one, and counting deltas across the streaming hot path
|
||||
// would race with token emission. Tool/phase ticks are atomic
|
||||
// volatile writes from the streaming layer, so reading them here
|
||||
// is cheap and correct.
|
||||
String currentTool = streamTracker.getRunningToolName(rec.childConversationId());
|
||||
String currentPhase = streamTracker.getCurrentPhase(rec.childConversationId());
|
||||
int phaseHash = currentPhase != null ? currentPhase.hashCode() : 0;
|
||||
|
||||
boolean toolChanged = !Objects.equals(currentTool, rec.lastSeenTool().get());
|
||||
boolean phaseChanged = phaseHash != rec.lastSeenIter().get();
|
||||
|
||||
if (toolChanged || phaseChanged) {
|
||||
rec.lastSeenTool().set(currentTool);
|
||||
rec.lastSeenIter().set(phaseHash);
|
||||
rec.staleCount().set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
int sc = rec.staleCount().incrementAndGet();
|
||||
int limit = (currentTool != null && !currentTool.isEmpty())
|
||||
? cfg.getStaleCyclesInTool()
|
||||
: cfg.getStaleCyclesIdle();
|
||||
|
||||
if (sc >= limit) {
|
||||
// Atomic transition: only the first thread to flip running -> stale
|
||||
// emits the event. Subsequent ticks fall through the running guard
|
||||
// in check().
|
||||
if (rec.status().compareAndSet("running", "stale")) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("subagentId", rec.subagentId());
|
||||
payload.put("cycles", sc);
|
||||
payload.put("lastTool", currentTool != null ? currentTool : "");
|
||||
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
|
||||
streamTracker.broadcastObject(rec.parentConversationId(), "subagent_stale", payload);
|
||||
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
|
||||
rec.subagentId(), sc, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Process-wide registry of live sub-agents spawned through the delegation flow.
|
||||
*
|
||||
* <p>Holds the in-memory subagent tree so the parent transcript, the heartbeat
|
||||
* watcher, and the operator UI can observe / interrupt children that the parent
|
||||
* conversation spawned. Records use atomic accessors throughout because the
|
||||
* heartbeat thread may mutate {@code staleCount} / {@code status} concurrently
|
||||
* with the spawning thread that registered the record.
|
||||
*
|
||||
* <p>The pause flag is keyed per parent conversation so two unrelated users
|
||||
* cannot freeze each other's spawning by toggling a global switch.
|
||||
*/
|
||||
@Component
|
||||
public class SubagentRegistry {
|
||||
|
||||
/**
|
||||
* Single live sub-agent.
|
||||
*
|
||||
* <p>Mutable counters are atomics so the heartbeat scheduler and the
|
||||
* spawn / completion thread can update them without locking. Status is
|
||||
* driven by external lifecycle events; allowed values are
|
||||
* {@code running} / {@code completed} / {@code interrupted} / {@code stale}
|
||||
* / {@code timeout}.
|
||||
*/
|
||||
public record SubagentRecord(
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
Long agentId,
|
||||
String goal,
|
||||
long startedAt,
|
||||
AtomicReference<String> status,
|
||||
AtomicInteger toolCount,
|
||||
AtomicReference<String> lastTool,
|
||||
AtomicReference<String> currentPhase,
|
||||
AtomicInteger lastSeenIter,
|
||||
AtomicReference<String> lastSeenTool,
|
||||
AtomicInteger staleCount,
|
||||
AtomicLong firstApiCallAt,
|
||||
Disposable disposable
|
||||
) {}
|
||||
|
||||
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Per-parent pause flag set: scoping prevents one user from freezing
|
||||
* another user's spawning. A parent conversation appears in this set iff
|
||||
* spawning is currently paused for it.
|
||||
*/
|
||||
private final Set<String> pausedParents = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final SecureRandom rng = new SecureRandom();
|
||||
|
||||
/**
|
||||
* Register a freshly spawned sub-agent. Returns the assigned subagentId
|
||||
* which the caller must thread through to {@link #unregister(String)} on
|
||||
* completion (success / failure / timeout) so the registry does not leak.
|
||||
*
|
||||
* <p>ID format {@code sa-<epoch_ms>-<8 hex chars>} keeps IDs sortable by
|
||||
* spawn time while the random suffix prevents collisions when many
|
||||
* children spawn within the same millisecond.
|
||||
*/
|
||||
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
|
||||
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
|
||||
active.put(sid, new SubagentRecord(
|
||||
sid,
|
||||
parentConvId,
|
||||
childConvId,
|
||||
agentId,
|
||||
goal,
|
||||
System.currentTimeMillis(),
|
||||
new AtomicReference<>("running"),
|
||||
new AtomicInteger(0),
|
||||
new AtomicReference<>(""),
|
||||
new AtomicReference<>("starting"),
|
||||
new AtomicInteger(0),
|
||||
new AtomicReference<>(null),
|
||||
new AtomicInteger(0),
|
||||
new AtomicLong(0),
|
||||
d));
|
||||
return sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a sub-agent as interrupted and dispose its underlying stream
|
||||
* subscription if one was registered. Returns {@code false} when the
|
||||
* subagentId is unknown (already cleaned up or never registered) so
|
||||
* callers can distinguish "not running anymore" from "interrupted".
|
||||
*/
|
||||
public boolean interrupt(String subagentId) {
|
||||
if (subagentId == null) return false;
|
||||
SubagentRecord r = active.get(subagentId);
|
||||
if (r == null) return false;
|
||||
r.status().set("interrupted");
|
||||
Disposable d = r.disposable();
|
||||
if (d != null && !d.isDisposed()) {
|
||||
d.dispose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<SubagentRecord> get(String subagentId) {
|
||||
return subagentId == null ? Optional.empty() : Optional.ofNullable(active.get(subagentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all sub-agents whose parent matches {@code parentConvId}.
|
||||
* Filtering at the registry boundary prevents callers from accidentally
|
||||
* surfacing other tenants' subagents in API responses.
|
||||
*/
|
||||
public List<SubagentRecord> snapshot(String parentConvId) {
|
||||
if (parentConvId == null) return List.of();
|
||||
return active.values().stream()
|
||||
.filter(r -> parentConvId.equals(r.parentConversationId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void unregister(String subagentId) {
|
||||
if (subagentId == null) return;
|
||||
active.remove(subagentId);
|
||||
}
|
||||
|
||||
public boolean isSpawnPaused(String parentConvId) {
|
||||
if (parentConvId == null) return false;
|
||||
return pausedParents.contains(parentConvId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the pause flag for one parent conversation. Returns the new
|
||||
* paused state so the caller can echo the resulting flag without an
|
||||
* extra read.
|
||||
*/
|
||||
public boolean setSpawnPaused(String parentConvId, boolean paused) {
|
||||
if (parentConvId == null) return false;
|
||||
if (paused) {
|
||||
pausedParents.add(parentConvId);
|
||||
} else {
|
||||
pausedParents.remove(parentConvId);
|
||||
}
|
||||
return paused;
|
||||
}
|
||||
|
||||
public Collection<SubagentRecord> allActive() {
|
||||
return active.values();
|
||||
}
|
||||
|
||||
/** Lowercase 8-hex-char suffix sourced from a SecureRandom. */
|
||||
private String nextHexSuffix() {
|
||||
byte[] bytes = new byte[4];
|
||||
rng.nextBytes(bytes);
|
||||
StringBuilder sb = new StringBuilder(8);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -53,15 +53,32 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
private final CompiledGraph compiledGraph;
|
||||
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
/**
|
||||
* Held only so {@link #buildInitialState} can include the tools schema in
|
||||
* the context-window budget — those bytes ride along on every LLM call
|
||||
* and were previously ignored, making compression decisions fire late.
|
||||
* Nullable for the legacy 5-arg constructor used by older tests.
|
||||
*/
|
||||
private final vip.mate.agent.AgentToolSet toolSet;
|
||||
|
||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
ConversationWindowManager conversationWindowManager) {
|
||||
this(chatClient, conversationService, compiledGraph, chatModel,
|
||||
conversationWindowManager, null);
|
||||
}
|
||||
|
||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
vip.mate.agent.AgentToolSet toolSet) {
|
||||
super(chatClient, conversationService);
|
||||
this.compiledGraph = compiledGraph;
|
||||
this.chatModel = chatModel;
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.toolSet = toolSet;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -175,6 +192,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 防重保护:同 chatStructuredStream
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||
// Silent-termination guard (mirrors chatStructuredStream)
|
||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
|
||||
|
||||
return compiledGraph.stream(inputs, config)
|
||||
.flatMapIterable(output -> {
|
||||
@ -192,6 +214,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||
|
||||
// 与 chatStructuredStream 一致:把每轮 STREAMED_CONTENT 用 persistOnly 推给 Accumulator,
|
||||
// 否则中间叙述(reasoning narrative + summarize)只在 SSE 上出现一次,刷新后丢失。
|
||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
|
||||
}
|
||||
|
||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
String answer = extractFinalAnswer(output);
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
@ -213,6 +243,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
|
||||
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
|
||||
if (hasFinalAnswer(output)
|
||||
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|
||||
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
|
||||
sawLegitimateExit.set(true);
|
||||
}
|
||||
|
||||
return deltas;
|
||||
})
|
||||
.concatWith(Mono.fromSupplier(() -> {
|
||||
@ -226,7 +264,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
}
|
||||
return null;
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
||||
.doOnComplete(() -> {
|
||||
setState(AgentState.IDLE);
|
||||
if (!sawLegitimateExit.get()) {
|
||||
log.error("[{}] StateGraph replay stream completed WITHOUT a final answer / "
|
||||
+ "limit_exceeded / finish_reason — likely framework-level silent "
|
||||
+ "termination. conversationId={}, lastIteration={}, softCap={}",
|
||||
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
|
||||
}
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
@ -265,6 +311,20 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||
// Silent-termination guardrail: track the highest iteration / soft cap
|
||||
// observed and whether the graph reached a legitimate exit (final answer
|
||||
// or limit-exceeded node). If the framework completes the Flux without
|
||||
// either signal we log.error in doOnComplete — the graph framework
|
||||
// historically treated its own recursion cap as a silent normal
|
||||
// completion, which masked turns ending mid-execution. Decoupling the
|
||||
// recursionLimit at compile time should keep this from firing, but the
|
||||
// guard catches any future regression instead of letting it ship silent.
|
||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
|
||||
|
||||
return compiledGraph.stream(inputs, config)
|
||||
.flatMapIterable(output -> {
|
||||
@ -287,6 +347,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
boolean thinkingAlreadyStreamed = output.state()
|
||||
.value(THINKING_STREAMED, false);
|
||||
|
||||
// 2a. 中间叙述内容持久化:每轮 ReasoningNode(带 tool_calls)和 SummarizingNode
|
||||
// 都把当轮 LLM 输出写入 STREAMED_CONTENT。NodeStreamingChatHelper 已实时广播
|
||||
// 给前端,但 Accumulator 不在 SSE 订阅链路上,必须用 persistOnly StreamDelta
|
||||
// 补一刀,否则刷新后正文文字全部丢失(只剩 final_answer + tool_call 卡片)。
|
||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
|
||||
}
|
||||
|
||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
String answer = extractFinalAnswer(output);
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
@ -310,6 +380,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
// 4. Silent-termination guard inputs
|
||||
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
|
||||
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
|
||||
if (hasFinalAnswer(output)
|
||||
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|
||||
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
|
||||
sawLegitimateExit.set(true);
|
||||
}
|
||||
|
||||
return deltas;
|
||||
})
|
||||
// 流正常完成后追加内部 usage 事件
|
||||
@ -324,7 +403,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
}
|
||||
return null;
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
||||
.doOnComplete(() -> {
|
||||
setState(AgentState.IDLE);
|
||||
if (!sawLegitimateExit.get()) {
|
||||
log.error("[{}] StateGraph structured stream completed WITHOUT a final answer / "
|
||||
+ "limit_exceeded / finish_reason — likely framework-level silent "
|
||||
+ "termination (recursionLimit reached or upstream truncation). "
|
||||
+ "conversationId={}, lastIteration={}, softCap={}",
|
||||
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
|
||||
}
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
@ -350,7 +438,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
maxInputTokens,
|
||||
chatModel,
|
||||
conversationId,
|
||||
parsedAgentId);
|
||||
parsedAgentId,
|
||||
toolSet != null ? toolSet.callbacks() : null);
|
||||
}
|
||||
|
||||
List<Message> messages = new ArrayList<>(historyMessages);
|
||||
@ -366,9 +455,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
|
||||
inputs.put(MESSAGES, messages);
|
||||
// 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用)
|
||||
// maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路,
|
||||
// 否则 thinking-on 会把"无限"误算成 5(变成"5 步就停")。
|
||||
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
|
||||
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||
int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations;
|
||||
int effectiveMaxIterations = (maxIterations <= 0)
|
||||
? 0
|
||||
: (thinkingOn ? maxIterations + 5 : maxIterations);
|
||||
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
|
||||
inputs.put(CURRENT_ITERATION, 0);
|
||||
// 初始化新字段
|
||||
@ -388,6 +481,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
|
||||
// and workspace, then write it into graph state so ActionNode +
|
||||
// StepExecutionNode can forward it to ToolExecutionExecutor → ToolContext.
|
||||
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||
Long parsedAgentIdForOrigin = null;
|
||||
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||
if (parsedAgentIdForOrigin != null) {
|
||||
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||
}
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(CHAT_ORIGIN, origin);
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}, " +
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
|
||||
*
|
||||
* <p>Layer 1 — per-tool cap — is implemented inside each tool itself.
|
||||
* Layer 2 — per-result spill — when a single tool result exceeds {@link #perResultThresholdChars}
|
||||
* the full output is written to disk and only a {@link #previewHeadChars} preview
|
||||
* (plus a pointer line) is sent back to the LLM.
|
||||
* Layer 3 — per-turn aggregate budget — after all tools in a turn complete, if
|
||||
* the cumulative response size exceeds {@link #perTurnBudgetChars}, the largest
|
||||
* non-spilled responses are spilled in turn until the aggregate fits.</p>
|
||||
*
|
||||
* <p>Spill files live under {@link #storageBaseDir} when set, otherwise under
|
||||
* {@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a
|
||||
* workspace is bound to the agent, otherwise under
|
||||
* {@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/}.</p>
|
||||
*
|
||||
* <pre>
|
||||
* mate:
|
||||
* agent:
|
||||
* tool-result:
|
||||
* enabled: true
|
||||
* per-result-threshold-chars: 16000
|
||||
* per-turn-budget-chars: 32000
|
||||
* preview-head-chars: 800
|
||||
* excluded-tool-inline-chars: 4000
|
||||
* storage-base-dir:
|
||||
* </pre>
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "mate.agent.tool-result")
|
||||
public class ToolResultProperties {
|
||||
|
||||
/** Master switch. When false, the executor falls back to plain truncation. */
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Layer 2 — a single tool result larger than this is spilled to disk.
|
||||
* The executor evaluates this against the raw result before applying the
|
||||
* final inline cap, so oversized content is preserved before it is shortened
|
||||
* for the model request.
|
||||
*/
|
||||
private int perResultThresholdChars = 16000; // was 4000 — prevents WebSearch spill-to-disk
|
||||
|
||||
/**
|
||||
* Layer 3 — aggregate cap on combined response size in one tool turn.
|
||||
* After all tools complete, the largest non-spilled responses are spilled
|
||||
* in turn until the cumulative size fits this budget.
|
||||
*/
|
||||
private int perTurnBudgetChars = 32000; // was 16000 — headroom for multi-tool turns
|
||||
|
||||
/** Number of leading characters kept inline as a preview after spilling. */
|
||||
private int previewHeadChars = 800;
|
||||
|
||||
/**
|
||||
* Retrieval-style tools are not spilled, but their inline content still must
|
||||
* fit the model context. When aggregate turn budget is exceeded and only
|
||||
* excluded tools remain, their results are compacted to this size.
|
||||
*/
|
||||
private int excludedToolInlineChars = 2500;
|
||||
|
||||
/**
|
||||
* Optional absolute path to override the default spill location.
|
||||
* When blank, falls back to {@code <workspace>/.mateclaw/tool-results/} or
|
||||
* {@code ${java.io.tmpdir}/mateclaw/tool-results/}.
|
||||
*/
|
||||
private String storageBaseDir = "";
|
||||
|
||||
/**
|
||||
* Tools whose results must NEVER be spilled. These are the tools the agent
|
||||
* uses to <i>retrieve</i> spilled content — spilling their output would
|
||||
* cause infinite recursion (read spill path → produces another spill →
|
||||
* agent reads new spill → …) and starve {@code MAX_TOOL_CALLS_PER_STEP}.
|
||||
*
|
||||
* <p>Defaults to file-read tools that already cap their own output internally.
|
||||
* Configurable so deployments can add more retrieval-style tools (e.g.,
|
||||
* MCP-provided readers) without code changes.</p>
|
||||
*/
|
||||
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
public int getPerResultThresholdChars() { return perResultThresholdChars; }
|
||||
public void setPerResultThresholdChars(int perResultThresholdChars) {
|
||||
this.perResultThresholdChars = perResultThresholdChars;
|
||||
}
|
||||
|
||||
public int getPerTurnBudgetChars() { return perTurnBudgetChars; }
|
||||
public void setPerTurnBudgetChars(int perTurnBudgetChars) {
|
||||
this.perTurnBudgetChars = perTurnBudgetChars;
|
||||
}
|
||||
|
||||
public int getPreviewHeadChars() { return previewHeadChars; }
|
||||
public void setPreviewHeadChars(int previewHeadChars) {
|
||||
this.previewHeadChars = previewHeadChars;
|
||||
}
|
||||
|
||||
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
|
||||
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
|
||||
this.excludedToolInlineChars = excludedToolInlineChars;
|
||||
}
|
||||
|
||||
public String getStorageBaseDir() { return storageBaseDir; }
|
||||
public void setStorageBaseDir(String storageBaseDir) {
|
||||
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
|
||||
}
|
||||
|
||||
public List<String> getExcludedTools() { return excludedTools; }
|
||||
public void setExcludedTools(List<String> excludedTools) {
|
||||
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
|
||||
}
|
||||
|
||||
/** O(1) membership test for the exclusion list, used on every tool result. */
|
||||
public Set<String> excludedToolsSet() {
|
||||
return Set.copyOf(excludedTools);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,296 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tool-result spill store implementing layers 2 and 3 of the RFC-008 Phase 3
|
||||
* three-layer budget. Layer 1 (per-tool cap) lives inside individual tools.
|
||||
*
|
||||
* <p><b>Layer 2 — per-result spill</b> ({@link #persistIfOversized}): a single
|
||||
* tool result that exceeds the configured threshold is written to disk and
|
||||
* the in-memory copy is replaced with a short preview plus a pointer line so
|
||||
* the LLM can use {@code read_file} to retrieve the full text on demand.</p>
|
||||
*
|
||||
* <p><b>Layer 3 — per-turn aggregate budget</b>
|
||||
* ({@link #enforceTurnBudget}): after every tool in one turn has executed,
|
||||
* if the combined response size still exceeds the turn budget, the largest
|
||||
* non-spilled responses are spilled in turn until the aggregate fits.</p>
|
||||
*
|
||||
* <p>Spill files live under one of, in order:</p>
|
||||
* <ol>
|
||||
* <li>{@code ToolResultProperties.storageBaseDir} when explicitly set</li>
|
||||
* <li>{@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a workspace is bound</li>
|
||||
* <li>{@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/} as the universal fallback</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Failures (disk full, IO error) degrade silently: the original result is
|
||||
* returned unchanged so the agent keeps working. Errors are logged at WARN.</p>
|
||||
*
|
||||
* <p>This class does <b>not</b> manage GC. Spill files accumulate until manually
|
||||
* cleaned. A scheduled cleanup job is tracked as a Phase 3 follow-up.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(ToolResultProperties.class)
|
||||
public class ToolResultStorage {
|
||||
|
||||
/** Marker placed in the in-context preview so callers and tools can recognize spill output. */
|
||||
public static final String SPILL_MARKER_PREFIX = "[mate-tool-result-spill]";
|
||||
|
||||
private final ToolResultProperties props;
|
||||
/** Cached at construction; refreshed lazily if the underlying list mutates (rare). */
|
||||
private volatile java.util.Set<String> excludedToolsSnapshot;
|
||||
|
||||
/** D-6: monotonically increasing spill counter for observability. */
|
||||
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
public ToolResultStorage(ToolResultProperties props) {
|
||||
this.props = props;
|
||||
this.excludedToolsSnapshot = props.excludedToolsSet();
|
||||
}
|
||||
|
||||
/** D-6: current cumulative spill count (monotonically increasing). */
|
||||
public long getSpillCount() {
|
||||
return spillCount.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when {@code toolName} is in the configured exclusion list.
|
||||
* Excluded tools (typically retrieval tools like {@code read_file}) are
|
||||
* never spilled — spilling their output would create a recursion where
|
||||
* the agent reads a spill path and produces yet another spill.
|
||||
*/
|
||||
private boolean isExcluded(String toolName) {
|
||||
if (toolName == null) return false;
|
||||
java.util.Set<String> snap = excludedToolsSnapshot;
|
||||
java.util.Set<String> live = props.excludedToolsSet();
|
||||
if (live != snap && !live.equals(snap)) {
|
||||
this.excludedToolsSnapshot = live;
|
||||
snap = live;
|
||||
}
|
||||
return snap.contains(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2. If {@code result} exceeds the per-result threshold, write the full
|
||||
* text to a spill file and return a preview-plus-pointer string. Otherwise
|
||||
* return the original result unchanged.
|
||||
*
|
||||
* @param result the raw tool output (may be null)
|
||||
* @param toolName used in the preview header so the LLM knows which tool produced it
|
||||
* @param toolUseId unique within a conversation; becomes the spill file's basename
|
||||
* @param conversationId scopes spill files by conversation
|
||||
* @param workspaceBasePath agent's workspace base path; may be null/blank
|
||||
*/
|
||||
public String persistIfOversized(String result, String toolName, String toolUseId,
|
||||
String conversationId, String workspaceBasePath) {
|
||||
if (!props.isEnabled() || result == null) {
|
||||
return result;
|
||||
}
|
||||
if (isExcluded(toolName)) {
|
||||
// Retrieval-style tool — never spill, would cause read-back recursion.
|
||||
return result;
|
||||
}
|
||||
if (result.length() <= props.getPerResultThresholdChars()) {
|
||||
return result;
|
||||
}
|
||||
Path file = spillFor(conversationId, toolUseId, workspaceBasePath);
|
||||
if (file == null) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, result, StandardCharsets.UTF_8);
|
||||
} catch (IOException ioe) {
|
||||
log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original",
|
||||
toolName, conversationId, ioe.getMessage());
|
||||
return result;
|
||||
}
|
||||
long count = spillCount.incrementAndGet();
|
||||
log.info("[ToolResultStorage] spill #{}: tool={} chars={} convId={}", count, toolName, result.length(), conversationId);
|
||||
return buildPreview(result, toolName, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 3. Walk the responses; if their aggregate length exceeds the turn
|
||||
* budget, spill the largest remaining non-spilled result and recompute.
|
||||
* Mutates the returned list in place by replacing oversized responses.
|
||||
*/
|
||||
public List<ToolResponseMessage.ToolResponse> enforceTurnBudget(
|
||||
List<ToolResponseMessage.ToolResponse> responses,
|
||||
String conversationId,
|
||||
String workspaceBasePath) {
|
||||
if (!props.isEnabled() || responses == null || responses.isEmpty()) {
|
||||
return responses;
|
||||
}
|
||||
int budget = props.getPerTurnBudgetChars();
|
||||
int aggregate = aggregateSize(responses);
|
||||
if (aggregate <= budget) {
|
||||
return responses;
|
||||
}
|
||||
|
||||
log.info("[ToolResultStorage] turn budget exceeded: {} chars > {} (responses={})",
|
||||
aggregate, budget, responses.size());
|
||||
|
||||
List<ToolResponseMessage.ToolResponse> mutable = new ArrayList<>(responses);
|
||||
|
||||
while (aggregate > budget) {
|
||||
// Find the largest response that has not yet been spilled and is
|
||||
// not produced by an excluded (retrieval-style) tool.
|
||||
int targetIdx = -1;
|
||||
int targetLen = -1;
|
||||
for (int i = 0; i < mutable.size(); i++) {
|
||||
ToolResponseMessage.ToolResponse r = mutable.get(i);
|
||||
String body = r.responseData();
|
||||
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
|
||||
if (isExcluded(r.name())) continue; // retrieval tools must not be spilled
|
||||
if (body.length() > targetLen) {
|
||||
targetLen = body.length();
|
||||
targetIdx = i;
|
||||
}
|
||||
}
|
||||
if (targetIdx < 0) {
|
||||
int compactedIdx = compactLargestExcludedResult(mutable);
|
||||
if (compactedIdx >= 0) {
|
||||
aggregate = aggregateSize(mutable);
|
||||
continue;
|
||||
}
|
||||
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
|
||||
aggregate);
|
||||
break;
|
||||
}
|
||||
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
|
||||
Path file = spillFor(conversationId, target.id(), workspaceBasePath);
|
||||
if (file == null) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, target.responseData(), StandardCharsets.UTF_8);
|
||||
} catch (IOException ioe) {
|
||||
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
|
||||
ioe.getMessage());
|
||||
break;
|
||||
}
|
||||
String preview = buildPreview(target.responseData(), target.name(), file);
|
||||
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), preview));
|
||||
aggregate = aggregateSize(mutable);
|
||||
}
|
||||
return mutable;
|
||||
}
|
||||
|
||||
private int compactLargestExcludedResult(List<ToolResponseMessage.ToolResponse> mutable) {
|
||||
int targetIdx = -1;
|
||||
int targetLen = props.getExcludedToolInlineChars();
|
||||
for (int i = 0; i < mutable.size(); i++) {
|
||||
ToolResponseMessage.ToolResponse r = mutable.get(i);
|
||||
String body = r.responseData();
|
||||
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
|
||||
if (!isExcluded(r.name())) continue;
|
||||
if (body.length() > targetLen) {
|
||||
targetLen = body.length();
|
||||
targetIdx = i;
|
||||
}
|
||||
}
|
||||
if (targetIdx < 0) {
|
||||
return -1;
|
||||
}
|
||||
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
|
||||
String compacted = compactInline(target.responseData(), target.name(), props.getExcludedToolInlineChars());
|
||||
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), compacted));
|
||||
log.info("[ToolResultStorage] compacted excluded tool result: tool={} chars={} -> {}",
|
||||
target.name(), targetLen, compacted.length());
|
||||
return targetIdx;
|
||||
}
|
||||
|
||||
static String compactInline(String body, String toolName, int maxChars) {
|
||||
if (body == null || body.length() <= maxChars) {
|
||||
return body;
|
||||
}
|
||||
int markerBudget = 120;
|
||||
int available = Math.max(200, maxChars - markerBudget);
|
||||
int headLen = Math.max(100, (int) (available * 0.45));
|
||||
int tailLen = Math.max(100, available - headLen);
|
||||
if (headLen + tailLen >= body.length()) {
|
||||
return body;
|
||||
}
|
||||
String marker = "\n\n... [tool result compacted for model context: tool="
|
||||
+ toolName + ", original_chars=" + body.length() + "] ...\n\n";
|
||||
return body.substring(0, headLen) + marker + body.substring(body.length() - tailLen);
|
||||
}
|
||||
|
||||
private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) {
|
||||
int sum = 0;
|
||||
for (ToolResponseMessage.ToolResponse r : responses) {
|
||||
if (r.responseData() != null) sum += r.responseData().length();
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private String buildPreview(String fullResult, String toolName, Path spillFile) {
|
||||
int previewLen = Math.min(props.getPreviewHeadChars(), fullResult.length());
|
||||
String head = fullResult.substring(0, previewLen);
|
||||
return SPILL_MARKER_PREFIX
|
||||
+ " tool=" + toolName
|
||||
+ " full_chars=" + fullResult.length()
|
||||
+ " path=" + spillFile.toAbsolutePath()
|
||||
+ "\n[Preview — first " + previewLen + " of " + fullResult.length()
|
||||
+ " chars. Use read_file with the path above to retrieve the rest.]\n"
|
||||
+ head
|
||||
+ "\n…[truncated]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the spill file path for a given (conversationId, toolUseId).
|
||||
* Returns {@code null} if no usable directory can be determined.
|
||||
*/
|
||||
private Path spillFor(String conversationId, String toolUseId, String workspaceBasePath) {
|
||||
String safeConv = sanitize(conversationId);
|
||||
String safeId = sanitize(toolUseId);
|
||||
if (safeId.isEmpty()) {
|
||||
safeId = "noid-" + System.nanoTime();
|
||||
}
|
||||
Path base = resolveBaseDir(workspaceBasePath);
|
||||
if (base == null) return null;
|
||||
return base.resolve(safeConv).resolve(safeId + ".txt");
|
||||
}
|
||||
|
||||
private Path resolveBaseDir(String workspaceBasePath) {
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
return Paths.get(props.getStorageBaseDir());
|
||||
}
|
||||
if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||
return Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||
}
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp == null || tmp.isEmpty()) return null;
|
||||
return Paths.get(tmp, "mateclaw", "tool-results");
|
||||
}
|
||||
|
||||
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
|
||||
private static String sanitize(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replaceAll("[^A-Za-z0-9_.-]", "_");
|
||||
}
|
||||
|
||||
/** Test/admin helper: lexicographic ordering by length, descending. Not used at runtime. */
|
||||
static Comparator<ToolResponseMessage.ToolResponse> byBodyLengthDesc() {
|
||||
return (a, b) -> Integer.compare(
|
||||
b.responseData() == null ? 0 : b.responseData().length(),
|
||||
a.responseData() == null ? 0 : a.responseData().length());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
@ -65,25 +66,68 @@ public class ActionNode implements NodeAction {
|
||||
// 获取工作区活动目录
|
||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||
|
||||
// RFC-063r §2.5: read the originating ChatOrigin from graph state and
|
||||
// forward it into the executor — tools see it via Spring AI ToolContext.
|
||||
vip.mate.agent.context.ChatOrigin origin = accessor.chatOrigin();
|
||||
|
||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
|
||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
|
||||
|
||||
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
||||
.responses(result.responses())
|
||||
.build();
|
||||
|
||||
// Use the executor's raw-stage ledger instead of re-parsing the
|
||||
// spill-compacted responses. ToolExecutionExecutor builds this
|
||||
// ledger from the full pre-truncate text, so a 30 KB grep result
|
||||
// whose head/tail-cut version no longer mentions a path will still
|
||||
// contribute that path to the evidence pool. Falls back to empty
|
||||
// for legacy executor stubs (tests, mocks) that didn't populate
|
||||
// the new field — fine, the merge with `accessor.sourceEvidenceLedger`
|
||||
// is no-op in that case.
|
||||
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
|
||||
? result.rawEvidenceLedger()
|
||||
: SourceEvidenceLedger.empty();
|
||||
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
|
||||
.toolResults(result.responses())
|
||||
.messages(List.of((Message) toolResponseMessage))
|
||||
.currentPhase("action")
|
||||
.events(result.events());
|
||||
.events(result.events())
|
||||
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
|
||||
|
||||
if (result.awaitingApproval()) {
|
||||
output.awaitingApproval(true);
|
||||
log.info("[ActionNode] Approval pending detected, setting AWAITING_APPROVAL=true to terminate graph");
|
||||
}
|
||||
|
||||
// RFC-052: any returnDirect tool in this batch ⇒ short-circuit the graph.
|
||||
// ObservationDispatcher will route to FinalAnswerNode (skipping the next
|
||||
// LLM call). Direct outputs and the trigger flag both live in state so
|
||||
// FinalAnswerNode can assemble the final answer verbatim.
|
||||
//
|
||||
// Priority guard: when an approval barrier ALSO fires in the same batch
|
||||
// (a direct tool ran successfully BEFORE a sibling tool that needed
|
||||
// approval), let the approval flow win. Otherwise the user would see a
|
||||
// "RETURN_DIRECT" final answer while an approval modal is still open
|
||||
// for the unresolved sibling — a confusing dual-track state. After the
|
||||
// user resolves the approval, the replay path will re-execute and the
|
||||
// direct tool's content reaches the user via the streamedContent path
|
||||
// instead. Same-batch direct+approval is rare; we explicitly defer to
|
||||
// approval for safety.
|
||||
if (result.hasDirectOutputs() && !result.awaitingApproval()) {
|
||||
output.returnDirectTriggered(true);
|
||||
output.directToolOutputs(result.directOutputs());
|
||||
log.info("[ActionNode] RETURN_DIRECT_TRIGGERED — {} direct tool output(s), " +
|
||||
"graph will route to FinalAnswerNode without re-entering LLM",
|
||||
result.directOutputs().size());
|
||||
} else if (result.hasDirectOutputs() && result.awaitingApproval()) {
|
||||
log.warn("[ActionNode] Mixed batch: {} direct output(s) co-occurring with approval " +
|
||||
"barrier on '{}'; deferring to approval flow (RFC-052 §6.5)",
|
||||
result.directOutputs().size(),
|
||||
result.barrierToolName() != null ? result.barrierToolName() : "unknown");
|
||||
}
|
||||
|
||||
// replay 完成后清空 forced_tool_call,防止下一轮再触发
|
||||
if (isReplay) {
|
||||
output.forcedToolCall("");
|
||||
|
||||
@ -3,9 +3,13 @@ package vip.mate.agent.graph.node;
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -34,6 +38,36 @@ public class FinalAnswerNode implements NodeAction {
|
||||
String finalThinking;
|
||||
FinishReason finishReason;
|
||||
|
||||
// RFC-052 — RETURN_DIRECT path takes the highest priority after stopping checks.
|
||||
// The full text of the direct tool result(s) becomes the final answer
|
||||
// verbatim; no LLM call has been made on it. Thinking from the LLM
|
||||
// call that *decided* to invoke the direct tool is preserved (it has
|
||||
// already been streamed; this just keeps the state symmetric with the
|
||||
// NORMAL / SUMMARIZED / LIMIT_EXCEEDED branches below).
|
||||
if (accessor.returnDirectTriggered()) {
|
||||
List<DirectToolOutput> outputs = accessor.directToolOutputs();
|
||||
if (!outputs.isEmpty()) {
|
||||
String assembled = assembleDirectAnswer(outputs);
|
||||
String currentThinking = accessor.currentThinking();
|
||||
String existingThinking = accessor.finalThinking();
|
||||
String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
|
||||
log.info("[FinalAnswerNode] RETURN_DIRECT — assembled final answer from {} direct " +
|
||||
"tool output(s), {} chars (thinking preserved: {} chars)",
|
||||
outputs.size(), assembled.length(), preservedThinking.length());
|
||||
var builder = MateClawStateAccessor.output()
|
||||
.finalAnswer(assembled)
|
||||
.finishReason(FinishReason.RETURN_DIRECT)
|
||||
.events(List.of(GraphEventPublisher.finishReason(
|
||||
FinishReason.RETURN_DIRECT.getValue())));
|
||||
if (!preservedThinking.isEmpty()) {
|
||||
builder.finalThinking(preservedThinking);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
log.warn("[FinalAnswerNode] RETURN_DIRECT_TRIGGERED=true but DIRECT_TOOL_OUTPUTS empty; " +
|
||||
"falling through to default final-answer assembly");
|
||||
}
|
||||
|
||||
// 审批等待路径:Graph 因 AWAITING_APPROVAL 终止,保留已流式推送的内容用于持久化
|
||||
if (accessor.awaitingApproval()) {
|
||||
String preservedContent = accessor.streamedContent();
|
||||
@ -46,7 +80,9 @@ public class FinalAnswerNode implements NodeAction {
|
||||
.finalAnswer(preservedContent)
|
||||
.finishReason(FinishReason.NORMAL)
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(true);
|
||||
.thinkingStreamed(true)
|
||||
.events(List.of(GraphEventPublisher.finishReason(
|
||||
FinishReason.NORMAL.getValue())));
|
||||
if (!preservedThinking.isEmpty()) {
|
||||
builder.finalThinking(preservedThinking);
|
||||
}
|
||||
@ -103,10 +139,28 @@ public class FinalAnswerNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
||||
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
||||
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
|
||||
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
|
||||
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
||||
validation.unsupportedReferences());
|
||||
}
|
||||
|
||||
// 不重置 CONTENT_STREAMED/THINKING_STREAMED,保留上游节点的标志
|
||||
var builder = MateClawStateAccessor.output()
|
||||
.finalAnswer(finalAnswer)
|
||||
.finishReason(finishReason);
|
||||
.finishReason(finishReason)
|
||||
// Emit the resolved FinishReason as a GraphEvent so it rides
|
||||
// the PENDING_EVENTS → StreamDelta pipeline that the channel-
|
||||
// side accumulator subscribes to. A sibling SSE broadcast (e.g.
|
||||
// streamTracker.broadcastObject) reaches the browser but never
|
||||
// touches the accumulator, so toMetadataJson() would not see
|
||||
// it and MemorySummarizationGate would lose the structured
|
||||
// signal. APPEND-strategy on PENDING_EVENTS means this
|
||||
// composes safely with any earlier events upstream nodes
|
||||
// attached.
|
||||
.events(List.of(GraphEventPublisher.finishReason(finishReason.getValue())));
|
||||
|
||||
if (!finalThinking.isEmpty()) {
|
||||
builder.finalThinking(finalThinking);
|
||||
@ -115,6 +169,33 @@ public class FinalAnswerNode implements NodeAction {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
||||
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
||||
+ String.join(", ", unsupportedReferences)
|
||||
+ "。请继续读取相关文件后再下结论。";
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-052 §2.5: assemble the final answer from direct tool outputs.
|
||||
* Single output ⇒ verbatim full text. Multiple outputs ⇒ each prefixed
|
||||
* with a Markdown heading so the user can tell them apart.
|
||||
*/
|
||||
private static String assembleDirectAnswer(List<DirectToolOutput> outputs) {
|
||||
if (outputs.size() == 1) {
|
||||
return outputs.get(0).fullResult();
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
DirectToolOutput out = outputs.get(i);
|
||||
if (i > 0) {
|
||||
sb.append("\n\n");
|
||||
}
|
||||
sb.append("### ").append(out.toolName()).append("\n");
|
||||
sb.append(out.fullResult());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private FinishReason parseFinishReason(String reason) {
|
||||
if (reason == null || reason.isEmpty()) {
|
||||
return FinishReason.NORMAL;
|
||||
|
||||
@ -13,6 +13,7 @@ import vip.mate.agent.graph.observation.ObservationProcessor;
|
||||
import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
import vip.mate.i18n.I18nService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -44,20 +45,28 @@ public class LimitExceededNode implements NodeAction {
|
||||
private final ChatModel chatModel;
|
||||
private final ObservationProcessor observationProcessor;
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
/** Optional i18n service; nullable so legacy/tests without Spring context still work. */
|
||||
private final I18nService i18n;
|
||||
|
||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||
NodeStreamingChatHelper streamingHelper) {
|
||||
this(chatModel, observationProcessor, streamingHelper, null);
|
||||
}
|
||||
|
||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
|
||||
NodeStreamingChatHelper streamingHelper, I18nService i18n) {
|
||||
this.chatModel = chatModel;
|
||||
this.observationProcessor = observationProcessor;
|
||||
this.streamingHelper = streamingHelper;
|
||||
this.i18n = i18n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use constructor with NodeStreamingChatHelper
|
||||
* @deprecated use the constructor with {@link NodeStreamingChatHelper} (and optionally {@link I18nService})
|
||||
*/
|
||||
@Deprecated
|
||||
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
|
||||
this(chatModel, observationProcessor, null);
|
||||
this(chatModel, observationProcessor, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -88,7 +97,7 @@ public class LimitExceededNode implements NodeAction {
|
||||
contextForLLM = observationProcessor.truncate(sb.toString(),
|
||||
observationProcessor.getMaxTotalObservationChars());
|
||||
} else {
|
||||
contextForLLM = "(尚未收集到工具调用结果)";
|
||||
contextForLLM = i18n != null ? i18n.msg("agent.limit_exceeded.empty_context") : "(no tool results)";
|
||||
}
|
||||
|
||||
// 构建 prompt
|
||||
@ -110,8 +119,11 @@ public class LimitExceededNode implements NodeAction {
|
||||
log.info("[LimitExceededNode] Generated limit-exceeded final answer: {} chars",
|
||||
finalDraft != null ? finalDraft.length() : 0);
|
||||
|
||||
String fallbackMsg = i18n != null
|
||||
? i18n.msg("agent.limit_exceeded.fallback")
|
||||
: "Sorry, the maximum reasoning steps were reached.";
|
||||
return MateClawStateAccessor.output()
|
||||
.finalAnswerDraft(finalDraft != null ? finalDraft : "抱歉,已达到最大推理步数,未能获得完整结果。")
|
||||
.finalAnswerDraft(finalDraft != null ? finalDraft : fallbackMsg)
|
||||
.currentThinking(result.thinking())
|
||||
.limitExceeded(true)
|
||||
.contentStreamed(true)
|
||||
|
||||
@ -4,6 +4,7 @@ import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.observation.ObservationProcessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
|
||||
@ -120,6 +121,17 @@ public class ObservationNode implements NodeAction {
|
||||
.shouldSummarize(shouldSummarize)
|
||||
.toolCallCount(newToolCallCount);
|
||||
|
||||
// Close out the iteration we just observed. We use currentIteration
|
||||
// (not nextIteration) so the index pairs with whatever
|
||||
// iteration_start the ReasoningNode emitted at the top of this turn.
|
||||
// Char totals are best-effort: ObservationNode doesn't see the LLM
|
||||
// delta stream directly, so 0/0 is acceptable for now — consumers
|
||||
// that care fall back to summing the deltas themselves.
|
||||
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
|
||||
builder.events(List.of(
|
||||
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
|
||||
}
|
||||
|
||||
// 重复观察时标记错误,让 ObservationDispatcher 路由到 limitExceededNode
|
||||
if (duplicateObservation) {
|
||||
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
|
||||
|
||||
@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
@ -24,6 +25,7 @@ import vip.mate.agent.context.RuntimeContextInjector;
|
||||
import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
@ -51,12 +53,52 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */
|
||||
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
||||
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
|
||||
return MateClawStateAccessor.output();
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成。
|
||||
* <p>
|
||||
* RFC-049 follow-up (2026-04-27): bumped 4096 → 16384. 4096 was hitting
|
||||
* the cap when models emit large generative tool_call args (e.g. renderDocx
|
||||
* with a multi-thousand-character markdown body) on top of thinking
|
||||
* content for reasoning_effort=high — the JSON args got truncated mid-
|
||||
* stream, the tool failed to parse, the docx was never generated. 16k is
|
||||
* the conservative ceiling that covers typical "write a long document"
|
||||
* tool calls without enabling true runaway loops (those are bounded by
|
||||
* iteration count, not per-call tokens).
|
||||
*/
|
||||
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
||||
|
||||
/**
|
||||
* Hermes-agent style enforcement clause appended to every ReasoningNode
|
||||
* system prompt. Treats narration ("I will now …") as a protocol violation
|
||||
* to prevent the recurring failure mode where a model says it will call a
|
||||
* tool but emits the description as final_answer text instead.
|
||||
*/
|
||||
private static final String TOOL_USE_ENFORCEMENT = "\n\n"
|
||||
+ "## 工具调用纪律(必读)\n\n"
|
||||
+ "- 你**必须**直接调用工具来产生结果,不允许只用文字描述\"接下来要做什么\"。\n"
|
||||
+ "- 当你说要执行某个动作(如生成文件、发送消息、调用接口、生成 docx),\n"
|
||||
+ " 你**必须**在同一条回复里**立即发出对应的 tool_call**,不允许只写文字承诺。\n"
|
||||
+ "- 禁止以\"现在 / 接下来 / 我将 / 直接生成 / 我直接\"+动作描述结束本轮回复——\n"
|
||||
+ " 这种叙述会让系统误判任务已完成,**实际上工具没被调用**,结果文件不会产生。\n"
|
||||
+ "- 如果上一次工具调用因 args JSON 截断(max_tokens 超限)失败,\n"
|
||||
+ " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n"
|
||||
+ "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n"
|
||||
+ " 才输出无 tool_call 的纯文字回答。\n";
|
||||
|
||||
private final ChatModel chatModel;
|
||||
private final List<ToolCallback> toolCallbacks;
|
||||
private final String reasoningEffort;
|
||||
/**
|
||||
* PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts
|
||||
* {@code reasoning_effort}. Drives the capability gate in
|
||||
* {@link #resolveEffectiveReasoningEffort()} so that a front-end {@code ThinkingLevelHolder}
|
||||
* override is dropped on chat-type models that cannot honor it.
|
||||
*/
|
||||
private final boolean supportsReasoningEffort;
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
@ -85,9 +127,31 @@ public class ReasoningNode implements NodeAction {
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
||||
// Backward-compatible delegate. Callers that have not migrated to the explicit
|
||||
// supportsReasoningEffort parameter inherit the pre-PR-1 behavior: treat the bound
|
||||
// model as supporting reasoning_effort iff reasoningEffort was resolved to a non-null
|
||||
// value at construction time. New callers (AgentGraphBuilder) should use the
|
||||
// 9-arg constructor below.
|
||||
this(chatModel, toolSet, reasoningEffort, reasoningEffort != null,
|
||||
streamingHelper, conversationWindowManager, streamTracker,
|
||||
maxOutputTokens, wikiContextService);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-1.2 (RFC-049): Primary constructor with explicit {@code supportsReasoningEffort}
|
||||
* capability flag — avoids inferring capability from {@code reasoningEffort == null},
|
||||
* which fails for a future "supports but not auto-enabled" scenario.
|
||||
*/
|
||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||
boolean supportsReasoningEffort,
|
||||
NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
||||
this.chatModel = chatModel;
|
||||
this.toolCallbacks = toolSet.callbacks();
|
||||
this.reasoningEffort = reasoningEffort;
|
||||
this.supportsReasoningEffort = supportsReasoningEffort;
|
||||
this.streamingHelper = streamingHelper;
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.streamTracker = streamTracker;
|
||||
@ -119,6 +183,7 @@ public class ReasoningNode implements NodeAction {
|
||||
this.chatModel = chatModel;
|
||||
this.toolCallbacks = toolCallbacks;
|
||||
this.reasoningEffort = null;
|
||||
this.supportsReasoningEffort = false;
|
||||
this.streamingHelper = null;
|
||||
this.conversationWindowManager = null;
|
||||
this.streamTracker = null;
|
||||
@ -151,7 +216,7 @@ public class ReasoningNode implements NodeAction {
|
||||
.toolCalls(List.of(toolCall))
|
||||
.build();
|
||||
|
||||
return MateClawStateAccessor.output()
|
||||
return reasonOutput()
|
||||
.needsToolCall(true)
|
||||
.toolCalls(List.of(toolCall))
|
||||
.messages(List.of((Message) syntheticMsg))
|
||||
@ -172,16 +237,93 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
// ======= 构建 Prompt =======
|
||||
String systemPrompt = accessor.systemPrompt();
|
||||
// RFC-049 follow-up: append a tool-use enforcement clause to every
|
||||
// ReasoningNode call. Without this, models (especially DeepSeek thinking
|
||||
// and Claude Opus) tend to "narrate" — emit a final_answer like "现在
|
||||
// 直接生成立项材料 docx" instead of actually calling renderDocx, which
|
||||
// makes the graph silently terminate at final_answer_node with the
|
||||
// narration as the user-facing reply.
|
||||
//
|
||||
// Pattern adopted from hermes-agent's TOOL_USE_ENFORCEMENT_GUIDANCE
|
||||
// (`/agent/prompt_builder.py:179-191`). Appended to systemPrompt rather
|
||||
// than woven into the AgentEntity-stored prompt so it stays out of the
|
||||
// user-editable agent UI but is still always-on at runtime.
|
||||
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
|
||||
List<Message> messages = accessor.messages();
|
||||
|
||||
// 消息列表膨胀防护
|
||||
// Guard against runaway message list growth.
|
||||
//
|
||||
// CRITICAL: a naive head+tail cut can break the OpenAI-compatible protocol invariant
|
||||
// that requires tool_call / tool_response pairs to be complete:
|
||||
//
|
||||
// P0 (originally observed): AssistantMessage(tool_calls) falls into the dropped gap,
|
||||
// its ToolResponseMessage lands in the kept tail → provider sees an orphaned
|
||||
// ToolResponseMessage → kimi-code 400 "tool_call_id is not found".
|
||||
//
|
||||
// P1 (symmetric): AssistantMessage(tool_calls) is kept in the head at the boundary,
|
||||
// its ToolResponseMessage falls into the dropped gap → provider sees an assistant
|
||||
// tool_call with no matching response → also a 400 on strict providers.
|
||||
//
|
||||
// Fix: perform the normal cut, then run an iterative bidirectional integrity pass until
|
||||
// the list is stable:
|
||||
// • Remove any ToolResponseMessage whose parent AssistantMessage.tool_calls id was
|
||||
// dropped (P0).
|
||||
// • Remove any AssistantMessage whose tool_calls have no matching ToolResponseMessage
|
||||
// (P1).
|
||||
// Iterate because a P1 removal could expose a new P0 orphan (and vice versa, though that
|
||||
// is pathological in practice). With ≤40 messages convergence is always fast.
|
||||
// Dropping incomplete pairs is safe — prior iterations already processed those
|
||||
// observations; the LLM needs the summary context, not the raw tool I/O.
|
||||
final int MAX_LOOP_MESSAGES = 40;
|
||||
if (messages.size() > MAX_LOOP_MESSAGES) {
|
||||
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
|
||||
messages.size(), MAX_LOOP_MESSAGES, conversationId);
|
||||
int headKeep = Math.min(4, messages.size());
|
||||
int tailKeep = MAX_LOOP_MESSAGES - headKeep;
|
||||
int tailStart = messages.size() - tailKeep;
|
||||
|
||||
List<Message> trimmed = new ArrayList<>(MAX_LOOP_MESSAGES);
|
||||
trimmed.addAll(messages.subList(0, Math.min(4, messages.size())));
|
||||
trimmed.addAll(messages.subList(messages.size() - (MAX_LOOP_MESSAGES - 4), messages.size()));
|
||||
trimmed.addAll(messages.subList(0, headKeep));
|
||||
trimmed.addAll(messages.subList(tailStart, messages.size()));
|
||||
|
||||
// Iterative bidirectional integrity pass.
|
||||
int totalRemoved = 0;
|
||||
boolean changed;
|
||||
do {
|
||||
// Snapshot current tool_call ids and response ids.
|
||||
Set<String> callIds = new java.util.HashSet<>();
|
||||
Set<String> respIds = new java.util.HashSet<>();
|
||||
for (Message m : trimmed) {
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) callIds.add(tc.id());
|
||||
}
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) respIds.add(r.id());
|
||||
}
|
||||
}
|
||||
int before = trimmed.size();
|
||||
trimmed.removeIf(m -> {
|
||||
// P0: ToolResponseMessage whose parent tool_call was dropped
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
return trm.getResponses().stream().anyMatch(r -> !callIds.contains(r.id()));
|
||||
}
|
||||
// P1: AssistantMessage whose tool_call has no ToolResponseMessage
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null
|
||||
&& !am.getToolCalls().isEmpty()) {
|
||||
return am.getToolCalls().stream().anyMatch(tc -> !respIds.contains(tc.id()));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
int removed = before - trimmed.size();
|
||||
totalRemoved += removed;
|
||||
changed = removed > 0;
|
||||
} while (changed);
|
||||
|
||||
if (totalRemoved > 0) {
|
||||
log.warn("[ReasoningNode] Removed {} message(s) with broken tool_call/response pairs "
|
||||
+ "after trim (bidirectional integrity guard), conv={}", totalRemoved, conversationId);
|
||||
}
|
||||
|
||||
messages = trimmed;
|
||||
}
|
||||
|
||||
@ -205,6 +347,9 @@ public class ReasoningNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
if (conversationWindowManager != null) {
|
||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
|
||||
}
|
||||
promptMessages.addAll(messages);
|
||||
|
||||
// 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置)
|
||||
@ -227,6 +372,17 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
|
||||
Map.of("iteration", accessor.iterationCount()));
|
||||
// Iteration boundary marker for the parent ReAct loop. Reason
|
||||
// distinguishes the very first turn of the conversation from a
|
||||
// mid-loop repeat for consumers grouping events into per-turn cards.
|
||||
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
||||
GraphEventPublisher.GraphEvent iterStartEvent = iterationEventsOn
|
||||
? GraphEventPublisher.iterationStart(
|
||||
accessor.iterationCount(),
|
||||
accessor.iterationCount() == 0 ? "first_turn" : "react_step",
|
||||
"parent",
|
||||
null)
|
||||
: null;
|
||||
pushPhase(conversationId, "reasoning", Map.of(
|
||||
"iteration", accessor.iterationCount(),
|
||||
"llmCallCount", nextLlmCallCount
|
||||
@ -266,7 +422,7 @@ public class ReasoningNode implements NodeAction {
|
||||
// 必须显式清零 needsToolCall/shouldSummarize,防止前一轮残留标志导致误路由。
|
||||
log.info("[ReasoningNode] CancellationException during LLM call (user stopped before first token), " +
|
||||
"returning empty answer with STOPPED, llmCallCount={}", nextLlmCallCount);
|
||||
return MateClawStateAccessor.output()
|
||||
return reasonOutput()
|
||||
.finalAnswer("")
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
@ -285,7 +441,7 @@ public class ReasoningNode implements NodeAction {
|
||||
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
||||
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), flushing as final answer",
|
||||
partialText.length(), partialThinking.length());
|
||||
var builder = MateClawStateAccessor.output()
|
||||
var builder = reasonOutput()
|
||||
.finalAnswer(partialText)
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
@ -300,13 +456,46 @@ public class ReasoningNode implements NodeAction {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Order matters: the partial-truncation branch MUST sit before
|
||||
// hasFatalError(). hasFatalError() is "no text + no tool calls + non-
|
||||
// null errorMessage", which is also the shape of a thinking-only cap
|
||||
// result (text is empty by definition). Without this ordering the
|
||||
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
|
||||
// INCOMPLETE semantics.
|
||||
|
||||
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
|
||||
// Soft thinking-only loop: the helper disposed the upstream stream
|
||||
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
|
||||
// reasoning_content without emitting any visible content or tool
|
||||
// calls. Treat as INCOMPLETE rather than fatal — the thinking text
|
||||
// has already been streamed and is preserved for the UI's collapse
|
||||
// panel; the user gets a short fallback line they can retry from.
|
||||
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
||||
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
|
||||
"INCOMPLETE",
|
||||
partialThinking.length());
|
||||
var builder = reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.finishReason(FinishReason.INCOMPLETE)
|
||||
.contentStreamed(false)
|
||||
.thinkingStreamed(true)
|
||||
.mergeUsage(state, result);
|
||||
if (!partialThinking.isEmpty()) {
|
||||
builder.finalThinking(partialThinking);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Fatal error:直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK,
|
||||
// 不走 LimitExceededNode(后者会再发一次 LLM 调用,语义不对且对认证/配额错误会再失败)。
|
||||
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize → finalAnswerNode,
|
||||
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用,finishReason 保持 ERROR_FALLBACK。
|
||||
if (result.hasFatalError()) {
|
||||
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
|
||||
return MateClawStateAccessor.output()
|
||||
return reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer("[错误] " + result.errorMessage())
|
||||
@ -319,7 +508,8 @@ public class ReasoningNode implements NodeAction {
|
||||
}
|
||||
|
||||
if (result.partial()) {
|
||||
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
|
||||
int partialChars = result.text() != null ? result.text().length() : 0;
|
||||
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
|
||||
}
|
||||
|
||||
if (result.hasToolCalls()) {
|
||||
@ -331,7 +521,7 @@ public class ReasoningNode implements NodeAction {
|
||||
"toolCount", result.toolCalls().size()
|
||||
));
|
||||
|
||||
return MateClawStateAccessor.output()
|
||||
return reasonOutput()
|
||||
.needsToolCall(true)
|
||||
.shouldSummarize(false)
|
||||
.toolCalls(result.toolCalls())
|
||||
@ -344,7 +534,7 @@ public class ReasoningNode implements NodeAction {
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(List.of(phaseEvent))
|
||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
||||
.build();
|
||||
} else {
|
||||
String content = result.text();
|
||||
@ -353,23 +543,49 @@ public class ReasoningNode implements NodeAction {
|
||||
"iteration", accessor.iterationCount(),
|
||||
"answerChars", content != null ? content.length() : 0
|
||||
));
|
||||
SourceEvidenceLedger.Validation validation =
|
||||
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
|
||||
boolean evidenceInsufficient = !validation.valid();
|
||||
String finalAnswer = evidenceInsufficient
|
||||
? evidenceWarning(validation.unsupportedReferences())
|
||||
: (content != null ? content : "");
|
||||
if (evidenceInsufficient) {
|
||||
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
||||
validation.unsupportedReferences());
|
||||
}
|
||||
|
||||
return MateClawStateAccessor.output()
|
||||
// Final-answer path: iteration ends in this same node because
|
||||
// ReAct never re-enters the loop afterwards.
|
||||
GraphEventPublisher.GraphEvent iterEndEvent = iterationEventsOn
|
||||
? GraphEventPublisher.iterationEnd(accessor.iterationCount(),
|
||||
"parent", null,
|
||||
content != null ? content.length() : 0,
|
||||
result.thinking() != null ? result.thinking().length() : 0)
|
||||
: null;
|
||||
return reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer(content != null ? content : "")
|
||||
.finalAnswer(finalAnswer)
|
||||
.finalThinking(result.thinking())
|
||||
.messages(List.of((Message) result.assistantMessage()))
|
||||
.currentPhase("reasoning")
|
||||
.contentStreamed(true)
|
||||
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
||||
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
||||
.contentStreamed(!evidenceInsufficient)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(List.of(phaseEvent))
|
||||
.events(buildEvents(phaseEvent, iterStartEvent, iterEndEvent))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private static String evidenceWarning(List<String> unsupportedReferences) {
|
||||
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
||||
+ String.join(", ", unsupportedReferences)
|
||||
+ "。请继续读取相关文件后再下结论。";
|
||||
}
|
||||
|
||||
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
@ -386,6 +602,19 @@ public class ReasoningNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the per-call event list, dropping any null entries so the
|
||||
* iteration-boundary toggle ({@code mateclaw.stream.iteration-events})
|
||||
* works without forcing every caller into branching code.
|
||||
*/
|
||||
private static List<GraphEventPublisher.GraphEvent> buildEvents(GraphEventPublisher.GraphEvent... events) {
|
||||
List<GraphEventPublisher.GraphEvent> out = new ArrayList<>(events.length);
|
||||
for (GraphEventPublisher.GraphEvent ev : events) {
|
||||
if (ev != null) out.add(ev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
|
||||
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
|
||||
return;
|
||||
@ -454,6 +683,13 @@ public class ReasoningNode implements NodeAction {
|
||||
* 解析有效的 reasoningEffort。
|
||||
* 优先级:ThinkingLevelHolder(请求级) > 构造时的 reasoningEffort(Agent/模型默认)。
|
||||
* "off" 会清除 reasoningEffort(返回 null)。
|
||||
*
|
||||
* <p>PR-1.2 (RFC-049 L1-B): If the bound model's family does not support
|
||||
* {@code reasoning_effort} (as declared via {@link #supportsReasoningEffort} at
|
||||
* construction time), the front-end thinking-level override is ignored.
|
||||
* Chat-type models like {@code deepseek-chat} must not be forced into thinking mode
|
||||
* just because the user ticked "deep thinking" in the UI — this is a product
|
||||
* contract, not a runtime option.
|
||||
*/
|
||||
private String resolveEffectiveReasoningEffort() {
|
||||
String requestLevel = ThinkingLevelHolder.get();
|
||||
@ -461,6 +697,11 @@ public class ReasoningNode implements NodeAction {
|
||||
if ("off".equalsIgnoreCase(requestLevel)) {
|
||||
return null;
|
||||
}
|
||||
if (!this.supportsReasoningEffort) {
|
||||
log.debug("[ReasoningNode] Ignoring thinkingLevel='{}' — bound model family does not support reasoning_effort",
|
||||
requestLevel);
|
||||
return null;
|
||||
}
|
||||
// thinkingLevel → reasoningEffort 映射
|
||||
return switch (requestLevel.toLowerCase()) {
|
||||
case "low" -> "low";
|
||||
|
||||
@ -3,12 +3,16 @@ package vip.mate.agent.graph.node;
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatModel;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
@ -101,9 +105,15 @@ public class SummarizingNode implements NodeAction {
|
||||
promptMessages.add(new SystemMessage(SYSTEM_PROMPT));
|
||||
promptMessages.add(new UserMessage(userPrompt));
|
||||
|
||||
// Summarization is mechanical text compression — disable thinking/reasoning to avoid
|
||||
// inheriting the user's thinkingLevel=high from the model's default options.
|
||||
// Without this override, a plain Prompt would inherit extended thinking from chatModel
|
||||
// defaults, causing 100+ second delays for a task that needs no deep reasoning.
|
||||
Prompt summarizePrompt = buildNoThinkingPrompt(promptMessages);
|
||||
|
||||
// 流式调用 LLM,实时推送 content/thinking
|
||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||
chatModel, new Prompt(promptMessages), conversationId, "summarizing");
|
||||
chatModel, summarizePrompt, conversationId, "summarizing");
|
||||
|
||||
// 错误处理:摘要失败时用原始观察的前 500 字符作为 fallback
|
||||
if (result.hasFatalError()) {
|
||||
@ -173,6 +183,9 @@ public class SummarizingNode implements NodeAction {
|
||||
// 摘要的 content 已流式推送,但它不是最终回答,标记防重即可
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
// 把当轮 summary 文本写入 STREAMED_CONTENT,让 StateGraphReActAgent 用 persistOnly
|
||||
// StreamDelta 推给 Accumulator 持久化(用户刷新页面后能看到摘要正文,否则只剩 tool_call 卡片)
|
||||
.streamedContent(summaryContent)
|
||||
.mergeUsage(state, result)
|
||||
// 不设 finishReason — summarizing 不是终止,循环继续
|
||||
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
|
||||
@ -181,6 +194,26 @@ public class SummarizingNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Prompt with thinking/reasoning explicitly disabled.
|
||||
* Summarization is mechanical compression — it never needs extended reasoning,
|
||||
* and inheriting the user's thinkingLevel=high from model defaults wastes 100+ seconds.
|
||||
*/
|
||||
private Prompt buildNoThinkingPrompt(List<Message> messages) {
|
||||
ChatOptions opts;
|
||||
if (chatModel instanceof AnthropicChatModel) {
|
||||
opts = AnthropicChatOptions.builder()
|
||||
.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.DISABLED, 0)
|
||||
.build();
|
||||
} else {
|
||||
// OpenAI / DashScope / other: omit reasoningEffort to disable chain-of-thought
|
||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder().build();
|
||||
oaiOpts.setStreamUsage(true);
|
||||
opts = oaiOpts;
|
||||
}
|
||||
return new Prompt(messages, opts);
|
||||
}
|
||||
|
||||
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
|
||||
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
|
||||
return;
|
||||
|
||||
@ -45,16 +45,28 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
private final PlanningService planningService;
|
||||
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
/** Held only so context-window budget includes the tools schema. Nullable for legacy constructor. */
|
||||
private final vip.mate.agent.AgentToolSet toolSet;
|
||||
|
||||
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph, PlanningService planningService,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
ConversationWindowManager conversationWindowManager) {
|
||||
this(chatClient, conversationService, compiledGraph, planningService,
|
||||
chatModel, conversationWindowManager, null);
|
||||
}
|
||||
|
||||
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph, PlanningService planningService,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
vip.mate.agent.AgentToolSet toolSet) {
|
||||
super(chatClient, conversationService);
|
||||
this.compiledGraph = compiledGraph;
|
||||
this.planningService = planningService;
|
||||
this.chatModel = chatModel;
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.toolSet = toolSet;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -254,7 +266,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
maxInputTokens,
|
||||
chatModel,
|
||||
conversationId,
|
||||
parsedAgentId);
|
||||
parsedAgentId,
|
||||
toolSet != null ? toolSet.callbacks() : null);
|
||||
}
|
||||
|
||||
List<Message> messages = new ArrayList<>(historyMessages);
|
||||
@ -285,6 +298,19 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
// RFC-063r §2.5: same as ReAct path — enrich and store the ChatOrigin
|
||||
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
|
||||
// can read it back from state.
|
||||
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||
Long parsedAgentIdForOrigin = null;
|
||||
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||
if (parsedAgentIdForOrigin != null) {
|
||||
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||
}
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 (2–6 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.md、PROFILE.md、记忆文件当成独立步骤;但如果用户目标明显依赖历史偏好、长期约束、过往决策或持续上下文,可以加入必要的记忆读取步骤。
|
||||
- 不要把技能文件当成独立步骤,除非用户任务明确要求。
|
||||
- 如果用户目标需要调用任何工具才能完成(包括记忆读写、文件操作、搜索、命令执行等),必须返回 needs_planning: true。只有纯知识问答(不需要调用任何工具的简单问题)才返回 needs_planning: false。
|
||||
- 如果无法确定,也必须返回合法 JSON,不能输出自然语言。
|
||||
(C) 多步任务 — 用户目标包含 2 个及以上明显独立、必须先后完成的子任务(例如"先调研 A 再调研 B 然后对比"、
|
||||
"读配置、迁移数据、验证结果")。子任务之间如果可以合并,应当合并。
|
||||
输出:{"needs_planning": true, "steps": ["步骤1", "步骤2", ...]}(2 到 6 个步骤)
|
||||
|
||||
关键原则:
|
||||
- 单工具调用绝对不拆成多步。例:"读 A 文件并总结" 是单步(B),不是两步。
|
||||
- 默认不要把 MEMORY.md / PROFILE.md / 技能文件读取当成独立步骤;仅当用户明确询问偏好、历史决策或长期约束时才加入。
|
||||
- 每个步骤必须是可执行动作,不写"思考一下""确认一下"之类的空话。
|
||||
- 解析不出来时,视作(B) 单步;宁愿单步也不要无脑拆分。
|
||||
""";
|
||||
|
||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||
@ -90,7 +104,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use constructor with full parameters
|
||||
* @deprecated use the full-parameter constructor instead
|
||||
*/
|
||||
@Deprecated
|
||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
|
||||
@ -110,7 +124,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
|
||||
|
||||
// Replay 模式:计划已在 state 中(由 chatWithReplayStream 注入),直接跳过 LLM
|
||||
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
|
||||
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
||||
if (existingPlanId != null) {
|
||||
List<String> existingSteps = accessor.planSteps();
|
||||
@ -128,42 +142,56 @@ public class PlanGenerationNode implements NodeAction {
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建 prompt 消息列表:PLANNING_PROMPT 作为独立 system message,
|
||||
// 不拼接完整 systemPrompt(wiki/技能/记忆指南等与规划决策无关,
|
||||
// 拼接后会稀释 PLANNING_PROMPT 的指令优先级)
|
||||
// PLANNING_PROMPT is the sole system message; we deliberately do NOT
|
||||
// concatenate the agent's full systemPrompt (wiki / skill / memory guidance),
|
||||
// which would dilute the triage instructions.
|
||||
List<Message> promptMessages = new ArrayList<>();
|
||||
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
|
||||
// 注入运行时上下文(当前时间 + 工作目录)
|
||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
|
||||
|
||||
// 注入可用工具名称,帮助 LLM 判断用户目标是否需要工具
|
||||
// Advertise available tools so the LLM can recognize when an action is possible,
|
||||
// but do NOT force "any tool usage implies multi-step" — single-hop tool use
|
||||
// should resolve to a 1-step plan, not a multi-step decomposition.
|
||||
if (toolSet != null && !toolSet.callbacks().isEmpty()) {
|
||||
String toolNames = toolSet.callbacks().stream()
|
||||
.map(cb -> cb.getToolDefinition().name())
|
||||
.collect(Collectors.joining(", "));
|
||||
promptMessages.add(new UserMessage(
|
||||
"你可以使用以下工具:" + toolNames
|
||||
+ "\n如果用户目标需要调用任何工具才能完成,必须返回 needs_planning: true。"));
|
||||
"可用工具:" + toolNames
|
||||
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
||||
}
|
||||
|
||||
// 注入 working context(对话历史摘要),让规划能感知之前对话的约束和补充条件
|
||||
// Inject working context (rolling conversation summary) so triage respects
|
||||
// prior constraints without re-reading full history.
|
||||
String workingContext = accessor.workingContext();
|
||||
if (!workingContext.isEmpty()) {
|
||||
promptMessages.add(new UserMessage(
|
||||
"以下是此前对话中用户提出的约束、说明和上下文,请在规划时充分考虑:\n\n"
|
||||
"以下是此前对话中用户提出的约束、说明和上下文,请在分流时参考:\n\n"
|
||||
+ workingContext));
|
||||
}
|
||||
|
||||
promptMessages.add(new UserMessage("用户目标:" + goal));
|
||||
|
||||
// Append JSON schema hint generated by BeanOutputConverter so the LLM
|
||||
// knows the exact expected structure (replaces hand-written schema in PLANNING_PROMPT).
|
||||
BeanOutputConverter<TriageResult> converter = new BeanOutputConverter<>(TriageResult.class);
|
||||
promptMessages.add(new UserMessage(converter.getFormat()));
|
||||
|
||||
Prompt prompt = new Prompt(promptMessages);
|
||||
|
||||
// 静默流式调用 LLM — 返回结构化 JSON,不直接推送给前端
|
||||
// Broadcast a lightweight progress token so the frontend shows activity
|
||||
// during the silent triage call (typically 1-3 s).
|
||||
if (streamingHelper != null) {
|
||||
streamingHelper.broadcastProgress(conversationId, "分析中...");
|
||||
}
|
||||
|
||||
// Silent streaming call — structured JSON is parsed below; tokens are not forwarded to the client.
|
||||
long triageStartMs = System.currentTimeMillis();
|
||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCallSilent(
|
||||
chatModel, prompt, conversationId, "plan_generation");
|
||||
|
||||
// PTL 处理:压缩后重试
|
||||
// Prompt-too-long handling: compact the conversation window and retry once.
|
||||
if (result.isPromptTooLong() && conversationWindowManager != null) {
|
||||
log.warn("[PlanGeneration] Prompt too long, attempting compaction and retry");
|
||||
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
|
||||
@ -177,23 +205,28 @@ public class PlanGenerationNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
long triageMs = System.currentTimeMillis() - triageStartMs;
|
||||
|
||||
String llmResponse = result.text();
|
||||
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
|
||||
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
|
||||
|
||||
// 清理 markdown 代码块标记
|
||||
String cleanedJson = cleanJsonResponse(llmResponse);
|
||||
// D-6: emit triage perf summary
|
||||
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
|
||||
"triage_ms", triageMs,
|
||||
"prompt_tokens", result.promptTokens(),
|
||||
"completion_tokens", result.completionTokens()
|
||||
)));
|
||||
|
||||
// 解析 JSON
|
||||
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
|
||||
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
|
||||
TriageResult triage = converter.convert(llmResponse);
|
||||
boolean needsPlanning = triage != null && triage.needsPlanning();
|
||||
|
||||
if (!needsPlanning) {
|
||||
// 简单问答快速退出 — 解析出 direct_answer 后手动推送给前端
|
||||
String directAnswer = parsed.get("direct_answer") != null
|
||||
? parsed.get("direct_answer").toString() : llmResponse;
|
||||
log.info("[PlanGeneration] Simple question detected, returning direct answer");
|
||||
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
||||
String directAnswer = triage != null && triage.directAnswer() != null
|
||||
? triage.directAnswer() : llmResponse;
|
||||
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
||||
|
||||
// 手动广播 direct_answer 文本(而不是原始 JSON)
|
||||
streamingHelper.broadcastContent(conversationId, directAnswer);
|
||||
|
||||
return PlanStateAccessor.output()
|
||||
@ -207,27 +240,21 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
// 需要规划:提取步骤
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> steps = (List<String>) parsed.get("steps");
|
||||
// Categories (B) single-step or (C) multi-step: extract steps.
|
||||
List<String> steps = triage != null ? triage.steps() : null;
|
||||
if (steps == null || steps.isEmpty()) {
|
||||
log.warn("[PlanGeneration] LLM returned needs_planning=true but empty steps, falling back to direct answer");
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer(llmResponse)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
.build();
|
||||
// LLM asked for planning but produced no steps — fall back to a
|
||||
// synthetic 1-step plan using the user's goal so the executor
|
||||
// can still reach the tools. (Previous behavior dropped back to
|
||||
// direct_answer, which silently stripped tool capability.)
|
||||
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
|
||||
steps = List.of(goal);
|
||||
}
|
||||
|
||||
// 持久化计划
|
||||
var plan = planningService.createPlan(agentId, goal, steps);
|
||||
log.info("[PlanGeneration] Plan created: id={}, steps={}", plan.getId(), steps.size());
|
||||
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
|
||||
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
|
||||
|
||||
// 发布 plan_created 事件
|
||||
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
||||
|
||||
return PlanStateAccessor.output()
|
||||
@ -244,36 +271,33 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[PlanGeneration] Failed to generate plan: {}", e.getMessage(), e);
|
||||
// 降级:作为简单问答处理,不向前端暴露内部异常细节
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer("抱歉,我暂时无法完成规划,请重试或换一种方式描述任务。")
|
||||
.currentPhase("direct_answer")
|
||||
.events(events)
|
||||
.build();
|
||||
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("抱歉,我暂时无法完成任务分流,请重试或换一种方式描述任务。")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.util.StringUtils;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@ -21,6 +20,7 @@ import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.context.ConversationWindowManager;
|
||||
import vip.mate.agent.context.RuntimeContextInjector;
|
||||
@ -36,7 +36,12 @@ import java.util.Map;
|
||||
* 步骤执行节点
|
||||
* <p>
|
||||
* 执行当前步骤,使用显式工具执行循环(internalToolExecutionEnabled=false)。
|
||||
* 单步最大工具调用次数限制为 5 次,防止无限循环。
|
||||
* 单步最大工具调用次数限制为 {@link #MAX_TOOL_CALLS_PER_STEP} 次,与
|
||||
* {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING} 对齐——因此实际生效的上限
|
||||
* 永远是 agent 的 {@code max_iterations}(DB 列),单步本身不会先于 agent
|
||||
* 的整体预算被打掉。早期 5 次的硬限制对"查新闻 + 整理 Word"这种合理多
|
||||
* 工具任务过紧,被 LimitExceededNode 提前拦截后用户看到的是冷冰冰的
|
||||
* "工具调用次数超出最大限制"。
|
||||
* <p>
|
||||
* 支持 NEEDS_APPROVAL 审批流程:对需要审批的工具调用创建 pending,
|
||||
* 发出 SSE 事件后立即返回审批提示(非阻塞)。审批通过后通过 replay 重新执行。
|
||||
@ -54,8 +59,29 @@ public class StepExecutionNode implements NodeAction {
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
private final String reasoningEffort;
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
private final long stepWallClockTimeoutMs;
|
||||
|
||||
private static final int MAX_TOOL_CALLS_PER_STEP = 5;
|
||||
/**
|
||||
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
|
||||
* Matching the agent-level cap means this constant is never the bottleneck —
|
||||
* the agent's own {@code max_iterations} (DB column) will fire first if a
|
||||
* task is genuinely runaway, and a well-budgeted multi-tool step (e.g.
|
||||
* web_search + browser_navigate + browser_read*N + file_write) is no longer
|
||||
* cut short by an arbitrary 5-call ceiling.
|
||||
*/
|
||||
private static final int MAX_TOOL_CALLS_PER_STEP = 100;
|
||||
|
||||
/**
|
||||
* Wall-clock budget per step, complementing {@link #MAX_TOOL_CALLS_PER_STEP}.
|
||||
* The call-count cap doesn't help when a single LLM stream stalls or a
|
||||
* concurrency-unsafe tool runs synchronously without a per-tool deadline
|
||||
* (the parallel batch path enforces {@code ToolTimeoutProperties}, but the
|
||||
* single-unsafe path in {@code ToolExecutionExecutor#executeSingleTool}
|
||||
* currently does not). 10 minutes is generous for legitimate long steps
|
||||
* (large file edits, multi-page browser flows) while still cutting off the
|
||||
* pathological cases where the agent appears frozen to the user.
|
||||
*/
|
||||
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
||||
@ -64,6 +90,19 @@ public class StepExecutionNode implements NodeAction {
|
||||
ChatStreamTracker streamTracker,
|
||||
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager) {
|
||||
this(chatModel, toolSet, executor, planningService, streamTracker,
|
||||
reasoningEffort, streamingHelper, conversationWindowManager,
|
||||
STEP_WALL_CLOCK_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Test-friendly overload — production callers use the default timeout. */
|
||||
StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
|
||||
ToolExecutionExecutor executor,
|
||||
PlanningService planningService,
|
||||
ChatStreamTracker streamTracker,
|
||||
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
long stepWallClockTimeoutMs) {
|
||||
this.chatModel = chatModel;
|
||||
this.toolSet = toolSet;
|
||||
this.executor = executor;
|
||||
@ -72,6 +111,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.reasoningEffort = reasoningEffort;
|
||||
this.streamingHelper = streamingHelper;
|
||||
this.stepWallClockTimeoutMs = stepWallClockTimeoutMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -86,6 +126,12 @@ public class StepExecutionNode implements NodeAction {
|
||||
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
|
||||
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||
// RFC-063r §2.5: read parent ChatOrigin from graph state so tools in
|
||||
// this step (and any DelegateAgentTool sub-graphs) inherit channel /
|
||||
// workspace / requester context.
|
||||
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||
|
||||
if (stepIndex >= steps.size()) {
|
||||
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
||||
@ -100,6 +146,15 @@ public class StepExecutionNode implements NodeAction {
|
||||
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
|
||||
|
||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||
// Iteration boundary for the plan-execute loop: each step is one
|
||||
// iteration that may itself fan out to multiple LLM calls. Reason is
|
||||
// "plan_step" so consumers can distinguish it from ReAct's
|
||||
// "react_step" / "first_turn" markers when both stream into the
|
||||
// same SSE feed.
|
||||
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||
}
|
||||
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
|
||||
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
|
||||
|
||||
@ -117,21 +172,46 @@ public class StepExecutionNode implements NodeAction {
|
||||
int stepPromptTokens = 0;
|
||||
int stepCompletionTokens = 0;
|
||||
|
||||
// RFC-052: any returnDirect tool that fires inside this step must
|
||||
// short-circuit the entire plan (not just this step). We accumulate
|
||||
// outputs across the inner loop and break out as soon as one appears.
|
||||
List<DirectToolOutput> stepDirectOutputs = new ArrayList<>();
|
||||
|
||||
// Wall-clock budget guards against a single hung LLM stream or
|
||||
// synchronous unsafe-tool call (the per-tool timeout is enforced only
|
||||
// in the parallel batch path of ToolExecutionExecutor). Checked at the
|
||||
// top of each iteration so we never issue another LLM call after the
|
||||
// budget is gone.
|
||||
long stepStartedAtMs = System.currentTimeMillis();
|
||||
boolean wallClockExceeded = false;
|
||||
|
||||
try {
|
||||
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
||||
ChatOptions options;
|
||||
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())
|
||||
.build();
|
||||
if (StringUtils.hasText(reasoningEffort)) {
|
||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.reasoningEffort(reasoningEffort)
|
||||
.build();
|
||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||
options = oaiOpts;
|
||||
} else {
|
||||
options = ToolCallingChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.internalToolExecutionEnabled(false)
|
||||
.build();
|
||||
oaiOpts.setReasoningEffort(reasoningEffort);
|
||||
}
|
||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||
ChatOptions options = oaiOpts;
|
||||
|
||||
if (conversationWindowManager != null) {
|
||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
|
||||
}
|
||||
|
||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||
@ -180,16 +260,23 @@ public class StepExecutionNode implements NodeAction {
|
||||
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
|
||||
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
|
||||
events.add(GraphEventPublisher.toolStart(toolCall.name(), toolCall.arguments()));
|
||||
// RFC-052: pass the directOutputs collector so that an
|
||||
// approved direct tool's full content is captured here
|
||||
// (instead of leaking into the next LLM round).
|
||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||
toolCall, storedArguments, events);
|
||||
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||
stepDirectOutputs);
|
||||
toolResponses.add(response);
|
||||
preApprovedPayload = ""; // 只消费一次
|
||||
} else {
|
||||
// 非预批准工具走正常执行器
|
||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
|
||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||
toolResponses.addAll(execResult.responses());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||
}
|
||||
if (execResult.awaitingApproval()) {
|
||||
approvalTriggered = true;
|
||||
approvalToolName = toolCall.name();
|
||||
@ -200,9 +287,12 @@ public class StepExecutionNode implements NodeAction {
|
||||
} else {
|
||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
|
||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||
toolResponses.addAll(execResult.responses());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||
}
|
||||
if (execResult.awaitingApproval()) {
|
||||
approvalTriggered = true;
|
||||
approvalToolName = execResult.barrierToolName() != null
|
||||
@ -221,6 +311,16 @@ public class StepExecutionNode implements NodeAction {
|
||||
if (approvalTriggered) {
|
||||
break;
|
||||
}
|
||||
|
||||
// RFC-052: returnDirect short-circuit. Any direct tool in this
|
||||
// step ends the plan immediately; the dispatcher routes via
|
||||
// currentPhase=plan_aborted so no further LLM call happens.
|
||||
if (!stepDirectOutputs.isEmpty()) {
|
||||
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
|
||||
"tool output(s); aborting plan execution",
|
||||
stepIndex, stepDirectOutputs.size());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理审批暂停
|
||||
@ -239,9 +339,52 @@ 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) {
|
||||
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
|
||||
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
|
||||
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) {
|
||||
@ -250,6 +393,10 @@ public class StepExecutionNode implements NodeAction {
|
||||
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
|
||||
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
|
||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||
shortError != null ? shortError.length() : 0, 0));
|
||||
}
|
||||
return PlanStateAccessor.output()
|
||||
.currentStepResult(shortError)
|
||||
.currentPhase("plan_aborted")
|
||||
@ -262,15 +409,32 @@ public class StepExecutionNode implements NodeAction {
|
||||
|
||||
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
|
||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||
finalResult != null ? finalResult.length() : 0,
|
||||
stepThinking != null ? stepThinking.length() : 0));
|
||||
}
|
||||
|
||||
log.info("[StepExecution] Step {}/{} completed: {}",
|
||||
stepIndex + 1, steps.size(),
|
||||
finalResult.length() > 100 ? finalResult.substring(0, 100) + "..." : finalResult);
|
||||
|
||||
// 更新 working context:将最新完成的步骤结果纳入摘要
|
||||
List<String> allCompleted = new ArrayList<>(accessor.completedResults());
|
||||
allCompleted.add(formatStepResult(stepIndex, finalResult));
|
||||
String updatedWorkingContext = rebuildWorkingContext(accessor, allCompleted);
|
||||
// RFC-008 P4.2: incremental working-context update.
|
||||
// Previous behavior rebuilt the entire context from history + every
|
||||
// completed result on every step (O(N) per step). On long plans this
|
||||
// re-walks the same conversation history each iteration. Now we take
|
||||
// the previous context as-is (which already encodes earlier history
|
||||
// and earlier completed steps) and append just the freshly-completed
|
||||
// step, then trim from the head if the running total exceeds the cap.
|
||||
// For first-step calls where prior context is empty, fall through to
|
||||
// the original rebuild path so the conversation history seed is still
|
||||
// captured.
|
||||
String prevWorkingContext = accessor.workingContext();
|
||||
String formattedNewStep = formatStepResult(stepIndex, finalResult);
|
||||
String updatedWorkingContext = prevWorkingContext.isEmpty()
|
||||
? rebuildWorkingContext(accessor,
|
||||
appendOne(accessor.completedResults(), formattedNewStep))
|
||||
: appendStepIncremental(prevWorkingContext, formattedNewStep);
|
||||
|
||||
return PlanStateAccessor.output()
|
||||
.currentStepResult(finalResult)
|
||||
@ -287,6 +451,26 @@ public class StepExecutionNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-052: assemble the final answer text from direct tool outputs in this
|
||||
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
|
||||
* sees the same shape regardless of which graph (ReAct / Plan-Execute)
|
||||
* produced the answer.
|
||||
*/
|
||||
private static String assembleDirectAnswerText(List<DirectToolOutput> outputs) {
|
||||
if (outputs.size() == 1) {
|
||||
return outputs.get(0).fullResult();
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
DirectToolOutput out = outputs.get(i);
|
||||
if (i > 0) sb.append("\n\n");
|
||||
sb.append("### ").append(out.toolName()).append("\n");
|
||||
sb.append(out.fullResult());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
|
||||
List<Message> messages = new ArrayList<>();
|
||||
|
||||
@ -414,9 +598,51 @@ public class StepExecutionNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/** Append helper used by the incremental working-context fast path. */
|
||||
private static List<String> appendOne(List<String> previous, String item) {
|
||||
List<String> out = new ArrayList<>(previous);
|
||||
out.add(item);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前 accessor 中的会话历史消息和更新后的已完成步骤结果,
|
||||
* 重建 working context。复用与 StateGraphPlanExecuteAgent.buildWorkingContext 相同的逻辑。
|
||||
* Incrementally extend the previous working context with one new step
|
||||
* result. Cheap O(1) path used for steps 2..N: avoids walking the full
|
||||
* conversation history again. The result is trimmed from the head if it
|
||||
* exceeds the same overall cap that {@link #rebuildWorkingContext}
|
||||
* enforces, so the budget invariant is preserved.
|
||||
*
|
||||
* <p>Per-step truncation: a single step result longer than 800 chars is
|
||||
* abbreviated before append, mirroring the per-step caps in
|
||||
* {@code rebuildWorkingContext}.</p>
|
||||
*/
|
||||
private static String appendStepIncremental(String previousContext, String formattedStepResult) {
|
||||
final int OVERALL_CAP = 6000;
|
||||
final int PER_STEP_CAP = 800;
|
||||
String stepLine = formattedStepResult.length() > PER_STEP_CAP
|
||||
? formattedStepResult.substring(0, PER_STEP_CAP) + "…"
|
||||
: formattedStepResult;
|
||||
String combined = previousContext + "\n" + stepLine + "\n";
|
||||
if (combined.length() <= OVERALL_CAP) {
|
||||
return combined;
|
||||
}
|
||||
// Drop oldest content from the head until we fit. Cut on a newline
|
||||
// boundary so we don't truncate mid-line.
|
||||
int overshoot = combined.length() - OVERALL_CAP;
|
||||
int cutFrom = combined.indexOf('\n', overshoot);
|
||||
if (cutFrom < 0 || cutFrom >= combined.length() - 1) {
|
||||
cutFrom = overshoot;
|
||||
} else {
|
||||
cutFrom += 1; // skip the newline itself
|
||||
}
|
||||
return "…(earlier context truncated)\n" + combined.substring(cutFrom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full rebuild of working context from conversation history plus all
|
||||
* completed step results. Reused on the cold path (first step, or when
|
||||
* the incremental path can't be applied). Mirrors
|
||||
* {@code StateGraphPlanExecuteAgent.buildWorkingContext}.
|
||||
*/
|
||||
private static String rebuildWorkingContext(PlanStateAccessor accessor, List<String> allCompletedResults) {
|
||||
List<Message> messages = accessor.messages();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ===== 步骤控制 =====
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
|
||||
import java.util.*;
|
||||
@ -195,12 +196,38 @@ public final class MateClawStateAccessor {
|
||||
return state.value(AWAITING_APPROVAL, false);
|
||||
}
|
||||
|
||||
// ===== RFC-052: returnDirect =====
|
||||
|
||||
public boolean returnDirectTriggered() {
|
||||
return state.value(RETURN_DIRECT_TRIGGERED, false);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<DirectToolOutput> directToolOutputs() {
|
||||
return state.<List<DirectToolOutput>>value(DIRECT_TOOL_OUTPUTS).orElse(List.of());
|
||||
}
|
||||
|
||||
public SourceEvidenceLedger sourceEvidenceLedger() {
|
||||
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
|
||||
}
|
||||
|
||||
// ===== 审批重放 =====
|
||||
|
||||
public String forcedToolCall() {
|
||||
return state.value(FORCED_TOOL_CALL, "");
|
||||
}
|
||||
|
||||
// ===== RFC-063r: ChatOrigin =====
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: the {@link ChatOrigin} written into graph state by the
|
||||
* top-level agent. Returns {@link ChatOrigin#EMPTY} when the entry path
|
||||
* did not supply one (e.g., legacy callers using the bridge overloads).
|
||||
*/
|
||||
public ChatOrigin chatOrigin() {
|
||||
return state.<ChatOrigin>value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
// ===== Token Usage =====
|
||||
|
||||
public int promptTokens() {
|
||||
@ -373,11 +400,29 @@ public final class MateClawStateAccessor {
|
||||
return put(AWAITING_APPROVAL, awaiting);
|
||||
}
|
||||
|
||||
// ---- RFC-052: returnDirect ----
|
||||
public OutputBuilder returnDirectTriggered(boolean triggered) {
|
||||
return put(RETURN_DIRECT_TRIGGERED, triggered);
|
||||
}
|
||||
|
||||
public OutputBuilder directToolOutputs(List<DirectToolOutput> outputs) {
|
||||
return put(DIRECT_TOOL_OUTPUTS, outputs);
|
||||
}
|
||||
|
||||
public OutputBuilder sourceEvidenceLedger(SourceEvidenceLedger ledger) {
|
||||
return put(SOURCE_EVIDENCE_LEDGER, ledger);
|
||||
}
|
||||
|
||||
// ---- 审批重放 ----
|
||||
public OutputBuilder forcedToolCall(String json) {
|
||||
return put(FORCED_TOOL_CALL, json);
|
||||
}
|
||||
|
||||
// ---- RFC-063r: ChatOrigin ----
|
||||
public OutputBuilder chatOrigin(ChatOrigin origin) {
|
||||
return put(CHAT_ORIGIN, origin);
|
||||
}
|
||||
|
||||
// ---- Token Usage ----
|
||||
|
||||
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
||||
|
||||
@ -140,4 +140,34 @@ public final class MateClawStateKeys {
|
||||
// ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)=====
|
||||
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
|
||||
public static final String RUNTIME_PROVIDER_ID = "runtime_provider_id";
|
||||
|
||||
// ===== RFC-052: Tool returnDirect 与数据隔离 =====
|
||||
|
||||
/**
|
||||
* RFC-052: when true the latest tool batch contained at least one tool
|
||||
* declared as returnDirect, so the graph must short-circuit to
|
||||
* {@link #FINAL_ANSWER_NODE} without re-entering the LLM.
|
||||
*/
|
||||
public static final String RETURN_DIRECT_TRIGGERED = "return_direct_triggered";
|
||||
|
||||
/**
|
||||
* RFC-052: list of {@code DirectToolOutput} accumulated from the most recent
|
||||
* tool batch, used by FinalAnswerNode to assemble the final answer.
|
||||
*/
|
||||
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
|
||||
|
||||
/** Source references observed from successful tool results during this run. */
|
||||
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
|
||||
|
||||
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: top-level agent writes the {@code ChatOrigin} value object
|
||||
* into graph state once at {@code buildInitialState}; nodes (especially
|
||||
* {@code StepExecutionNode} in the Plan-Execute sub-graph) read it
|
||||
* read-only when invoking {@link vip.mate.agent.graph.executor.ToolExecutionExecutor}
|
||||
* so child graphs and delegated agents inherit the originating channel /
|
||||
* workspace context.
|
||||
*/
|
||||
public static final String CHAT_ORIGIN = "chat_origin";
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 / max,null 表示跟随模型默认 */
|
||||
private String defaultThinkingLevel;
|
||||
|
||||
@ -61,6 +84,5 @@ public class AgentEntity {
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
|
||||
@ -21,6 +21,13 @@ public class TemplateDTO {
|
||||
private String agentType;
|
||||
private String tags;
|
||||
private Integer maxIterations;
|
||||
/**
|
||||
* Optional pre-rendered system prompt seeded into the new agent. Templates
|
||||
* use H2 sections (## Role / ## Goal / ## Backstory / ## Additional
|
||||
* Instructions) so the editor UI can split the prompt into structured
|
||||
* fields and derive a one-line tagline for the agent card.
|
||||
*/
|
||||
private String systemPrompt;
|
||||
private List<WorkspaceFileTemplate> workspaceFiles;
|
||||
|
||||
@Data
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
package vip.mate.agent.runtime;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.delegation.SubagentRegistry;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry})
|
||||
* with agent metadata so the admin Backstage UI can render one card per
|
||||
* working agent without making the frontend traverse three independent
|
||||
* services.
|
||||
*
|
||||
* <p>The "stuck" verdict is computed here rather than persisted on
|
||||
* {@link RunSnapshot} so the thresholds can be tuned at runtime without
|
||||
* touching every producer.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentRuntimeAggregator {
|
||||
|
||||
/**
|
||||
* Idle threshold: a run with no events for this long while NOT inside a
|
||||
* tool call is treated as wedged. Aligns with the upstream cli reference
|
||||
* (5 × 30s heartbeat cycles) so pre-token latency does not false-alarm.
|
||||
*/
|
||||
private static final long STUCK_IDLE_MS = 150_000L;
|
||||
|
||||
/**
|
||||
* In-tool threshold: a run with a {@code runningToolName} but no events
|
||||
* for this long. Looser than idle because slow tool calls (LLM-backed
|
||||
* tools, long-running shell commands) routinely sit silent for minutes.
|
||||
*/
|
||||
private static final long STUCK_TOOL_MS = 600_000L;
|
||||
|
||||
/**
|
||||
* Hard cap regardless of activity. A run older than this is suspicious
|
||||
* even when the bytes are flowing: the user has likely walked away and
|
||||
* the model is in a feedback loop.
|
||||
*/
|
||||
private static final long STUCK_HARD_CAP_MS = 1_800_000L;
|
||||
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final SubagentRegistry subagentRegistry;
|
||||
private final AgentService agentService;
|
||||
|
||||
/** One in-flight run, enriched with agent label and stuck verdict. */
|
||||
public record RunCard(
|
||||
String conversationId,
|
||||
Long agentId,
|
||||
String agentName,
|
||||
String agentIcon,
|
||||
String username,
|
||||
String currentPhase,
|
||||
String runningToolName,
|
||||
String waitingReason,
|
||||
boolean done,
|
||||
boolean stopRequested,
|
||||
boolean firstTokenReceived,
|
||||
int subscriberCount,
|
||||
int queueLen,
|
||||
long ageMs,
|
||||
long msSinceLastEvent,
|
||||
String stuckReason,
|
||||
boolean orphan,
|
||||
int subagentCount
|
||||
) {}
|
||||
|
||||
/** One sub-agent under a parent run, ready for tree rendering. */
|
||||
public record SubagentCard(
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
Long agentId,
|
||||
String agentName,
|
||||
String agentIcon,
|
||||
String goal,
|
||||
String status,
|
||||
String currentPhase,
|
||||
String lastTool,
|
||||
int toolCount,
|
||||
long ageMs
|
||||
) {}
|
||||
|
||||
/** Top-level summary used to drive the breathing sidebar dot. */
|
||||
public record Summary(
|
||||
int running,
|
||||
int stuck,
|
||||
int orphan,
|
||||
int queued,
|
||||
int subagentsActive
|
||||
) {}
|
||||
|
||||
/** Full snapshot envelope returned to the admin UI. */
|
||||
public record RuntimeSnapshot(
|
||||
Summary summary,
|
||||
List<RunCard> runs,
|
||||
List<SubagentCard> subagents,
|
||||
long timestamp
|
||||
) {}
|
||||
|
||||
public RuntimeSnapshot snapshot() {
|
||||
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
||||
Set<Long> agentIds = rawRuns.stream()
|
||||
.map(RunSnapshot::agentId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
for (var rec : subagentRegistry.allActive()) {
|
||||
if (rec.agentId() != null) agentIds.add(rec.agentId());
|
||||
}
|
||||
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
|
||||
|
||||
Map<String, Long> subagentCountByParent = new HashMap<>();
|
||||
for (var rec : subagentRegistry.allActive()) {
|
||||
String parent = rec.parentConversationId();
|
||||
if (parent != null) {
|
||||
subagentCountByParent.merge(parent, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
List<RunCard> cards = new ArrayList<>(rawRuns.size());
|
||||
int stuckCount = 0;
|
||||
int orphanCount = 0;
|
||||
int queuedTotal = 0;
|
||||
int runningCount = 0;
|
||||
for (RunSnapshot s : rawRuns) {
|
||||
if (s.done()) continue;
|
||||
runningCount++;
|
||||
String stuckReason = computeStuckReason(s);
|
||||
boolean orphan = s.subscriberCount() == 0;
|
||||
if (stuckReason != null) stuckCount++;
|
||||
if (orphan) orphanCount++;
|
||||
queuedTotal += s.queueLen();
|
||||
int subCount = subagentCountByParent.getOrDefault(s.conversationId(), 0L).intValue();
|
||||
AgentEntity ag = s.agentId() == null ? null : agentInfo.get(s.agentId());
|
||||
cards.add(new RunCard(
|
||||
s.conversationId(),
|
||||
s.agentId(),
|
||||
ag == null ? null : ag.getName(),
|
||||
ag == null ? null : ag.getIcon(),
|
||||
s.username(),
|
||||
s.currentPhase(),
|
||||
s.runningToolName(),
|
||||
s.waitingReason(),
|
||||
s.done(),
|
||||
s.stopRequested(),
|
||||
s.firstTokenReceived(),
|
||||
s.subscriberCount(),
|
||||
s.queueLen(),
|
||||
s.ageMs(),
|
||||
s.msSinceLastEvent(),
|
||||
stuckReason,
|
||||
orphan,
|
||||
subCount
|
||||
));
|
||||
}
|
||||
// Sort: stuck first (loudest first), then orphan, then by lastEventAt asc
|
||||
cards.sort((a, b) -> {
|
||||
int aStuck = a.stuckReason() != null ? 1 : 0;
|
||||
int bStuck = b.stuckReason() != null ? 1 : 0;
|
||||
if (aStuck != bStuck) return bStuck - aStuck;
|
||||
int aOrph = a.orphan() ? 1 : 0;
|
||||
int bOrph = b.orphan() ? 1 : 0;
|
||||
if (aOrph != bOrph) return bOrph - aOrph;
|
||||
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
|
||||
});
|
||||
|
||||
List<SubagentCard> subCards = subagentRegistry.allActive().stream()
|
||||
.map(rec -> {
|
||||
long now = System.currentTimeMillis();
|
||||
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
|
||||
return new SubagentCard(
|
||||
rec.subagentId(),
|
||||
rec.parentConversationId(),
|
||||
rec.childConversationId(),
|
||||
rec.agentId(),
|
||||
ag == null ? null : ag.getName(),
|
||||
ag == null ? null : ag.getIcon(),
|
||||
rec.goal(),
|
||||
rec.status() != null ? rec.status().get() : null,
|
||||
rec.currentPhase() != null ? rec.currentPhase().get() : null,
|
||||
rec.lastTool() != null ? rec.lastTool().get() : null,
|
||||
rec.toolCount() != null ? rec.toolCount().get() : 0,
|
||||
now - rec.startedAt()
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
Summary summary = new Summary(
|
||||
runningCount,
|
||||
stuckCount,
|
||||
orphanCount,
|
||||
queuedTotal,
|
||||
subCards.size()
|
||||
);
|
||||
|
||||
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when the run looks healthy. The returned tag is a stable
|
||||
* machine-readable code (not a translated label) so the frontend can
|
||||
* decide presentation: {@code idle_silent} / {@code tool_silent} /
|
||||
* {@code hard_cap}.
|
||||
*/
|
||||
private String computeStuckReason(RunSnapshot s) {
|
||||
if (s.ageMs() > STUCK_HARD_CAP_MS) return "hard_cap";
|
||||
boolean inTool = s.runningToolName() != null && !s.runningToolName().isBlank();
|
||||
long since = s.msSinceLastEvent();
|
||||
if (inTool && since > STUCK_TOOL_MS) return "tool_silent";
|
||||
if (!inTool && since > STUCK_IDLE_MS) return "idle_silent";
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<Long, AgentEntity> resolveAgents(Set<Long> ids) {
|
||||
Map<Long, AgentEntity> out = new LinkedHashMap<>();
|
||||
for (Long id : ids) {
|
||||
if (id == null) continue;
|
||||
try {
|
||||
AgentEntity a = agentService.getAgent(id);
|
||||
if (a != null) out.put(id, a);
|
||||
} catch (Exception e) {
|
||||
log.debug("agent lookup failed for id={}: {}", id, e.getMessage());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
package vip.mate.agent.runtime;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.agent.delegation.SubagentRegistry;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.i18n.I18nService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Admin-only Backstage surface: the global view of every in-flight agent
|
||||
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
|
||||
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
|
||||
* owner-scoped — this controller is intentionally cross-tenant for the
|
||||
* operator role.
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Agent Runtime (Backstage)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/agent-runtime")
|
||||
@RequiredArgsConstructor
|
||||
public class AgentRuntimeController {
|
||||
|
||||
private final AgentRuntimeAggregator aggregator;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final SubagentRegistry subagentRegistry;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ConversationService conversationService;
|
||||
private final I18nService i18nService;
|
||||
|
||||
@Operation(summary = "Snapshot of every in-flight agent turn")
|
||||
@GetMapping("/snapshot")
|
||||
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
return R.ok(aggregator.snapshot());
|
||||
}
|
||||
|
||||
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
|
||||
@PostMapping("/runs/{conversationId}/stop")
|
||||
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
boolean ok = streamTracker.requestStop(conversationId);
|
||||
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
|
||||
return R.ok(Map.of("stopped", ok));
|
||||
}
|
||||
|
||||
@Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored")
|
||||
@PostMapping("/runs/{conversationId}/recycle")
|
||||
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
boolean ok = streamTracker.forceRecycle(conversationId);
|
||||
if (ok) {
|
||||
finalizeRecycledConversation(conversationId);
|
||||
}
|
||||
recordAudit(auth, "agent-runtime.recycle", conversationId, Map.of("result", ok));
|
||||
return R.ok(Map.of("recycled", ok));
|
||||
}
|
||||
|
||||
@Operation(summary = "Interrupt one sub-agent (admin override of ownership check)")
|
||||
@PostMapping("/subagents/{subagentId}/interrupt")
|
||||
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
boolean ok = subagentRegistry.interrupt(subagentId);
|
||||
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
|
||||
return R.ok(Map.of("interrupted", ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk recycle every run that the aggregator currently flags as stuck.
|
||||
* Returns the conversationIds that were touched so the caller can render
|
||||
* a confirmation toast without re-fetching.
|
||||
*/
|
||||
@Operation(summary = "Recycle every run currently flagged as stuck")
|
||||
@PostMapping("/sweep")
|
||||
public R<Map<String, Object>> sweep(Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
|
||||
List<String> ids = snap.runs().stream()
|
||||
.filter(r -> r.stuckReason() != null)
|
||||
.map(AgentRuntimeAggregator.RunCard::conversationId)
|
||||
.toList();
|
||||
int recycled = 0;
|
||||
for (String cid : ids) {
|
||||
if (streamTracker.forceRecycle(cid)) {
|
||||
recycled++;
|
||||
finalizeRecycledConversation(cid);
|
||||
}
|
||||
}
|
||||
recordAudit(auth, "agent-runtime.sweep", "all",
|
||||
Map.of("targets", ids, "recycled", recycled));
|
||||
return R.ok(Map.of("recycled", recycled, "ids", ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Common DB-side cleanup after a successful {@code forceRecycle}:
|
||||
* <ol>
|
||||
* <li>Flip {@code stream_status} off 'running' so the sidebar drops the
|
||||
* 生成中 badge immediately. (The late doOnCancel / doOnComplete may
|
||||
* re-set this to 'idle' when the agent finally yields — same value,
|
||||
* no-op.)</li>
|
||||
* <li>If the conversation's last message is still a user turn — i.e.
|
||||
* the agent was disposed before any text streamed and the
|
||||
* emergencySaveCallback found nothing to persist — write a
|
||||
* "已被用户中止" assistant marker so the UI shows what happened
|
||||
* instead of a blank reply.</li>
|
||||
* </ol>
|
||||
*/
|
||||
private void finalizeRecycledConversation(String conversationId) {
|
||||
try {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
} catch (Exception e) {
|
||||
log.warn("recycle: failed to reset stream_status for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
}
|
||||
try {
|
||||
conversationService.saveStopMarkerIfDangling(
|
||||
conversationId, i18nService.msg("chat.stopMarker.userAborted"), "stopped");
|
||||
} catch (Exception e) {
|
||||
log.warn("recycle: failed to save stop marker for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdmin(Authentication auth) {
|
||||
if (auth == null) {
|
||||
throw new MateClawException(401, "authentication required");
|
||||
}
|
||||
boolean isAdmin = auth.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch("ROLE_ADMIN"::equals);
|
||||
if (!isAdmin) {
|
||||
throw new MateClawException(403, "admin role required");
|
||||
}
|
||||
}
|
||||
|
||||
private void recordAudit(Authentication auth, String action,
|
||||
String resourceId, Map<String, Object> detail) {
|
||||
try {
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("by", username);
|
||||
payload.putAll(detail);
|
||||
auditEventService.record(action, "agent-runtime", resourceId, resourceId,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload));
|
||||
} catch (Exception e) {
|
||||
log.warn("audit serialization failed for {}: {}", action, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,26 +63,54 @@ public class TemplateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用模板创建 Agent 及其工作区文件
|
||||
*
|
||||
* @param templateId 模板 ID
|
||||
* @return 创建的 AgentEntity
|
||||
* Backwards-compatible overload that defaults to the template's English
|
||||
* display strings (existing callers without locale context).
|
||||
*/
|
||||
@Transactional
|
||||
public AgentEntity applyTemplate(String templateId) {
|
||||
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId) {
|
||||
return applyTemplate(templateId, workspaceId, creatorUserId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a template, picking name/description in the caller's preferred
|
||||
* language so the resulting agent reads natively in their locale. Falls
|
||||
* back to the template's primary (English) fields when the localized
|
||||
* variant is missing or no locale was supplied.
|
||||
*
|
||||
* @param templateId template ID
|
||||
* @param workspaceId target workspace ID (from X-Workspace-Id header)
|
||||
* @param creatorUserId current user ID (creator attribution)
|
||||
* @param acceptLanguage raw Accept-Language header; null/blank → English
|
||||
*/
|
||||
@Transactional
|
||||
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId, String acceptLanguage) {
|
||||
TemplateDTO template = listTemplates().stream()
|
||||
.filter(t -> t.getId().equals(templateId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new MateClawException("err.agent.template_not_found", "模板不存在: " + templateId));
|
||||
|
||||
// 1. 创建 Agent
|
||||
boolean preferZh = isChineseLocale(acceptLanguage);
|
||||
String displayName = preferZh && template.getNameZh() != null && !template.getNameZh().isBlank()
|
||||
? template.getNameZh()
|
||||
: template.getName();
|
||||
String displayDesc = preferZh && template.getDescriptionZh() != null && !template.getDescriptionZh().isBlank()
|
||||
? template.getDescriptionZh()
|
||||
: template.getDescription();
|
||||
|
||||
// 1. Create the Agent. workspaceId/creatorUserId are passed in
|
||||
// explicitly so the DB default does not silently fall back to 1.
|
||||
AgentEntity agent = new AgentEntity();
|
||||
agent.setName(template.getName());
|
||||
agent.setDescription(template.getDescription());
|
||||
agent.setName(displayName);
|
||||
agent.setDescription(displayDesc);
|
||||
agent.setAgentType(template.getAgentType());
|
||||
agent.setIcon(template.getIcon());
|
||||
agent.setTags(template.getTags());
|
||||
agent.setMaxIterations(template.getMaxIterations());
|
||||
if (template.getSystemPrompt() != null && !template.getSystemPrompt().isBlank()) {
|
||||
agent.setSystemPrompt(template.getSystemPrompt());
|
||||
}
|
||||
agent.setWorkspaceId(workspaceId);
|
||||
agent.setCreatorUserId(creatorUserId);
|
||||
AgentEntity created = agentService.createAgent(agent);
|
||||
|
||||
// 2. 创建工作区文件
|
||||
@ -109,4 +137,15 @@ public class TemplateService {
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the raw Accept-Language header best-matches a Chinese locale.
|
||||
* Implementation is intentionally simple — we only need to disambiguate
|
||||
* "Chinese vs not" for picking nameZh / descriptionZh.
|
||||
*/
|
||||
private boolean isChineseLocale(String acceptLanguage) {
|
||||
if (acceptLanguage == null || acceptLanguage.isBlank()) return false;
|
||||
String first = acceptLanguage.split(",")[0].trim().toLowerCase();
|
||||
return first.startsWith("zh");
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,10 @@ package vip.mate.approval;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
@ -15,10 +13,16 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工具执行审批接口
|
||||
* Approval read-only endpoints.
|
||||
* <p>
|
||||
* 提供 approve / deny 端点,供前端在收到 tool_approval_requested SSE 事件后调用。
|
||||
* 批准后自动触发工具重放,结果通过 SSE 流推送给前端。
|
||||
* Web approve / deny actions ride the SSE {@code POST /chat/stream} path with
|
||||
* {@code /approve} or {@code /deny} text commands ({@link vip.mate.channel.web.ChatController}
|
||||
* intercepts), so a write-style {@code POST /approve} REST endpoint was deleted
|
||||
* in RFC-067 PR 6 — it bypassed the unified workflow lifecycle and let any
|
||||
* future caller silently regress to the pre-RFC ghost-approval state.
|
||||
* <p>
|
||||
* Only {@link #getPendingApprovals} remains, used by the frontend for hydration
|
||||
* after page refresh.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -29,72 +33,13 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalController {
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
private final ApprovalWorkflowService approvalService;
|
||||
private final ConversationService conversationService;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
|
||||
/**
|
||||
* 批准或拒绝工具执行
|
||||
* <p>
|
||||
* 批准后自动触发工具重放(异步执行),结果通过已有的 SSE 连接推送给前端。
|
||||
*/
|
||||
@Operation(summary = "审批工具执行")
|
||||
@PostMapping("/{conversationId}/approve")
|
||||
public R<String> approve(
|
||||
@PathVariable String conversationId,
|
||||
@RequestBody ApprovalRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
if (auth == null) {
|
||||
return R.fail(401, "未登录,请先登录");
|
||||
}
|
||||
String username = auth.getName();
|
||||
|
||||
// 校验会话归属
|
||||
if (!conversationService.isConversationOwner(conversationId, username)) {
|
||||
log.warn("[Approval] Unauthorized: user={} is not owner of conversation={}", username, conversationId);
|
||||
return R.fail(403, "无权操作该会话");
|
||||
}
|
||||
|
||||
// 校验 pendingId
|
||||
if (request.getPendingId() == null || request.getPendingId().isBlank()) {
|
||||
return R.fail("pendingId 不能为空");
|
||||
}
|
||||
|
||||
// 校验 decision
|
||||
String decision = request.getDecision();
|
||||
if (decision == null || (!decision.equalsIgnoreCase("approved") && !decision.equalsIgnoreCase("denied"))) {
|
||||
return R.fail("decision 必须为 approved 或 denied");
|
||||
}
|
||||
|
||||
try {
|
||||
approvalService.resolve(request.getPendingId(), username, decision);
|
||||
log.info("[Approval] User {} {} pending {} for conversation {}",
|
||||
username, decision, request.getPendingId(), conversationId);
|
||||
|
||||
// Web 端的 replay 由前端发送 /approve 消息到 POST /stream 触发(ChatController 拦截)
|
||||
// 此端点只更新审批状态,保留给 IM 渠道(DingTalk/Feishu 等通过 ChannelMessageRouter 调用)
|
||||
|
||||
// 拒绝时通过 SSE 通知前端(如果流还活着)
|
||||
if ("denied".equalsIgnoreCase(decision) && streamTracker.isRunning(conversationId)) {
|
||||
streamTracker.broadcastObject(conversationId, "tool_approval_resolved", Map.of(
|
||||
"pendingId", request.getPendingId(),
|
||||
"decision", "denied",
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
|
||||
return R.ok("操作成功");
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[Approval] Resolve failed: {}", e.getMessage());
|
||||
return R.fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定会话下的待审批记录
|
||||
* <p>
|
||||
* 用于页面刷新后恢复审批卡片(hydration)。
|
||||
* Hydration query for page refresh: returns every pending approval still
|
||||
* waiting in the conversation. The frontend uses this to rebuild the
|
||||
* approval banner after a reload.
|
||||
*/
|
||||
@Operation(summary = "查询待审批记录")
|
||||
@GetMapping("/{conversationId}/pending-approvals")
|
||||
@ -114,11 +59,4 @@ public class ApprovalController {
|
||||
List<Map<String, Object>> pending = approvalService.getPendingByConversation(conversationId);
|
||||
return R.ok(pending);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ApprovalRequest {
|
||||
private String pendingId;
|
||||
/** "approved" 或 "denied" */
|
||||
private String decision;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
package vip.mate.approval;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@ -9,21 +7,26 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 工具执行审批服务(消息驱动版 — 非阻塞)
|
||||
* In-memory approval store (RFC-067).
|
||||
* <p>
|
||||
* 核心变化:不再阻塞线程等待审批。
|
||||
* INTERNAL — do not call mutating methods directly. Business code must go through
|
||||
* {@link ApprovalWorkflowService}, which owns the DB / message-metadata / memory
|
||||
* three-way state machine. This class only exposes:
|
||||
* <ul>
|
||||
* <li>{@link #createPending} 创建待审批记录后立即返回</li>
|
||||
* <li>{@link #resolve} 更新状态为 approved/denied</li>
|
||||
* <li>{@link #findPendingByConversation} 查找会话最早的 pending(FIFO)</li>
|
||||
* <li>{@link #consumeApproved} 一次性消费已批准记录供重放</li>
|
||||
* <li>{@link #garbageCollect} 定时清理过期记录</li>
|
||||
* <li>{@link #createPending} — used by tool guard to register a new approval;
|
||||
* paired with {@link ApprovalWorkflowService#createPending} for DB persistence</li>
|
||||
* <li>read-only queries: {@link #getPending}, {@link #findPendingByConversation},
|
||||
* {@link #getPendingByConversation}</li>
|
||||
* <li>package-private snapshot / mutate helpers consumed by {@link ApprovalWorkflowService}
|
||||
* (recovery, GC, two-phase resolve)</li>
|
||||
* </ul>
|
||||
* Public mutating methods (resolve / resolveAndConsume / consumeApproved /
|
||||
* cancelStalePending / denyAllByConversation) were removed in PR-4 once all
|
||||
* callers migrated to the workflow service. Reintroducing them is a regression —
|
||||
* they bypass DB and message-metadata writes, which is the original ghost-approval
|
||||
* source.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -33,31 +36,13 @@ public class ApprovalService {
|
||||
|
||||
private final ConcurrentHashMap<String, PendingApproval> pendingMap = new ConcurrentHashMap<>();
|
||||
|
||||
/** GC 常量 */
|
||||
private static final Duration PENDING_TTL = Duration.ofMinutes(30);
|
||||
private static final Duration RESOLVED_TTL = Duration.ofHours(1);
|
||||
private static final int MAX_PENDING = 200;
|
||||
private static final int MAX_RESOLVED = 500;
|
||||
|
||||
private ScheduledExecutorService gcScheduler;
|
||||
|
||||
@PostConstruct
|
||||
void initGc() {
|
||||
gcScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "approval-gc");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
gcScheduler.scheduleAtFixedRate(this::garbageCollect, 5, 5, TimeUnit.MINUTES);
|
||||
log.info("[Approval] GC scheduler started (interval=5min)");
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdownGc() {
|
||||
if (gcScheduler != null) {
|
||||
gcScheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
/** GC constants. Package-visible so {@link ApprovalWorkflowService}'s GC loop
|
||||
* (RFC-067 §4.4) can apply the same TTL / cap thresholds while owning the
|
||||
* scheduler clock + DB+metadata sync. */
|
||||
static final Duration PENDING_TTL = Duration.ofMinutes(30);
|
||||
static final Duration RESOLVED_TTL = Duration.ofHours(1);
|
||||
static final int MAX_PENDING = 200;
|
||||
static final int MAX_RESOLVED = 500;
|
||||
|
||||
// ==================== 创建 ====================
|
||||
|
||||
@ -93,31 +78,41 @@ public class ApprovalService {
|
||||
return pendingId;
|
||||
}
|
||||
|
||||
// ==================== 解决 ====================
|
||||
/**
|
||||
* INTERNAL — drop a pending entry from the map without changing its status.
|
||||
* Used by {@link ApprovalWorkflowService} as the final memory-mutation step
|
||||
* after DB + metadata writes commit. Status is mutated separately by the
|
||||
* caller so consume / resolve flows can keep the {@code consumed} /
|
||||
* {@code resolved} terminal state visible on the snapshot they return.
|
||||
* <p>
|
||||
* Only {@code ApprovalWorkflowService} should call this.
|
||||
*/
|
||||
void removeFromMap(String pendingId) {
|
||||
if (pendingId == null) return;
|
||||
pendingMap.remove(pendingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解决审批(批准或拒绝)
|
||||
*
|
||||
* @param pendingId 待审批 ID
|
||||
* @param userId 操作用户
|
||||
* @param decision "approved" 或 "denied"
|
||||
* @throws IllegalArgumentException 如果 pending 不存在
|
||||
* INTERNAL — register a {@link PendingApproval} reconstructed from DB during JVM startup.
|
||||
* Bypasses id generation and pre-existing-entry checks; the snapshot's {@code pendingId}
|
||||
* must already match the DB row. Idempotent: if the same id already lives in the map
|
||||
* (concurrent recovery path), the second call is logged and dropped.
|
||||
* <p>
|
||||
* Only {@code ApprovalWorkflowService.recoverFromDb} should call this.
|
||||
*/
|
||||
public void resolve(String pendingId, String userId, String decision) {
|
||||
PendingApproval pending = pendingMap.get(pendingId);
|
||||
if (pending == null) {
|
||||
throw new IllegalArgumentException("审批记录不存在或已过期: " + pendingId);
|
||||
void registerRecovered(PendingApproval snapshot) {
|
||||
if (snapshot == null || snapshot.getPendingId() == null) {
|
||||
log.warn("[Approval] registerRecovered: ignoring null snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("approved".equalsIgnoreCase(decision)) {
|
||||
pending.setStatus("approved");
|
||||
} else {
|
||||
pending.setStatus("denied");
|
||||
PendingApproval existing = pendingMap.putIfAbsent(snapshot.getPendingId(), snapshot);
|
||||
if (existing != null) {
|
||||
log.warn("[Approval] registerRecovered: pending id {} already in map, skipping",
|
||||
snapshot.getPendingId());
|
||||
return;
|
||||
}
|
||||
pending.setResolvedAt(Instant.now());
|
||||
pending.setResolvedBy(userId);
|
||||
|
||||
log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId);
|
||||
log.info("[Approval] Recovered pending from DB: id={}, tool={}, conversation={}",
|
||||
snapshot.getPendingId(), snapshot.getToolName(), snapshot.getConversationId());
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
@ -172,154 +167,110 @@ public class ApprovalService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 原子解决+消费(IM 渠道 /approve 命令) ====================
|
||||
|
||||
/**
|
||||
* 原子地 resolve 并 consume 审批记录(用于 IM 渠道 /approve 命令)
|
||||
* <p>
|
||||
* 合并 resolve() + consumeApproved() 为单一操作,消除 race condition。
|
||||
*
|
||||
* @param pendingId 待审批 ID
|
||||
* @param userId 操作用户
|
||||
* @return 已消费的 PendingApproval(含 toolCallPayload),不存在或已处理返回 null
|
||||
* INTERNAL — return the earliest {@code approved} pending matching the conversation +
|
||||
* tool, WITHOUT removing it. Workflow uses this to take a snapshot before the
|
||||
* two-phase DB / metadata write; the actual map removal happens via
|
||||
* {@link #removeFromMap(String)} after commit.
|
||||
*/
|
||||
public synchronized PendingApproval resolveAndConsume(String pendingId, String userId) {
|
||||
PendingApproval pending = pendingMap.get(pendingId);
|
||||
if (pending == null || !"pending".equals(pending.getStatus())) {
|
||||
log.warn("[Approval] resolveAndConsume: not found or not pending: id={}", pendingId);
|
||||
return null;
|
||||
}
|
||||
pending.setStatus("consumed");
|
||||
pending.setResolvedAt(Instant.now());
|
||||
pending.setResolvedBy(userId);
|
||||
pendingMap.remove(pendingId);
|
||||
log.info("[Approval] Resolved and consumed atomically: id={}, tool={}", pendingId, pending.getToolName());
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ==================== 消费(重放时调用) ====================
|
||||
|
||||
/**
|
||||
* 消费已批准的审批记录(一次性消费)
|
||||
* <p>
|
||||
* 验证 toolName 匹配(如果指定),防止参数替换攻击。
|
||||
* 移除记录并返回 PendingApproval 供重放。
|
||||
*
|
||||
* @param conversationId 会话 ID
|
||||
* @param toolName 要验证的工具名(null 跳过验证)
|
||||
* @return 已消费的 PendingApproval,或 null 如果无匹配
|
||||
*/
|
||||
public PendingApproval consumeApproved(String conversationId, String toolName) {
|
||||
return consumeApproved(conversationId, toolName, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费一条已审批的记录(带参数匹配校验,防止审批后参数替换攻击)
|
||||
*/
|
||||
public PendingApproval consumeApproved(String conversationId, String toolName, String toolArguments) {
|
||||
PendingApproval target = pendingMap.values().stream()
|
||||
PendingApproval findApprovedForConsume(String conversationId, String toolName) {
|
||||
return pendingMap.values().stream()
|
||||
.filter(p -> conversationId.equals(p.getConversationId()))
|
||||
.filter(p -> "approved".equals(p.getStatus()))
|
||||
.filter(p -> toolName == null || toolName.equals(p.getToolName()))
|
||||
.filter(p -> toolArguments == null || toolArguments.equals(p.getToolArguments()))
|
||||
.min(Comparator.comparing(PendingApproval::getCreatedAt))
|
||||
.orElse(null);
|
||||
|
||||
if (target == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
target.setStatus("consumed");
|
||||
pendingMap.remove(target.getPendingId());
|
||||
log.info("[Approval] Consumed approved: id={}, tool={}, conversation={}",
|
||||
target.getPendingId(), target.getToolName(), conversationId);
|
||||
return target;
|
||||
}
|
||||
|
||||
// ==================== 取消与清理 ====================
|
||||
|
||||
/**
|
||||
* 取消指定会话的所有 pending(用户发新消息时旧 pending 自动取消)
|
||||
*
|
||||
* @param conversationId 会话 ID
|
||||
* @param excludePendingId 排除的 pendingId(当前正在创建的,可为 null)
|
||||
*/
|
||||
public void cancelStalePending(String conversationId, String excludePendingId) {
|
||||
pendingMap.values().stream()
|
||||
.filter(p -> conversationId.equals(p.getConversationId()))
|
||||
.filter(p -> "pending".equals(p.getStatus()))
|
||||
.filter(p -> !p.getPendingId().equals(excludePendingId))
|
||||
.forEach(p -> {
|
||||
p.setStatus("superseded");
|
||||
p.setResolvedAt(Instant.now());
|
||||
pendingMap.remove(p.getPendingId());
|
||||
log.info("[Approval] Cancelled stale pending: id={}", p.getPendingId());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时清理过期记录
|
||||
* <ul>
|
||||
* <li>pending 超过 30 分钟 → 标记 TIMEOUT 并清除</li>
|
||||
* <li>resolved(非 pending)超过 1 小时 → 清除</li>
|
||||
* <li>上限:pending 200 条,resolved 500 条</li>
|
||||
* </ul>
|
||||
* INTERNAL — return a list snapshot of every {@code pending} record in the
|
||||
* conversation, optionally excluding one id. Read-only; no map mutation.
|
||||
* Workflow iterates this list and runs the two-phase resolve on each.
|
||||
*/
|
||||
public void garbageCollect() {
|
||||
Instant now = Instant.now();
|
||||
int expiredPending = 0;
|
||||
int expiredResolved = 0;
|
||||
|
||||
List<String> toRemove = new ArrayList<>();
|
||||
|
||||
List<PendingApproval> snapshotPendingByConversation(String conversationId,
|
||||
String excludePendingId) {
|
||||
List<PendingApproval> out = new ArrayList<>();
|
||||
for (PendingApproval p : pendingMap.values()) {
|
||||
if ("pending".equals(p.getStatus())) {
|
||||
if (Duration.between(p.getCreatedAt(), now).compareTo(PENDING_TTL) > 0) {
|
||||
p.setStatus("timeout");
|
||||
p.setResolvedAt(now);
|
||||
toRemove.add(p.getPendingId());
|
||||
expiredPending++;
|
||||
}
|
||||
} else {
|
||||
// 已解决的记录
|
||||
Instant resolvedAt = p.getResolvedAt() != null ? p.getResolvedAt() : p.getCreatedAt();
|
||||
if (Duration.between(resolvedAt, now).compareTo(RESOLVED_TTL) > 0) {
|
||||
toRemove.add(p.getPendingId());
|
||||
expiredResolved++;
|
||||
}
|
||||
if (!conversationId.equals(p.getConversationId())) continue;
|
||||
if (!"pending".equals(p.getStatus())) continue;
|
||||
if (excludePendingId != null && excludePendingId.equals(p.getPendingId())) continue;
|
||||
out.add(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ==================== GC snapshot helpers (used by ApprovalWorkflowService) ====================
|
||||
|
||||
/**
|
||||
* INTERNAL — return a list snapshot of {@code pending} records whose age
|
||||
* exceeds {@link #PENDING_TTL}. Read-only; the workflow GC loop iterates this
|
||||
* list and runs the two-phase {@code markTimeout} on each.
|
||||
*/
|
||||
List<PendingApproval> snapshotExpiredPending(Instant now) {
|
||||
List<PendingApproval> out = new ArrayList<>();
|
||||
for (PendingApproval p : pendingMap.values()) {
|
||||
if (!"pending".equals(p.getStatus())) continue;
|
||||
if (Duration.between(p.getCreatedAt(), now).compareTo(PENDING_TTL) > 0) {
|
||||
out.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
toRemove.forEach(pendingMap::remove);
|
||||
|
||||
// 上限检查
|
||||
enforceLimit("pending", MAX_PENDING);
|
||||
enforceLimit("resolved", MAX_RESOLVED);
|
||||
|
||||
if (expiredPending > 0 || expiredResolved > 0) {
|
||||
log.info("[Approval] GC: expired {} pending, {} resolved, remaining={}",
|
||||
expiredPending, expiredResolved, pendingMap.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void enforceLimit(String statusType, int maxCount) {
|
||||
boolean isPending = "pending".equals(statusType);
|
||||
List<PendingApproval> matching = pendingMap.values().stream()
|
||||
.filter(p -> isPending ? "pending".equals(p.getStatus()) : !"pending".equals(p.getStatus()))
|
||||
/**
|
||||
* INTERNAL — when total pending count is over {@code maxPending}, return the
|
||||
* oldest excess entries so the workflow GC loop can {@code markTimeout} each.
|
||||
* Read-only; sorts by createdAt ascending.
|
||||
*/
|
||||
List<PendingApproval> snapshotExcessPending(int maxPending) {
|
||||
List<PendingApproval> pending = pendingMap.values().stream()
|
||||
.filter(p -> "pending".equals(p.getStatus()))
|
||||
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
|
||||
.toList();
|
||||
if (pending.size() <= maxPending) return List.of();
|
||||
return new ArrayList<>(pending.subList(0, pending.size() - maxPending));
|
||||
}
|
||||
|
||||
if (matching.size() > maxCount) {
|
||||
int toEvict = matching.size() - maxCount;
|
||||
for (int i = 0; i < toEvict; i++) {
|
||||
PendingApproval oldest = matching.get(i);
|
||||
if (isPending) {
|
||||
oldest.setStatus("timeout");
|
||||
oldest.setResolvedAt(Instant.now());
|
||||
}
|
||||
pendingMap.remove(oldest.getPendingId());
|
||||
/**
|
||||
* INTERNAL — drop already-resolved (non-{@code pending}) entries that exceed
|
||||
* either the resolved-TTL or the resolved-cap. Memory-only: these rows are
|
||||
* already terminal in DB, so no DB / metadata sync is required.
|
||||
*
|
||||
* @return number of map entries dropped
|
||||
*/
|
||||
int dropResolvedExceedingLimits(Instant now) {
|
||||
int dropped = 0;
|
||||
// TTL-based drops first
|
||||
List<String> ttlExpired = new ArrayList<>();
|
||||
for (PendingApproval p : pendingMap.values()) {
|
||||
if ("pending".equals(p.getStatus())) continue;
|
||||
Instant resolvedAt = p.getResolvedAt() != null ? p.getResolvedAt() : p.getCreatedAt();
|
||||
if (Duration.between(resolvedAt, now).compareTo(RESOLVED_TTL) > 0) {
|
||||
ttlExpired.add(p.getPendingId());
|
||||
}
|
||||
log.info("[Approval] Evicted {} {} records (exceeded limit {})", toEvict, statusType, maxCount);
|
||||
}
|
||||
ttlExpired.forEach(pendingMap::remove);
|
||||
dropped += ttlExpired.size();
|
||||
|
||||
// Cap-based drops second
|
||||
List<PendingApproval> resolved = pendingMap.values().stream()
|
||||
.filter(p -> !"pending".equals(p.getStatus()))
|
||||
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
|
||||
.toList();
|
||||
if (resolved.size() > MAX_RESOLVED) {
|
||||
int toEvict = resolved.size() - MAX_RESOLVED;
|
||||
for (int i = 0; i < toEvict; i++) {
|
||||
pendingMap.remove(resolved.get(i).getPendingId());
|
||||
}
|
||||
dropped += toEvict;
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERNAL — current pending-map size, used by GC summary logs.
|
||||
*/
|
||||
int size() {
|
||||
return pendingMap.size();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,22 +4,34 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
import vip.mate.tool.guard.model.GuardFinding;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 审批工作流服务(write-through: 内存 + DB 双写)
|
||||
@ -37,14 +49,48 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
private final ApprovalService approvalService;
|
||||
private final ToolApprovalMapper approvalMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ConversationService conversationService;
|
||||
|
||||
/**
|
||||
* GC scheduler — owns the 5-minute clock for the entire approval state machine
|
||||
* (RFC-067 §4.4). Lives on the workflow rather than {@link ApprovalService} so
|
||||
* timeout / overflow eviction goes through the same DB+metadata+memory two-phase
|
||||
* path as approve / deny — the in-memory map can no longer drift ahead of DB.
|
||||
*/
|
||||
private ScheduledExecutorService gcScheduler;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
recoverFromDb();
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void initGc() {
|
||||
gcScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "approval-gc");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
gcScheduler.scheduleAtFixedRate(this::garbageCollect, 5, 5, TimeUnit.MINUTES);
|
||||
log.info("[ApprovalWorkflow] GC scheduler started (interval=5min)");
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdownGc() {
|
||||
if (gcScheduler != null) {
|
||||
gcScheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时从 DB 恢复 PENDING 审批到内存
|
||||
* Reconstruct in-memory pending approvals from DB at startup, preserving the
|
||||
* original {@code pendingId} and {@code createdAt} so subsequent resolve / GC
|
||||
* paths stay consistent with the persisted row.
|
||||
* <p>
|
||||
* Effective expiration follows {@code expireAt != null ? expireAt : createdAt + PENDING_TTL},
|
||||
* so legacy / test rows whose {@code expireAt} column is NULL still time out. Expired
|
||||
* rows are reconciled (DB → TIMEOUT, message metadata → DENIED) and skipped from
|
||||
* the in-memory map. See RFC-067 §4.1.
|
||||
*/
|
||||
void recoverFromDb() {
|
||||
try {
|
||||
@ -55,56 +101,96 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
);
|
||||
|
||||
int recovered = 0;
|
||||
int expired = 0;
|
||||
Instant now = Instant.now();
|
||||
for (ToolApprovalEntity entity : pendingRecords) {
|
||||
// 检查是否已过期(30 分钟)
|
||||
if (entity.getCreatedAt() != null) {
|
||||
Instant createdAt = entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant();
|
||||
if (Instant.now().minusSeconds(1800).isAfter(createdAt)) {
|
||||
// 已过期,更新 DB 状态
|
||||
entity.setStatus("TIMEOUT");
|
||||
entity.setResolvedAt(LocalDateTime.now());
|
||||
approvalMapper.updateById(entity);
|
||||
continue;
|
||||
}
|
||||
// Defensive null handling: a row with neither createdAt nor expireAt is
|
||||
// treated as freshly created so the next GC tick can revisit it instead
|
||||
// of being silently lost.
|
||||
Instant createdAt = entity.getCreatedAt() != null
|
||||
? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
|
||||
: now;
|
||||
Instant effectiveExpireAt = entity.getExpireAt() != null
|
||||
? entity.getExpireAt().atZone(ZoneId.systemDefault()).toInstant()
|
||||
: createdAt.plus(ApprovalService.PENDING_TTL);
|
||||
|
||||
if (now.isAfter(effectiveExpireAt)) {
|
||||
expireRecoveredRow(entity);
|
||||
expired++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 恢复到内存
|
||||
String pendingId = approvalService.createPending(
|
||||
PendingApproval snapshot = new PendingApproval(
|
||||
entity.getPendingId(),
|
||||
entity.getConversationId(),
|
||||
entity.getUserId(),
|
||||
entity.getToolName(),
|
||||
entity.getToolArguments(),
|
||||
entity.getSummary(),
|
||||
entity.getToolCallPayload(),
|
||||
entity.getSiblingToolCalls(),
|
||||
entity.getAgentId()
|
||||
);
|
||||
|
||||
// 修正内存中的 pendingId 以匹配 DB
|
||||
// 由于 ApprovalService.createPending 会生成新 ID,我们需要取消它并使用原始 ID
|
||||
approvalService.cancelStalePending(entity.getConversationId(), null);
|
||||
pendingId = approvalService.createPending(
|
||||
entity.getConversationId(),
|
||||
entity.getUserId(),
|
||||
entity.getToolName(),
|
||||
entity.getToolArguments(),
|
||||
entity.getSummary(),
|
||||
entity.getToolCallPayload(),
|
||||
entity.getSiblingToolCalls(),
|
||||
entity.getAgentId()
|
||||
createdAt,
|
||||
"pending"
|
||||
);
|
||||
snapshot.setToolCallPayload(entity.getToolCallPayload());
|
||||
snapshot.setSiblingToolCalls(entity.getSiblingToolCalls());
|
||||
snapshot.setAgentId(entity.getAgentId());
|
||||
snapshot.setChannelType(entity.getChannelType());
|
||||
snapshot.setRequesterName(entity.getRequesterName());
|
||||
snapshot.setReplyTarget(entity.getReplyTarget());
|
||||
snapshot.setFindingsJson(entity.getFindingsJson());
|
||||
snapshot.setMaxSeverity(entity.getMaxSeverity());
|
||||
snapshot.setSummary(entity.getSummary());
|
||||
snapshot.setChatOrigin(entity.getChatOrigin());
|
||||
|
||||
approvalService.registerRecovered(snapshot);
|
||||
recovered++;
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.info("[ApprovalWorkflow] Recovered {} pending approvals from DB", recovered);
|
||||
if (recovered > 0 || expired > 0) {
|
||||
log.info("[ApprovalWorkflow] DB recovery: recovered={}, expired={}", recovered, expired);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to recover from DB (table may not exist yet): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an expired DB row to TIMEOUT and reconcile message metadata so the UI does
|
||||
* not hydrate a ghost approval after restart.
|
||||
* <p>
|
||||
* Order matters: metadata writes are gated on DB success. If {@code updateById}
|
||||
* throws or affects zero rows, we skip the metadata flip so the three persistence
|
||||
* loci (DB / message metadata / in-memory map) cannot drift apart — DB stuck on
|
||||
* PENDING + metadata flipped to DENIED is the worst-case ghost state because the
|
||||
* next recoverFromDb would re-revive the approval while the UI insists it was
|
||||
* already settled.
|
||||
*/
|
||||
private void expireRecoveredRow(ToolApprovalEntity entity) {
|
||||
int rowsUpdated;
|
||||
try {
|
||||
entity.setStatus("TIMEOUT");
|
||||
entity.setResolvedAt(LocalDateTime.now());
|
||||
rowsUpdated = approvalMapper.updateById(entity);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to mark expired row {} as TIMEOUT: {}",
|
||||
entity.getPendingId(), e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (rowsUpdated == 0) {
|
||||
log.warn("[ApprovalWorkflow] Expire skipped: DB row for pending {} affected 0 rows " +
|
||||
"(concurrent resolve?); leaving metadata untouched", entity.getPendingId());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
conversationService.markPendingApprovalsResolved(
|
||||
entity.getConversationId(),
|
||||
Set.of(entity.getPendingId()),
|
||||
MetadataDecision.DENIED);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to reconcile metadata for expired pending {}: {}",
|
||||
entity.getPendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建待审批记录(增强版,含 GuardEvaluation)
|
||||
*/
|
||||
@ -117,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
conversationId, userId, toolName, toolArguments, reason,
|
||||
toolCallPayload, siblingToolCalls, agentId);
|
||||
|
||||
// RFC-063r §2.12: capture the originating ChatOrigin from the holder.
|
||||
// The holder was set by AgentService.{chat,chatStream,...} for the
|
||||
// duration of the agent invocation that produced this approval — so
|
||||
// it is non-null for IM / web triggered tool calls. Snapshot is
|
||||
// serialized once here and persisted on the DB row so cross-restart
|
||||
// replays keep the channel binding.
|
||||
String chatOriginJson = serializeChatOrigin(ChatOriginHolder.get());
|
||||
|
||||
// 2. 增强内存记录
|
||||
approvalService.getPending(pendingId).ifPresent(pending -> {
|
||||
if (evaluation != null) {
|
||||
@ -124,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
|
||||
pending.setSummary(evaluation.summary());
|
||||
}
|
||||
pending.setChatOrigin(chatOriginJson);
|
||||
});
|
||||
|
||||
// 3. DB 层
|
||||
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
|
||||
toolCallPayload, siblingToolCalls, agentId, evaluation);
|
||||
toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
|
||||
|
||||
return pendingId;
|
||||
}
|
||||
@ -144,50 +239,277 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解决审批
|
||||
* Resolve a pending approval (approve / deny) following the RFC-067 §4.2 two-phase
|
||||
* contract: snapshot → DB UPDATE conditional on {@code status='PENDING'} →
|
||||
* metadata reconciliation → memory mutation queued for after-commit.
|
||||
* <p>
|
||||
* Idempotent under concurrent resolve: when the DB UPDATE affects 0 rows (because
|
||||
* another caller — IM channel, GC, recoverFromDb — already moved the row off
|
||||
* PENDING), this method returns {@link ResolveOutcome#alreadyResolved(String)}
|
||||
* without touching metadata or in-memory state. Callers should treat this as a
|
||||
* silent no-op; do not surface a user-facing error.
|
||||
* <p>
|
||||
* On DB / metadata write failure the transaction rolls back and in-memory state
|
||||
* stays untouched, so a retry from the next GC tick can recover. Memory mutation
|
||||
* is registered as an {@code afterCommit} synchronization, never inline, so a
|
||||
* post-update commit failure cannot leave memory ahead of DB.
|
||||
*
|
||||
* @param pendingId target approval id
|
||||
* @param userId actor performing the resolution (for audit)
|
||||
* @param decision case-insensitive {@code "approved"} or {@code "denied"}
|
||||
* @return {@link ResolveOutcome} carrying the resolved snapshot + DB / metadata
|
||||
* counters; idempotent return on no-op
|
||||
*/
|
||||
public void resolve(String pendingId, String userId, String decision) {
|
||||
approvalService.resolve(pendingId, userId, decision);
|
||||
updateDbStatus(pendingId, decision.toUpperCase(), userId);
|
||||
@Transactional
|
||||
public ResolveOutcome resolve(String pendingId, String userId, String decision) {
|
||||
boolean approved = "approved".equalsIgnoreCase(decision);
|
||||
String dbStatus = approved ? "APPROVED" : "DENIED";
|
||||
MetadataDecision metaDecision = approved ? MetadataDecision.APPROVED : MetadataDecision.DENIED;
|
||||
String snapshotStatus = approved ? "approved" : "denied";
|
||||
|
||||
return performResolve(pendingId, userId, dbStatus, metaDecision, snapshotStatus,
|
||||
/* removeFromMap */ false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子解决+消费
|
||||
* Atomically resolve {@code approved} and consume the snapshot for replay.
|
||||
* Same two-phase contract as {@link #resolve}, additionally removing the
|
||||
* pending entry from the in-memory map after commit so a subsequent
|
||||
* {@link #findPendingByConversation(String)} returns null and consume is
|
||||
* single-shot. The returned {@link ResolveOutcome#consumedSnapshot()} carries
|
||||
* {@code toolCallPayload} for replay.
|
||||
*/
|
||||
public PendingApproval resolveAndConsume(String pendingId, String userId) {
|
||||
PendingApproval consumed = approvalService.resolveAndConsume(pendingId, userId);
|
||||
if (consumed != null) {
|
||||
updateDbStatus(pendingId, "CONSUMED", userId);
|
||||
@Transactional
|
||||
public ResolveOutcome resolveAndConsume(String pendingId, String userId) {
|
||||
return performResolve(pendingId, userId, "CONSUMED", MetadataDecision.APPROVED,
|
||||
"consumed", /* removeFromMap */ true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the earliest already-{@code approved} record for the conversation +
|
||||
* tool — used when an out-of-band approval (e.g. /approve text command flow that
|
||||
* resolved the record) needs to be redeemed for replay.
|
||||
*/
|
||||
@Transactional
|
||||
public ResolveOutcome consumeApproved(String conversationId, String toolName) {
|
||||
PendingApproval target = approvalService.findApprovedForConsume(conversationId, toolName);
|
||||
if (target == null) {
|
||||
return ResolveOutcome.alreadyResolved(null);
|
||||
}
|
||||
return consumed;
|
||||
return performResolveOnSnapshot(target, null, "CONSUMED", MetadataDecision.APPROVED,
|
||||
"consumed", /* removeFromMap */ true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费已批准记录
|
||||
* Bulk-deny every pending approval in the conversation (RFC-067 §4.4.1). Used by
|
||||
* the Web Stop endpoint to clear orphaned approvals when the user halts a turn
|
||||
* mid-stream; without this sweep, in-flight pendings linger in the map and
|
||||
* resurrect via metadata after refresh / restart.
|
||||
* <p>
|
||||
* Two-phase per row: DB → {@code DENIED}, message metadata → {@code DENIED},
|
||||
* map removed. Per-row failures are logged and the sweep continues; the returned
|
||||
* list contains only the outcomes that successfully advanced through DB.
|
||||
*
|
||||
* @return outcome per pending that successfully transitioned to {@code DENIED}
|
||||
*/
|
||||
public PendingApproval consumeApproved(String conversationId, String toolName) {
|
||||
PendingApproval consumed = approvalService.consumeApproved(conversationId, toolName);
|
||||
if (consumed != null) {
|
||||
updateDbStatus(consumed.getPendingId(), "CONSUMED", null);
|
||||
@Transactional
|
||||
public List<ResolveOutcome> denyAllByConversation(String conversationId, String userId) {
|
||||
List<PendingApproval> targets = approvalService.snapshotPendingByConversation(
|
||||
conversationId, /* excludePendingId */ null);
|
||||
if (targets.isEmpty()) return List.of();
|
||||
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
|
||||
for (PendingApproval target : targets) {
|
||||
try {
|
||||
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "DENIED",
|
||||
MetadataDecision.DENIED, "denied", /* removeFromMap */ true);
|
||||
if (outcome.dbSynced()) outcomes.add(outcome);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] denyAll: failed to deny {}: {}",
|
||||
target.getPendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return consumed;
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消过期 pending
|
||||
* Cancel every other pending approval in the conversation (excluding optional
|
||||
* {@code excludePendingId}) — used when a user submits a fresh message and the
|
||||
* old approval is implicitly abandoned. Each cancelled record goes through the
|
||||
* same two-phase contract; metadata flips to {@code DENIED} (per RFC-067 §4.4.1
|
||||
* state mapping for {@code superseded}).
|
||||
*
|
||||
* @return one outcome per pending that was actually moved off PENDING (empty list
|
||||
* if there was nothing to cancel)
|
||||
*/
|
||||
public void cancelStalePending(String conversationId, String excludePendingId) {
|
||||
approvalService.cancelStalePending(conversationId, excludePendingId);
|
||||
@Transactional
|
||||
public List<ResolveOutcome> cancelStalePending(String conversationId, String excludePendingId) {
|
||||
List<PendingApproval> targets = approvalService.snapshotPendingByConversation(
|
||||
conversationId, excludePendingId);
|
||||
if (targets.isEmpty()) return List.of();
|
||||
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
|
||||
for (PendingApproval target : targets) {
|
||||
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "SUPERSEDED",
|
||||
MetadataDecision.DENIED, "superseded", /* removeFromMap */ true);
|
||||
if (outcome.dbSynced()) outcomes.add(outcome);
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time out a single pending approval (RFC-067 §4.4): same two-phase contract as
|
||||
* {@link #resolve} but with DB → {@code TIMEOUT} and metadata → {@code DENIED}
|
||||
* (per RFC-067 §4.4.1 state mapping). Called by the GC scheduler for entries
|
||||
* past {@link ApprovalService#PENDING_TTL} or beyond {@link ApprovalService#MAX_PENDING}.
|
||||
* Package-private — not part of the external resolve API.
|
||||
*/
|
||||
@Transactional
|
||||
ResolveOutcome markTimeout(String pendingId) {
|
||||
return performResolve(pendingId, null, "TIMEOUT", MetadataDecision.DENIED, "timeout",
|
||||
/* removeFromMap */ true);
|
||||
}
|
||||
|
||||
/**
|
||||
* GC tick (5-minute cadence; runs in {@code approval-gc} daemon thread).
|
||||
* <ol>
|
||||
* <li>Phase A — pending older than {@link ApprovalService#PENDING_TTL} time out
|
||||
* through the full DB+metadata+memory contract.</li>
|
||||
* <li>Phase B — when total pending count exceeds {@link ApprovalService#MAX_PENDING},
|
||||
* evict the oldest excess via the same {@code markTimeout} path.</li>
|
||||
* <li>Phase C — already-resolved entries (DB row already terminal) past
|
||||
* {@link ApprovalService#RESOLVED_TTL} or beyond
|
||||
* {@link ApprovalService#MAX_RESOLVED} are dropped from the map only —
|
||||
* the DB does not need touching, nor does message metadata.</li>
|
||||
* </ol>
|
||||
* Each pending entry's transition runs in its own transaction (markTimeout is
|
||||
* @Transactional) so a single bad row doesn't block the rest of the sweep.
|
||||
*/
|
||||
public void garbageCollect() {
|
||||
Instant now = Instant.now();
|
||||
|
||||
// Phase A — TTL-expired pending. Snapshot first so we don't mutate a map
|
||||
// we're iterating; markTimeout handles its own DB+metadata+memory contract.
|
||||
int timedOut = 0;
|
||||
for (PendingApproval expired : approvalService.snapshotExpiredPending(now)) {
|
||||
try {
|
||||
ResolveOutcome outcome = markTimeout(expired.getPendingId());
|
||||
if (outcome.dbSynced()) timedOut++;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] GC: markTimeout failed for {}: {}",
|
||||
expired.getPendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Phase B — pending overflow eviction. Same path; just driven by a count cap.
|
||||
int evictedPending = 0;
|
||||
for (PendingApproval excess : approvalService.snapshotExcessPending(ApprovalService.MAX_PENDING)) {
|
||||
try {
|
||||
ResolveOutcome outcome = markTimeout(excess.getPendingId());
|
||||
if (outcome.dbSynced()) evictedPending++;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] GC: overflow markTimeout failed for {}: {}",
|
||||
excess.getPendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Phase C — resolved cleanup. Memory-only; DB rows for these entries are
|
||||
// already terminal (CONSUMED / DENIED / TIMEOUT / SUPERSEDED) so nothing
|
||||
// would change in DB or metadata.
|
||||
int droppedResolved = approvalService.dropResolvedExceedingLimits(now);
|
||||
|
||||
if (timedOut > 0 || evictedPending > 0 || droppedResolved > 0) {
|
||||
log.info("[ApprovalWorkflow] GC: timed-out {}, evicted-pending {}, dropped-resolved {}, remaining={}",
|
||||
timedOut, evictedPending, droppedResolved, approvalService.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- shared two-phase machinery ----------
|
||||
|
||||
private ResolveOutcome performResolve(String pendingId, String userId,
|
||||
String dbStatus, MetadataDecision metaDecision,
|
||||
String snapshotStatus, boolean removeFromMap) {
|
||||
PendingApproval snapshot = approvalService.getPending(pendingId).orElse(null);
|
||||
if (snapshot == null || !"pending".equals(snapshot.getStatus())) {
|
||||
log.debug("[ApprovalWorkflow] resolve {}: not pending (snapshot={}, status={})",
|
||||
pendingId, snapshot != null, snapshot != null ? snapshot.getStatus() : "n/a");
|
||||
return ResolveOutcome.alreadyResolved(pendingId);
|
||||
}
|
||||
return performResolveOnSnapshot(snapshot, userId, dbStatus, metaDecision,
|
||||
snapshotStatus, removeFromMap);
|
||||
}
|
||||
|
||||
private ResolveOutcome performResolveOnSnapshot(PendingApproval snapshot, String userId,
|
||||
String dbStatus, MetadataDecision metaDecision,
|
||||
String snapshotStatus, boolean removeFromMap) {
|
||||
// Phase 1 — DB UPDATE (conditional). The eq("PENDING") guard makes the call
|
||||
// idempotent: if another path already won, we get rows=0 and bail without
|
||||
// touching metadata or memory.
|
||||
int rows;
|
||||
try {
|
||||
approvalMapper.update(null, new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||
.eq(ToolApprovalEntity::getConversationId, conversationId)
|
||||
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||
.eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId())
|
||||
.eq(ToolApprovalEntity::getStatus, "PENDING")
|
||||
.ne(excludePendingId != null, ToolApprovalEntity::getPendingId, excludePendingId)
|
||||
.set(ToolApprovalEntity::getStatus, "SUPERSEDED")
|
||||
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now()));
|
||||
.set(ToolApprovalEntity::getStatus, dbStatus)
|
||||
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now());
|
||||
if (userId != null) {
|
||||
wrapper.set(ToolApprovalEntity::getResolvedBy, userId);
|
||||
}
|
||||
rows = approvalMapper.update(null, wrapper);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to cancel stale in DB: {}", e.getMessage());
|
||||
log.warn("[ApprovalWorkflow] DB UPDATE failed for {} -> {}: {}",
|
||||
snapshot.getPendingId(), dbStatus, e.getMessage());
|
||||
// Re-throw so @Transactional rolls back any partial state and the caller sees the failure.
|
||||
throw e;
|
||||
}
|
||||
if (rows == 0) {
|
||||
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in PENDING (concurrent resolve)",
|
||||
snapshot.getPendingId());
|
||||
return ResolveOutcome.alreadyResolved(snapshot.getPendingId());
|
||||
}
|
||||
|
||||
// Phase 2 — metadata. Same transaction. If this throws, @Transactional rolls back DB.
|
||||
int rewritten = conversationService.markPendingApprovalsResolved(
|
||||
snapshot.getConversationId(),
|
||||
Set.of(snapshot.getPendingId()),
|
||||
metaDecision);
|
||||
|
||||
// Phase 3 — memory mutation, deferred until after commit. Registering inside
|
||||
// a @Transactional method binds the hook to the active tx; if the tx rolls
|
||||
// back (post-method but pre-commit failure, e.g. constraint violation at
|
||||
// flush), the hook never fires and memory stays consistent with DB.
|
||||
Instant resolvedAt = Instant.now();
|
||||
afterCommit(() -> {
|
||||
snapshot.setStatus(snapshotStatus);
|
||||
snapshot.setResolvedAt(resolvedAt);
|
||||
if (userId != null) snapshot.setResolvedBy(userId);
|
||||
if (removeFromMap) approvalService.removeFromMap(snapshot.getPendingId());
|
||||
});
|
||||
|
||||
boolean consumed = "consumed".equals(snapshotStatus);
|
||||
ResolveOutcome outcome = consumed
|
||||
? ResolveOutcome.consumed(snapshot, true, rewritten)
|
||||
: ResolveOutcome.resolved(snapshot,
|
||||
"superseded".equals(snapshotStatus) ? "superseded" : snapshotStatus,
|
||||
true, rewritten);
|
||||
log.info("[ApprovalWorkflow] resolved id={}, decision={}, dbStatus={}, messagesRewritten={}",
|
||||
snapshot.getPendingId(), outcome.decision(), dbStatus, rewritten);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a memory mutation only after the surrounding {@code @Transactional} method's
|
||||
* tx commits. When called outside a transaction (e.g. unit tests that bypass the
|
||||
* proxy), executes immediately to keep test ergonomics simple.
|
||||
*/
|
||||
private void afterCommit(Runnable hook) {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
hook.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
hook.run();
|
||||
}
|
||||
}
|
||||
|
||||
@ -207,7 +529,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
private void persistToDb(String pendingId, String conversationId, String userId,
|
||||
String toolName, String toolArguments,
|
||||
String toolCallPayload, String siblingToolCalls, String agentId,
|
||||
GuardEvaluation evaluation) {
|
||||
GuardEvaluation evaluation, String chatOriginJson) {
|
||||
try {
|
||||
ToolApprovalEntity entity = new ToolApprovalEntity();
|
||||
entity.setPendingId(pendingId);
|
||||
@ -221,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
entity.setStatus("PENDING");
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
|
||||
// RFC-063r §2.12: persist Memento snapshot. Null when the entry
|
||||
// path didn't supply an origin — replay falls back to ChatOrigin.EMPTY.
|
||||
entity.setChatOrigin(chatOriginJson);
|
||||
|
||||
if (evaluation != null) {
|
||||
entity.setFindingsJson(serializeFindings(evaluation.findings()));
|
||||
@ -238,6 +563,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.12: serialize a {@link ChatOrigin} for persistence on
|
||||
* {@code mate_tool_approval.chat_origin}. Returns null for
|
||||
* {@code ChatOrigin.EMPTY} so legacy approvals that never captured an
|
||||
* origin do not store a meaningless empty record.
|
||||
*/
|
||||
private String serializeChatOrigin(ChatOrigin origin) {
|
||||
if (origin == null || origin == ChatOrigin.EMPTY) return null;
|
||||
if (origin.agentId() == null && origin.channelId() == null
|
||||
&& origin.conversationId() == null && origin.workspaceId() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(origin);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to serialize ChatOrigin: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.12: deserialize a persisted Memento back into a
|
||||
* {@link ChatOrigin}. Returns {@link ChatOrigin#EMPTY} when the column
|
||||
* is null or the payload is corrupt — the caller treats that as
|
||||
* "no channel binding" and replay proceeds with a web-style flow.
|
||||
*/
|
||||
public ChatOrigin restoreChatOrigin(String json) {
|
||||
if (json == null || json.isBlank()) return ChatOrigin.EMPTY;
|
||||
try {
|
||||
ChatOrigin restored = objectMapper.readValue(json, ChatOrigin.class);
|
||||
return restored != null ? restored : ChatOrigin.EMPTY;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to restore ChatOrigin: {} (payload-len={})",
|
||||
e.getMessage(), json.length());
|
||||
return ChatOrigin.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
|
||||
try {
|
||||
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||
|
||||