Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
493910bf5a | ||
|
|
da8005a8cb | ||
|
|
1d64194e15 | ||
|
|
f47cf8c6be | ||
|
|
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/**
|
||||
57
.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,48 @@ JWT_SECRET=
|
||||
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。
|
||||
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
|
||||
MATECLAW_CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
|
||||
# openssl rand -hex 32
|
||||
SEARXNG_SECRET=
|
||||
|
||||
# ==================== 浏览器工具(可选) ====================
|
||||
#
|
||||
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。
|
||||
# 只有在下述场景才需要 override:
|
||||
#
|
||||
# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接:
|
||||
# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222
|
||||
#
|
||||
# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome):
|
||||
# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable
|
||||
#
|
||||
# 3) 强制使用 Playwright channel(chrome / msedge / chrome-beta …):
|
||||
# MATECLAW_BROWSER_CHANNEL=chrome
|
||||
MATECLAW_BROWSER_CDP_URL=
|
||||
MATECLAW_BROWSER_CHROME_PATH=
|
||||
MATECLAW_BROWSER_CHANNEL=
|
||||
|
||||
# ==================== OpenAI OAuth(Docker,可选) ====================
|
||||
#
|
||||
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code,
|
||||
# 不需要自定义 client secret。
|
||||
#
|
||||
# 默认留空即可。后端会根据访问 Host 自动选择:
|
||||
# - localhost / 127.0.0.1 / ::1 → LOCAL(PKCE 回调)
|
||||
# - IP / 域名 / 反向代理访问 → DEVICE_CODE(无缝远程授权)
|
||||
#
|
||||
# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成
|
||||
# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL,并让容器内
|
||||
# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到:
|
||||
# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local
|
||||
# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0
|
||||
#
|
||||
# 强制模式调试时也可设为:local / device_code / manual_paste
|
||||
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
|
||||
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
|
||||
|
||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||
#MAVEN_FLAGS=-Paliyun-first
|
||||
|
||||
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
Normal file
@ -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
|
||||
17
.gitignore
vendored
@ -29,6 +29,12 @@ nbbuild/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
### frontend build artifacts ###
|
||||
# Vite's primary output goes to mateclaw-server/.../static; the only thing
|
||||
# that lands here is rollup-plugin-visualizer's stats.html when running
|
||||
# ANALYZE=1 pnpm build.
|
||||
mateclaw-ui/dist/
|
||||
|
||||
### maven ###
|
||||
target/
|
||||
*.war
|
||||
@ -92,5 +98,16 @@ deploy/nginx/ssl/*.pem
|
||||
deploy/.env
|
||||
|
||||
# Claude Code local settings
|
||||
CLAUDE.md
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
|
||||
# Codex CLI local artifacts
|
||||
.codex/
|
||||
|
||||
# Sync tooling local state (generated each run; report is intentionally tracked)
|
||||
scripts/.*-sync-state.json
|
||||
|
||||
# Sandbox / external client work that lives in this directory
|
||||
# but should not ship in the repo.
|
||||
outputs/
|
||||
|
||||
261
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,114 +28,113 @@
|
||||
|
||||
---
|
||||
|
||||
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. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
|
||||
- **Workspace memory** — `AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
|
||||
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
|
||||
|
||||
### Skills · MCP · ACP — three ways to extend capability
|
||||
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
|
||||
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
|
||||
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
|
||||
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
|
||||
|
||||
### Business orchestration (1.3.0+)
|
||||
- **Workflow** — compose multiple employees plus system actions (approval / channel dispatch / write-memory) into a publishable, triggerable, replayable linear DSL. Seven step modes (`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`). JSON-first authoring with Monaco + schema validation, or natural-language → draft generation
|
||||
- **Triggers** — wire system events to workflows or to employee conversations. Six pattern types (`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`). Default-on event governance: dedup, per-trigger rate limit, bot-self filter, recursion guard, fail-closed unknown patterns
|
||||
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
|
||||
|
||||
### You see what every employee is doing
|
||||
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
|
||||
|
||||
### Multimodal creation
|
||||
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
|
||||
|
||||
### Enterprise-ready
|
||||
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
|
||||
|
||||
---
|
||||
|
||||
## AI is becoming infrastructure
|
||||
|
||||
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
|
||||
|
||||
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
|
||||
|
||||
**MateClaw is that layer — built the Spring Boot way.**
|
||||
|
||||
---
|
||||
|
||||
## Why MateClaw
|
||||
|
||||
Most AI tools do one thing well. MateClaw does the whole thing.
|
||||
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||
| **Multi-vendor failover** | **Chain + health tracker + cooldown** | Swap providers via config | Orchestration w/ retry | Anthropic only | One model |
|
||||
| **Knowledge digestion** | **LLM Wiki + page-level citations** | Canvas + memory | Skills Hub + memory | — | Code index |
|
||||
| **Multi-user admin** | **RBAC + approval + audit + runtime console** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
|
||||
| **Capability extension** | **Skills (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
|
||||
| **Surfaces** | Web admin + Desktop + Widget + SDK + 8 IM | 25+ chat channels | 15+ channels (CLI-led) | 3 IM preview | IDE only |
|
||||
| **Stack** | **Java (Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||
| **License / Price** | **Apache 2.0 · Free** | MIT · Free | MIT · Free | Proprietary · $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
|
||||
@ -156,55 +155,67 @@ cp .env.example .env
|
||||
docker compose up -d # http://localhost:18080
|
||||
```
|
||||
|
||||
### Desktop App
|
||||
### Desktop
|
||||
|
||||
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21 — no Java needed.
|
||||
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
## Architecture
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
|
||||
| Agent | StateGraph Runtime |
|
||||
| Database | H2 (dev) / MySQL 8.0+ (prod) |
|
||||
| ORM | MyBatis Plus 3.5 |
|
||||
| Auth | Spring Security + JWT |
|
||||
| Frontend | Vue 3 · TypeScript · Vite |
|
||||
| UI | Element Plus · TailwindCSS 4 |
|
||||
| Desktop | Electron · electron-updater |
|
||||
<p align="center">
|
||||
<img src="assets/architecture-biz-en.svg" alt="Business Architecture" width="800">
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary><b>Technical architecture</b></summary>
|
||||
<p align="center">
|
||||
<img src="assets/architecture-tech-en.svg" alt="Technical Architecture" width="800">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
## Project structure
|
||||
|
||||
```
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot backend
|
||||
├── mateclaw-ui/ Vue 3 SPA frontend
|
||||
├── mateclaw-desktop/ Electron desktop app
|
||||
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
|
||||
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
||||
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
||||
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
||||
├── mateclaw-plugin-sample/ Reference plugin implementation
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
|
||||
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
|
||||
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
|
||||
| Database | H2 (dev) · MySQL 8.0+ (prod) |
|
||||
| Auth | Spring Security + JWT |
|
||||
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
||||
| Widget | Vite library mode · UMD + ES bundles |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
|
||||
|
||||
---
|
||||
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, architecture, each subsystem, API reference.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Richer multi-agent collaboration
|
||||
- Smarter model routing
|
||||
- Deeper multimodal understanding
|
||||
- Stronger long-term memory
|
||||
- Richer ClawHub ecosystem
|
||||
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0) for the full story.
|
||||
|
||||
---
|
||||
**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations.
|
||||
|
||||
## Contributing
|
||||
|
||||
@ -217,14 +228,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.
|
||||
|
||||
255
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,115 +28,114 @@
|
||||
|
||||
---
|
||||
|
||||
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
|
||||
> **别的 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。**加工器引擎**(1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
|
||||
- **工作区记忆** — `AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
|
||||
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
|
||||
|
||||
### 技能 · MCP · ACP — 三种"接外部能力"的方式
|
||||
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
|
||||
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**(1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
|
||||
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
|
||||
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
|
||||
|
||||
### 业务流程编排(1.3.0+)
|
||||
- **工作流(Workflow)** — 把多位员工 + 系统动作(审批 / 渠道分发 / 写记忆)按线性 step DSL 编排成一条可发布、可触发、可重放的业务流程。7 种 step mode(`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`)。JSON-first 编辑(Monaco + JSON schema + Pebble 静态检查),或者用一句话生成草稿
|
||||
- **触发器(Trigger)** — 把"系统里发生的事"自动接到工作流或员工对话上。6 种 pattern type(`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`)。事件治理默认开:去重、per-trigger 限速、bot 自循环过滤、A→B→A 递归保护、未知 pattern fail-closed
|
||||
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合,reverse-citation 绑定到源 chunk,JSON 输出 + 可选 JSON Schema,每个模板独立选模型
|
||||
|
||||
### 你看得见每位员工正在干什么
|
||||
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示(思考 / 工具 / 回答),SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
|
||||
|
||||
### 多模态创作
|
||||
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
||||
|
||||
### 企业就绪
|
||||
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema,升级时自愈。一个 JAR 交付。生产用 MySQL,开发用 H2,代码零改动。
|
||||
|
||||
---
|
||||
|
||||
## AI 正在变成基础设施
|
||||
|
||||
2026 年 3 月 2 日,Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次,**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
|
||||
|
||||
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
|
||||
|
||||
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
||||
|
||||
---
|
||||
|
||||
## 为什么选 MateClaw
|
||||
|
||||
大多数 AI 工具只做好一件事。MateClaw 做好整件事。
|
||||
| | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||
| **多厂商失败转移** | **Chain + 健康追踪 + 冷却** | 切换供应商(改配置) | 内置编排重试 | 仅 Anthropic | 单模型 |
|
||||
| **知识消化式加工** | **Wiki + 页面级引用溯源** | Canvas + 记忆 | Skills Hub + 记忆 | — | 代码索引 |
|
||||
| **多用户管理** | **RBAC + 审批流 + 审计 + 运行时控制台** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
|
||||
| **能力扩展接口** | **技能 (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
|
||||
| **用户触点** | Web 管理台 + 桌面 + 嵌入 + SDK + 8 IM | 25+ 聊天渠道 | 15+ 渠道(CLI 为主) | 3 IM(预览) | 仅 IDE |
|
||||
| **技术栈** | **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/月
|
||||
|
||||
---
|
||||
|
||||
## 架构全景
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary><b>技术架构</b></summary>
|
||||
<p align="center">
|
||||
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 智能体引擎
|
||||
|
||||
- **ReAct 智能体** — 思考、行动、观察、循环。迭代推理直到完成任务
|
||||
- **计划-执行智能体** — 将复杂工作分解为有序步骤,逐一执行
|
||||
- **动态配置** — 运行时从数据库加载智能体的人格、工具和约束
|
||||
- **运行时韧性** — 上下文裁剪、智能截断、僵死流清理、异常恢复
|
||||
|
||||
### 知识与记忆
|
||||
|
||||
- **LLM Wiki 知识库** — AI 驱动的知识库,将原始资料消化为结构化、有链接的页面
|
||||
- **工作区记忆** — `AGENTS.md`、`SOUL.md`、`PROFILE.md`、`MEMORY.md`、每日笔记
|
||||
- **记忆生命周期** — 对话后自动提取、定时整理、记忆涌现工作流
|
||||
- **记忆应该积累** — 理解随时间加深,而非每次查询都从零开始
|
||||
|
||||
### 工具、技能与 MCP
|
||||
|
||||
- **内置工具** — 联网搜索、文件操作、记忆访问、日期时间等
|
||||
- **MCP 集成** — 支持 stdio、SSE、Streamable HTTP 三种传输
|
||||
- **技能系统** — 可安装的 `SKILL.md` 技能包 + ClawHub 市场
|
||||
- **工具防护** — 审批流、文件路径保护、运行时过滤
|
||||
|
||||
### 多模态创作
|
||||
|
||||
语音合成 · 语音识别 · 图片生成 · 音乐生成 · 视频生成
|
||||
|
||||
### 模型灵活性
|
||||
|
||||
14+ 供应商支持,包括 DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等。在 Web 界面中配置一切。
|
||||
|
||||
### 用户触点
|
||||
|
||||
- **Web 控制台** — 对话、智能体、工具、技能、知识、模型、安全、设置
|
||||
- **桌面端** — Electron + 内嵌 JRE 21,无需安装 Java
|
||||
- **多渠道** — 钉钉、飞书、企业微信、Telegram、Discord、QQ
|
||||
**同一套"完整一整套"哲学,不同的重心。**
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Java 17+ · Node.js 18+ · pnpm · Maven 3.9+
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd mateclaw-server
|
||||
@ -158,22 +157,22 @@ docker compose up -d # http://localhost:18080
|
||||
|
||||
### 桌面端
|
||||
|
||||
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外安装 Java。
|
||||
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
## 架构全景
|
||||
|
||||
| 层次 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
|
||||
| 智能体 | StateGraph 运行时 |
|
||||
| 数据库 | H2(开发)/ MySQL 8.0+(生产)|
|
||||
| ORM | MyBatis Plus 3.5 |
|
||||
| 认证 | Spring Security + JWT |
|
||||
| 前端 | Vue 3 · TypeScript · Vite |
|
||||
| UI | Element Plus · TailwindCSS 4 |
|
||||
| 桌面端 | Electron · electron-updater |
|
||||
<p align="center">
|
||||
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary><b>技术架构</b></summary>
|
||||
<p align="center">
|
||||
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@ -181,30 +180,42 @@ docker compose up -d # http://localhost:18080
|
||||
|
||||
```
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot 后端
|
||||
├── mateclaw-ui/ Vue 3 SPA 前端
|
||||
├── mateclaw-desktop/ Electron 桌面端
|
||||
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时)
|
||||
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
||||
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
||||
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
||||
├── mateclaw-plugin-sample/ 参考插件实现
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层次 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
|
||||
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
|
||||
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
||||
| 数据库 | H2(开发)· MySQL 8.0+(生产)|
|
||||
| 认证 | Spring Security + JWT |
|
||||
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
||||
| Webchat | Vite library 模式 · UMD + ES bundle |
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档请访问 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**
|
||||
|
||||
---
|
||||
完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
|
||||
|
||||
## 路线图
|
||||
|
||||
- 更丰富的多智能体协作
|
||||
- 更智能的模型路由
|
||||
- 更深度的多模态理解
|
||||
- 更强的长期记忆
|
||||
- 更丰富的 ClawHub 生态
|
||||
**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。完整故事见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
|
||||
|
||||
---
|
||||
**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
@ -221,10 +232,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">+ Transformations (1.3.0+)</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">A library, not a vector store</text>
|
||||
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Right: Tools & Skills ===== -->
|
||||
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/>
|
||||
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Tools & 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,12 +98,19 @@
|
||||
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
||||
|
||||
<!-- ===== Bottom Center: Models ===== -->
|
||||
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
|
||||
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
|
||||
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
|
||||
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
|
||||
|
||||
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
|
||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
||||
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Model Layer</text>
|
||||
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ Providers</text>
|
||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
||||
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Provider Pool · Failover</text>
|
||||
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ providers</text>
|
||||
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
|
||||
<!-- Arrow up to agent (short, stops before orchestration band) -->
|
||||
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
|
||||
<text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.8 KiB |
@ -33,18 +33,19 @@
|
||||
<!-- ===== Center: Agent Core ===== -->
|
||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">智能体</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">推理 · 规划 · 执行</text>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
|
||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||
|
||||
<!-- ===== Top: User Surfaces ===== -->
|
||||
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/>
|
||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
|
||||
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">用户触点</text>
|
||||
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
||||
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
||||
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
|
||||
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||
<text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
||||
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
||||
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
|
||||
<text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
|
||||
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
|
||||
<!-- Arrow down -->
|
||||
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
||||
@ -53,35 +54,42 @@
|
||||
<rect x="40" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
|
||||
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">知识系统</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">Wiki 知识库</text>
|
||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 链接</text>
|
||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">记忆提取与整理</text>
|
||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">工作区上下文文件</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">知识不是存储,是塑造</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki 知识库</text>
|
||||
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 双向链接</text>
|
||||
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">引用溯源 + 软归档</text>
|
||||
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ 加工器流水线(1.3.0+)</text>
|
||||
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">是图书馆,不是向量库</text>
|
||||
<!-- Arrow right -->
|
||||
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Right: Tools & Skills ===== -->
|
||||
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/>
|
||||
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">工具与技能</text>
|
||||
<text x="820" y="264" text-anchor="middle" font-size="10" fill="#665245">内置工具集</text>
|
||||
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP 协议扩展</text>
|
||||
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">技能包 + ClawHub</text>
|
||||
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">安全审批与防护</text>
|
||||
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">能力需要边界</text>
|
||||
<!-- Arrow left -->
|
||||
<line x1="552" y1="280" x2="720" y2="280" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,275 546,280 554,285" fill="#d96d46" opacity="0.6"/>
|
||||
<!-- ===== Right Top: Tools & Skills ===== -->
|
||||
<rect x="720" y="210" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="210" width="4" height="65" rx="2" fill="url(#primary)"/>
|
||||
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">技能 · MCP · ACP</text>
|
||||
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
|
||||
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Claude Code 也来当员工</text>
|
||||
<!-- Arrow to Tools -->
|
||||
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Bottom Left: Memory ===== -->
|
||||
<!-- ===== Right Bottom: Security & Approval ===== -->
|
||||
<rect x="720" y="285" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="916" y="285" width="4" height="65" rx="2" fill="url(#accent)"/>
|
||||
<text x="820" y="307" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">安全与审批</text>
|
||||
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + 审批流</text>
|
||||
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">会动手,不擅自动手</text>
|
||||
<!-- Arrow to Security -->
|
||||
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
|
||||
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆层</text>
|
||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文管理</text>
|
||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">长期提取 + 定时整理</text>
|
||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">记忆应该积累而非消散</text>
|
||||
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆 · Dreaming</text>
|
||||
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文 + 长期提取</text>
|
||||
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">夜里整合,早上接着</text>
|
||||
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">你睡了它在工作</text>
|
||||
<!-- Arrow up-right -->
|
||||
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
||||
@ -97,13 +105,19 @@
|
||||
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
|
||||
|
||||
<!-- ===== Bottom Center: Models ===== -->
|
||||
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
|
||||
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
|
||||
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
|
||||
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
|
||||
|
||||
<!-- ===== Bottom Center: Models + Failover ===== -->
|
||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
||||
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型供应</text>
|
||||
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
||||
<!-- Arrow up -->
|
||||
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
|
||||
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
|
||||
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
||||
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">健康追踪 · 自动切换</text>
|
||||
<!-- Arrow up to agent (short, stops before orchestration band) -->
|
||||
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
|
||||
|
||||
<!-- Footer -->
|
||||
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 9.0 KiB |
@ -56,37 +56,39 @@
|
||||
</g>
|
||||
<g transform="translate(632, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Channel Adapters</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">DingTalk / Feishu / WeCom</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">China IM (5)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">DingTalk · Feishu</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">WeCom · WeChat · QQ</text>
|
||||
</g>
|
||||
<g transform="translate(776, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhooks</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Telegram / Discord / QQ</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Global IM (3)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram · Discord</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT ENGINE</text>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
|
||||
|
||||
<g transform="translate(56, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Think → Act → Observe</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Iterative Reasoning Loop</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
|
||||
</g>
|
||||
<g transform="translate(244, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Decompose → Step Execute</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Complex Task Orchestration</text>
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Workflow + Trigger</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
|
||||
</g>
|
||||
<g transform="translate(432, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool System</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in + MCP + Skills</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval + Guard Rules</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
|
||||
</g>
|
||||
<g transform="translate(620, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -97,8 +99,8 @@
|
||||
<g transform="translate(808, 222)">
|
||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">Digestion</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 3: Core Services ===== -->
|
||||
@ -132,9 +134,9 @@
|
||||
|
||||
<g transform="translate(516, 356)">
|
||||
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Unified Abstraction</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Provider Pool</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI · Failover</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Health Tracker · Cooldown</text>
|
||||
</g>
|
||||
<g transform="translate(650, 356)">
|
||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -176,8 +178,9 @@
|
||||
</g>
|
||||
<g transform="translate(632, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron Scheduler</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Task Automation</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · Event Bus</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock distributed</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Ambient AI · Proactive</text>
|
||||
</g>
|
||||
<g transform="translate(776, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
@ -191,7 +194,7 @@
|
||||
<line x1="250" y1="438" x2="250" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
||||
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@ -59,49 +59,51 @@
|
||||
</g>
|
||||
<g transform="translate(632, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">多渠道适配器</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">钉钉 / 飞书 / 企微 / TG</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">国内 IM (5)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">钉钉·飞书·企微</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">微信·QQ</text>
|
||||
</g>
|
||||
<g transform="translate(776, 118)">
|
||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhook</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Discord / QQ</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">海外 IM (3)</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram·Discord</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">智能体引擎</text>
|
||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
|
||||
|
||||
<g transform="translate(56, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">思考 → 行动 → 观察</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">循环推理引擎</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
|
||||
</g>
|
||||
<g transform="translate(244, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">计划分解 → 逐步执行</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">复杂任务编排</text>
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工作流 + 触发器</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step mode · 6 pattern</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排(1.3.0+)</text>
|
||||
</g>
|
||||
<g transform="translate(432, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具系统</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 + MCP + 技能包</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">安全审批 + 防护规则</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
|
||||
</g>
|
||||
<g transform="translate(620, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆系统</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">定时整理 + 记忆涌现</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
|
||||
</g>
|
||||
<g transform="translate(808, 222)">
|
||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">结构化页面</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器(1.3.0)</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 3: Core Services ===== -->
|
||||
@ -135,9 +137,9 @@
|
||||
|
||||
<g transform="translate(516, 356)">
|
||||
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">统一模型抽象</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text>
|
||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">模型池 · Failover</text>
|
||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI 统一抽象</text>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">健康追踪 · 自动切换</text>
|
||||
</g>
|
||||
<g transform="translate(650, 356)">
|
||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
@ -179,8 +181,9 @@
|
||||
</g>
|
||||
<g transform="translate(632, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">定时任务</text>
|
||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Cron 调度引擎</text>
|
||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · 事件总线</text>
|
||||
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock 分布式锁</text>
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付 · Ambient AI</text>
|
||||
</g>
|
||||
<g transform="translate(776, 488)">
|
||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
||||
@ -195,7 +198,7 @@
|
||||
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
|
||||
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
|
||||
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 1.0 MiB |
@ -21,8 +21,9 @@ services:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
|
||||
# Schema and seed data are managed by Flyway on application startup.
|
||||
# Do NOT mount legacy schema.sql / data.sql here — Flyway creates all
|
||||
# tables from V1 baseline and applies incremental migrations automatically.
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
@ -30,18 +31,26 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# SearXNG 搜索引擎(keyless 搜索 provider,零配置可用)
|
||||
# SearXNG 搜索引擎(keyless 搜索 provider)
|
||||
#
|
||||
# The custom image at docker/searxng/ bakes in settings.yml so the sidecar
|
||||
# works out of the box (upstream image ships JSON disabled + Limiter enabled,
|
||||
# both of which silently break mateclaw's SearXNGSearchProvider).
|
||||
# No host bind-mount — edit docker/searxng/settings.yml and rebuild.
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
build:
|
||||
context: ./docker/searxng
|
||||
container_name: mateclaw-searxng
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://searxng:8080
|
||||
volumes:
|
||||
- searxng_data:/etc/searxng
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
|
||||
- UWSGI_WORKERS=2
|
||||
- UWSGI_THREADS=4
|
||||
ports:
|
||||
- "8088:8080"
|
||||
healthcheck:
|
||||
# Healthz needs json format, so this also doubles as an integration check.
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
@ -50,8 +59,10 @@ services:
|
||||
# MateClaw 后端服务
|
||||
mateclaw-server:
|
||||
build:
|
||||
context: ./mateclaw-server
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: mateclaw-server/Dockerfile
|
||||
args:
|
||||
MAVEN_FLAGS: ${MAVEN_FLAGS:-}
|
||||
container_name: mateclaw-server
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@ -66,16 +77,35 @@ services:
|
||||
DB_NAME: ${DB_NAME:-mateclaw}
|
||||
DB_USERNAME: ${DB_USERNAME:-mateclaw}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
|
||||
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?DASHSCOPE_API_KEY is required in .env}
|
||||
# LLM provider keys (DashScope / OpenAI / Anthropic / DeepSeek / Kimi / …) are
|
||||
# NOT configured via env vars. After startup, add providers in the admin UI:
|
||||
# Settings → Models → Add Provider
|
||||
# Keys are stored in mate_model_provider and hot-reloaded.
|
||||
SERPER_API_KEY: ${SERPER_API_KEY:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
|
||||
# SearXNG: tell the app where to reach the sidecar container
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080}
|
||||
# Browser automation: the runtime image (mcr.microsoft.com/playwright:*)
|
||||
# bakes Chromium + system libs + fonts in, so the tool works out of the box.
|
||||
# Override these if you want to attach to an external Chrome (CDP sidecar):
|
||||
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
|
||||
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
|
||||
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
|
||||
# OAuth 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 DEVICE_CODE。
|
||||
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
|
||||
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
|
||||
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
|
||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||
# recommendation for Playwright / headless chrome.
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- "18080:18080"
|
||||
- "18080:18088" # host:container — app listens on 18088 inside the container
|
||||
- "1455:1455"
|
||||
volumes:
|
||||
- server_data:/app/data
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
server_data:
|
||||
searxng_data:
|
||||
|
||||
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,112 @@
|
||||
# 多阶段构建
|
||||
FROM maven:3.9-eclipse-temurin-21 AS builder
|
||||
WORKDIR /build
|
||||
COPY pom.xml .
|
||||
RUN mvn dependency:go-offline -q
|
||||
COPY src ./src
|
||||
RUN mvn package -DskipTests -q
|
||||
# Multi-stage build
|
||||
#
|
||||
# Stage 1 — Frontend (Node / pnpm)
|
||||
# Builds the Vue 3 admin SPA and emits static files to /static inside the
|
||||
# build container. These files are later copied into the JAR's classpath so
|
||||
# Spring Boot serves the SPA at the root URL.
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
# Pin pnpm to a major version so the Docker build doesn't break when the npm
|
||||
# `latest` tag jumps majors. pnpm v10+ blocks dependency lifecycle scripts by
|
||||
# default; the allowed packages live under `pnpm.onlyBuiltDependencies` in
|
||||
# mateclaw-ui/package.json.
|
||||
RUN npm install -g pnpm@10 --silent
|
||||
WORKDIR /frontend
|
||||
# Install dependencies first (layer cache)
|
||||
COPY mateclaw-ui/package.json mateclaw-ui/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Copy source and build
|
||||
COPY mateclaw-ui/ ./
|
||||
# Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which
|
||||
# is outside this container; call vite directly to control --outDir.
|
||||
# NODE_OPTIONS=--max-old-space-size=6144 keeps Rollup's `rendering chunks`
|
||||
# phase from getting SIGKILL'd by the host kernel's OOM-killer on memory-
|
||||
# constrained servers. The earlier removal of this flag relied on lazy-
|
||||
# loading + manualChunks dropping the per-chunk peak, but Rollup still
|
||||
# minifies several vendor chunks (monaco / mermaid / echarts) in parallel
|
||||
# so the cumulative working set blows past Node's default ~1.5 GB heap
|
||||
# and trips the OOM-killer mid-build. The fix is not the heap flag
|
||||
# itself; it is keeping the build reproducible on smaller hosts.
|
||||
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
|
||||
# the production Docker image build.
|
||||
RUN NODE_OPTIONS=--max-old-space-size=6144 pnpm exec vite build --outDir /static --emptyOutDir
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# Stage 2 — Backend (Maven)
|
||||
FROM maven:3.9-eclipse-temurin-21 AS builder
|
||||
|
||||
# Optional Maven extra flags passed at build time.
|
||||
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun
|
||||
# repos first — 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
|
||||
EXPOSE 1455
|
||||
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.3.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MateClaw Server</name>
|
||||
@ -15,17 +15,17 @@
|
||||
<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 Alibaba 1.1.2.2(对应 Spring AI 1.1.x) -->
|
||||
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
|
||||
<!-- Spring AI 1.1.6 正式版(patch upgrade from 1.1.5) -->
|
||||
<spring-ai.version>1.1.6</spring-ai.version>
|
||||
<!-- Spring AI Alibaba 1.1.2.3(对应 Spring AI 1.1.x) -->
|
||||
<spring-ai-alibaba.version>1.1.2.3</spring-ai-alibaba.version>
|
||||
<mybatis-plus.version>3.5.16</mybatis-plus.version>
|
||||
<hutool.version>5.8.26</hutool.version>
|
||||
<springdoc.version>2.8.16</springdoc.version>
|
||||
@ -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,122 @@
|
||||
<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>
|
||||
|
||||
<!-- ===== Markdown -> PDF rendering =====
|
||||
Flying Saucer 9.13 ships a single `flying-saucer-pdf` artifact that
|
||||
writes PDF via OpenPDF (LGPL fork of iText 5). It does NOT depend on
|
||||
PDFBox, so it sidesteps a version conflict with the existing
|
||||
pdfbox:3.0.3 dependency. CSS3 paged-media features (@page,
|
||||
counter(page), counter(pages), @top-center / @bottom-center) are
|
||||
supported, which the cover / header / footer rendering relies on.
|
||||
|
||||
commonmark-java is the reference CommonMark implementation,
|
||||
actively maintained on a monthly cadence (vs. flexmark, whose
|
||||
upstream stalled at 0.64.8 in 2023). It parses markdown into the
|
||||
XHTML Flying Saucer consumes. The alternative LibreOffice path in
|
||||
PdfRenderTool reuses MarkdownDocxRenderer + a soffice subprocess
|
||||
and adds no dependencies of its own. -->
|
||||
<dependency>
|
||||
<groupId>org.xhtmlrenderer</groupId>
|
||||
<artifactId>flying-saucer-pdf</artifactId>
|
||||
<version>9.13.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-autolink</artifactId>
|
||||
<version>0.28.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Database Migration (Flyway) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
@ -290,6 +412,63 @@
|
||||
<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>
|
||||
|
||||
<!-- Expression language used by the workflow compiler to evaluate
|
||||
conditional step expressions and template variable references.
|
||||
Restricted to a small subset (~20 operators / filters) at the
|
||||
evaluator wrapper layer; arbitrary template includes / extends
|
||||
are blocked. -->
|
||||
<dependency>
|
||||
<groupId>io.pebbletemplates</groupId>
|
||||
<artifactId>pebble</artifactId>
|
||||
<version>3.2.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@ -306,6 +485,165 @@
|
||||
</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>
|
||||
|
||||
<!--
|
||||
Profile: focused test run for image / video generation features.
|
||||
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
|
||||
Limits surefire to JUnit 5 tests carrying @Tag("media-gen") so the
|
||||
full ~50-min suite is skipped when iterating on this surface.
|
||||
Add a tag to a new test with @Tag("media-gen") to opt it in.
|
||||
-->
|
||||
<profile>
|
||||
<id>media-gen</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<groups>media-gen</groups>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
||||
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;
|
||||
@ -16,13 +15,19 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@SpringBootApplication(exclude = {
|
||||
// 禁用 Spring AI MCP Client 自动配置(由 McpClientManager 自行管理生命周期)
|
||||
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
|
||||
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
|
||||
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
|
||||
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
|
||||
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
|
||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
|
||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
||||
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
|
||||
// not the chat model. We don't use it — model configuration is admin-UI driven and
|
||||
// built by AgentDashScopeChatModelBuilder. Its auto-config strictly requires
|
||||
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
|
||||
// ApplicationContext fail when users deploy via Docker without setting the key.
|
||||
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
|
||||
})
|
||||
@EnableScheduling
|
||||
@MapperScan("vip.mate.**.repository")
|
||||
@ -33,12 +38,17 @@ public class MateClawApplication {
|
||||
}
|
||||
|
||||
/**
|
||||
* MyBatis Plus 分页插件
|
||||
* MyBatis Plus pagination plugin.
|
||||
*
|
||||
* <p>DbType is auto-detected from the JDBC connection at runtime rather
|
||||
* than hardcoded. Hardcoding H2 here meant the MySQL deployment used
|
||||
* the H2 dialect for the count query, which silently returned 0 —
|
||||
* frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -3,19 +3,29 @@ package vip.mate.agent;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.agent.event.AgentLifecycleEvent;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.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 +43,13 @@ public class AgentService {
|
||||
private final AgentMapper agentMapper;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
private final MemoryLifecycleMediator lifecycleMediator;
|
||||
private final MemoryProperties memoryProperties;
|
||||
|
||||
/** Field-injected publisher for agent_lifecycle trigger events; the
|
||||
* trigger module's bridge listens and forwards into ingest. */
|
||||
@Autowired(required = false)
|
||||
private ApplicationEventPublisher events;
|
||||
|
||||
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
||||
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
||||
@ -48,9 +65,26 @@ public class AgentService {
|
||||
* 按工作区列出 Agent
|
||||
*/
|
||||
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
|
||||
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
||||
.eq(AgentEntity::getWorkspaceId, workspaceId)
|
||||
.orderByDesc(AgentEntity::getCreateTime));
|
||||
return listAgentsByWorkspace(workspaceId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按工作区列出 Agent,可选过滤启用状态。
|
||||
*
|
||||
* @param enabled non-null restricts the result set to agents whose
|
||||
* {@code enabled} column matches the given value.
|
||||
* Pass {@code true} from chat selectors so disabled
|
||||
* agents disappear from the picker; the admin
|
||||
* management page passes {@code null} to keep
|
||||
* disabled rows visible for re-enabling.
|
||||
*/
|
||||
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId, Boolean enabled) {
|
||||
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
|
||||
.eq(AgentEntity::getWorkspaceId, workspaceId);
|
||||
if (enabled != null) {
|
||||
q.eq(AgentEntity::getEnabled, enabled);
|
||||
}
|
||||
return agentMapper.selectList(q.orderByDesc(AgentEntity::getCreateTime));
|
||||
}
|
||||
|
||||
public AgentEntity getAgent(Long id) {
|
||||
@ -66,19 +100,100 @@ public class AgentService {
|
||||
if (agent.getAgentType() == null) {
|
||||
agent.setAgentType("react");
|
||||
}
|
||||
requireUniqueName(agent, null);
|
||||
agentMapper.insert(agent);
|
||||
publishLifecycle(agent, "spawned");
|
||||
return agent;
|
||||
}
|
||||
|
||||
public AgentEntity updateAgent(AgentEntity agent) {
|
||||
// Detect enabled-flag flip so the lifecycle event reflects the
|
||||
// intent rather than every metadata edit. Reading the prior row
|
||||
// is cheap and gives us a clean diff source.
|
||||
AgentEntity prior = agentMapper.selectById(agent.getId());
|
||||
// Only re-validate uniqueness when the name actually changes —
|
||||
// a pure metadata edit (icon, prompt, ...) shouldn't pay the
|
||||
// SELECT cost or risk a false positive against the row itself.
|
||||
if (prior != null
|
||||
&& agent.getName() != null
|
||||
&& !agent.getName().equals(prior.getName())) {
|
||||
// Workspace cannot be moved (Controller pins it to prior.workspaceId),
|
||||
// so reuse it for the lookup even if the incoming DTO left it null.
|
||||
if (agent.getWorkspaceId() == null) {
|
||||
agent.setWorkspaceId(prior.getWorkspaceId());
|
||||
}
|
||||
requireUniqueName(agent, agent.getId());
|
||||
}
|
||||
agentMapper.updateById(agent);
|
||||
agentInstances.remove(agent.getId());
|
||||
if (prior != null && prior.getEnabled() != null
|
||||
&& !prior.getEnabled().equals(agent.getEnabled())) {
|
||||
publishLifecycle(agent,
|
||||
Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled");
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly business-code surface for the {@code (workspace_id, name)}
|
||||
* unique index added in V102.
|
||||
*
|
||||
* <p>The wire shape is the project-wide R<T> envelope: HTTP status
|
||||
* stays 200 (per the convention in {@code R.fail} and the axios
|
||||
* interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in
|
||||
* the response body's {@code code} field so the front-end can branch
|
||||
* without breaking on an axios error. Without this pre-check the
|
||||
* duplicate save would surface as an opaque
|
||||
* {@code DataIntegrityViolation} stack trace.
|
||||
*
|
||||
* @param excludeId when non-null, skip this row in the lookup so
|
||||
* {@link #updateAgent} doesn't mistake the row for its
|
||||
* own duplicate.
|
||||
*/
|
||||
private void requireUniqueName(AgentEntity agent, Long excludeId) {
|
||||
if (agent.getName() == null || agent.getName().isBlank()) {
|
||||
throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空");
|
||||
}
|
||||
Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
|
||||
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
|
||||
.eq(AgentEntity::getWorkspaceId, workspaceId)
|
||||
.eq(AgentEntity::getName, agent.getName());
|
||||
if (excludeId != null) {
|
||||
q.ne(AgentEntity::getId, excludeId);
|
||||
}
|
||||
Long count = agentMapper.selectCount(q);
|
||||
if (count != null && count > 0) {
|
||||
throw new MateClawException("err.agent.duplicate_name", 409,
|
||||
"工作区内已存在同名 Agent: " + agent.getName());
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteAgent(Long id) {
|
||||
AgentEntity prior = agentMapper.selectById(id);
|
||||
agentMapper.deleteById(id);
|
||||
agentInstances.remove(id);
|
||||
if (prior != null) publishLifecycle(prior, "terminated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort publish of an {@link AgentLifecycleEvent}. A publish
|
||||
* failure must never roll back the agent CRUD that just succeeded —
|
||||
* the agent_lifecycle trigger surface is observability, not the
|
||||
* canonical record.
|
||||
*/
|
||||
private void publishLifecycle(AgentEntity agent, String phase) {
|
||||
if (events == null || agent == null) return;
|
||||
try {
|
||||
events.publishEvent(new AgentLifecycleEvent(
|
||||
agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(),
|
||||
agent.getId() == null ? 0L : agent.getId(),
|
||||
agent.getName(),
|
||||
phase,
|
||||
System.currentTimeMillis()));
|
||||
} catch (Exception e) {
|
||||
log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}",
|
||||
agent.getId(), phase, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -91,28 +206,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 +283,45 @@ public class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
if (agent instanceof StructuredStreamCapable capable) {
|
||||
return capable.chatStructuredStream(message, conversationId,
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear());
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
||||
ThinkingLevelHolder.clear();
|
||||
return agent.chatStream(message, conversationId)
|
||||
.map(chunk -> new StreamDelta(chunk, null));
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId)
|
||||
.map(chunk -> new StreamDelta(chunk, null)),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId) {
|
||||
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.execute(goal, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, goal, conversationId,
|
||||
(msg, convId) -> agent.execute(msg, convId));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,9 +335,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 +356,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 +410,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) {
|
||||
@ -222,24 +484,55 @@ public class AgentService {
|
||||
|
||||
// ==================== StreamDelta ====================
|
||||
|
||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) {
|
||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
|
||||
boolean persistenceOnly, boolean segmentOnly) {
|
||||
|
||||
// 兼容构造器(广播+持久化)
|
||||
public StreamDelta(String content, String thinking) {
|
||||
this(content, thinking, null, null, false);
|
||||
this(content, thinking, null, null, false, false);
|
||||
}
|
||||
|
||||
// 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
|
||||
public StreamDelta(String content, String thinking, String eventType,
|
||||
Map<String, Object> eventData, boolean persistenceOnly) {
|
||||
this(content, thinking, eventType, eventData, persistenceOnly, false);
|
||||
}
|
||||
|
||||
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
|
||||
public static StreamDelta persistOnly(String content, String thinking) {
|
||||
return new StreamDelta(content, thinking, null, null, true);
|
||||
return new StreamDelta(content, thinking, null, null, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-iteration narrative routing for ReasoningNode / SummarizingNode output.
|
||||
*
|
||||
* <p>The accumulator should:
|
||||
* <ul>
|
||||
* <li>append the text to the in-flight {@code segments} entry so the UI's
|
||||
* segmented view still renders the intermediate "I'll look it up…"
|
||||
* narration between tool cards;</li>
|
||||
* <li>NOT broadcast — already broadcast live by NodeStreamingChatHelper;</li>
|
||||
* <li>NOT append to the top-level {@code content} StringBuilder, which is
|
||||
* what gets persisted as {@code mate_message.content}. That field
|
||||
* should hold the final-answer span only — otherwise multiple
|
||||
* iterations stack into "我来…让我…然后…" walls that next-turn replay
|
||||
* sees as unanswered chain-of-thought (issue #120 narration leg).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Implies {@code persistenceOnly} (no broadcast) at the accumulator
|
||||
* layer, but is a stricter promise: <em>nothing</em> reaches the top-level
|
||||
* persisted content field via this flavor.
|
||||
*/
|
||||
public static StreamDelta segmentOnly(String content, String thinking) {
|
||||
return new StreamDelta(content, thinking, null, null, true, true);
|
||||
}
|
||||
|
||||
public static StreamDelta empty() {
|
||||
return new StreamDelta(null, null, null, null, false);
|
||||
return new StreamDelta(null, null, null, null, false, false);
|
||||
}
|
||||
|
||||
public static StreamDelta event(String type, Map<String, Object> data) {
|
||||
return new StreamDelta(null, null, type, data, false);
|
||||
return new StreamDelta(null, null, type, data, false, false);
|
||||
}
|
||||
|
||||
public boolean isEvent() {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
package vip.mate.agent;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
@ -10,6 +12,12 @@ 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.model.ModelConfigEntity;
|
||||
import vip.mate.llm.routing.MediaCaptionService;
|
||||
import vip.mate.llm.routing.MultimodalRouter;
|
||||
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
@ -18,7 +26,11 @@ 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.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
@ -43,8 +55,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 +69,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;
|
||||
|
||||
@ -70,6 +94,31 @@ public abstract class BaseAgent {
|
||||
/** 构建时使用的 provider ID(运行时快照) */
|
||||
protected String runtimeProviderId;
|
||||
|
||||
/**
|
||||
* Full runtime model configuration used by the multimodal router to
|
||||
* decide whether the primary model can handle attachments natively.
|
||||
* Set by {@code AgentGraphBuilder} alongside {@link #modelCapabilities}.
|
||||
*/
|
||||
protected ModelConfigEntity runtimeModelConfig;
|
||||
|
||||
/**
|
||||
* The agent's effective tool set. Lifted from subclasses so
|
||||
* {@link #buildUserMessage} can ask whether the agent has any media-capable
|
||||
* tool when the primary model rejects an attachment.
|
||||
*/
|
||||
protected vip.mate.agent.AgentToolSet toolSet;
|
||||
|
||||
/**
|
||||
* Optional sidecar routing services. Null when not wired (e.g. tests with
|
||||
* minimal builders); the routing path then degrades to the legacy
|
||||
* skip-with-text-hint behavior without any extra LLM calls.
|
||||
*/
|
||||
protected MultimodalRouter multimodalRouter;
|
||||
protected MediaCaptionService mediaCaptionService;
|
||||
|
||||
/** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */
|
||||
protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE;
|
||||
|
||||
|
||||
protected BaseAgent(ChatClient chatClient, ConversationService conversationService) {
|
||||
this.chatClient = chatClient;
|
||||
@ -191,17 +240,46 @@ public abstract class BaseAgent {
|
||||
agentName, history.size(), totalCount, windowSize);
|
||||
}
|
||||
|
||||
// ===== 识别持久化的压缩摘要:从摘要位置开始,跳过更早消息 =====
|
||||
for (int i = 0; i < history.size(); i++) {
|
||||
// ===== Slice from the LATEST compression boundary, not the first =====
|
||||
// A long-running conversation can accumulate several boundaries; the
|
||||
// newest one is the only relevant cut-off because every earlier
|
||||
// boundary's content is already folded into the newer summary. Walking
|
||||
// forward and breaking on the first boundary kept everything between
|
||||
// boundaries — the very redundancy compaction was supposed to remove.
|
||||
boolean boundaryFoundInWindow = false;
|
||||
for (int i = history.size() - 1; i >= 0; i--) {
|
||||
MessageEntity msg = history.get(i);
|
||||
if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) {
|
||||
history = new ArrayList<>(history.subList(i, history.size()));
|
||||
log.info("[{}] Found compression summary, loading from index {} ({} messages)",
|
||||
boundaryFoundInWindow = true;
|
||||
log.info("[{}] Found latest compression boundary at index {}; loading {} messages forward",
|
||||
agentName, i, history.size());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Latest boundary may live OUTSIDE the recent window =====
|
||||
// On a long conversation that compacted hours/days ago and has paged
|
||||
// fewer than `windowSize` new messages since, `listRecentMessages`
|
||||
// returns only the raw tail — the boundary sat at index 0 of the
|
||||
// original list and never made it into `history`. Without prepending
|
||||
// it, the model would forget the original goal even though we already
|
||||
// paid the LLM cost to produce a structured summary.
|
||||
if (!boundaryFoundInWindow && totalCount > windowSize) {
|
||||
try {
|
||||
MessageEntity latestBoundary = conversationService.findLatestCompressionBoundary(conversationId);
|
||||
if (latestBoundary != null) {
|
||||
history = new ArrayList<>(history);
|
||||
history.add(0, latestBoundary);
|
||||
log.info("[{}] Prepended out-of-window compression boundary id={} so the model keeps the summary context",
|
||||
agentName, latestBoundary.getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[{}] findLatestCompressionBoundary failed; loading recent window without boundary: {}",
|
||||
agentName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 转换为 Spring AI Message 对象 =====
|
||||
int limit = history.size();
|
||||
if (limit > 0) {
|
||||
@ -217,20 +295,424 @@ 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);
|
||||
if (springMessage != null) {
|
||||
messages.add(springMessage);
|
||||
messages.addAll(expandToSpringMessages(history.get(i)));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Head guard — orphan tool-response strip.
|
||||
//
|
||||
// Independent of the compaction pair-safe boundary in
|
||||
// ConversationWindowManager: that one protects the *compaction* cut,
|
||||
// this one protects the *pagination* cut. listRecentMessages returns
|
||||
// the last N rows verbatim, and the first row of that page can be a
|
||||
// ToolResponseMessage whose owning AssistantMessage sat one row
|
||||
// earlier — i.e. outside the page. Sending such a sequence to any
|
||||
// OpenAI-compatible provider returns 400 because every tool response
|
||||
// must be preceded by an assistant message issuing that tool_call_id.
|
||||
//
|
||||
// The boundary prepend earlier inserts a SystemMessage at the head;
|
||||
// the orphan, if present, sits at index 1 in that case. Skip leading
|
||||
// SystemMessages and drop any leading ToolResponseMessage whose
|
||||
// response ids are not all issued by a *preceding* AssistantMessage
|
||||
// — i.e. one we have already walked past in this scan. Provider
|
||||
// validity is order-sensitive; a later same-id assistant deeper in
|
||||
// the window does NOT redeem an earlier orphan. See
|
||||
// stripHeadOrphanToolResponses below for the forward-scan details.
|
||||
//
|
||||
// Dropping is correct rather than expanding backward to fetch the
|
||||
// missing assistant: if the AssistantMessage is outside the window,
|
||||
// its content is already lost to the model anyway, and the boundary
|
||||
// summary (if any) covers it. Keeping the orphan would just trade a
|
||||
// dropped row for a 400.
|
||||
stripHeadOrphanToolResponses(messages, agentName);
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop leading {@link ToolResponseMessage}s whose owning
|
||||
* {@link AssistantMessage} sits <em>before</em> them in this list. Provider
|
||||
* validity is order-sensitive: a tool response must follow the assistant
|
||||
* that issued the tool_call_id; an unrelated later AssistantMessage that
|
||||
* happens to carry the same id does not redeem an earlier orphan.
|
||||
*
|
||||
* <p>Algorithm: forward scan with a {@code seenIssuedIds} set. Leading
|
||||
* {@link SystemMessage}s (boundary rows, system prompts) pass through
|
||||
* untouched but contribute no ids. The first {@link AssistantMessage} or
|
||||
* {@link UserMessage} we hit stops the repair walk — by that point we're
|
||||
* out of head-orphan territory. Every {@link ToolResponseMessage} we
|
||||
* encounter before that stop is checked against {@code seenIssuedIds};
|
||||
* if every response id is unseen, the message is dropped and the scan
|
||||
* re-examines the new head. A response whose ids are all in the seen
|
||||
* set (e.g. {@code [system, assistant(X), toolResponse(X), ...]} when
|
||||
* the assistant fell at index 1 of the slice) is left in place.
|
||||
*
|
||||
* <p>Mixed responses (some ids matched, some not) inside a single
|
||||
* leading {@code ToolResponseMessage} are dropped wholesale rather than
|
||||
* surgically rewritten — the provider would reject partially-broken
|
||||
* sequences anyway, and the mixed case implies an upstream invariant
|
||||
* violation that surfaces in logs.
|
||||
*
|
||||
* <p>Package-private + static so unit tests can drive it without standing
|
||||
* up a full BaseAgent subclass.
|
||||
*/
|
||||
static int stripHeadOrphanToolResponses(List<Message> messages, String agentName) {
|
||||
if (messages.isEmpty()) return 0;
|
||||
|
||||
// Built up as we walk; only assistants we've already passed count
|
||||
// toward "preceding". An assistant that sits behind a head orphan is
|
||||
// irrelevant: provider order-validity asks "was this tool_call id
|
||||
// issued BEFORE this response?", not "anywhere in the prompt".
|
||||
Set<String> seenIssuedIds = new HashSet<>();
|
||||
|
||||
int dropped = 0;
|
||||
int i = 0;
|
||||
while (i < messages.size()) {
|
||||
Message m = messages.get(i);
|
||||
if (m instanceof SystemMessage) {
|
||||
// Boundary rows / system prompts pass through; advance and
|
||||
// keep looking for orphan tool responses that sit behind them.
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (m instanceof AssistantMessage am) {
|
||||
// Reached a preceding assistant — head danger is over. The
|
||||
// tool_call ids it issued are valid for any tool responses
|
||||
// that follow, but we stop the repair walk here either way.
|
||||
if (am.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||
if (tc.id() != null && !tc.id().isEmpty()) {
|
||||
seenIssuedIds.add(tc.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
// Every response id must have been issued by a preceding
|
||||
// assistant we already walked through. If any single id is
|
||||
// missing from seenIssuedIds, the message is invalid in
|
||||
// place. Empty / null ids don't count for or against.
|
||||
boolean anyUnmatched = trm.getResponses().stream()
|
||||
.map(ToolResponseMessage.ToolResponse::id)
|
||||
.filter(id -> id != null && !id.isEmpty())
|
||||
.anyMatch(id -> !seenIssuedIds.contains(id));
|
||||
if (anyUnmatched) {
|
||||
messages.remove(i);
|
||||
dropped++;
|
||||
continue; // re-examine the new messages[i]
|
||||
}
|
||||
// All ids match a preceding assistant — keep, and stop the
|
||||
// repair walk. Anything past here is well-formed by
|
||||
// construction (provider validates each subsequent pair as
|
||||
// we go).
|
||||
break;
|
||||
}
|
||||
// UserMessage (or anything else) — past the head danger. Stop.
|
||||
break;
|
||||
}
|
||||
if (dropped > 0) {
|
||||
log.info("[{}] Stripped {} leading orphan ToolResponseMessage(s) — no preceding AssistantMessage in scope",
|
||||
agentName, dropped);
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a persisted message as 1..N Spring AI {@link Message}s.
|
||||
*
|
||||
* <p>Plain user/system/assistant rows expand to a single message via
|
||||
* {@link #sanitizeForLlm}. Assistant rows that issued tool calls during
|
||||
* the original turn expand to TWO messages:
|
||||
* <ol>
|
||||
* <li>{@link AssistantMessage} carrying the persisted narration <em>and</em>
|
||||
* the {@link AssistantMessage.ToolCall} list reconstructed from
|
||||
* {@code metadata.toolCalls}.</li>
|
||||
* <li>A single {@link ToolResponseMessage} bundling one
|
||||
* {@link ToolResponseMessage.ToolResponse} per completed tool call,
|
||||
* with the persisted {@code result} string as content.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Without this expansion the next turn would see a bare assistant text
|
||||
* row containing chain-of-thought like "Let me try the browser..." but no
|
||||
* tool calls and no observations. The LLM concludes the action wasn't
|
||||
* actually performed and retries the same tool, looping until the iteration
|
||||
* cap. Issuing the structured tool_call + tool_response pair lets the model
|
||||
* see what already ran and reason from the result instead.
|
||||
*
|
||||
* <p>Only completed tool calls (status=completed AND result present) are
|
||||
* replayed. Calls left in awaiting_approval or running state are dropped
|
||||
* — replaying them without a paired response would produce a sequence the
|
||||
* provider rejects (every tool_call_id must have a matching tool_response).
|
||||
*
|
||||
* <p>RFC-052 direct-tool rows take the existing scrub path unchanged; the
|
||||
* placeholder text already names the originating tool and instructs the
|
||||
* model to re-call if needed, which is the correct multi-turn signal for
|
||||
* returnDirect tools.
|
||||
*/
|
||||
List<Message> expandToSpringMessages(MessageEntity entity) {
|
||||
if (entity == null) return List.of();
|
||||
// Stages 0/1/1.5 in sanitizeForLlm drop cron-header system rows,
|
||||
// approval-placeholder assistants, and error-status assistants. Those
|
||||
// rows must NOT replay structured tool exchanges even if metadata still
|
||||
// carries them — the underlying turn is broken or synthetic. Centralize
|
||||
// that decision so we can apply it before the empty-content check.
|
||||
if (shouldFullyFilter(entity)) return List.of();
|
||||
|
||||
// Non-assistant rows expand to exactly one message via the existing
|
||||
// conversion path (which also handles user-message media injection).
|
||||
if (!"assistant".equals(entity.getRole())) {
|
||||
Message m = toSpringMessage(entity);
|
||||
return m == null ? List.of() : List.of(m);
|
||||
}
|
||||
|
||||
// RFC-052 direct-tool rows: keep the existing placeholder-only path.
|
||||
// The placeholder names the originating tool and tells the model to
|
||||
// re-call it; resurrecting the original tool_call/tool_response pair
|
||||
// would leak the very content RFC-052 elides.
|
||||
if (!directToolNamesIn(entity).isEmpty()) {
|
||||
Message m = toSpringMessage(entity);
|
||||
return m == null ? List.of() : List.of(m);
|
||||
}
|
||||
|
||||
String renderedContent = conversationService.renderMessageContent(entity);
|
||||
List<PersistedToolCall> persisted = extractCompletedToolCalls(entity);
|
||||
|
||||
// No tool calls to replay → fall back to the legacy single-message
|
||||
// path, which preserves the "drop on blank content" behavior for
|
||||
// narration-only assistants.
|
||||
if (persisted.isEmpty()) {
|
||||
Message m = toSpringMessage(entity);
|
||||
return m == null ? List.of() : List.of(m);
|
||||
}
|
||||
|
||||
// Have completed tool calls → emit the structured pair regardless of
|
||||
// whether the rendered content is blank. A pure tool-call turn (LLM
|
||||
// returned tool_calls with no preamble text) is the canonical case
|
||||
// here: previously the row was dropped entirely because
|
||||
// renderMessageContent collapsed to "" once the tool_call part was
|
||||
// skipped, leaving the next turn with no record that the tools ran.
|
||||
return buildToolExchange(entity, renderedContent == null ? "" : renderedContent, persisted);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the persisted row must be dropped before any history reaches
|
||||
* the LLM, irrespective of its tool-call payload. Mirrors stages 0/1/1.5
|
||||
* of {@link #sanitizeForLlm}: cron-run header system rows, approval
|
||||
* placeholders, and error-status / "[错误] " assistants. Replaying tool
|
||||
* exchanges from these would resurface UI scaffolding or self-replicate
|
||||
* provider failures.
|
||||
*/
|
||||
static boolean shouldFullyFilter(MessageEntity entity) {
|
||||
if (entity == null) return true;
|
||||
String role = entity.getRole();
|
||||
if ("system".equals(role)
|
||||
&& entity.getContent() != null
|
||||
&& entity.getContent().startsWith("📋 ")) return true;
|
||||
if ("assistant".equals(role)
|
||||
&& isApprovalPlaceholder(entity.getContent())) return true;
|
||||
if ("assistant".equals(role)
|
||||
&& ("error".equals(entity.getStatus())
|
||||
|| (entity.getContent() != null
|
||||
&& entity.getContent().startsWith("[错误] ")))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the structured {@code [AssistantMessage(toolCalls), ToolResponseMessage]}
|
||||
* pair from a persisted row. Pure: depends only on its arguments, so it
|
||||
* can be exercised directly from unit tests without a BaseAgent fixture.
|
||||
*
|
||||
* <p>Both sides reuse the same id for each call so the in-prompt sequence
|
||||
* validates with every provider's tool_call_id pairing rule. For legacy
|
||||
* rows persisted before {@code toolCallId} was captured, synthesize a
|
||||
* stable id from {@code entity.id + index}. The id never escapes this
|
||||
* prompt; synthetic and real ids cannot collide downstream.
|
||||
*/
|
||||
static List<Message> buildToolExchange(MessageEntity entity, String content,
|
||||
List<PersistedToolCall> persisted) {
|
||||
List<AssistantMessage.ToolCall> toolCalls = new ArrayList<>(persisted.size());
|
||||
List<ToolResponseMessage.ToolResponse> responses = new ArrayList<>(persisted.size());
|
||||
for (int i = 0; i < persisted.size(); i++) {
|
||||
PersistedToolCall p = persisted.get(i);
|
||||
String id = (p.toolCallId() == null || p.toolCallId().isEmpty())
|
||||
? "legacy-" + entity.getId() + "-" + i
|
||||
: p.toolCallId();
|
||||
toolCalls.add(new AssistantMessage.ToolCall(id, "function", p.name(), p.arguments()));
|
||||
responses.add(new ToolResponseMessage.ToolResponse(id, p.name(), p.result()));
|
||||
}
|
||||
AssistantMessage rebuilt = AssistantMessage.builder()
|
||||
.content(content == null ? "" : content)
|
||||
.toolCalls(toolCalls)
|
||||
.build();
|
||||
ToolResponseMessage toolResponses = ToolResponseMessage.builder()
|
||||
.responses(responses)
|
||||
.build();
|
||||
return List.of(rebuilt, toolResponses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@code metadata.toolCalls} into a list of completed entries that
|
||||
* are safe to replay. Returns empty if metadata is missing, malformed, or
|
||||
* carries no entry with both {@code status='completed'} and a non-null
|
||||
* {@code result}.
|
||||
*
|
||||
* <p>Handles H2's JSON-column double-wrap (the column read can produce a
|
||||
* JSON-encoded string of JSON) the same way
|
||||
* {@code ConversationService#reconcileResolvedMessages} does.
|
||||
*/
|
||||
static List<PersistedToolCall> extractCompletedToolCalls(MessageEntity entity) {
|
||||
if (entity == null) return List.of();
|
||||
String raw = entity.getMetadata();
|
||||
if (raw == null || raw.isBlank() || !raw.contains("toolCalls")) return List.of();
|
||||
try {
|
||||
String json = raw.trim();
|
||||
if (json.startsWith("\"") && json.endsWith("\"")) {
|
||||
json = HISTORY_METADATA_MAPPER.readValue(json, String.class);
|
||||
}
|
||||
Map<String, Object> meta = HISTORY_METADATA_MAPPER.readValue(json,
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
Object tc = meta.get("toolCalls");
|
||||
if (!(tc instanceof List<?> list)) return List.of();
|
||||
List<PersistedToolCall> result = new ArrayList<>(list.size());
|
||||
for (Object entry : list) {
|
||||
if (!(entry instanceof Map<?, ?> raw2)) continue;
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> call = (Map<String, Object>) raw2;
|
||||
if (!"completed".equals(String.valueOf(call.get("status")))) continue;
|
||||
Object resultField = call.get("result");
|
||||
if (resultField == null) continue;
|
||||
String name = String.valueOf(call.getOrDefault("name", ""));
|
||||
if (name.isBlank()) continue;
|
||||
String args = String.valueOf(call.getOrDefault("arguments", ""));
|
||||
String toolCallId = String.valueOf(call.getOrDefault("toolCallId", ""));
|
||||
result.add(new PersistedToolCall(toolCallId, name, args, String.valueOf(resultField)));
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.warn("[BaseAgent] Failed to parse metadata.toolCalls for replay (msgId={}): {}",
|
||||
entity.getId(), e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final ObjectMapper HISTORY_METADATA_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* A completed tool call recovered from {@code mate_message.metadata}, ready
|
||||
* to be replayed as an {@link AssistantMessage.ToolCall} / matching
|
||||
* {@link ToolResponseMessage.ToolResponse} pair.
|
||||
*/
|
||||
record PersistedToolCall(String toolCallId, String name, String arguments, String result) {}
|
||||
|
||||
/**
|
||||
* 判断消息是否为持久化的压缩摘要。
|
||||
*/
|
||||
@ -256,6 +738,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 +826,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,28 +861,99 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 UserMessage,支持 multimodal:如果消息包含图片/视频附件,直接注入 Spring AI Media 对象,
|
||||
* 让模型在 prompt 中直接看到媒体内容,不需要再调 MCP read_media_file 工具。
|
||||
* Build a {@link UserMessage} for the current turn, including any image/video
|
||||
* media the agent's primary model can handle natively. Returns the message
|
||||
* paired with the {@link MultimodalRoutingDecision} taken so the caller can
|
||||
* persist it as message metadata and emit a routing event.
|
||||
*/
|
||||
protected CurrentTurnUserMessage buildUserMessageForCurrentTurn(MessageEntity message, String renderedContent) {
|
||||
return buildUserMessageInternal(message, renderedContent, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* History-replay variant: text-only, no media reinjected, no routing decision.
|
||||
* Many providers cap at one video per request, so re-injecting old attachments
|
||||
* on every replay would break the call.
|
||||
*/
|
||||
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent) {
|
||||
return buildUserMessageInternal(message, renderedContent, true).userMessage();
|
||||
}
|
||||
|
||||
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent, boolean injectMedia) {
|
||||
return buildUserMessageInternal(message, renderedContent, injectMedia).userMessage();
|
||||
}
|
||||
|
||||
private CurrentTurnUserMessage buildUserMessageInternal(MessageEntity message, String renderedContent, boolean injectMedia) {
|
||||
if (!injectMedia) {
|
||||
return new CurrentTurnUserMessage(
|
||||
new UserMessage(renderedContent == null ? "" : renderedContent),
|
||||
null);
|
||||
}
|
||||
List<MessageContentPart> parts = conversationService.parseMessageParts(message);
|
||||
|
||||
// Sidecar routing — runs first so caption text gets folded into finalText
|
||||
// before native media injection considers the same parts again.
|
||||
MultimodalRoutingDecision decision = multimodalRouter != null
|
||||
? multimodalRouter.route(parts, runtimeModelConfig)
|
||||
: MultimodalRoutingDecision.none();
|
||||
|
||||
StringBuilder textBuilder = new StringBuilder(renderedContent == null ? "" : renderedContent);
|
||||
java.util.Set<String> sidecarHandledIdentifiers = new java.util.HashSet<>();
|
||||
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
|
||||
&& mediaCaptionService != null
|
||||
&& decision.sidecarModel() != null) {
|
||||
for (MessageContentPart part : parts) {
|
||||
if (part == null) continue;
|
||||
String contentType = part.getContentType();
|
||||
boolean isImage = ("image".equals(part.getType()) || "file".equals(part.getType()))
|
||||
&& contentType != null && contentType.startsWith("image/")
|
||||
&& !contentType.contains("svg");
|
||||
if (!isImage) continue;
|
||||
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
|
||||
decision.sidecarModel(), part, userLocale);
|
||||
if (result.isFailure()) {
|
||||
log.warn("[{}] Sidecar caption failed for {}: {}",
|
||||
agentName, part.getFileName(), result.failure().getMessage());
|
||||
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
|
||||
.append(part.getFileName())
|
||||
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
|
||||
continue;
|
||||
}
|
||||
textBuilder.append("\n\n[图片附件描述: ")
|
||||
.append(part.getFileName() == null ? "image" : part.getFileName())
|
||||
.append("]\n")
|
||||
.append(result.description())
|
||||
.append("\n[/图片附件描述]");
|
||||
String identifier = identifyPart(part);
|
||||
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
List<Media> mediaList = new ArrayList<>();
|
||||
List<String> skippedAttachments = new ArrayList<>();
|
||||
boolean videoSupported = modelSupportsVideo();
|
||||
boolean visionSupported = modelSupportsVision();
|
||||
|
||||
for (MessageContentPart part : parts) {
|
||||
if (part == null) continue;
|
||||
// Sidecar already produced text for this image; never inject the
|
||||
// raw bytes — the primary model would receive them and try to
|
||||
// process natively, defeating the cost-saving purpose.
|
||||
String identifier = identifyPart(part);
|
||||
if (identifier != null && sidecarHandledIdentifiers.contains(identifier)) continue;
|
||||
|
||||
String partType = part.getType();
|
||||
String contentType = part.getContentType();
|
||||
// image 类型的 part 可能没有精确 contentType,补全为 image/jpeg
|
||||
@ -315,6 +971,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 +987,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 +995,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 +1007,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,16 +1019,92 @@ 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() + "(媒体加载失败)");
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaList.isEmpty()) {
|
||||
return new UserMessage(renderedContent);
|
||||
if (!skippedAttachments.isEmpty()) {
|
||||
textBuilder.append("\n\n[系统提示] 以下附件未能传入当前模型:")
|
||||
.append(String.join("、", skippedAttachments))
|
||||
.append("。");
|
||||
// Only suggest switching models when no media-capable tool is bound
|
||||
// either. With a media tool the LLM may legitimately choose to
|
||||
// delegate to the tool — never instruct it not to use tools.
|
||||
if (!hasMediaCapableTools()) {
|
||||
textBuilder.append("\n请用对话语言清晰、友好地告诉用户:当前模型无法处理这类附件,建议切换到具备相应能力的多模态模型,或在「设置 → 模型」中配置视觉/视频模型作为旁路。");
|
||||
}
|
||||
return UserMessage.builder()
|
||||
.text(renderedContent)
|
||||
.media(mediaList)
|
||||
.build();
|
||||
}
|
||||
|
||||
String finalText = textBuilder.toString();
|
||||
UserMessage built = mediaList.isEmpty()
|
||||
? new UserMessage(finalText)
|
||||
: UserMessage.builder().text(finalText).media(mediaList).build();
|
||||
return new CurrentTurnUserMessage(built, decision);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identifier for de-duplicating parts already handled by the sidecar
|
||||
* pass. Falls back across {@code path → mediaId → fileName} since not every
|
||||
* channel populates the same field.
|
||||
*/
|
||||
private static String identifyPart(MessageContentPart part) {
|
||||
if (part == null) return null;
|
||||
if (part.getPath() != null && !part.getPath().isBlank()) return "p:" + part.getPath();
|
||||
if (part.getMediaId() != null && !part.getMediaId().isBlank()) return "m:" + part.getMediaId();
|
||||
if (part.getFileName() != null && !part.getFileName().isBlank()) return "f:" + part.getFileName();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the agent has at least one tool whose name or description
|
||||
* suggests it can read images / video / audio. The check is intentionally
|
||||
* loose — false positives just mean the agent is allowed to attempt media
|
||||
* processing on its own, which is the safer default.
|
||||
*/
|
||||
private static final Set<String> MEDIA_TOOL_KEYWORDS = Set.of(
|
||||
"image", "图片", "vision", "视觉",
|
||||
"video", "视频", "ffmpeg",
|
||||
"ocr", "caption", "media", "audio", "音频");
|
||||
|
||||
private boolean hasMediaCapableTools() {
|
||||
if (toolSet == null) return false;
|
||||
var callbacks = toolSet.callbacks();
|
||||
if (callbacks == null || callbacks.isEmpty()) return false;
|
||||
return callbacks.stream().anyMatch(cb -> {
|
||||
try {
|
||||
String name = String.valueOf(cb.getToolDefinition().name()).toLowerCase();
|
||||
String desc = String.valueOf(cb.getToolDefinition().description()).toLowerCase();
|
||||
return MEDIA_TOOL_KEYWORDS.stream().anyMatch(k -> name.contains(k) || desc.contains(k));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair returned from the current-turn user message build path: the assembled
|
||||
* {@link UserMessage} and the routing decision the caller should persist as
|
||||
* {@code metadata.routing} and surface to the SSE consumer.
|
||||
*/
|
||||
public record CurrentTurnUserMessage(UserMessage userMessage, MultimodalRoutingDecision routingDecision) {}
|
||||
|
||||
/**
|
||||
* Extract a routing-decision payload from the graph input map (placed there
|
||||
* by {@code buildInitialState}) and turn it into a startup
|
||||
* {@link vip.mate.agent.AgentService.StreamDelta} the SSE accumulator can
|
||||
* persist. Returns an empty Flux when no routing happened this turn so we
|
||||
* don't emit zero-value events.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static reactor.core.publisher.Flux<vip.mate.agent.AgentService.StreamDelta> routingStartupDelta(
|
||||
java.util.Map<String, Object> inputs) {
|
||||
Object decision = inputs.get(vip.mate.agent.graph.state.MateClawStateKeys.ROUTING_DECISION);
|
||||
if (decision instanceof java.util.Map<?, ?> map && !map.isEmpty()) {
|
||||
return reactor.core.publisher.Flux.just(vip.mate.agent.AgentService.StreamDelta.event(
|
||||
vip.mate.agent.GraphEventPublisher.EVENT_ROUTING_DECISION,
|
||||
(java.util.Map<String, Object>) map));
|
||||
}
|
||||
return reactor.core.publisher.Flux.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -381,6 +1125,17 @@ public abstract class BaseAgent {
|
||||
* @return 带图片 Media 的 UserMessage(如果有图片附件),否则纯文本 UserMessage
|
||||
*/
|
||||
protected UserMessage buildCurrentUserMessage(String conversationId, String userMessageText) {
|
||||
return buildCurrentUserMessageWithRouting(conversationId, userMessageText).userMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #buildCurrentUserMessage} but also returns the multimodal
|
||||
* routing decision taken for this turn so the caller can persist it as
|
||||
* {@code metadata.routing} and emit a SSE-side event for the chat UI.
|
||||
* Returns a decision with NONE strategy when the message has no attachments
|
||||
* the primary model can't already handle.
|
||||
*/
|
||||
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
|
||||
try {
|
||||
List<MessageEntity> history = conversationService.listMessages(conversationId);
|
||||
// 倒序取最后一条 user 消息(buildInitialState 在 saveMessage 后调用,所以最后一条就是当前消息)
|
||||
@ -389,14 +1144,14 @@ public abstract class BaseAgent {
|
||||
if ("user".equals(msg.getRole())) {
|
||||
// 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text
|
||||
String content = conversationService.renderMessageContent(msg);
|
||||
return buildUserMessage(msg, content != null && !content.isBlank() ? content : userMessageText);
|
||||
return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[{}] Failed to load current user message parts for multimodal: {}",
|
||||
agentName, e.getMessage());
|
||||
}
|
||||
return new UserMessage(userMessageText);
|
||||
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
|
||||
}
|
||||
|
||||
protected Path resolveImagePath(String relativePath) {
|
||||
|
||||
@ -27,6 +27,48 @@ public final class GraphEventPublisher {
|
||||
public static final String EVENT_STEP_STARTED = "plan_step_started";
|
||||
public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
|
||||
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
|
||||
/** RFC-06 D-6: lightweight performance summary emitted per-phase. */
|
||||
public static final String EVENT_PERF_SUMMARY = "perf_summary";
|
||||
/**
|
||||
* RFC-052: a tool with returnDirect=true completed; its full result is
|
||||
* carried in the payload and is intended to be rendered as part of the
|
||||
* assistant message (renderAs=assistant_message), bypassing the LLM.
|
||||
*/
|
||||
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
|
||||
|
||||
/**
|
||||
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
|
||||
* emitted at FinalAnswerNode so channel-side accumulators can persist it
|
||||
* into message metadata. Downstream filters (e.g. memory promotion gate)
|
||||
* branch on this structured value instead of doing brittle text matching
|
||||
* on the assistant content.
|
||||
*/
|
||||
public static final String EVENT_FINISH_REASON = "finish_reason";
|
||||
|
||||
/**
|
||||
* User-facing recovery affordances offered after a turn ends in a
|
||||
* non-transient error. Carries the error type + message + a
|
||||
* data-driven list of actions ({@code retry}, {@code regenerate},
|
||||
* {@code report}) so the frontend can render the right buttons
|
||||
* without hard-coding which categories deserve which actions.
|
||||
*
|
||||
* <p>Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
|
||||
* machine-readable reason). The two are kept separate so legacy
|
||||
* consumers of {@code finish_reason} don't have to learn a new
|
||||
* payload shape — and so a future graph branch (e.g. evidence-
|
||||
* insufficient → "rerun with the listed files attached") can emit
|
||||
* feedback affordances without abusing the finish_reason channel.
|
||||
*/
|
||||
public static final String EVENT_FEEDBACK = "feedback_event";
|
||||
|
||||
/**
|
||||
* Multimodal sidecar routing decision for the current turn. Emitted once
|
||||
* per turn before the graph starts streaming; the channel-side accumulator
|
||||
* stores it under {@code metadata.routing} so the chat UI can show which
|
||||
* sidecar (if any) was invoked. Underscore-prefixed name keeps it out of
|
||||
* IM channel rebroadcast (see {@code ChannelMessageRouter}).
|
||||
*/
|
||||
public static final String EVENT_ROUTING_DECISION = "_routing_decision";
|
||||
|
||||
/**
|
||||
* 事件记录
|
||||
@ -44,8 +86,23 @@ public final class GraphEventPublisher {
|
||||
}
|
||||
|
||||
public static GraphEvent toolStart(String toolName, String arguments) {
|
||||
return toolStart(null, toolName, arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a tool_call_started event with the LLM-provided tool_call.id so the
|
||||
* frontend can match start/complete pairs precisely. Without the id, the
|
||||
* UI uses toolName + status="running" + findLast() to pair completes back
|
||||
* to the original card; when the LLM fires multiple calls of the same tool
|
||||
* (e.g. several execute_shell_command in a row) the matching collapses to
|
||||
* "the most recent running" and earlier cards get stranded with a
|
||||
* permanent spinner. Pass the id whenever it's available; null is OK for
|
||||
* legacy callers.
|
||||
*/
|
||||
public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) {
|
||||
long ts = System.currentTimeMillis();
|
||||
return new GraphEvent(EVENT_TOOL_START, Map.of(
|
||||
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||
"toolName", toolName,
|
||||
"arguments", arguments != null ? arguments : "",
|
||||
"timestamp", ts
|
||||
@ -53,10 +110,20 @@ public final class GraphEventPublisher {
|
||||
}
|
||||
|
||||
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
|
||||
return toolComplete(null, toolName, result, success);
|
||||
}
|
||||
|
||||
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Carry the full tool result; transport-layer chunking lives in
|
||||
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
|
||||
// into ordered tool_result_chunk events when they exceed the 8 KB
|
||||
// single-event budget. The previous unconditional 500-char truncation
|
||||
// here destroyed data that the front-end could otherwise render in full.
|
||||
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
||||
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||
"toolName", toolName,
|
||||
"result", result != null ? truncateResult(result) : "",
|
||||
"result", result != null ? result : "",
|
||||
"success", success,
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
@ -82,9 +149,11 @@ public final class GraphEventPublisher {
|
||||
|
||||
public static GraphEvent stepCompleted(int index, String result) {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Full step result; broadcastChunked splits at the transport layer
|
||||
// when the payload exceeds the per-event size budget.
|
||||
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
|
||||
"index", index,
|
||||
"result", result != null ? truncateResult(result) : "",
|
||||
"result", result != null ? result : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
}
|
||||
@ -95,7 +164,7 @@ public final class GraphEventPublisher {
|
||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
|
||||
"pendingId", pendingId,
|
||||
"toolName", toolName != null ? toolName : "",
|
||||
"arguments", arguments != null ? truncateResult(arguments) : "",
|
||||
"arguments", arguments != null ? arguments : "",
|
||||
"reason", reason != null ? reason : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
@ -112,7 +181,7 @@ public final class GraphEventPublisher {
|
||||
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("pendingId", pendingId);
|
||||
data.put("toolName", toolName != null ? toolName : "");
|
||||
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
|
||||
data.put("arguments", arguments != null ? arguments : "");
|
||||
data.put("reason", reason != null ? reason : "");
|
||||
data.put("summary", summary);
|
||||
data.put("maxSeverity", maxSeverity);
|
||||
@ -121,6 +190,82 @@ public final class GraphEventPublisher {
|
||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-06 D-6: emit a lightweight performance summary for a phase.
|
||||
* Consumers (dashboard, audit, _usage_final) can aggregate these
|
||||
* to reconstruct per-turn latency profiles without full tracing.
|
||||
*
|
||||
* @param phase e.g. "triage", "reasoning", "tool_execution"
|
||||
* @param metrics arbitrary key-value pairs (e.g. "retry_count", "backoff_wait_ms")
|
||||
*/
|
||||
/**
|
||||
* RFC-052: emit a tool result that was produced by a returnDirect tool.
|
||||
* The full text is carried verbatim and the {@code renderAs="assistant_message"}
|
||||
* hint instructs the SSE consumer (front-end / accumulator) to fold the
|
||||
* payload into the assistant bubble rather than into a tool card.
|
||||
*/
|
||||
public static GraphEvent toolDirectResult(String toolCallId, String toolName, String fullResult) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("toolCallId", toolCallId != null ? toolCallId : "");
|
||||
data.put("toolName", toolName != null ? toolName : "");
|
||||
data.put("result", fullResult != null ? fullResult : "");
|
||||
data.put("renderAs", "assistant_message");
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_TOOL_DIRECT_RESULT, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
public static GraphEvent perfSummary(String phase, Map<String, Object> metrics) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.HashMap<>(metrics);
|
||||
data.put("phase", phase);
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_PERF_SUMMARY, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal {@code finish_reason} event. Emitted from FinalAnswerNode so it
|
||||
* rides through the same PENDING_EVENTS → StreamDelta pipeline that
|
||||
* channel-side accumulators consume — a sibling SSE-only broadcast would
|
||||
* bypass {@code ChatController.StreamAccumulator.accept(...)} and fail to
|
||||
* persist the reason into message metadata.
|
||||
*
|
||||
* @param reason {@link vip.mate.agent.graph.state.FinishReason#getValue()}
|
||||
* (e.g. {@code "incomplete"}, {@code "stopped"},
|
||||
* {@code "evidence_insufficient"}, {@code "normal"}).
|
||||
*/
|
||||
public static GraphEvent finishReason(String reason) {
|
||||
long ts = System.currentTimeMillis();
|
||||
return new GraphEvent(EVENT_FINISH_REASON, Map.of(
|
||||
"reason", reason != null ? reason : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a recovery-affordance event for the frontend. {@code errorType}
|
||||
* mirrors the {@code NodeStreamingChatHelper.ErrorType} value (e.g.
|
||||
* {@code AUTH_ERROR}, {@code BILLING}, {@code MODEL_NOT_FOUND}, or
|
||||
* the generic {@code UNKNOWN}); {@code errorMessage} is the
|
||||
* user-friendly text already displayed in the bubble; {@code actions}
|
||||
* is the ordered list of buttons to render. Default offering is the
|
||||
* standard {@code retry / regenerate / report} triad — call sites
|
||||
* can narrow this if a category has limitations (e.g. AUTH_ERROR
|
||||
* shouldn't offer "retry" until the key is fixed).
|
||||
*/
|
||||
public static GraphEvent feedback(String errorType, String errorMessage,
|
||||
java.util.List<String> actions) {
|
||||
long ts = System.currentTimeMillis();
|
||||
return new GraphEvent(EVENT_FEEDBACK, Map.of(
|
||||
"errorType", errorType != null ? errorType : "",
|
||||
"errorMessage", errorMessage != null ? errorMessage : "",
|
||||
"actions", actions != null && !actions.isEmpty()
|
||||
? actions
|
||||
: java.util.List.of("retry", "regenerate", "report"),
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
}
|
||||
|
||||
// ===== 提取方法 =====
|
||||
|
||||
/**
|
||||
@ -135,14 +280,90 @@ public final class GraphEventPublisher {
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through; preserved for source/binary compatibility with older callers.
|
||||
* Truncation at the SSE transport layer is now handled by {@code
|
||||
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
|
||||
* ordered chunk events instead of dropping bytes. Logs a one-time
|
||||
* deprecation hint when invoked.
|
||||
*
|
||||
* @deprecated callers should pass full payloads and let the transport layer
|
||||
* decide whether to chunk.
|
||||
*/
|
||||
@Deprecated
|
||||
private static String truncateResult(String result) {
|
||||
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
|
||||
warnTruncateDeprecation();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串用于直推广播(公共方法,供 Node 直接构造广播数据时使用)
|
||||
* Pass-through, kept for source compatibility with code that built broadcast
|
||||
* payloads directly. Same deprecation reason as {@link #truncateResult}.
|
||||
*
|
||||
* @deprecated callers should pass full payloads.
|
||||
*/
|
||||
@Deprecated
|
||||
public static String truncateForBroadcast(String text) {
|
||||
return truncateResult(text);
|
||||
warnTruncateDeprecation();
|
||||
return text;
|
||||
}
|
||||
|
||||
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
|
||||
private static void warnTruncateDeprecation() {
|
||||
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
|
||||
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
|
||||
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
|
||||
"no-op pass-throughs; payloads are no longer truncated here. " +
|
||||
"Move callers to send the full string and rely on " +
|
||||
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Iteration lifecycle events =====
|
||||
|
||||
public static final String EVENT_ITERATION_START = "iteration_start";
|
||||
public static final String EVENT_ITERATION_END = "iteration_end";
|
||||
|
||||
/**
|
||||
* Marks the entry of an iteration boundary so consumers can group later
|
||||
* tool / content / thinking events under a single logical step. The
|
||||
* {@code scope} field distinguishes the parent agent ("parent") from a
|
||||
* delegated sub-agent ("subagent"); when scope is "subagent" the
|
||||
* {@code subagentId} payload field is populated by the producer.
|
||||
*/
|
||||
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("index", index);
|
||||
data.put("reason", reason != null ? reason : "");
|
||||
data.put("scope", scope != null ? scope : "parent");
|
||||
if (subagentId != null && !subagentId.isEmpty()) {
|
||||
data.put("subagentId", subagentId);
|
||||
}
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
|
||||
* and {@code thinkingChars} let consumers render a compact "this turn
|
||||
* produced X content / Y thinking" header without re-aggregating the
|
||||
* underlying delta events.
|
||||
*/
|
||||
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
|
||||
int contentChars, int thinkingChars) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("index", index);
|
||||
data.put("scope", scope != null ? scope : "parent");
|
||||
if (subagentId != null && !subagentId.isEmpty()) {
|
||||
data.put("subagentId", subagentId);
|
||||
}
|
||||
data.put("contentChars", contentChars);
|
||||
data.put("thinkingChars", thinkingChars);
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,29 @@ package vip.mate.agent.binding.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.binding.model.AgentProviderPreference;
|
||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.acp.AcpSkillBridge;
|
||||
import vip.mate.skill.mcp.McpSkillBridge;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.tool.model.AvailableToolDTO;
|
||||
import vip.mate.tool.service.AvailableToolService;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@ -25,11 +41,63 @@ 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;
|
||||
/**
|
||||
* Source of truth for what the picker can offer (built-in + MCP). Used
|
||||
* by {@link #setToolBindings} to refuse new tool names that the runtime
|
||||
* couldn't resolve anyway — closes the gap where a UI-disabled row
|
||||
* could still be saved by hitting the API directly.
|
||||
*/
|
||||
private final AvailableToolService availableToolService;
|
||||
/**
|
||||
* Direct mapper access (instead of {@code AgentService}) to look up an
|
||||
* agent's workspace before binding a skill. {@code AgentService} pulls
|
||||
* in {@code AgentGraphBuilder}, which itself depends on
|
||||
* {@code AgentBindingService} — going through the service would create a
|
||||
* boot-time cycle. The mapper has no such transitive dependency.
|
||||
*/
|
||||
private final AgentMapper agentMapper;
|
||||
/** Same reasoning as {@link #agentMapper}: skill workspace lookup. */
|
||||
private final SkillMapper skillMapper;
|
||||
/**
|
||||
* ACP virtual skills aren't rows in {@code mate_skill}; the bridge
|
||||
* synthesizes them from {@code mate_acp_endpoint}. We need this to
|
||||
* answer "what workspace does this virtual id belong to?" when an
|
||||
* agent tries to bind one. MCP virtual skills don't need a bridge
|
||||
* reference — {@link McpSkillBridge#isVirtualMcpSkillId(Long)} is a
|
||||
* static range check, and MCP servers carry no workspace today, so
|
||||
* binding any MCP virtual id is allowed for any agent.
|
||||
*/
|
||||
private final AcpSkillBridge acpSkillBridge;
|
||||
|
||||
@Autowired
|
||||
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
|
||||
AgentToolBindingMapper toolBindingMapper,
|
||||
AgentProviderPreferenceMapper providerPreferenceMapper,
|
||||
@Lazy SkillRuntimeService skillRuntimeService,
|
||||
AvailableToolService availableToolService,
|
||||
AgentMapper agentMapper,
|
||||
SkillMapper skillMapper,
|
||||
AcpSkillBridge acpSkillBridge) {
|
||||
this.skillBindingMapper = skillBindingMapper;
|
||||
this.toolBindingMapper = toolBindingMapper;
|
||||
this.providerPreferenceMapper = providerPreferenceMapper;
|
||||
this.skillRuntimeService = skillRuntimeService;
|
||||
this.availableToolService = availableToolService;
|
||||
this.agentMapper = agentMapper;
|
||||
this.skillMapper = skillMapper;
|
||||
this.acpSkillBridge = acpSkillBridge;
|
||||
}
|
||||
|
||||
// ==================== Skill Bindings ====================
|
||||
|
||||
@ -56,6 +124,7 @@ public class AgentBindingService {
|
||||
}
|
||||
|
||||
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
|
||||
requireSameWorkspace(agentId, skillId);
|
||||
// 检查是否已绑定
|
||||
AgentSkillBinding existing = skillBindingMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
@ -85,6 +154,15 @@ public class AgentBindingService {
|
||||
* 批量设置 Agent 的 skill 绑定(替换模式)
|
||||
*/
|
||||
public void setSkillBindings(Long agentId, List<Long> skillIds) {
|
||||
// Validate every incoming skill BEFORE touching the binding rows;
|
||||
// a half-applied save (old bindings dropped, new set rejected
|
||||
// mid-loop) would leave the agent silently un-bound from skills it
|
||||
// had a moment ago.
|
||||
if (skillIds != null) {
|
||||
for (Long skillId : skillIds) {
|
||||
requireSameWorkspace(agentId, skillId);
|
||||
}
|
||||
}
|
||||
// 删除旧绑定
|
||||
skillBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
@ -101,6 +179,78 @@ public class AgentBindingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to bind a skill that doesn't share the agent's workspace.
|
||||
* Skills are per-workspace installable artifacts (each workspace has
|
||||
* its own catalog under {@code mate_skill.workspace_id}); letting
|
||||
* workspace A's agent bind workspace B's skill would leak capabilities
|
||||
* — and prompt content — across the tenancy boundary.
|
||||
*
|
||||
* <p>Three skill id flavors to handle:
|
||||
* <ul>
|
||||
* <li><b>Real {@code mate_skill} rows</b> — straight mapper lookup,
|
||||
* compare {@code workspace_id} to the agent's.</li>
|
||||
* <li><b>Virtual MCP-derived ids</b> ({@code >= McpSkillBridge.VIRTUAL_ID_BASE})
|
||||
* — pass through. MCP servers carry no workspace concept today,
|
||||
* so any agent in any workspace may bind any MCP virtual skill.
|
||||
* The picker (/skills/enabled) hands these out to every workspace.</li>
|
||||
* <li><b>Virtual ACP-derived ids</b> ({@code AcpSkillBridge}'s range)
|
||||
* — resolve through the bridge so the {@link SkillEntity#getWorkspaceId()}
|
||||
* comes from the backing {@code mate_acp_endpoint.workspace_id},
|
||||
* then apply the same workspace comparison.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Most {@code mate_skill} rows currently sit in the default workspace
|
||||
* (id=1) because skill creation doesn't yet honor the
|
||||
* {@code X-Workspace-Id} header; the real-skill branch is therefore
|
||||
* defense-in-depth right now and flips on automatically the moment
|
||||
* workspace-scoped skill creation lands. ACP enforcement is live today.
|
||||
*
|
||||
* @throws MateClawException 404 if the agent or skill doesn't exist;
|
||||
* 403 on a workspace mismatch.
|
||||
*/
|
||||
private void requireSameWorkspace(Long agentId, Long skillId) {
|
||||
if (agentId == null) {
|
||||
throw new MateClawException("err.agent.not_found", 404, "Agent ID is required");
|
||||
}
|
||||
if (skillId == null) {
|
||||
throw new MateClawException("err.skill.not_found", 404, "Skill ID is required");
|
||||
}
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent == null) {
|
||||
throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId);
|
||||
}
|
||||
// MCP virtual: no workspace on McpServerEntity — globally bindable.
|
||||
if (McpSkillBridge.isVirtualMcpSkillId(skillId)) {
|
||||
return;
|
||||
}
|
||||
SkillEntity skill;
|
||||
if (AcpSkillBridge.isVirtualAcpSkillId(skillId)) {
|
||||
// ACP virtual: synthesize from the bridge so workspace_id flows
|
||||
// through from mate_acp_endpoint. A null reply here means the
|
||||
// backing endpoint was deleted or disabled between picker render
|
||||
// and save — same surface as a deleted real skill.
|
||||
skill = acpSkillBridge.findEntityById(skillId);
|
||||
if (skill == null) {
|
||||
throw new MateClawException("err.skill.not_found", 404,
|
||||
"ACP endpoint backing skill " + skillId + " is gone or disabled");
|
||||
}
|
||||
} else {
|
||||
skill = skillMapper.selectById(skillId);
|
||||
if (skill == null) {
|
||||
throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId);
|
||||
}
|
||||
}
|
||||
long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
|
||||
long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
|
||||
if (agentWs != skillWs) {
|
||||
throw new MateClawException("err.skill.cross_workspace_binding", 403,
|
||||
"Skill " + skillId + " (workspace=" + skillWs
|
||||
+ ") cannot be bound to Agent " + agentId
|
||||
+ " (workspace=" + agentWs + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tool Bindings ====================
|
||||
|
||||
public List<AgentToolBinding> listToolBindings(Long agentId) {
|
||||
@ -125,6 +275,216 @@ 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>
|
||||
*
|
||||
* <p>Auto-included on every non-null result, in addition to the bound
|
||||
* tools and skill-expanded tools:
|
||||
* <ul>
|
||||
* <li>{@link #SYSTEM_LEVEL_TOOLS} — agent-wide primitives.</li>
|
||||
* <li>Every currently-bindable MCP tool (any tool with
|
||||
* {@code source="mcp"} and {@code available=true} in the picker).
|
||||
* MCP servers are administrator-level capabilities; once enabled
|
||||
* globally they should not be silently hidden from an agent that
|
||||
* happens to have any other binding. To deny a specific MCP tool
|
||||
* to a specific agent, use the tool-guard deny path applied
|
||||
* upstream in {@code AgentGraphBuilder}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public Set<String> getEffectiveToolNames(Long agentId) {
|
||||
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
|
||||
Set<String> directTools = getBoundToolNames(agentId);
|
||||
|
||||
// (1) null + null → no restriction; defer to the global default.
|
||||
if (boundSkillIds == null && directTools == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> merged = new LinkedHashSet<>();
|
||||
|
||||
if (boundSkillIds != null) {
|
||||
for (Long skillId : boundSkillIds) {
|
||||
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
||||
if (resolved == null) continue;
|
||||
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
|
||||
// §14.2 fix: a disabled / security-blocked / setup-needed
|
||||
// skill must not contribute tools to the LLM
|
||||
// advertisement even if it's still bound. Without this
|
||||
// guard, users see ghost tools for skills they thought
|
||||
// were off.
|
||||
continue;
|
||||
}
|
||||
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
||||
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
|
||||
}
|
||||
}
|
||||
|
||||
if (directTools != null) {
|
||||
// ∪ Advanced 直选的原子 tool(§9.2 调整 B)
|
||||
merged.addAll(directTools);
|
||||
}
|
||||
|
||||
// System-level tools that don't belong to any single skill but
|
||||
// are agent-wide capabilities. Without this carve-out, binding
|
||||
// any skill silently strips record_lesson / remember / structured-
|
||||
// memory tools, breaking the §11 self-evolution loop entirely
|
||||
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
|
||||
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
||||
|
||||
// Enabled MCP server tools auto-join the allowlist for the same
|
||||
// reason SYSTEM_LEVEL_TOOLS does: MCP servers are an
|
||||
// administrator-enabled capability, not a per-agent opt-in. Without
|
||||
// this union, an agent with any skill or built-in tool bound would
|
||||
// silently lose every MCP tool — users hit this when they bound one
|
||||
// built-in tool, didn't tick the MCP rows, and observed "only
|
||||
// built-in tools work". Operators who need to hide a specific MCP
|
||||
// tool from a specific agent still have the tool-guard deny path
|
||||
// (AgentGraphBuilder applies withDeniedToolsFiltered before this).
|
||||
merged.addAll(getEnabledMcpToolNames());
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of every currently-bindable MCP tool, sourced from the same
|
||||
* picker that the agent edit screen reads. Failures (picker outage,
|
||||
* cache parse error) yield an empty set so the caller's allowlist is
|
||||
* strictly narrower, never wider, than the picker — never throws.
|
||||
*/
|
||||
private Set<String> getEnabledMcpToolNames() {
|
||||
try {
|
||||
return availableToolService.listAvailable().stream()
|
||||
.filter(t -> "mcp".equals(t.getSource()))
|
||||
.filter(AvailableToolDTO::isAvailable)
|
||||
.map(AvailableToolDTO::getName)
|
||||
.filter(n -> n != null && !n.isBlank())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
} catch (Exception e) {
|
||||
log.warn("AvailableToolService unavailable while computing effective tool allowlist; "
|
||||
+ "MCP tools will be excluded for this resolve cycle: {}", e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
// HTML → PNG rasteriser. Closes the loop for HTML-producing skills
|
||||
// (architecture-diagram, infographics, dashboards) so IM channels
|
||||
// can deliver the artifact as a native image instead of a file or
|
||||
// a dead markdown link.
|
||||
"render_html_image",
|
||||
// Universal capabilities the global system prompts (SOUL.md /
|
||||
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
|
||||
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
|
||||
// available; the new gate silently hid them on any agent with
|
||||
// skills bound, so the prompt promises a tool the registry then
|
||||
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
|
||||
// Code Reviewer agent — the model called search → got
|
||||
// not-found → gave up before ever reaching renderDocx.
|
||||
"search",
|
||||
"browser_use",
|
||||
"read_file",
|
||||
"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>()
|
||||
@ -151,9 +511,27 @@ public class AgentBindingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置 Agent 的 tool 绑定(替换模式)
|
||||
* Replace the agent's tool binding set.
|
||||
*
|
||||
* <p>Validation rule for each incoming name:
|
||||
* <ul>
|
||||
* <li><b>Already in the existing binding</b> → always allowed (so the
|
||||
* user can keep a previously-bound tool whose upstream MCP server
|
||||
* is currently stale or even removed; the client just keeps what
|
||||
* it already had).</li>
|
||||
* <li><b>New addition (not in existing binding)</b> → must appear in
|
||||
* {@link AvailableToolService#listAvailable()} with
|
||||
* {@code available == true}. Names that are unknown
|
||||
* (typos / legacy unprefixed MCP names / hand-crafted strings) or
|
||||
* that the picker marked unavailable (hash collision, etc.) are
|
||||
* rejected — saving them would put a {@code mate_agent_tool} row
|
||||
* in the database that the runtime can never resolve, which then
|
||||
* silently drops the tool when the agent runs.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public void setToolBindings(Long agentId, List<String> toolNames) {
|
||||
validateNewToolBindings(agentId, toolNames);
|
||||
|
||||
toolBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId));
|
||||
@ -167,4 +545,102 @@ public class AgentBindingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse the save when any *newly-added* tool name doesn't resolve to
|
||||
* an {@code available=true} row in the picker. Names already in the
|
||||
* existing binding are exempt so that subsequent edits (especially
|
||||
* "remove this stale tool") still succeed even if upstream state has
|
||||
* drifted.
|
||||
*/
|
||||
private void validateNewToolBindings(Long agentId, List<String> incoming) {
|
||||
if (incoming == null || incoming.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> existing = listToolBindings(agentId).stream()
|
||||
.map(AgentToolBinding::getToolName)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> bindable;
|
||||
try {
|
||||
bindable = availableToolService.listAvailable().stream()
|
||||
.filter(AvailableToolDTO::isAvailable)
|
||||
.map(AvailableToolDTO::getName)
|
||||
.collect(Collectors.toSet());
|
||||
} catch (Exception e) {
|
||||
// The picker source briefly failing must not block the user
|
||||
// from saving a binding that's still in their existing set.
|
||||
// Re-validate everything against just the existing set —
|
||||
// strictly conservative: only allow keeps, refuse adds.
|
||||
log.warn("AvailableToolService unavailable during binding validation, falling back to existing-only: {}",
|
||||
e.getMessage());
|
||||
bindable = Set.of();
|
||||
}
|
||||
|
||||
List<String> rejected = new java.util.ArrayList<>();
|
||||
for (String name : incoming) {
|
||||
if (name == null || name.isBlank()) {
|
||||
rejected.add("<blank>");
|
||||
continue;
|
||||
}
|
||||
if (existing.contains(name)) continue; // keeps are always allowed
|
||||
if (!bindable.contains(name)) rejected.add(name);
|
||||
}
|
||||
if (!rejected.isEmpty()) {
|
||||
String preview = rejected.size() <= 5
|
||||
? String.join(", ", rejected)
|
||||
: String.join(", ", rejected.subList(0, 5)) + " (+" + (rejected.size() - 5) + " more)";
|
||||
throw new MateClawException("err.agent.tool_binding_unbindable",
|
||||
"Tool name(s) cannot be bound: " + preview
|
||||
+ ". Either the name is unknown or the picker marked it unavailable "
|
||||
+ "(e.g. hash collision, upstream server removed).");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Provider Preferences (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");
|
||||
}
|
||||
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,8 +11,10 @@ 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.graph.executor.ToolResultStorage;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
import vip.mate.config.ConversationWindowProperties;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
@ -20,6 +22,7 @@ import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@ -60,18 +63,50 @@ public class ConversationWindowManager {
|
||||
/** 迭代更新:合并旧摘要 + 新轮次 */
|
||||
private static final String STRUCTURED_SUMMARY_UPDATE = PromptLoader.loadPrompt("context/structured-summary-update");
|
||||
|
||||
/** 摘要注入前缀 */
|
||||
private static final String SUMMARY_PREFIX =
|
||||
/** 摘要注入前缀 (package-private for test assertions) */
|
||||
static final String SUMMARY_PREFIX =
|
||||
"[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" +
|
||||
"以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" +
|
||||
"请基于摘要和当前状态继续,避免重复已完成的工作:\n\n";
|
||||
|
||||
/**
|
||||
* Marker prefix used by the first-user anchor. Lets compaction skip
|
||||
* previously-injected anchors when looking for the "real" first user
|
||||
* message in a subsequent round.
|
||||
*
|
||||
* <p>Package-private so unit tests can assert on the marker.
|
||||
*/
|
||||
static final String ANCHOR_PREFIX = "[Original goal]\n";
|
||||
|
||||
// ==================== 序列化截断参数 ====================
|
||||
|
||||
private static final int CONTENT_MAX = 6000;
|
||||
private static final int CONTENT_HEAD = 4000;
|
||||
private static final int CONTENT_TAIL = 1500;
|
||||
|
||||
/**
|
||||
* Minimum body size at which the duplicate-output placeholder is preferred
|
||||
* over keeping the verbatim copy. Below this size the placeholder text
|
||||
* (~80 chars) is comparable to the body itself, so deduplication only
|
||||
* complicates the prompt without saving meaningful tokens. Above this
|
||||
* size the dedup placeholder is a real win.
|
||||
*/
|
||||
private static final int DEDUP_MIN_CHARS = 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"
|
||||
);
|
||||
|
||||
// ==================== 冷却机制 ====================
|
||||
|
||||
/** 摘要失败后的冷却时间(毫秒):10 分钟 */
|
||||
@ -83,6 +118,35 @@ public class ConversationWindowManager {
|
||||
private final MemoryManager memoryManager;
|
||||
private final ConversationService conversationService;
|
||||
|
||||
/**
|
||||
* Optional spill store, injected via setter so unit tests and the two
|
||||
* existing 3-arg constructor callers in tests stay source-compatible.
|
||||
* When {@code null}, prune falls back to "keep originals verbatim" — no
|
||||
* lossy summary rewrite is ever applied. Spring autowires this when
|
||||
* {@link ToolResultStorage} is on the context.
|
||||
*/
|
||||
private ToolResultStorage toolResultStorage;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setToolResultStorage(ToolResultStorage toolResultStorage) {
|
||||
this.toolResultStorage = toolResultStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional stream tracker for broadcasting {@code compact_status}
|
||||
* SSE events. Wired via setter so unit tests can leave it {@code null}
|
||||
* without dragging in the channel layer. When present, every
|
||||
* compaction emits start/skipped/summarize/done events so the
|
||||
* frontend can render a boundary card and a status line in real
|
||||
* time.
|
||||
*/
|
||||
private vip.mate.channel.web.ChatStreamTracker streamTracker;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setStreamTracker(vip.mate.channel.web.ChatStreamTracker streamTracker) {
|
||||
this.streamTracker = streamTracker;
|
||||
}
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/** 摘要缓存:key = "conversationId:oldMessageCount" */
|
||||
@ -116,9 +180,50 @@ public class ConversationWindowManager {
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId, Long agentId) {
|
||||
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||
maxInputTokens, chatModel, conversationId, agentId, null, 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) {
|
||||
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||
maxInputTokens, chatModel, conversationId, agentId, toolCallbacks, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Most comprehensive overload — adds {@code workspaceBasePath} so the
|
||||
* pre-pass that prunes old tool results can route oversized bodies to
|
||||
* the agent's workspace spill directory via {@link ToolResultStorage}.
|
||||
*
|
||||
* <p>When {@code workspaceBasePath} is {@code null}, spill files land in
|
||||
* the configured base dir, or the JVM tmpdir as last resort (see
|
||||
* {@link ToolResultStorage#resolveBaseDir(String)}). Workspace-aware
|
||||
* callers should always pass the path so historical spill files stay
|
||||
* grouped with the workspace that produced them.
|
||||
*/
|
||||
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId, Long agentId,
|
||||
java.util.Collection<ToolCallback> toolCallbacks,
|
||||
String workspaceBasePath) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
|
||||
|
||||
messages = pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
|
||||
|
||||
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
|
||||
? maxInputTokens : properties.getDefaultMaxInputTokens();
|
||||
@ -127,21 +232,22 @@ 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);
|
||||
// RFC-025 Change 1: reserve 硬封顶到 effectiveMax 的 50%。
|
||||
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
|
||||
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
|
||||
// 预留 reserve 硬封顶到 effectiveMax 的 50%。
|
||||
// 小上下文模型(Ollama 16K、本地 8K)下,systemTokens + currentMsgTokens 很容易
|
||||
// 接近或超过 effectiveMax,不封顶会让 historyBudget 变负数导致死循环压缩
|
||||
// (压缩目标比压缩前还大 → 压缩后又触发压缩)。
|
||||
@ -156,7 +262,8 @@ public class ConversationWindowManager {
|
||||
// 尾部保护 token 预算:阈值的 20%(与 Hermes 一致)
|
||||
int tailTokenBudget = (int) (triggerThreshold * 0.20);
|
||||
|
||||
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel, conversationId, agentId);
|
||||
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
|
||||
conversationId, agentId, totalTokens, spillsAtEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -172,18 +279,62 @@ public class ConversationWindowManager {
|
||||
|
||||
// ==================== 核心压缩逻辑 ====================
|
||||
|
||||
/** Broadcast a single compact_status event; silent no-op when no tracker is wired. */
|
||||
private void broadcastCompactStatus(String conversationId, String status, Map<String, Object> extra) {
|
||||
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||
payload.put("status", status);
|
||||
payload.put("timestamp", System.currentTimeMillis());
|
||||
if (extra != null) payload.putAll(extra);
|
||||
streamTracker.broadcastObject(conversationId, "compact_status", payload);
|
||||
} catch (Exception e) {
|
||||
log.debug("[ConversationWindow] broadcast compact_status failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Message> compactMessages(List<Message> messages, int historyBudget,
|
||||
int tailTokenBudget, ChatModel chatModel,
|
||||
String conversationId, Long agentId) {
|
||||
String conversationId, Long agentId,
|
||||
int preTokens, long spillsAtEntry) {
|
||||
broadcastCompactStatus(conversationId, "start", Map.of(
|
||||
"preTokens", preTokens,
|
||||
"messagesIn", messages.size(),
|
||||
"trigger", "token_threshold"
|
||||
));
|
||||
|
||||
// 动态计算尾部保护边界(替代固定 preserveRecentPairs)
|
||||
int headEnd = 0; // 头部保护:暂不保护(system prompt 已在外部计算)
|
||||
int tailStart = findTailBoundary(messages, headEnd, tailTokenBudget);
|
||||
|
||||
if (tailStart <= headEnd) {
|
||||
log.debug("[ConversationWindow] 消息数不足以拆分,跳过压缩");
|
||||
broadcastCompactStatus(conversationId, "skipped",
|
||||
Map.of("reason", "insufficient_messages"));
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Pair safety: never split an AssistantMessage's tool_calls from its
|
||||
// matching ToolResponseMessages. The cut may walk forward (i.e. the
|
||||
// tail grows) until every call/response cluster lives on one side of
|
||||
// the boundary. If no safe cut survives the walk, skip compaction —
|
||||
// a broken pair would 400 every OpenAI-compatible provider, which is
|
||||
// strictly worse than letting context cross the budget by one extra
|
||||
// turn.
|
||||
int pairSafeCut = enforcePairSafeBoundary(messages, headEnd, tailStart);
|
||||
if (pairSafeCut <= headEnd) {
|
||||
broadcastCompactStatus(conversationId, "skipped",
|
||||
Map.of("reason", "pair_boundary_collapsed"));
|
||||
return messages;
|
||||
}
|
||||
if (pairSafeCut != tailStart) {
|
||||
broadcastCompactStatus(conversationId, "pair_safe", Map.of(
|
||||
"movedFrom", tailStart, "movedTo", pairSafeCut));
|
||||
}
|
||||
tailStart = pairSafeCut;
|
||||
|
||||
List<Message> oldMessages = new ArrayList<>(messages.subList(headEnd, tailStart));
|
||||
List<Message> recentMessages = messages.subList(tailStart, messages.size());
|
||||
|
||||
@ -239,13 +390,20 @@ public class ConversationWindowManager {
|
||||
// 计算动态摘要预算
|
||||
int summaryBudget = computeSummaryBudget(forSummary);
|
||||
|
||||
broadcastCompactStatus(conversationId, "summarize", Map.of(
|
||||
"messagesToSummarize", oldMessages.size(),
|
||||
"summaryBudget", summaryBudget
|
||||
));
|
||||
|
||||
// 检查缓存
|
||||
String cacheKey = conversationId + ":" + oldMessages.size();
|
||||
CachedSummary cached = summaryCache.get(cacheKey);
|
||||
String summary;
|
||||
boolean fromCache = false;
|
||||
|
||||
if (cached != null && !cached.isExpired(CACHE_TTL_MS)) {
|
||||
summary = cached.summary();
|
||||
fromCache = true;
|
||||
log.debug("[ConversationWindow] 命中摘要缓存, conv={}", conversationId);
|
||||
} else {
|
||||
summary = generateSummary(forSummary, chatModel, conversationId, summaryBudget, memoryExtraContext);
|
||||
@ -254,27 +412,32 @@ public class ConversationWindowManager {
|
||||
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
|
||||
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
|
||||
summary.length(), count, oldMessages.size(), conversationId);
|
||||
|
||||
// 持久化摘要到 DB:下次加载历史时可直接从摘要位置开始,跳过重复压缩
|
||||
if (conversationService != null) {
|
||||
try {
|
||||
conversationService.saveCompressionSummary(
|
||||
conversationId, SUMMARY_PREFIX + summary, oldMessages.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("[ConversationWindow] Failed to persist compression summary: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组装结果
|
||||
List<Message> result = new ArrayList<>();
|
||||
boolean anchored = false;
|
||||
if (summary != null && !summary.isBlank()) {
|
||||
result.add(new UserMessage(SUMMARY_PREFIX + summary));
|
||||
|
||||
// Anchor the original user goal so a long task that paged through
|
||||
// dozens of turns can still see what was originally asked. Always
|
||||
// as a UserMessage — promoting historical user input to a
|
||||
// SystemMessage would be a privilege-escalation risk.
|
||||
Message anchor = buildFirstUserAnchor(oldMessages);
|
||||
if (anchor != null) {
|
||||
result.add(anchor);
|
||||
anchored = true;
|
||||
}
|
||||
} else if (!oldMessages.isEmpty()) {
|
||||
log.warn("[ConversationWindow] 摘要生成失败,降级为保留最近 4 条旧消息, conv={}", conversationId);
|
||||
int fallbackKeep = Math.min(4, oldMessages.size());
|
||||
result.addAll(oldMessages.subList(oldMessages.size() - fallbackKeep, oldMessages.size()));
|
||||
broadcastCompactStatus(conversationId, "failed", Map.of(
|
||||
"reason", "summary_generation_failed",
|
||||
"fallbackKept", fallbackKeep
|
||||
));
|
||||
}
|
||||
result.addAll(recentMessages);
|
||||
|
||||
@ -283,6 +446,48 @@ public class ConversationWindowManager {
|
||||
if (resultTokens > historyBudget && result.size() > 2) {
|
||||
log.warn("[ConversationWindow] 压缩后仍超预算: {} > {}, 执行二次裁剪", resultTokens, historyBudget);
|
||||
result = trimToFit(result, historyBudget);
|
||||
resultTokens = TokenEstimator.estimateTokens(result);
|
||||
}
|
||||
|
||||
// Persist the boundary + announce completion only when the summary
|
||||
// actually wrote a row. Failed-summary fallback already broadcast
|
||||
// its own event above.
|
||||
if (summary != null && !summary.isBlank() && conversationService != null && !fromCache) {
|
||||
long spillsThisTurn = (toolResultStorage != null)
|
||||
? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry)
|
||||
: 0L;
|
||||
Map<String, Object> boundaryMetadata = new java.util.LinkedHashMap<>();
|
||||
boundaryMetadata.put("trigger", "token_threshold");
|
||||
boundaryMetadata.put("preTokens", preTokens);
|
||||
boundaryMetadata.put("postTokens", resultTokens);
|
||||
boundaryMetadata.put("messagesSummarized", oldMessages.size());
|
||||
boundaryMetadata.put("tailKept", recentMessages.size());
|
||||
boundaryMetadata.put("toolResultsSpilled", spillsThisTurn);
|
||||
boundaryMetadata.put("anchored", anchored);
|
||||
Long summaryId = null;
|
||||
try {
|
||||
summaryId = conversationService.saveCompressionSummaryReturningId(
|
||||
conversationId, SUMMARY_PREFIX + summary, oldMessages.size(),
|
||||
boundaryMetadata);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ConversationWindow] Failed to persist compression boundary: {}", e.getMessage());
|
||||
}
|
||||
if (summaryId != null) {
|
||||
// Mirror the DB row's metadata: the SSE consumer needs the id
|
||||
// to deep-link the boundary card without having to refetch.
|
||||
boundaryMetadata.put("summaryId", summaryId);
|
||||
}
|
||||
broadcastCompactStatus(conversationId, "done", boundaryMetadata);
|
||||
} else if (summary != null && !summary.isBlank() && fromCache) {
|
||||
// Cached summary path — no new DB row, but emit done so the
|
||||
// frontend status bar still updates.
|
||||
broadcastCompactStatus(conversationId, "done", Map.of(
|
||||
"preTokens", preTokens,
|
||||
"postTokens", resultTokens,
|
||||
"messagesSummarized", oldMessages.size(),
|
||||
"tailKept", recentMessages.size(),
|
||||
"fromCache", true
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -327,6 +532,201 @@ public class ConversationWindowManager {
|
||||
return Math.max(cutIdx, headEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the candidate boundary so an {@link AssistantMessage}'s
|
||||
* {@code toolCalls} are never separated from their matching
|
||||
* {@link ToolResponseMessage}s.
|
||||
*
|
||||
* <p>Walks forward, collecting every {@code tool_call_id}'s assistant
|
||||
* index and the indices of its matching responses. Whenever an
|
||||
* assistant in the prefix has at least one response in the tail, the
|
||||
* cut moves backward to that assistant — pulling the whole cluster
|
||||
* into the tail. The walk repeats until convergence because moving
|
||||
* the cut can expose pairs that were previously fully in the tail.
|
||||
*
|
||||
* <p>The method preserves pair integrity above any other concern. If
|
||||
* the cut collapses all the way to {@code headEnd}, callers must
|
||||
* interpret the return as "skip compaction this turn" — splitting a
|
||||
* pair would produce HTTP 400 on every OpenAI-compatible provider,
|
||||
* which is a worse failure mode than letting context grow by one turn.
|
||||
*
|
||||
* <p>An orphan {@code ToolResponseMessage} (id matching no
|
||||
* assistant in scope) does not trigger movement; the upstream code
|
||||
* paths should never produce one, and logging at WARN gives us a
|
||||
* breadcrumb if they ever do.
|
||||
*
|
||||
* @return adjusted cut index, or {@code headEnd} when no pair-safe
|
||||
* cut larger than {@code headEnd} can be produced.
|
||||
*/
|
||||
// Package-private so unit tests in the same package can drive it directly
|
||||
// without standing up a ChatModel + the rest of the compactMessages pipeline.
|
||||
int enforcePairSafeBoundary(List<Message> messages, int headEnd, int tailStart) {
|
||||
if (tailStart <= headEnd || tailStart >= messages.size()) {
|
||||
return tailStart;
|
||||
}
|
||||
int cut = tailStart;
|
||||
int safety = messages.size() + 1; // hard guard against pathological loops
|
||||
while (safety-- > 0) {
|
||||
// Map: tool_call_id -> earliest assistant index that issued it.
|
||||
java.util.Map<String, Integer> assistantIdxById = new java.util.HashMap<>();
|
||||
// Map: tool_call_id -> max response index closing it.
|
||||
java.util.Map<String, Integer> latestResponseIdxById = new java.util.HashMap<>();
|
||||
|
||||
for (int i = headEnd; i < messages.size(); i++) {
|
||||
Message m = messages.get(i);
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||
String tid = tc.id();
|
||||
if (tid == null || tid.isEmpty()) continue;
|
||||
// Keep the first occurrence so the cut "snaps" to the
|
||||
// earliest assistant for any duplicated ids; the same
|
||||
// id should never repeat anyway.
|
||||
assistantIdxById.putIfAbsent(tid, i);
|
||||
}
|
||||
} else if (m instanceof ToolResponseMessage trm) {
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
String tid = r.id();
|
||||
if (tid == null || tid.isEmpty()) continue;
|
||||
latestResponseIdxById.merge(tid, i, Math::max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the earliest in-prefix assistant whose pair is split.
|
||||
int earliestSplitAssistant = Integer.MAX_VALUE;
|
||||
for (var e : assistantIdxById.entrySet()) {
|
||||
String id = e.getKey();
|
||||
int aIdx = e.getValue();
|
||||
Integer rIdx = latestResponseIdxById.get(id);
|
||||
if (rIdx == null) {
|
||||
// Assistant issued a call but no response — orphan call,
|
||||
// would already break the provider. Not a pair-split, ignore.
|
||||
continue;
|
||||
}
|
||||
if (aIdx < cut && rIdx >= cut && aIdx < earliestSplitAssistant) {
|
||||
earliestSplitAssistant = aIdx;
|
||||
}
|
||||
if (aIdx >= cut && rIdx < cut) {
|
||||
log.warn("[ConversationWindow] Orphan tool response in prefix without preceding assistant in tail (id={}); leaving boundary alone",
|
||||
id);
|
||||
}
|
||||
}
|
||||
|
||||
if (earliestSplitAssistant == Integer.MAX_VALUE) {
|
||||
break; // converged: no splits remain
|
||||
}
|
||||
cut = earliestSplitAssistant;
|
||||
}
|
||||
|
||||
if (cut <= headEnd) {
|
||||
log.info("[ConversationWindow] Pair-safe boundary collapsed to {} for conv: skipping compaction this turn to avoid splitting a tool_call ↔ tool_response pair",
|
||||
headEnd);
|
||||
return headEnd;
|
||||
}
|
||||
|
||||
int prefixSize = cut - headEnd;
|
||||
int minPrefix = Math.max(0, properties.getPairSafeMinPrefixToCompact());
|
||||
if (prefixSize < minPrefix) {
|
||||
log.info("[ConversationWindow] Pair-safe boundary left {} prefix message(s) (< minPrefix={}); skipping compaction",
|
||||
prefixSize, minPrefix);
|
||||
return headEnd;
|
||||
}
|
||||
|
||||
if (cut != tailStart) {
|
||||
log.info("[ConversationWindow] Pair-safe boundary moved {} -> {} to keep tool_call ↔ tool_response pairs intact",
|
||||
tailStart, cut);
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an anchor message replaying the first <em>real</em> user input
|
||||
* found in the compressed prefix. "Real" here excludes prior
|
||||
* compaction artifacts ({@link #SUMMARY_PREFIX} / {@link #ANCHOR_PREFIX}
|
||||
* messages from earlier rounds), because anchoring the previous
|
||||
* summary defeats the purpose — the model would just see "[Original
|
||||
* goal] [上下文压缩] …" pointing at compressor output, not at the user's
|
||||
* actual request.
|
||||
*
|
||||
* <p>Sizing rules:
|
||||
* <ul>
|
||||
* <li>≤ {@code firstUserAnchorMaxTokens}: keep the original text verbatim.</li>
|
||||
* <li>≤ 3× the budget: head+tail truncate to the budget so most of
|
||||
* the prompt-cache benefit survives.</li>
|
||||
* <li>> 3× the budget: degrade to a 200-char pointer line so we
|
||||
* don't blow prompt cache or the summary budget on a single
|
||||
* message that was probably a pasted spec the model can re-read
|
||||
* from the workspace anyway.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Always returns a {@link UserMessage}. {@code null} when anchoring
|
||||
* is disabled, no real first user exists in the prefix, or the body is
|
||||
* blank.
|
||||
*
|
||||
* <p>Package-private for direct unit testing — the surrounding
|
||||
* {@link #compactMessages} path needs a ChatModel and the whole
|
||||
* structured-summary pipeline, which the anchor logic does not.
|
||||
*/
|
||||
Message buildFirstUserAnchor(List<Message> oldMessages) {
|
||||
if (!properties.isFirstUserAnchorEnabled()) {
|
||||
return null;
|
||||
}
|
||||
UserMessage firstUser = null;
|
||||
for (Message m : oldMessages) {
|
||||
if (!(m instanceof UserMessage um)) continue;
|
||||
String text = um.getText();
|
||||
if (text == null) continue;
|
||||
// Skip synthetic prior-round artifacts.
|
||||
if (text.startsWith(SUMMARY_PREFIX) || text.startsWith(ANCHOR_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
firstUser = um;
|
||||
break;
|
||||
}
|
||||
if (firstUser == null) return null;
|
||||
|
||||
String text = firstUser.getText();
|
||||
if (text == null || text.isBlank()) return null;
|
||||
|
||||
int maxAnchorTokens = Math.max(40, properties.getFirstUserAnchorMaxTokens());
|
||||
int textTokens = TokenEstimator.estimateTokens(text);
|
||||
|
||||
if (textTokens <= maxAnchorTokens) {
|
||||
return new UserMessage(ANCHOR_PREFIX + text);
|
||||
}
|
||||
|
||||
// > 3× budget: cheap pointer line so we don't pay token tax for a
|
||||
// gigantic pasted spec. The model still knows the original goal
|
||||
// existed without seeing the full body.
|
||||
if (textTokens > maxAnchorTokens * 3L) {
|
||||
int pointerChars = Math.min(text.length(), 200);
|
||||
String pointer = text.substring(0, pointerChars).stripTrailing()
|
||||
+ (text.length() > pointerChars ? "..." : "");
|
||||
log.info("[ConversationWindow] First-user anchor downgraded to pointer ({} tokens > 3× budget {})",
|
||||
textTokens, maxAnchorTokens);
|
||||
return new UserMessage(ANCHOR_PREFIX + pointer);
|
||||
}
|
||||
|
||||
// Within 3× — head+tail truncate to the budget. The 2 chars/token
|
||||
// ratio is a deliberate over-estimate so the anchor never inflates
|
||||
// past the configured budget on ASCII-heavy input.
|
||||
int budgetChars = Math.max(160, maxAnchorTokens * 2);
|
||||
if (budgetChars >= text.length()) {
|
||||
return new UserMessage(ANCHOR_PREFIX + text);
|
||||
}
|
||||
int headLen = (int) (budgetChars * 0.6);
|
||||
int tailLen = Math.max(40, budgetChars - headLen - 40);
|
||||
if (headLen + tailLen >= text.length()) {
|
||||
return new UserMessage(ANCHOR_PREFIX + text);
|
||||
}
|
||||
String truncated = text.substring(0, headLen)
|
||||
+ "\n...[" + (text.length() - headLen - tailLen) + " chars truncated]...\n"
|
||||
+ text.substring(text.length() - tailLen);
|
||||
log.info("[ConversationWindow] First-user anchor head+tail truncated ({} -> ~{} chars)",
|
||||
text.length(), truncated.length());
|
||||
return new UserMessage(ANCHOR_PREFIX + truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算摘要字数预算:被压缩内容 token 的 20%,不低于 500、不超过 3000。
|
||||
*/
|
||||
@ -340,15 +740,171 @@ public class ConversationWindowManager {
|
||||
// ==================== 工具结果处理 ====================
|
||||
|
||||
/**
|
||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
* Backwards-compatible overload — older tool results that are oversized
|
||||
* stay verbatim because no {@link ToolResultStorage} target is in
|
||||
* scope. New call sites should use the 3-arg overload with explicit
|
||||
* {@code conversationId} and {@code workspaceBasePath} so oversized
|
||||
* bodies can be spilled to disk and recovered via {@code read_file}.
|
||||
*/
|
||||
private int softTrimToolResults(List<Message> messages) {
|
||||
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
|
||||
return pruneOldToolResultsForModelInput(messages, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the messages newest-to-oldest, keeping the latest tool response
|
||||
* verbatim and applying space-saving rewrites to older ones:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Bodies already starting with {@link ToolResultStorage#SPILL_MARKER_PREFIX}
|
||||
* were spilled at tool-execution time — pass through untouched.</li>
|
||||
* <li>If a body matches an identical body already seen in a newer turn,
|
||||
* replace it with a short "duplicate tool output omitted" placeholder
|
||||
* (only above {@link #DEDUP_MIN_CHARS} so we don't bloat tiny acks).</li>
|
||||
* <li>Otherwise, when a {@link ToolResultStorage} is wired and a
|
||||
* conversation id is available, try
|
||||
* {@link ToolResultStorage#persistIfOversized} to spill the raw
|
||||
* bytes to disk and replace the inline body with a preview + path
|
||||
* so the model can read_file the original on demand.</li>
|
||||
* <li>If none of the above apply, leave the body verbatim. Bodies
|
||||
* under the spill threshold or running without a storage hook are
|
||||
* preserved exactly — the lossy "summarized for model context"
|
||||
* single-liner that used to fire here destroyed enough context
|
||||
* on long tasks to be the wrong default.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The {@link #PRUNE_EXEMPT_TOOLS} set still bypasses everything:
|
||||
* sub-agent delegations are not replayable, so their full transcript
|
||||
* stays in context.
|
||||
*
|
||||
* @param messages full conversation in chronological order
|
||||
* @param conversationId used to scope spill files; {@code null} disables spill
|
||||
* @param workspaceBasePath used to locate the spill directory; {@code null}
|
||||
* falls back through the storage's resolveBaseDir chain
|
||||
*/
|
||||
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages,
|
||||
String conversationId,
|
||||
String workspaceBasePath) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return 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;
|
||||
}
|
||||
|
||||
boolean canSpill = toolResultStorage != null
|
||||
&& conversationId != null && !conversationId.isEmpty();
|
||||
|
||||
List<Message> pruned = new ArrayList<>(messages);
|
||||
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
|
||||
int changed = 0;
|
||||
int spilled = 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();
|
||||
String name = r.name();
|
||||
boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name);
|
||||
boolean alreadySpilled = data != null
|
||||
&& data.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
|
||||
|
||||
// Pass through: the latest response, exempt tools, empty bodies,
|
||||
// already-spilled previews — none should be rewritten.
|
||||
if (keepFull || exempt || data == null || data.isEmpty() || alreadySpilled) {
|
||||
newResponses.add(r);
|
||||
if (data != null && data.length() > DEDUP_MIN_CHARS) {
|
||||
seenLargeOutputs.add(data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dedup: identical body seen in a later turn already.
|
||||
if (data.length() > DEDUP_MIN_CHARS && seenLargeOutputs.contains(data)) {
|
||||
String replacement = "[" + name
|
||||
+ "] duplicate tool output omitted; same content appeared later.";
|
||||
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, replacement));
|
||||
messageChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spill on demand: route oversized bodies to disk so the model
|
||||
// can read_file them rather than losing them to a lossy summary.
|
||||
if (canSpill) {
|
||||
String candidate = toolResultStorage.persistIfOversized(
|
||||
data, name, r.id(), conversationId, workspaceBasePath);
|
||||
if (candidate != null
|
||||
&& candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
|
||||
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, candidate));
|
||||
seenLargeOutputs.add(data);
|
||||
messageChanged = true;
|
||||
spilled++;
|
||||
continue;
|
||||
}
|
||||
// returned unchanged: under threshold, excluded tool, or write failed.
|
||||
// Fall through to "keep verbatim".
|
||||
}
|
||||
|
||||
// Default: keep the body verbatim. Better to send a few extra
|
||||
// tokens than to silently destroy data the model might need.
|
||||
newResponses.add(r);
|
||||
if (data.length() > DEDUP_MIN_CHARS) {
|
||||
seenLargeOutputs.add(data);
|
||||
}
|
||||
}
|
||||
if (messageChanged) {
|
||||
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (changed > 0) {
|
||||
log.info("[ConversationWindow] Pruned {} older tool response message(s) ({} spilled to disk) before model request",
|
||||
changed, spilled);
|
||||
}
|
||||
return changed > 0 ? pruned : messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spill-marker responses already point at an on-disk full copy via
|
||||
* {@code path=...} in their body. Trimming, replacing, or pre-pruning
|
||||
* them would destroy the very pointer the model needs to recover the
|
||||
* original output with {@code read_file} — which is the whole reason
|
||||
* we spilled in the first place. All three compaction phases consult
|
||||
* this guard before touching a response.
|
||||
*/
|
||||
static boolean isSpillMarker(ToolResponseMessage.ToolResponse r) {
|
||||
return r != null
|
||||
&& r.responseData() != null
|
||||
&& r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
* <p>Spill-marker responses are left untouched so their on-disk pointer
|
||||
* survives intact across compaction.
|
||||
*/
|
||||
int softTrimToolResults(List<Message> messages) {
|
||||
int trimmed = 0;
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage trm) {
|
||||
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
if (isSpillMarker(r)) {
|
||||
// Pointer + preview already; trimming would lose the path.
|
||||
newResponses.add(r);
|
||||
continue;
|
||||
}
|
||||
String data = r.responseData();
|
||||
if (data != null && data.length() > 500) {
|
||||
String head = data.substring(0, 200);
|
||||
@ -371,35 +927,56 @@ public class ConversationWindowManager {
|
||||
|
||||
/**
|
||||
* Phase 2 - Hard clear:将所有旧工具结果替换为占位符。
|
||||
* <p>Spill-marker responses are left untouched so the on-disk pointer
|
||||
* survives — a placeholder here would force the model to abandon a
|
||||
* tool output it could otherwise recover via {@code read_file}.
|
||||
*/
|
||||
private int hardClearToolResults(List<Message> messages) {
|
||||
int hardClearToolResults(List<Message> messages) {
|
||||
int cleared = 0;
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage trm) {
|
||||
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
|
||||
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"))
|
||||
.toList();
|
||||
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
|
||||
boolean changed = false;
|
||||
List<ToolResponseMessage.ToolResponse> replaced = new ArrayList<>(trm.getResponses().size());
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
if (isSpillMarker(r)) {
|
||||
replaced.add(r);
|
||||
continue;
|
||||
}
|
||||
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"));
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
messages.set(i, ToolResponseMessage.builder().responses(replaced).build());
|
||||
cleared++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 Pre-prune:在 LLM 摘要前,将工具输出替换为占位符(减少摘要输入 token)。
|
||||
* <p>Spill-marker responses are left untouched so the summary input
|
||||
* still has the on-disk path the model might cite back in its summary.
|
||||
*/
|
||||
private int prePruneForSummary(List<Message> messages) {
|
||||
int prePruneForSummary(List<Message> messages) {
|
||||
int pruned = 0;
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage trm) {
|
||||
boolean hasSubstantial = trm.getResponses().stream()
|
||||
.anyMatch(r -> r.responseData() != null && r.responseData().length() > 200);
|
||||
.anyMatch(r -> !isSpillMarker(r)
|
||||
&& r.responseData() != null
|
||||
&& r.responseData().length() > 200);
|
||||
if (hasSubstantial) {
|
||||
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
|
||||
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(),
|
||||
"[旧工具输出已清理以节省上下文空间]"))
|
||||
.toList();
|
||||
List<ToolResponseMessage.ToolResponse> placeholders = new ArrayList<>(trm.getResponses().size());
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
if (isSpillMarker(r)) {
|
||||
placeholders.add(r);
|
||||
continue;
|
||||
}
|
||||
placeholders.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
|
||||
"[旧工具输出已清理以节省上下文空间]"));
|
||||
}
|
||||
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
|
||||
pruned++;
|
||||
}
|
||||
@ -435,19 +1012,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,26 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import vip.mate.channel.web.Utf8SseEmitter;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.vo.AgentCapabilitiesVO;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@ -33,16 +44,24 @@ public class AgentController {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final AuthService authService;
|
||||
private final WorkspaceService workspaceService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelCapabilityService modelCapabilityService;
|
||||
private final SystemSettingService systemSettingService;
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@Operation(summary = "获取Agent列表")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<AgentEntity>> list(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestParam(value = "enabled", required = false) Boolean enabled) {
|
||||
// 无 header 时强制使用默认 workspace,不返回全局数据
|
||||
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||
return R.ok(agentService.listAgentsByWorkspace(wsId));
|
||||
// enabled=true: chat selectors hide disabled agents.
|
||||
// enabled=null: admin management page sees enabled + disabled.
|
||||
return R.ok(agentService.listAgentsByWorkspace(wsId, enabled));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取Agent详情")
|
||||
@ -55,14 +74,68 @@ public class AgentController {
|
||||
return R.ok(agent);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取Agent当前能力(modality 集合 + sidecar 配置),用于聊天页提示条")
|
||||
@GetMapping("/{id}/capabilities")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<AgentCapabilitiesVO> capabilities(
|
||||
@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
|
||||
|
||||
ModelConfigEntity primary;
|
||||
try {
|
||||
primary = modelConfigService.resolveModel(agent.getModelName());
|
||||
} catch (Exception e) {
|
||||
// No default model configured yet — return a capabilities snapshot that
|
||||
// tells the UI "we can't say anything about this agent's modalities".
|
||||
return R.ok(AgentCapabilitiesVO.builder()
|
||||
.agentId(id)
|
||||
.modelName("")
|
||||
.providerId("")
|
||||
.modalities(List.of())
|
||||
.build());
|
||||
}
|
||||
java.util.Set<ModelCapabilityService.Modality> modalities =
|
||||
modelCapabilityService.resolve(primary.getModelName(), primary.getModalities());
|
||||
|
||||
SystemSettingsDTO settings = systemSettingService.getSettings();
|
||||
Long visionId = settings.getDefaultVisionModelId();
|
||||
Long videoId = settings.getDefaultVideoModelId();
|
||||
|
||||
return R.ok(AgentCapabilitiesVO.builder()
|
||||
.agentId(id)
|
||||
.modelName(primary.getModelName())
|
||||
.providerId(primary.getProvider())
|
||||
.modalities(modalities.stream().map(Enum::name).toList())
|
||||
.defaultVisionModelId(visionId)
|
||||
.defaultVisionModelLabel(resolveSidecarLabel(visionId))
|
||||
.defaultVideoModelId(videoId)
|
||||
.defaultVideoModelLabel(resolveSidecarLabel(videoId))
|
||||
.build());
|
||||
}
|
||||
|
||||
private String resolveSidecarLabel(Long modelId) {
|
||||
if (modelId == null) return null;
|
||||
try {
|
||||
ModelConfigEntity m = modelConfigService.getModel(modelId);
|
||||
return m == null ? null : m.getProvider() + " / " + m.getModelName();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "创建Agent")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentEntity> create(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestBody AgentEntity agent) {
|
||||
@RequestBody AgentEntity agent,
|
||||
Authentication auth) {
|
||||
// 始终注入 workspace_id,无 header 时使用默认
|
||||
agent.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
|
||||
// RFC-077 §4.4: 记录创建者,让 member 后续可删除自建 Agent
|
||||
agent.setCreatorUserId(resolveUserId(auth));
|
||||
AgentEntity created = agentService.createAgent(agent);
|
||||
auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null);
|
||||
return R.ok(created);
|
||||
@ -84,11 +157,24 @@ public class AgentController {
|
||||
|
||||
@Operation(summary = "删除Agent")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> delete(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId);
|
||||
|
||||
// RFC-077 §4.4: 三选一鉴权 — 系统 admin / workspace admin+ / 创建者本人
|
||||
Long userId = resolveUserId(auth);
|
||||
boolean systemAdmin = isSystemAdmin(auth);
|
||||
boolean workspaceAdmin = !systemAdmin
|
||||
&& workspaceService.hasPermission(agent.getWorkspaceId(), userId, "admin");
|
||||
boolean isCreator = userId.equals(agent.getCreatorUserId());
|
||||
if (!systemAdmin && !workspaceAdmin && !isCreator) {
|
||||
throw new MateClawException("err.agent.delete_forbidden", 403,
|
||||
"Only the creator or a workspace admin can delete this Agent");
|
||||
}
|
||||
|
||||
agentService.deleteAgent(id);
|
||||
auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent.getName(), null);
|
||||
return R.ok();
|
||||
@ -104,8 +190,10 @@ public class AgentController {
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
|
||||
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
|
||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
||||
sseExecutor.execute(() -> {
|
||||
try {
|
||||
agentService.chatStream(id, message, conversationId)
|
||||
@ -142,6 +230,7 @@ public class AgentController {
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
|
||||
}
|
||||
|
||||
@ -154,6 +243,7 @@ public class AgentController {
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
|
||||
}
|
||||
|
||||
@ -183,4 +273,38 @@ public class AgentController {
|
||||
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block runtime calls against an agent flagged as disabled.
|
||||
*
|
||||
* <p>{@code AgentService#getOrBuildAgent} also checks the flag, but only on
|
||||
* a cache miss — once the {@code BaseAgent} instance is warm, a flip to
|
||||
* disabled would silently keep serving requests until something else
|
||||
* invalidates the cache. Enforcing here at the controller closes that gap
|
||||
* for every external entry point.
|
||||
*/
|
||||
private void verifyAgentEnabled(AgentEntity agent) {
|
||||
if (agent != null && !Boolean.TRUE.equals(agent.getEnabled())) {
|
||||
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + agent.getName());
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveUserId(Authentication auth) {
|
||||
if (auth == null) {
|
||||
throw new MateClawException("err.auth.unauthenticated", 401, "Not authenticated");
|
||||
}
|
||||
UserEntity user = authService.findByUsername(auth.getName());
|
||||
if (user == null) {
|
||||
throw new MateClawException("err.auth.user_not_found", 401, "User not found: " + auth.getName());
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private boolean isSystemAdmin(Authentication auth) {
|
||||
if (auth == null) {
|
||||
return false;
|
||||
}
|
||||
UserEntity user = authService.findByUsername(auth.getName());
|
||||
return user != null && "admin".equalsIgnoreCase(user.getRole());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package vip.mate.agent.event;
|
||||
|
||||
/**
|
||||
* Spring application event fired when an agent's lifecycle state changes.
|
||||
* The trigger module subscribes via {@code @EventListener} and forwards
|
||||
* the payload through {@code TriggerEventIngestService} so triggers of
|
||||
* pattern type {@code agent_lifecycle} can fan out to workflows.
|
||||
*
|
||||
* <p>{@code phase} matches the matcher's vocabulary: {@code spawned} for
|
||||
* a fresh create, {@code enabled} / {@code disabled} for a flag flip,
|
||||
* {@code terminated} for a delete. {@code crashed} is reserved for v1
|
||||
* once the agent runtime grows a structured error hook.
|
||||
*
|
||||
* <p>The dedup key downstream is {@code phase + ":" + agentId + ":" +
|
||||
* timestamp}; that's stable across retries of the same operation but
|
||||
* lets the same agent flip enabled/disabled repeatedly without the
|
||||
* trigger pipeline collapsing the events.
|
||||
*/
|
||||
public record AgentLifecycleEvent(
|
||||
long workspaceId,
|
||||
long agentId,
|
||||
String agentName,
|
||||
String phase,
|
||||
long timestamp
|
||||
) {}
|
||||
@ -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,8 +192,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 防重保护:同 chatStructuredStream
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||
// Silent-termination guard (mirrors chatStructuredStream)
|
||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
|
||||
|
||||
return compiledGraph.stream(inputs, config)
|
||||
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||
.flatMapIterable(output -> {
|
||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
|
||||
@ -192,7 +214,33 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||
|
||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
||||
// SummarizingNode output) into segments only — final-answer
|
||||
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
||||
// this used persistOnly, which appended every iteration's
|
||||
// narration into the persisted assistant content; next-turn
|
||||
// replay then saw a chain of "Let me try X..." with no
|
||||
// observations and looped retrying tools.
|
||||
//
|
||||
// Exception — evidence-insufficient terminal turn
|
||||
// (ReasoningNode.java:617): when an answer is rejected for
|
||||
// unsupported references, FINAL_ANSWER is replaced with a
|
||||
// short "[证据不足]" warning and STREAMED_CONTENT carries the
|
||||
// actual answer body the user/UI need to see. Falling back
|
||||
// to persistOnly for that case keeps both the original
|
||||
// answer text and the warning in mate_message.content; with
|
||||
// pure segmentOnly the persisted content would shrink to
|
||||
// just the warning, breaking single-segment renderers like
|
||||
// copy / TTS / history reload (segments.length<=1 disables
|
||||
// the segmented view in MessageBubble).
|
||||
boolean isFinalAnswerTurn = hasFinalAnswer(output);
|
||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
|
||||
}
|
||||
|
||||
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
String answer = extractFinalAnswer(output);
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
deltas.add(contentAlreadyStreamed
|
||||
@ -213,6 +261,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
|
||||
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
|
||||
if (hasFinalAnswer(output)
|
||||
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|
||||
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
|
||||
sawLegitimateExit.set(true);
|
||||
}
|
||||
|
||||
return deltas;
|
||||
})
|
||||
.concatWith(Mono.fromSupplier(() -> {
|
||||
@ -225,8 +281,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
));
|
||||
}
|
||||
return null;
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
|
||||
.doOnComplete(() -> {
|
||||
setState(AgentState.IDLE);
|
||||
if (!sawLegitimateExit.get()) {
|
||||
log.error("[{}] StateGraph replay stream completed WITHOUT a final answer / "
|
||||
+ "limit_exceeded / finish_reason — likely framework-level silent "
|
||||
+ "termination. conversationId={}, lastIteration={}, softCap={}",
|
||||
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
|
||||
}
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
@ -265,8 +329,22 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||
// Silent-termination guardrail: track the highest iteration / soft cap
|
||||
// observed and whether the graph reached a legitimate exit (final answer
|
||||
// or limit-exceeded node). If the framework completes the Flux without
|
||||
// either signal we log.error in doOnComplete — the graph framework
|
||||
// historically treated its own recursion cap as a silent normal
|
||||
// completion, which masked turns ending mid-execution. Decoupling the
|
||||
// recursionLimit at compile time should keep this from firing, but the
|
||||
// guard catches any future regression instead of letting it ship silent.
|
||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
|
||||
|
||||
return compiledGraph.stream(inputs, config)
|
||||
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||
.flatMapIterable(output -> {
|
||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||
// 1. 提取所有累积的事件,只发送新增部分
|
||||
@ -287,7 +365,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
boolean thinkingAlreadyStreamed = output.state()
|
||||
.value(THINKING_STREAMED, false);
|
||||
|
||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
// 2a. Route per-iteration narrative into the segments timeline
|
||||
// so the segmented UI view still shows "我来…" preludes
|
||||
// between tool cards, but keep the top-level content
|
||||
// field (= persisted mate_message.content) reserved for
|
||||
// the final-answer span. NodeStreamingChatHelper already
|
||||
// broadcast the live deltas; segmentOnly suppresses
|
||||
// re-broadcast and skips content.append while still
|
||||
// populating the segments[] entry.
|
||||
//
|
||||
// Exception — evidence-insufficient terminal turn
|
||||
// (ReasoningNode.java:617): STREAMED_CONTENT carries
|
||||
// the rejected answer body, FINAL_ANSWER is just the
|
||||
// short "[证据不足]" warning. Use persistOnly there so
|
||||
// mate_message.content keeps both the answer text and
|
||||
// the warning — single-segment renderers (copy / TTS /
|
||||
// history reload) read content, not segments.
|
||||
boolean isFinalAnswerTurn = hasFinalAnswer(output);
|
||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
|
||||
}
|
||||
|
||||
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
String answer = extractFinalAnswer(output);
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
deltas.add(contentAlreadyStreamed
|
||||
@ -310,6 +411,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
// 4. Silent-termination guard inputs
|
||||
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
|
||||
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
|
||||
if (hasFinalAnswer(output)
|
||||
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|
||||
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
|
||||
sawLegitimateExit.set(true);
|
||||
}
|
||||
|
||||
return deltas;
|
||||
})
|
||||
// 流正常完成后追加内部 usage 事件
|
||||
@ -323,8 +433,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
));
|
||||
}
|
||||
return null;
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
|
||||
.doOnComplete(() -> {
|
||||
setState(AgentState.IDLE);
|
||||
if (!sawLegitimateExit.get()) {
|
||||
log.error("[{}] StateGraph structured stream completed WITHOUT a final answer / "
|
||||
+ "limit_exceeded / finish_reason — likely framework-level silent "
|
||||
+ "termination (recursionLimit reached or upstream truncation). "
|
||||
+ "conversationId={}, lastIteration={}, softCap={}",
|
||||
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
|
||||
}
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
@ -350,12 +469,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
maxInputTokens,
|
||||
chatModel,
|
||||
conversationId,
|
||||
parsedAgentId);
|
||||
parsedAgentId,
|
||||
toolSet != null ? toolSet.callbacks() : null,
|
||||
workspaceBasePath);
|
||||
}
|
||||
|
||||
List<Message> messages = new ArrayList<>(historyMessages);
|
||||
// 构建当前用户消息:支持 multimodal(如果有图片附件,直接注入 Media)
|
||||
messages.add(buildCurrentUserMessage(conversationId, userMessage));
|
||||
// 同步获取 routing decision,写入 state 供后续节点 / accumulator 读取。
|
||||
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
|
||||
messages.add(currentTurn.userMessage());
|
||||
|
||||
Map<String, Object> inputs = new HashMap<>();
|
||||
// 输入
|
||||
@ -366,9 +489,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,9 +515,60 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
// Multimodal sidecar routing — null when the turn carries no media or
|
||||
// the primary model already covers the modalities. Stored as a Map so
|
||||
// graph state stays JSON-friendly.
|
||||
if (currentTurn.routingDecision() != null
|
||||
&& currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|
||||
|| (currentTurn.routingDecision() != null && !currentTurn.routingDecision().skipped().isEmpty())) {
|
||||
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
|
||||
}
|
||||
|
||||
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
|
||||
// and workspace, then write it into graph state so ActionNode +
|
||||
// StepExecutionNode can forward it to ToolExecutionExecutor → ToolContext.
|
||||
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||
Long parsedAgentIdForOrigin = null;
|
||||
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||
if (parsedAgentIdForOrigin != null) {
|
||||
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||
}
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(CHAT_ORIGIN, origin);
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the right {@link AgentService.StreamDelta} flavor for the per-iteration
|
||||
* {@code STREAMED_CONTENT} the graph just emitted.
|
||||
*
|
||||
* <p>The contract:
|
||||
* <ul>
|
||||
* <li>Intermediate ReAct iterations (no {@code FINAL_ANSWER} yet) →
|
||||
* {@code segmentOnly}. The content is reasoning preamble / mid-loop
|
||||
* summary that belongs in the segments timeline, not in the persisted
|
||||
* {@code mate_message.content}.</li>
|
||||
* <li>Terminal turn where {@code FINAL_ANSWER} is set →
|
||||
* {@code persistOnly}. This covers the evidence-insufficient path
|
||||
* (ReasoningNode.java:617) where {@code STREAMED_CONTENT} carries the
|
||||
* actual rejected answer body and {@code FINAL_ANSWER} is just a short
|
||||
* "[证据不足]" warning. Persisting the streamed body keeps single-segment
|
||||
* renderers (copy / TTS / history reload) showing the full text.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Package-private so the unit test can pin the decision without standing
|
||||
* up a full StateGraph fixture. Returning {@code null} for blank input is the
|
||||
* caller's responsibility — this helper just decides flavor for non-blank
|
||||
* content.
|
||||
*/
|
||||
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
|
||||
return isFinalAnswerTurn
|
||||
? AgentService.StreamDelta.persistOnly(streamed, null)
|
||||
: AgentService.StreamDelta.segmentOnly(streamed, null);
|
||||
}
|
||||
|
||||
private boolean hasFinalAnswer(NodeOutput output) {
|
||||
if (output == null || output.state() == null) {
|
||||
return false;
|
||||
|
||||
@ -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,167 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
|
||||
*
|
||||
* <p>Layer 1 — per-tool cap — is implemented inside each tool itself.
|
||||
* Layer 2 — per-result spill — when a single tool result exceeds {@link #perResultThresholdChars}
|
||||
* the full output is written to disk and only a {@link #previewHeadChars} preview
|
||||
* (plus a pointer line) is sent back to the LLM.
|
||||
* Layer 3 — per-turn aggregate budget — after all tools in a turn complete, if
|
||||
* the cumulative response size exceeds {@link #perTurnBudgetChars}, the largest
|
||||
* non-spilled responses are spilled in turn until the aggregate fits.</p>
|
||||
*
|
||||
* <p>Spill files live under {@link #storageBaseDir} when set, otherwise under
|
||||
* {@code <workspaceBasePath>/.mateclaw/tool-results/<conversationId>/} when a
|
||||
* workspace is bound to the agent, otherwise under
|
||||
* {@code ${java.io.tmpdir}/mateclaw/tool-results/<conversationId>/}.</p>
|
||||
*
|
||||
* <pre>
|
||||
* mate:
|
||||
* agent:
|
||||
* tool-result:
|
||||
* enabled: true
|
||||
* per-result-threshold-chars: 16000
|
||||
* per-turn-budget-chars: 32000
|
||||
* preview-head-chars: 800
|
||||
* excluded-tool-inline-chars: 4000
|
||||
* storage-base-dir:
|
||||
* </pre>
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "mate.agent.tool-result")
|
||||
public class ToolResultProperties {
|
||||
|
||||
/** Master switch. When false, the executor falls back to plain truncation. */
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Per-result spill threshold. A single tool result larger than this is
|
||||
* spilled to disk and the in-context view is replaced with a short
|
||||
* preview + path so the model can call {@code read_file} on demand.
|
||||
*
|
||||
* <p>Aligned with {@code ToolExecutionExecutor.MAX_TOOL_RESULT_CHARS}
|
||||
* (8000): the executor now tries to spill the RAW result first; only
|
||||
* when spilling is disabled, the tool is on {@link #excludedTools}, the
|
||||
* body is under this threshold, or the disk write fails, does it fall
|
||||
* back to truncating inline to 8000 chars. Keeping the threshold equal
|
||||
* to the truncate cap yields a single semantic ladder — above the
|
||||
* threshold means "preserved on disk", at-or-below means "stays inline
|
||||
* verbatim".
|
||||
*
|
||||
* <p>If you want to keep more text inline before spilling, raise this
|
||||
* value AND raise the executor's hard cap together; otherwise the
|
||||
* 8000-char fallback truncate would silently shorten anything between
|
||||
* this threshold and 8000 even when spill is disabled, defeating the
|
||||
* intent.
|
||||
*/
|
||||
private int perResultThresholdChars = 8000;
|
||||
|
||||
/**
|
||||
* Layer 3 — aggregate cap on combined response size in one tool turn.
|
||||
* After all tools complete, the largest non-spilled responses are spilled
|
||||
* in turn until the cumulative size fits this budget.
|
||||
*/
|
||||
private int perTurnBudgetChars = 32000; // was 16000 — headroom for multi-tool turns
|
||||
|
||||
/** Number of leading characters kept inline as a preview after spilling. */
|
||||
private int previewHeadChars = 800;
|
||||
|
||||
/**
|
||||
* Retrieval-style tools are not spilled, but their inline content still must
|
||||
* fit the model context. When aggregate turn budget is exceeded and only
|
||||
* excluded tools remain, their results are compacted to this size.
|
||||
*/
|
||||
private int excludedToolInlineChars = 2500;
|
||||
|
||||
/**
|
||||
* Optional absolute path to override the default spill location.
|
||||
* When blank, falls back to {@code <workspace>/.mateclaw/tool-results/} or
|
||||
* {@code ${java.io.tmpdir}/mateclaw/tool-results/}.
|
||||
*/
|
||||
private String storageBaseDir = "";
|
||||
|
||||
/**
|
||||
* Tools whose results must NEVER be spilled. These are the tools the agent
|
||||
* uses to <i>retrieve</i> spilled content — spilling their output would
|
||||
* cause infinite recursion (read spill path → produces another spill →
|
||||
* agent reads new spill → …) and starve {@code MAX_TOOL_CALLS_PER_STEP}.
|
||||
*
|
||||
* <p>Defaults to file-read tools that already cap their own output internally.
|
||||
* Configurable so deployments can add more retrieval-style tools (e.g.,
|
||||
* MCP-provided readers) without code changes.</p>
|
||||
*/
|
||||
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
|
||||
|
||||
/**
|
||||
* Days to retain spill files before the scheduled cleanup deletes them.
|
||||
* <p><b>Default 0 means time-based cleanup is disabled</b> — spill files
|
||||
* stay on disk until the owning conversation is explicitly deleted (which
|
||||
* fires {@code purgeConversation} via {@code ConversationService}).
|
||||
* This preserves the "recoverable" invariant: a summary or preview that
|
||||
* cites a spill path will keep working for the whole life of the
|
||||
* conversation, no matter how long it sits dormant.
|
||||
* <p>Set to a positive value if disk pressure outweighs recoverability
|
||||
* for your deployment. The scheduled sweep will then delete files whose
|
||||
* mtime falls outside the retention horizon.
|
||||
*/
|
||||
private int retentionDays = 0;
|
||||
|
||||
/**
|
||||
* Cron expression for the spill-cleanup task. Defaults to once a day at
|
||||
* 03:00 server-local time so cleanup runs during quiet hours. Set this
|
||||
* to a Spring-recognised value (six-field cron) or change the bean
|
||||
* wiring to disable it entirely.
|
||||
*/
|
||||
private String cleanupCron = "0 0 3 * * ?";
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
public int getPerResultThresholdChars() { return perResultThresholdChars; }
|
||||
public void setPerResultThresholdChars(int perResultThresholdChars) {
|
||||
this.perResultThresholdChars = perResultThresholdChars;
|
||||
}
|
||||
|
||||
public int getPerTurnBudgetChars() { return perTurnBudgetChars; }
|
||||
public void setPerTurnBudgetChars(int perTurnBudgetChars) {
|
||||
this.perTurnBudgetChars = perTurnBudgetChars;
|
||||
}
|
||||
|
||||
public int getPreviewHeadChars() { return previewHeadChars; }
|
||||
public void setPreviewHeadChars(int previewHeadChars) {
|
||||
this.previewHeadChars = previewHeadChars;
|
||||
}
|
||||
|
||||
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
|
||||
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
|
||||
this.excludedToolInlineChars = excludedToolInlineChars;
|
||||
}
|
||||
|
||||
public String getStorageBaseDir() { return storageBaseDir; }
|
||||
public void setStorageBaseDir(String storageBaseDir) {
|
||||
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
|
||||
}
|
||||
|
||||
public List<String> getExcludedTools() { return excludedTools; }
|
||||
public void setExcludedTools(List<String> excludedTools) {
|
||||
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
|
||||
}
|
||||
|
||||
public int getRetentionDays() { return retentionDays; }
|
||||
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
|
||||
|
||||
public String getCleanupCron() { return cleanupCron; }
|
||||
public void setCleanupCron(String cleanupCron) {
|
||||
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
|
||||
}
|
||||
|
||||
/** O(1) membership test for the exclusion list, used on every tool result. */
|
||||
public Set<String> excludedToolsSet() {
|
||||
return Set.copyOf(excludedTools);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
|
||||
* spill files don't accumulate forever. Kept in its own class instead of
|
||||
* inlined into {@link ToolResultStorage} for two reasons:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Tests can exercise {@code cleanupExpired()} directly without
|
||||
* fighting the Spring scheduler.</li>
|
||||
* <li>Deployments that want to disable the schedule entirely can simply
|
||||
* leave this component out of the autoconfigure path.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The cron expression comes from
|
||||
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
|
||||
* i.e. once a day at 03:00 server-local time). The retention horizon comes
|
||||
* from {@link ToolResultProperties#getRetentionDays()}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolResultRetentionScheduler {
|
||||
|
||||
private final ToolResultStorage storage;
|
||||
private final ToolResultProperties props;
|
||||
|
||||
/**
|
||||
* Cron-fired hook. Failures are logged at WARN so they show up in
|
||||
* standard log scrapes without aborting the scheduler thread — losing
|
||||
* a single sweep is fine, the next one will catch the same files.
|
||||
*/
|
||||
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
|
||||
public void cleanup() {
|
||||
if (props.getRetentionDays() <= 0) {
|
||||
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int deleted = storage.cleanupExpired();
|
||||
if (deleted > 0) {
|
||||
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
|
||||
deleted, props.getRetentionDays());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,452 @@
|
||||
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();
|
||||
|
||||
/**
|
||||
* Workspace roots observed during this JVM's lifetime. Populated every
|
||||
* time a successful spill resolves a base directory; consulted by the
|
||||
* scheduled retention sweep and by {@link #purgeConversation} so we
|
||||
* don't have to query the database for every workspace path. Cross-JVM
|
||||
* orphans are not covered — that is documented in the cleanup javadoc.
|
||||
*/
|
||||
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ToolResultStorage(ToolResultProperties props) {
|
||||
this.props = props;
|
||||
this.excludedToolsSnapshot = props.excludedToolsSet();
|
||||
}
|
||||
|
||||
/** D-6: current cumulative spill count (monotonically increasing). */
|
||||
public long getSpillCount() {
|
||||
return spillCount.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when {@code toolName} is in the configured exclusion list.
|
||||
* Excluded tools (typically retrieval tools like {@code read_file}) are
|
||||
* never spilled — spilling their output would create a recursion where
|
||||
* the agent reads a spill path and produces yet another spill.
|
||||
*/
|
||||
private boolean isExcluded(String toolName) {
|
||||
if (toolName == null) return false;
|
||||
java.util.Set<String> snap = excludedToolsSnapshot;
|
||||
java.util.Set<String> live = props.excludedToolsSet();
|
||||
if (live != snap && !live.equals(snap)) {
|
||||
this.excludedToolsSnapshot = live;
|
||||
snap = live;
|
||||
}
|
||||
return snap.contains(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2. If {@code result} exceeds the per-result threshold, write the full
|
||||
* text to a spill file and return a preview-plus-pointer string. Otherwise
|
||||
* return the original result unchanged.
|
||||
*
|
||||
* @param result the raw tool output (may be null)
|
||||
* @param toolName used in the preview header so the LLM knows which tool produced it
|
||||
* @param toolUseId unique within a conversation; becomes the spill file's basename
|
||||
* @param conversationId scopes spill files by conversation
|
||||
* @param workspaceBasePath agent's workspace base path; may be null/blank
|
||||
*/
|
||||
public String persistIfOversized(String result, String toolName, String toolUseId,
|
||||
String conversationId, String workspaceBasePath) {
|
||||
if (!props.isEnabled() || result == null) {
|
||||
return result;
|
||||
}
|
||||
if (isExcluded(toolName)) {
|
||||
// Retrieval-style tool — never spill, would cause read-back recursion.
|
||||
return result;
|
||||
}
|
||||
if (result.length() <= props.getPerResultThresholdChars()) {
|
||||
return result;
|
||||
}
|
||||
Path file = spillFor(conversationId, toolUseId, workspaceBasePath);
|
||||
if (file == null) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, result, StandardCharsets.UTF_8);
|
||||
} catch (IOException ioe) {
|
||||
log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original",
|
||||
toolName, conversationId, ioe.getMessage());
|
||||
return result;
|
||||
}
|
||||
long count = spillCount.incrementAndGet();
|
||||
log.info("[ToolResultStorage] spill #{}: tool={} chars={} convId={}", count, toolName, result.length(), conversationId);
|
||||
return buildPreview(result, toolName, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 3. Walk the responses; if their aggregate length exceeds the turn
|
||||
* budget, spill the largest remaining non-spilled result and recompute.
|
||||
* Mutates the returned list in place by replacing oversized responses.
|
||||
*/
|
||||
public List<ToolResponseMessage.ToolResponse> enforceTurnBudget(
|
||||
List<ToolResponseMessage.ToolResponse> responses,
|
||||
String conversationId,
|
||||
String workspaceBasePath) {
|
||||
if (!props.isEnabled() || responses == null || responses.isEmpty()) {
|
||||
return responses;
|
||||
}
|
||||
int budget = props.getPerTurnBudgetChars();
|
||||
int aggregate = aggregateSize(responses);
|
||||
if (aggregate <= budget) {
|
||||
return responses;
|
||||
}
|
||||
|
||||
log.info("[ToolResultStorage] turn budget exceeded: {} chars > {} (responses={})",
|
||||
aggregate, budget, responses.size());
|
||||
|
||||
List<ToolResponseMessage.ToolResponse> mutable = new ArrayList<>(responses);
|
||||
|
||||
while (aggregate > budget) {
|
||||
// Find the largest response that has not yet been spilled and is
|
||||
// not produced by an excluded (retrieval-style) tool.
|
||||
int targetIdx = -1;
|
||||
int targetLen = -1;
|
||||
for (int i = 0; i < mutable.size(); i++) {
|
||||
ToolResponseMessage.ToolResponse r = mutable.get(i);
|
||||
String body = r.responseData();
|
||||
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
|
||||
if (isExcluded(r.name())) continue; // retrieval tools must not be spilled
|
||||
if (body.length() > targetLen) {
|
||||
targetLen = body.length();
|
||||
targetIdx = i;
|
||||
}
|
||||
}
|
||||
if (targetIdx < 0) {
|
||||
int compactedIdx = compactLargestExcludedResult(mutable);
|
||||
if (compactedIdx >= 0) {
|
||||
aggregate = aggregateSize(mutable);
|
||||
continue;
|
||||
}
|
||||
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
|
||||
aggregate);
|
||||
break;
|
||||
}
|
||||
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
|
||||
Path file = spillFor(conversationId, target.id(), workspaceBasePath);
|
||||
if (file == null) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, target.responseData(), StandardCharsets.UTF_8);
|
||||
} catch (IOException ioe) {
|
||||
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
|
||||
ioe.getMessage());
|
||||
break;
|
||||
}
|
||||
String preview = buildPreview(target.responseData(), target.name(), file);
|
||||
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), preview));
|
||||
aggregate = aggregateSize(mutable);
|
||||
}
|
||||
return mutable;
|
||||
}
|
||||
|
||||
private int compactLargestExcludedResult(List<ToolResponseMessage.ToolResponse> mutable) {
|
||||
int targetIdx = -1;
|
||||
int targetLen = props.getExcludedToolInlineChars();
|
||||
for (int i = 0; i < mutable.size(); i++) {
|
||||
ToolResponseMessage.ToolResponse r = mutable.get(i);
|
||||
String body = r.responseData();
|
||||
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
|
||||
if (!isExcluded(r.name())) continue;
|
||||
if (body.length() > targetLen) {
|
||||
targetLen = body.length();
|
||||
targetIdx = i;
|
||||
}
|
||||
}
|
||||
if (targetIdx < 0) {
|
||||
return -1;
|
||||
}
|
||||
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
|
||||
String compacted = compactInline(target.responseData(), target.name(), props.getExcludedToolInlineChars());
|
||||
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), compacted));
|
||||
log.info("[ToolResultStorage] compacted excluded tool result: tool={} chars={} -> {}",
|
||||
target.name(), targetLen, compacted.length());
|
||||
return targetIdx;
|
||||
}
|
||||
|
||||
static String compactInline(String body, String toolName, int maxChars) {
|
||||
if (body == null || body.length() <= maxChars) {
|
||||
return body;
|
||||
}
|
||||
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) {
|
||||
Path base;
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
base = Paths.get(props.getStorageBaseDir());
|
||||
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||
} else {
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp == null || tmp.isEmpty()) return null;
|
||||
base = Paths.get(tmp, "mateclaw", "tool-results");
|
||||
}
|
||||
// Register so the retention sweep and conversation-delete hook can
|
||||
// reach this root even when the workspace path is no longer in scope.
|
||||
observedRoots.add(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roots currently known to this instance. Exposed package-private so the
|
||||
* scheduled retention sweep and unit tests can enumerate them without
|
||||
* touching the underlying set directly.
|
||||
*/
|
||||
java.util.Set<Path> getObservedRoots() {
|
||||
return java.util.Collections.unmodifiableSet(observedRoots);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: delete every spill file and per-conversation directory
|
||||
* older than {@link ToolResultProperties#getRetentionDays()} across all
|
||||
* roots this storage has seen, plus the configured base dir and the
|
||||
* tmpdir fallback. Returns the number of files deleted.
|
||||
*
|
||||
* <p>Workspaces that never received a spill in this JVM's lifetime are
|
||||
* not covered. Persisting an observed-roots registry across restarts
|
||||
* could fix that, but is intentionally out of scope — the operator-side
|
||||
* remedy is to run a one-off cleanup with {@code storage-base-dir}
|
||||
* pointed at the historical workspace.
|
||||
*/
|
||||
public int cleanupExpired() {
|
||||
if (props.getRetentionDays() <= 0) {
|
||||
return 0;
|
||||
}
|
||||
long cutoffEpochMillis = System.currentTimeMillis()
|
||||
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
|
||||
|
||||
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||
}
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp != null && !tmp.isEmpty()) {
|
||||
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
for (Path root : roots) {
|
||||
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
|
||||
}
|
||||
if (deleted > 0) {
|
||||
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
|
||||
deleted, roots.size());
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
|
||||
if (root == null || !java.nio.file.Files.isDirectory(root)) {
|
||||
return 0;
|
||||
}
|
||||
int deleted = 0;
|
||||
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
|
||||
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||
if (p.equals(root)) continue;
|
||||
if (!java.nio.file.Files.isRegularFile(p)) continue;
|
||||
try {
|
||||
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
|
||||
if (mtime < cutoffEpochMillis) {
|
||||
java.nio.file.Files.deleteIfExists(p);
|
||||
deleted++;
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
|
||||
return deleted;
|
||||
}
|
||||
// Best-effort: remove emptied per-conversation directories.
|
||||
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
|
||||
for (Path child : (Iterable<Path>) stream::iterator) {
|
||||
if (!java.nio.file.Files.isDirectory(child)) continue;
|
||||
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
|
||||
if (kids.findAny().isEmpty()) {
|
||||
java.nio.file.Files.deleteIfExists(child);
|
||||
}
|
||||
} catch (java.io.IOException ignored) {
|
||||
// empty-check failure is not fatal — leave the directory alone
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every spill file produced for {@code conversationId} across
|
||||
* all observed roots, plus the configured base and tmpdir fallback.
|
||||
* Called by {@code ConversationService.deleteConversation} so spill
|
||||
* directories don't outlive the conversation that owns them.
|
||||
*
|
||||
* <p>Silently no-ops when nothing matches — a conversation that never
|
||||
* spilled, or one whose workspace root was never observed in this JVM,
|
||||
* is simply left alone. Returns the number of files deleted.
|
||||
*/
|
||||
public int purgeConversation(String conversationId) {
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
String safeConv = sanitize(conversationId);
|
||||
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||
}
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp != null && !tmp.isEmpty()) {
|
||||
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||
}
|
||||
int deleted = 0;
|
||||
for (Path root : roots) {
|
||||
Path convDir = root.resolve(safeConv);
|
||||
if (!java.nio.file.Files.isDirectory(convDir)) continue;
|
||||
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
|
||||
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||
try {
|
||||
if (java.nio.file.Files.isRegularFile(p)) {
|
||||
java.nio.file.Files.deleteIfExists(p);
|
||||
deleted++;
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
|
||||
}
|
||||
try {
|
||||
java.nio.file.Files.deleteIfExists(convDir);
|
||||
} catch (java.io.IOException ignored) {
|
||||
// non-empty after deletes (another writer raced us) — fine, leave it
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
|
||||
private static String sanitize(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replaceAll("[^A-Za-z0-9_.-]", "_");
|
||||
}
|
||||
|
||||
/** Test/admin helper: lexicographic ordering by length, descending. Not used at runtime. */
|
||||
static Comparator<ToolResponseMessage.ToolResponse> byBodyLengthDesc() {
|
||||
return (a, b) -> Integer.compare(
|
||||
b.responseData() == null ? 0 : b.responseData().length(),
|
||||
a.responseData() == null ? 0 : a.responseData().length());
|
||||
}
|
||||
}
|
||||
@ -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,14 @@ package vip.mate.agent.graph.node;
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -26,6 +31,22 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class FinalAnswerNode implements NodeAction {
|
||||
|
||||
/**
|
||||
* Cache used to vet {@code /api/v1/files/generated/{id}} URLs the LLM
|
||||
* may have written into the final answer. {@code null} disables the
|
||||
* guard (legacy callers, narrow unit tests that don't exercise file
|
||||
* outputs).
|
||||
*/
|
||||
private final GeneratedFileCache generatedFileCache;
|
||||
|
||||
public FinalAnswerNode() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
|
||||
this.generatedFileCache = generatedFileCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||
@ -34,9 +55,39 @@ public class FinalAnswerNode implements NodeAction {
|
||||
String finalThinking;
|
||||
FinishReason finishReason;
|
||||
|
||||
// RFC-052 — RETURN_DIRECT path takes the highest priority after stopping checks.
|
||||
// The full text of the direct tool result(s) becomes the final answer
|
||||
// verbatim; no LLM call has been made on it. Thinking from the LLM
|
||||
// call that *decided* to invoke the direct tool is preserved (it has
|
||||
// already been streamed; this just keeps the state symmetric with the
|
||||
// NORMAL / SUMMARIZED / LIMIT_EXCEEDED branches below).
|
||||
if (accessor.returnDirectTriggered()) {
|
||||
List<DirectToolOutput> outputs = accessor.directToolOutputs();
|
||||
if (!outputs.isEmpty()) {
|
||||
String assembled = scrubFakeUrls(assembleDirectAnswer(outputs));
|
||||
String currentThinking = accessor.currentThinking();
|
||||
String existingThinking = accessor.finalThinking();
|
||||
String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
|
||||
log.info("[FinalAnswerNode] RETURN_DIRECT — assembled final answer from {} direct " +
|
||||
"tool output(s), {} chars (thinking preserved: {} chars)",
|
||||
outputs.size(), assembled.length(), preservedThinking.length());
|
||||
var builder = MateClawStateAccessor.output()
|
||||
.finalAnswer(assembled)
|
||||
.finishReason(FinishReason.RETURN_DIRECT)
|
||||
.events(List.of(GraphEventPublisher.finishReason(
|
||||
FinishReason.RETURN_DIRECT.getValue())));
|
||||
if (!preservedThinking.isEmpty()) {
|
||||
builder.finalThinking(preservedThinking);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
log.warn("[FinalAnswerNode] RETURN_DIRECT_TRIGGERED=true but DIRECT_TOOL_OUTPUTS empty; " +
|
||||
"falling through to default final-answer assembly");
|
||||
}
|
||||
|
||||
// 审批等待路径:Graph 因 AWAITING_APPROVAL 终止,保留已流式推送的内容用于持久化
|
||||
if (accessor.awaitingApproval()) {
|
||||
String preservedContent = accessor.streamedContent();
|
||||
String preservedContent = scrubFakeUrls(accessor.streamedContent());
|
||||
String preservedThinking = !accessor.streamedThinking().isEmpty()
|
||||
? accessor.streamedThinking() : accessor.currentThinking();
|
||||
log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " +
|
||||
@ -46,7 +97,9 @@ public class FinalAnswerNode implements NodeAction {
|
||||
.finalAnswer(preservedContent)
|
||||
.finishReason(FinishReason.NORMAL)
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(true);
|
||||
.thinkingStreamed(true)
|
||||
.events(List.of(GraphEventPublisher.finishReason(
|
||||
FinishReason.NORMAL.getValue())));
|
||||
if (!preservedThinking.isEmpty()) {
|
||||
builder.finalThinking(preservedThinking);
|
||||
}
|
||||
@ -103,10 +156,52 @@ public class FinalAnswerNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
// Scrub hallucinated `/api/v1/files/generated/{id}` URLs whose ids
|
||||
// were never inserted into the cache. Done before evidence
|
||||
// validation so the validator sees the user-visible warning rather
|
||||
// than treating the fake link as a "reference".
|
||||
finalAnswer = scrubFakeUrls(finalAnswer);
|
||||
|
||||
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
||||
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
||||
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
|
||||
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
|
||||
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
||||
validation.unsupportedReferences());
|
||||
}
|
||||
|
||||
// Build the event list. Always carries the finish_reason event so
|
||||
// downstream consumers (memory gate, channel accumulator, message
|
||||
// metadata persistence) see a machine-readable status. When the
|
||||
// turn ended in a non-transient error, also attach a
|
||||
// feedback_event so the frontend can render retry/regenerate/
|
||||
// report affordances next to the red "[错误] ..." bubble — without
|
||||
// this, fatal errors leave the user staring at error text with no
|
||||
// way to recover short of retyping the whole prompt.
|
||||
List<GraphEventPublisher.GraphEvent> events =
|
||||
new java.util.ArrayList<>(2);
|
||||
events.add(GraphEventPublisher.finishReason(finishReason.getValue()));
|
||||
if (finishReason == FinishReason.ERROR_FALLBACK) {
|
||||
events.add(GraphEventPublisher.feedback(
|
||||
"ERROR_FALLBACK",
|
||||
finalAnswer,
|
||||
List.of("retry", "regenerate", "report")));
|
||||
}
|
||||
|
||||
// 不重置 CONTENT_STREAMED/THINKING_STREAMED,保留上游节点的标志
|
||||
var builder = MateClawStateAccessor.output()
|
||||
.finalAnswer(finalAnswer)
|
||||
.finishReason(finishReason);
|
||||
.finishReason(finishReason)
|
||||
// Emit the resolved FinishReason as a GraphEvent so it rides
|
||||
// the PENDING_EVENTS → StreamDelta pipeline that the channel-
|
||||
// side accumulator subscribes to. A sibling SSE broadcast (e.g.
|
||||
// streamTracker.broadcastObject) reaches the browser but never
|
||||
// touches the accumulator, so toMetadataJson() would not see
|
||||
// it and MemorySummarizationGate would lose the structured
|
||||
// signal. APPEND-strategy on PENDING_EVENTS means this
|
||||
// composes safely with any earlier events upstream nodes
|
||||
// attached.
|
||||
.events(events);
|
||||
|
||||
if (!finalThinking.isEmpty()) {
|
||||
builder.finalThinking(finalThinking);
|
||||
@ -115,6 +210,43 @@ public class FinalAnswerNode implements NodeAction {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
||||
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
||||
+ String.join(", ", unsupportedReferences)
|
||||
+ "。请继续读取相关文件后再下结论。";
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-052 §2.5: assemble the final answer from direct tool outputs.
|
||||
* Single output ⇒ verbatim full text. Multiple outputs ⇒ each prefixed
|
||||
* with a Markdown heading so the user can tell them apart.
|
||||
*/
|
||||
private static String assembleDirectAnswer(List<DirectToolOutput> outputs) {
|
||||
if (outputs.size() == 1) {
|
||||
return outputs.get(0).fullResult();
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
DirectToolOutput out = outputs.get(i);
|
||||
if (i > 0) {
|
||||
sb.append("\n\n");
|
||||
}
|
||||
sb.append("### ").append(out.toolName()).append("\n");
|
||||
sb.append(out.fullResult());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
|
||||
* with a user-visible warning. No-op when no cache is wired (legacy
|
||||
* tests) or when the answer is empty.
|
||||
*/
|
||||
private String scrubFakeUrls(String text) {
|
||||
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
|
||||
return generatedFileCache.scrubMissingReferences(text);
|
||||
}
|
||||
|
||||
private FinishReason parseFinishReason(String reason) {
|
||||
if (reason == null || reason.isEmpty()) {
|
||||
return FinishReason.NORMAL;
|
||||
|
||||
@ -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,14 @@ public class ReasoningNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
if (conversationWindowManager != null) {
|
||||
// Pass conversationId + workspaceBasePath so oversized older
|
||||
// tool results can be spilled to the workspace spill directory
|
||||
// (preserving the full body for read_file recovery) instead of
|
||||
// being rewritten into a lossy single-line summary.
|
||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
|
||||
messages, conversationId, workspaceBasePath);
|
||||
}
|
||||
promptMessages.addAll(messages);
|
||||
|
||||
// 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置)
|
||||
@ -227,6 +377,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 +427,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 +446,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 +461,86 @@ public class ReasoningNode implements NodeAction {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Order matters: the partial-truncation branch MUST sit before
|
||||
// hasFatalError(). hasFatalError() is "no text + no tool calls + non-
|
||||
// null errorMessage", which is also the shape of a thinking-only cap
|
||||
// result (text is empty by definition). Without this ordering the
|
||||
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
|
||||
// INCOMPLETE semantics.
|
||||
|
||||
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
|
||||
// Soft thinking-only loop: the helper disposed the upstream stream
|
||||
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
|
||||
// reasoning_content without emitting any visible content or tool
|
||||
// calls. Treat as INCOMPLETE rather than fatal — the thinking text
|
||||
// has already been streamed and is preserved for the UI's collapse
|
||||
// panel; the user gets a short fallback line they can retry from.
|
||||
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
||||
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
|
||||
"INCOMPLETE",
|
||||
partialThinking.length());
|
||||
var builder = reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.finishReason(FinishReason.INCOMPLETE)
|
||||
.contentStreamed(false)
|
||||
.thinkingStreamed(true)
|
||||
.mergeUsage(state, result);
|
||||
if (!partialThinking.isEmpty()) {
|
||||
builder.finalThinking(partialThinking);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
if (result.partial() && "content_repetition".equals(result.errorMessage())) {
|
||||
// Reasoning loop: the helper disposed the stream because the
|
||||
// model emitted the same paragraph 4+ times in a row (qwen3.6
|
||||
// / deepseek-r1 self-arguing pattern). The streamed text
|
||||
// already showed the duplicates to the user — we can't unsend
|
||||
// SSE chunks — but the persisted finalAnswer should be ONE
|
||||
// clean copy so the IM channel reply and any page-reload
|
||||
// history don't show the wall of repetition. Skip
|
||||
// FinalAnswerNode's evidence validation: the answer is
|
||||
// already truncated, applying validateAnswer on top would
|
||||
// double-stamp warnings on something the user already knows
|
||||
// is incomplete.
|
||||
String rawContent = result.text() != null ? result.text() : "";
|
||||
String dedupedAnswer = NodeStreamingChatHelper.dedupTrailingRepeats(
|
||||
rawContent,
|
||||
NodeStreamingChatHelper.CONTENT_REPEAT_MIN_PERIOD,
|
||||
NodeStreamingChatHelper.CONTENT_REPEAT_MAX_PERIOD);
|
||||
log.warn("[ReasoningNode] Content-repetition cap hit (raw={} chars → deduped={} chars); " +
|
||||
"INCOMPLETE",
|
||||
rawContent.length(), dedupedAnswer.length());
|
||||
var builder = reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer(dedupedAnswer.isEmpty()
|
||||
? "(模型反复输出同一段内容,已自动截断。请尝试重新生成或换个问法。)"
|
||||
: dedupedAnswer)
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.finishReason(FinishReason.INCOMPLETE)
|
||||
// contentStreamed=true because the user already saw
|
||||
// the looping text in their bubble; persisting again
|
||||
// via streamedContent would replay it.
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
|
||||
.mergeUsage(state, result);
|
||||
if (result.thinking() != null && !result.thinking().isEmpty()) {
|
||||
builder.finalThinking(result.thinking());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Fatal error:直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK,
|
||||
// 不走 LimitExceededNode(后者会再发一次 LLM 调用,语义不对且对认证/配额错误会再失败)。
|
||||
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize → finalAnswerNode,
|
||||
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用,finishReason 保持 ERROR_FALLBACK。
|
||||
if (result.hasFatalError()) {
|
||||
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
|
||||
return MateClawStateAccessor.output()
|
||||
return reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer("[错误] " + result.errorMessage())
|
||||
@ -319,7 +553,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 +566,7 @@ public class ReasoningNode implements NodeAction {
|
||||
"toolCount", result.toolCalls().size()
|
||||
));
|
||||
|
||||
return MateClawStateAccessor.output()
|
||||
return reasonOutput()
|
||||
.needsToolCall(true)
|
||||
.shouldSummarize(false)
|
||||
.toolCalls(result.toolCalls())
|
||||
@ -344,7 +579,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 +588,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 +647,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 +728,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 +742,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
|
||||
@ -136,7 +148,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
||||
|
||||
return compiledGraph.stream(inputs, config)
|
||||
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||
.flatMapIterable(output -> {
|
||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||
// 1. 提取事件(只发送新增部分)
|
||||
@ -206,7 +218,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
));
|
||||
}
|
||||
return null;
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
|
||||
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())))
|
||||
.doOnComplete(() -> setState(AgentState.IDLE))
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
|
||||
@ -254,11 +266,14 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
maxInputTokens,
|
||||
chatModel,
|
||||
conversationId,
|
||||
parsedAgentId);
|
||||
parsedAgentId,
|
||||
toolSet != null ? toolSet.callbacks() : null,
|
||||
workspaceBasePath);
|
||||
}
|
||||
|
||||
List<Message> messages = new ArrayList<>(historyMessages);
|
||||
messages.add(buildCurrentUserMessage(conversationId, userMessage));
|
||||
BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
|
||||
messages.add(currentTurn.userMessage());
|
||||
|
||||
// 构建 working context:对历史消息做受控长度摘要
|
||||
String workingContext = buildWorkingContext(historyMessages, List.of());
|
||||
@ -285,6 +300,25 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
if (currentTurn.routingDecision() != null
|
||||
&& (currentTurn.routingDecision().strategy() != vip.mate.llm.routing.model.MultimodalRoutingDecision.Strategy.NONE
|
||||
|| !currentTurn.routingDecision().skipped().isEmpty())) {
|
||||
inputs.put(MateClawStateKeys.ROUTING_DECISION, currentTurn.routingDecision().toMap());
|
||||
}
|
||||
|
||||
// RFC-063r §2.5: same as ReAct path — enrich and store the ChatOrigin
|
||||
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
|
||||
// can read it back from state.
|
||||
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||
Long parsedAgentIdForOrigin = null;
|
||||
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||
if (parsedAgentIdForOrigin != null) {
|
||||
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||
}
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||
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);
|
||||
// 降级:作为简单问答处理,不向前端暴露内部异常细节
|
||||
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);
|
||||
// When the triage LLM fails or returns unparseable output we now fall back to
|
||||
// a single-step plan (the user's goal verbatim) instead of a direct text
|
||||
// answer. This preserves tool access on the failure path; the previous
|
||||
// "direct answer" fallback silently degraded tool-requiring tasks.
|
||||
try {
|
||||
var plan = planningService.createPlan(agentId, goal, List.of(goal));
|
||||
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(true)
|
||||
.planId(plan.getId())
|
||||
.planSteps(List.of(goal))
|
||||
.planValid(true)
|
||||
.currentStepIndex(0)
|
||||
.currentPhase("plan_generated")
|
||||
.events(events)
|
||||
.build();
|
||||
} catch (Exception persistErr) {
|
||||
log.error("[PlanGeneration] Single-step fallback persistence also failed: {}", persistErr.getMessage());
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer("抱歉,我暂时无法完成规划,请重试或换一种方式描述任务。")
|
||||
.directAnswer("抱歉,我暂时无法完成任务分流,请重试或换一种方式描述任务。")
|
||||
.currentPhase("direct_answer")
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 LLM 返回的 JSON,移除可能的 markdown 代码块标记。
|
||||
* 若响应中不包含合法的 JSON 对象,抛出异常让调用方走降级路径。
|
||||
*/
|
||||
private String cleanJsonResponse(String response) {
|
||||
if (response == null) {
|
||||
throw new IllegalArgumentException("LLM returned null response");
|
||||
}
|
||||
String cleaned = response.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned.replaceAll("```json?\\n?", "").replaceAll("```", "").trim();
|
||||
}
|
||||
// 找到第一个 { 和最后一个 }
|
||||
int start = cleaned.indexOf('{');
|
||||
int end = cleaned.lastIndexOf('}');
|
||||
if (start < 0 || end <= start) {
|
||||
throw new IllegalArgumentException(
|
||||
"LLM response does not contain a valid JSON object: " + cleaned.substring(0, Math.min(80, cleaned.length())));
|
||||
}
|
||||
return cleaned.substring(start, end + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,50 @@ public class StepExecutionNode implements NodeAction {
|
||||
int stepPromptTokens = 0;
|
||||
int stepCompletionTokens = 0;
|
||||
|
||||
// RFC-052: any returnDirect tool that fires inside this step must
|
||||
// short-circuit the entire plan (not just this step). We accumulate
|
||||
// outputs across the inner loop and break out as soon as one appears.
|
||||
List<DirectToolOutput> stepDirectOutputs = new ArrayList<>();
|
||||
|
||||
// Wall-clock budget guards against a single hung LLM stream or
|
||||
// synchronous unsafe-tool call (the per-tool timeout is enforced only
|
||||
// in the parallel batch path of ToolExecutionExecutor). Checked at the
|
||||
// top of each iteration so we never issue another LLM call after the
|
||||
// budget is gone.
|
||||
long stepStartedAtMs = System.currentTimeMillis();
|
||||
boolean wallClockExceeded = false;
|
||||
|
||||
try {
|
||||
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
||||
ChatOptions options;
|
||||
if (StringUtils.hasText(reasoningEffort)) {
|
||||
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
|
||||
if (elapsedMs > stepWallClockTimeoutMs) {
|
||||
log.warn("[StepExecution] Step {} exceeded wall-clock budget " +
|
||||
"({} ms > {} ms) after {} tool round(s); aborting step",
|
||||
stepIndex, elapsedMs, stepWallClockTimeoutMs, toolCallCount);
|
||||
wallClockExceeded = true;
|
||||
break;
|
||||
}
|
||||
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
|
||||
// producer in NodeStreamingChatHelper.doStreamCall can attach the
|
||||
// user-token. Using ToolCallingChatOptions when reasoningEffort is
|
||||
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
|
||||
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
|
||||
// would 400 again.
|
||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.reasoningEffort(reasoningEffort)
|
||||
.build();
|
||||
if (StringUtils.hasText(reasoningEffort)) {
|
||||
oaiOpts.setReasoningEffort(reasoningEffort);
|
||||
}
|
||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||
options = oaiOpts;
|
||||
} else {
|
||||
options = ToolCallingChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.internalToolExecutionEnabled(false)
|
||||
.build();
|
||||
ChatOptions options = oaiOpts;
|
||||
|
||||
if (conversationWindowManager != null) {
|
||||
// Pass conversationId + workspaceBasePath so oversized
|
||||
// older tool results can be spilled to disk instead of
|
||||
// being rewritten into a lossy single-line summary.
|
||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
|
||||
messages, conversationId, workspaceBasePath);
|
||||
}
|
||||
|
||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||
@ -180,16 +264,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 +291,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 +315,16 @@ public class StepExecutionNode implements NodeAction {
|
||||
if (approvalTriggered) {
|
||||
break;
|
||||
}
|
||||
|
||||
// RFC-052: returnDirect short-circuit. Any direct tool in this
|
||||
// step ends the plan immediately; the dispatcher routes via
|
||||
// currentPhase=plan_aborted so no further LLM call happens.
|
||||
if (!stepDirectOutputs.isEmpty()) {
|
||||
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
|
||||
"tool output(s); aborting plan execution",
|
||||
stepIndex, stepDirectOutputs.size());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理审批暂停
|
||||
@ -239,10 +343,53 @@ public class StepExecutionNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
// RFC-052: direct tool short-circuit at the plan level. Treat the
|
||||
// assembled direct text as the final summary and abort the plan;
|
||||
// the dispatcher routes plan_aborted to END so no further LLM call
|
||||
// is made. Persisting RETURN_DIRECT_TRIGGERED + DIRECT_TOOL_OUTPUTS
|
||||
// lets the SSE accumulator pick up directToolNames metadata so
|
||||
// history scrub (BaseAgent.isDirectToolMessage) kicks in next turn.
|
||||
//
|
||||
// Plan status is "completed" (not "failed"): the user got their
|
||||
// answer correctly, the plan just terminated earlier than the
|
||||
// model's planning stage anticipated. Marking as failed would skew
|
||||
// operational dashboards and confuse plan-history readers.
|
||||
if (!stepDirectOutputs.isEmpty()) {
|
||||
String assembled = assembleDirectAnswerText(stepDirectOutputs);
|
||||
planningService.updateSubPlanResult(planId, stepIndex, assembled);
|
||||
planningService.completePlan(planId,
|
||||
"Plan completed via returnDirect tool: " +
|
||||
stepDirectOutputs.get(0).toolName());
|
||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled));
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||
assembled != null ? assembled.length() : 0, 0));
|
||||
}
|
||||
return PlanStateAccessor.output()
|
||||
.currentStepResult(assembled)
|
||||
.currentStepIndex(steps.size()) // 越界 → dispatcher 收束
|
||||
.currentPhase("plan_aborted")
|
||||
.finalSummary(assembled)
|
||||
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
|
||||
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
|
||||
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS,
|
||||
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS,
|
||||
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
|
||||
if (finalResult == null) {
|
||||
if (wallClockExceeded) {
|
||||
finalResult = "步骤执行超过最大耗时限制("
|
||||
+ (stepWallClockTimeoutMs / 1000) + "秒),已中止本步骤";
|
||||
} else {
|
||||
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
|
||||
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[StepExecution] Step {} execution failed: {}", stepIndex, e.getMessage(), e);
|
||||
@ -250,6 +397,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 +413,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 +455,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 +602,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 已有值上 */
|
||||
|
||||
@ -83,6 +83,15 @@ public final class MateClawStateKeys {
|
||||
// ===== 事件流(APPEND 策略)=====
|
||||
public static final String PENDING_EVENTS = "pending_events";
|
||||
|
||||
/**
|
||||
* Multimodal routing decision for the current turn (REPLACE strategy).
|
||||
* Stored as a Map ready for JSON serialization. Set by BaseAgent before
|
||||
* the reasoning node runs; read back by FinalAnswerNode and (separately)
|
||||
* emitted as a graph event for the SSE accumulator to write into the
|
||||
* persisted message metadata under {@code metadata.routing}.
|
||||
*/
|
||||
public static final String ROUTING_DECISION = "routing_decision";
|
||||
|
||||
// ===== 阶段标记(REPLACE 策略)=====
|
||||
public static final String CURRENT_PHASE = "current_phase";
|
||||
|
||||
@ -140,4 +149,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,8 +21,34 @@ public class TemplateDTO {
|
||||
private String agentType;
|
||||
private String tags;
|
||||
private Integer maxIterations;
|
||||
/**
|
||||
* Optional pre-rendered system prompt seeded into the new agent. Templates
|
||||
* use H2 sections (## Role / ## Goal / ## Backstory / ## Additional
|
||||
* Instructions) so the editor UI can split the prompt into structured
|
||||
* fields and derive a one-line tagline for the agent card.
|
||||
*/
|
||||
private String systemPrompt;
|
||||
private List<WorkspaceFileTemplate> workspaceFiles;
|
||||
|
||||
/**
|
||||
* Skill slugs (matching {@code mate_skill.name}) to pre-bind to the newly
|
||||
* hired agent. Resolved against the target workspace at apply time; any
|
||||
* slug whose row is missing in that workspace is logged and skipped so a
|
||||
* partially-installed environment can still hire the agent. Templates ship
|
||||
* with classpath-stable slugs, not numeric IDs, because skill ids vary per
|
||||
* install.
|
||||
*/
|
||||
private List<String> defaultSkillSlugs;
|
||||
|
||||
/**
|
||||
* Tool names to pre-bind directly (bypassing the skill layer). Filtered
|
||||
* against {@code AvailableToolService.listAvailable()} at apply time —
|
||||
* names the picker can't resolve are dropped with a warning rather than
|
||||
* aborting the hire. Use for capabilities that aren't owned by any skill,
|
||||
* not for system-level tools that are already universally available.
|
||||
*/
|
||||
private List<String> defaultToolNames;
|
||||
|
||||
@Data
|
||||
public static class WorkspaceFileTemplate {
|
||||
private String filename;
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.agent.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -8,9 +9,14 @@ import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.model.TemplateDTO;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.tool.model.AvailableToolDTO;
|
||||
import vip.mate.tool.service.AvailableToolService;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -18,6 +24,7 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -36,6 +43,9 @@ public class TemplateService {
|
||||
private final AgentService agentService;
|
||||
private final WorkspaceFileService workspaceFileService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentBindingService agentBindingService;
|
||||
private final SkillMapper skillMapper;
|
||||
private final AvailableToolService availableToolService;
|
||||
|
||||
/**
|
||||
* 列出所有可用模板
|
||||
@ -63,26 +73,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. 创建工作区文件
|
||||
@ -107,6 +145,131 @@ public class TemplateService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Pre-bind skills the template declares so a hired agent is
|
||||
// usable out of the box ("数据分析师" already knows SQL, "代码审查员"
|
||||
// already has the test-driven-development playbook). Resolution
|
||||
// failures (slug missing in this workspace) are skipped with a
|
||||
// warning; bind-service exceptions still propagate and roll back
|
||||
// the @Transactional hire — see helper Javadoc.
|
||||
applyDefaultSkillBindings(template, created);
|
||||
|
||||
// 5. Pre-bind any standalone tools the template wants. Picker
|
||||
// outage and unknown names are dropped with a warning; a
|
||||
// setToolBindings exception still propagates (same contract as
|
||||
// skills) — see helper Javadoc.
|
||||
applyDefaultToolBindings(template, created);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve {@link TemplateDTO#getDefaultSkillSlugs()} to skill ids inside
|
||||
* the agent's own workspace and pre-bind them.
|
||||
*
|
||||
* <p><b>Failure contract — read carefully.</b>
|
||||
* <ul>
|
||||
* <li><b>Resolution failures</b> (slug not present in the agent's
|
||||
* workspace, blank entries) → logged and dropped. A template MUST
|
||||
* stay applyable on an offline upgrade or partial-seed install
|
||||
* where some bundled skills haven't landed yet.</li>
|
||||
* <li><b>Service-layer failures</b>
|
||||
* ({@link AgentBindingService#setSkillBindings} throws — e.g. a
|
||||
* race deletes the skill row between resolve and bind, or the
|
||||
* workspace check rejects it) → <em>propagate</em>. Because
|
||||
* {@link #applyTemplate} runs under {@code @Transactional}, this
|
||||
* rolls back the whole hire. That's deliberate: such a throw is
|
||||
* a real wiring/race problem, and pretending the hire succeeded
|
||||
* would leave the user with a half-configured agent.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Reads the workspace off the just-persisted {@link AgentEntity}
|
||||
* rather than a separate parameter so the lookup and the validator
|
||||
* inside {@code AgentBindingService.requireSameWorkspace} can never
|
||||
* disagree on which workspace they're talking about.
|
||||
*/
|
||||
private void applyDefaultSkillBindings(TemplateDTO template, AgentEntity created) {
|
||||
List<String> slugs = template.getDefaultSkillSlugs();
|
||||
if (slugs == null || slugs.isEmpty()) return;
|
||||
|
||||
// Mirror the fallback inside AgentBindingService.requireSameWorkspace:
|
||||
// a null workspace_id on a row is treated as workspace 1, so the
|
||||
// lookup needs to agree or we'd silently turn `eq(workspaceId, null)`
|
||||
// into `IS NULL` and match nothing.
|
||||
Long workspaceId = created.getWorkspaceId() == null ? 1L : created.getWorkspaceId();
|
||||
|
||||
List<Long> resolvedIds = new ArrayList<>();
|
||||
for (String slug : slugs) {
|
||||
if (slug == null || slug.isBlank()) continue;
|
||||
SkillEntity skill = skillMapper.selectOne(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getName, slug.trim())
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId));
|
||||
if (skill == null) {
|
||||
log.warn("[Template] template {} requested skill slug '{}' not found in workspace {}; skipping",
|
||||
template.getId(), slug, workspaceId);
|
||||
continue;
|
||||
}
|
||||
resolvedIds.add(skill.getId());
|
||||
}
|
||||
if (resolvedIds.isEmpty()) return;
|
||||
|
||||
agentBindingService.setSkillBindings(created.getId(), resolvedIds);
|
||||
log.info("[Template] template {} pre-bound {} skill(s) on agent {}",
|
||||
template.getId(), resolvedIds.size(), created.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-filter the template's tool names through the picker so
|
||||
* {@link AgentBindingService#setToolBindings} sees only resolvable names
|
||||
* — its own validation would otherwise abort the call on the first
|
||||
* unknown name and leave the agent with no tool bindings at all.
|
||||
*
|
||||
* <p>Failure contract mirrors {@link #applyDefaultSkillBindings}:
|
||||
* picker outage and unknown names are dropped with a warning; an
|
||||
* exception from {@code setToolBindings} itself still propagates and
|
||||
* rolls back the hire.
|
||||
*/
|
||||
private void applyDefaultToolBindings(TemplateDTO template, AgentEntity created) {
|
||||
List<String> names = template.getDefaultToolNames();
|
||||
if (names == null || names.isEmpty()) return;
|
||||
|
||||
Set<String> bindable;
|
||||
try {
|
||||
bindable = availableToolService.listAvailable().stream()
|
||||
.filter(AvailableToolDTO::isAvailable)
|
||||
.map(AvailableToolDTO::getName)
|
||||
.collect(Collectors.toSet());
|
||||
} catch (Exception e) {
|
||||
log.warn("[Template] picker unavailable during template {} apply; skipping tool pre-bind: {}",
|
||||
template.getId(), e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> filtered = new ArrayList<>();
|
||||
for (String name : names) {
|
||||
if (name == null || name.isBlank()) continue;
|
||||
String trimmed = name.trim();
|
||||
if (bindable.contains(trimmed)) {
|
||||
filtered.add(trimmed);
|
||||
} else {
|
||||
log.warn("[Template] template {} requested tool '{}' not currently bindable; skipping",
|
||||
template.getId(), trimmed);
|
||||
}
|
||||
}
|
||||
if (filtered.isEmpty()) return;
|
||||
|
||||
agentBindingService.setToolBindings(created.getId(), filtered);
|
||||
log.info("[Template] template {} pre-bound {} tool(s) on agent {}",
|
||||
template.getId(), filtered.size(), created.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
package vip.mate.agent.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lightweight capability snapshot for an agent — answers two questions
|
||||
* the chat console needs synchronously while the user is composing a message:
|
||||
*
|
||||
* <ul>
|
||||
* <li>What modalities does the agent's primary model support? (drives the
|
||||
* attachment routing hint above the input box.)</li>
|
||||
* <li>Are sidecar models configured at the system level? (drives the
|
||||
* "configure a vision model" CTA when a user attaches an image to an
|
||||
* agent whose primary model can't process it.)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Returned by {@code GET /api/v1/agents/{id}/capabilities}. Computed on
|
||||
* each request — cheap because everything is cached service-side and we only
|
||||
* read at most three rows. Not persisted on {@code mate_agent}; sidecar
|
||||
* configuration is system-wide and {@code modelCapabilities} is derived from
|
||||
* {@code mate_model_config.modalities}.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
public class AgentCapabilitiesVO {
|
||||
private Long agentId;
|
||||
private String modelName;
|
||||
private String providerId;
|
||||
/** Resolved modality set: any of {@code TEXT / VISION / VIDEO / AUDIO}. */
|
||||
private List<String> modalities;
|
||||
/** System-level vision sidecar model id, null when not configured. */
|
||||
private Long defaultVisionModelId;
|
||||
/** Display name of the configured vision sidecar (provider/modelName), null when not configured. */
|
||||
private String defaultVisionModelLabel;
|
||||
/** System-level video sidecar model id (reserved in v1; never wired). */
|
||||
private Long defaultVideoModelId;
|
||||
private String defaultVideoModelLabel;
|
||||
}
|
||||