release: v1.2.0

This commit is contained in:
matevip 2026-05-05 20:09:58 +08:00
parent 9ada305b8a
commit b338757f04
653 changed files with 106203 additions and 2965 deletions

3
.gitignore vendored
View File

@ -98,3 +98,6 @@ CLAUDE.md
# Codex CLI local artifacts
.codex/
# Sync tooling local state (generated each run; report is intentionally tracked)
scripts/.*-sync-state.json

View File

@ -60,7 +60,7 @@ This is the difference between a warehouse and a library.
| Surface | What it is |
|---|---|
| **Web Console** | Full admin — agents, models, tools, skills, knowledge, security, cron |
| **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 |
@ -74,22 +74,28 @@ Same brain. Same memory. Same tools. Different doors.
## What's in the box
### Agent runtime
**ReAct** for iterative reasoning. **Plan-and-Execute** for complex multi-step work. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### 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
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, dreaming workflows
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows
### Tools, skills, MCP
Built-in tools for web search, files, memory, date/time. **MCP** over stdio / SSE / Streamable HTTP. **SKILL.md** packages from the ClawHub marketplace. A **Tool Guard** layer with RBAC, approval flows, and path protection — capability needs boundaries.
### Skills · MCP · ACP — three ways to extend capability
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video. First-class, not add-ons.
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons.
### Enterprise-ready
RBAC + JWT. 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.
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.
---
@ -109,14 +115,15 @@ This is the same shift databases went through around 2010 and cloud went through
|:---|:---:|:---:|:---:|:---:|:---:|
| **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 flow + audit** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
| **Multi-user admin** | **RBAC + approval + audit + runtime console** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
| **Capability extension** | **Skills (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
| **Surfaces** | Web admin + Desktop + Widget + SDK + 8 IM | 25+ chat channels | 15+ channels (CLI-led) | 3 IM preview | IDE only |
| **Stack** | **Java (Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
| **License / Price** | **Apache 2.0 · Free** | MIT · Free | MIT · Free | Proprietary · $20200/mo | Proprietary · $0200/mo |
**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.
**MateClaw is the version built for teams.** RBAC per agent, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. A web admin dashboard where one operator manages 50 agents across 14 vendors. Spring Boot inside — drop-in for any Java shop already running production services.
**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.
Same "whole widget" philosophy. Different center of gravity.
@ -184,7 +191,8 @@ Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Agent | StateGraph runtime · ReAct + Plan-Execute |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
@ -199,7 +207,7 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
Sharper multi-agent collaboration · Smarter model routing · Deeper multimodal understanding · Longer-lived memory · A richer ClawHub.
Sharper multi-employee collaboration · Smarter model routing · Deeper multimodal understanding · Longer-lived memory · A richer ClawHub · More ACP upstream integrations.
## Contributing

View File

@ -60,7 +60,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
| 入口 | 它是什么 |
|---|---|
| **Web 控制台** | 完整的管理后台——智能体、模型、工具、技能、知识、安全、定时任务 |
| **Web 控制台** | 完整的管理后台——数字员工、模型、技能、知识、安全、定时任务、**运行时控制台**(看见每位员工正在干什么、一键回收) |
| **桌面端** | Electron + 内嵌 JRE 21双击即用无需装 Java |
| **网页嵌入式聊天** | 一个 `<script>` 标签就能嵌进任何网站 |
| **IM 渠道** | 钉钉 · 飞书 · 企业微信 · 微信 · Telegram · Discord · QQ · Slack |
@ -74,22 +74,28 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
## 盒子里有什么
### 智能体引擎
**ReAct** 做迭代推理。**Plan-and-Execute** 做复杂多步任务。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面**热点缓存**自动注入到员工的 system prompt
- **工作区记忆**`AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
- **记忆生命周期** — 对话后自动提取 · 定时整理 · 记忆涌现工作流
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流
### 工具、技能、MCP
内置工具覆盖搜索、文件、记忆、日期。**MCP** 支持 stdio / SSE / Streamable HTTP 三种传输。**SKILL.md** 包可从 ClawHub 市场安装。**工具防护**层提供 RBAC、审批流、文件路径保护——能力必须有边界。
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md用得越多越聪明**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频。一等公民,不是附加插件。
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。
### 企业就绪
RBAC + JWT。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
---
@ -109,14 +115,15 @@ RBAC + JWT。完整审计事件流。Flyway 管理数据库 schema升级时
|:---|:---:|:---:|:---:|:---:|:---:|
| **多厂商失败转移** | **Chain + 健康追踪 + 冷却** | 切换供应商(改配置) | 内置编排重试 | 仅 Anthropic | 单模型 |
| **知识消化式加工** | **Wiki + 页面级引用溯源** | Canvas + 记忆 | Skills Hub + 记忆 | — | 代码索引 |
| **多用户管理** | **RBAC + 审批流 + 审计** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
| **多用户管理** | **RBAC + 审批流 + 审计 + 运行时控制台** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
| **能力扩展接口** | **技能 (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
| **用户触点** | Web 管理台 + 桌面 + 嵌入 + SDK + 8 IM | 25+ 聊天渠道 | 15+ 渠道CLI 为主) | 3 IM预览 | 仅 IDE |
| **技术栈** | **JavaSpring Boot** | TypeScript | Python | TypeScript | Electron/TS |
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20200/月 | 闭源 · $0200/月 |
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优选它们没问题。两家的社区规模今天都大于 MateClaw。
**MateClaw 是那个给团队用的版本。** 每个 agent、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。一个 Web 管理台里,一个运维能同时管 50 个 agent 跑在 14 家供应商上。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
**同一套"完整一整套"哲学,不同的重心。**
@ -184,7 +191,8 @@ mateclaw/
| 层次 | 技术 |
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| 智能体 | StateGraph 运行时 · ReAct + Plan-Execute |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
| 能力扩展 | SKILL.md 包 · MCP 协议stdio / SSE / HTTP· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· MySQL 8.0+(生产)|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
@ -199,7 +207,7 @@ mateclaw/
## 路线图
更强的多智能体协作 · 更智能的模型路由 · 更深度的多模态理解 · 更长久的记忆 · 更繁荣的 ClawHub。
更强的多员工协作 · 更智能的模型路由 · 更深度的多模态理解 · 更长久的记忆 · 更繁荣的 ClawHub · 更多 ACP 上游集成
## 参与贡献

View File

@ -32,8 +32,8 @@
<!-- ===== 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 (5 items) ===== -->
@ -63,9 +63,9 @@
<!-- ===== 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">Tools &amp; Skills</text>
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">Built-in + MCP + Skills</text>
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">ClawHub Marketplace</text>
<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"/>

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -33,8 +33,8 @@
<!-- ===== 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 (5 items) ===== -->
@ -66,9 +66,9 @@
<!-- ===== 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">工具与技能</text>
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">内置 + MCP + 技能包</text>
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">ClawHub 市场扩展</text>
<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"/>

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -70,7 +70,7 @@
<!-- ===== 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)"/>
@ -86,9 +86,9 @@
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool System</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in + MCP + Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval + Guard Rules</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
</g>
<g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -73,7 +73,7 @@
<!-- ===== 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)"/>
@ -89,9 +89,9 @@
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具系统</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)"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 897 KiB

View File

@ -33,7 +33,7 @@ COPY mateclaw-server/settings.xml /root/.m2/settings.xml
WORKDIR /plugin-api
COPY mateclaw-plugin-api/pom.xml ./pom.xml
COPY mateclaw-plugin-api/src ./src
RUN mvn install -DskipTests -q ${MAVEN_FLAGS}
RUN mvn install -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Pre-fetch mateclaw-server dependencies (uses mirror, so this won't hang)
WORKDIR /build
@ -44,7 +44,7 @@ RUN mvn dependency:go-offline -q ${MAVEN_FLAGS}
COPY mateclaw-server/src ./src
COPY --from=frontend-builder /static ./src/main/resources/static
RUN mvn package -DskipTests -q ${MAVEN_FLAGS}
RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Stage 3 — Runtime
#

View File

@ -6,7 +6,7 @@
<groupId>vip.mate</groupId>
<artifactId>mateclaw-server</artifactId>
<version>1.1.137</version>
<version>1.2.0</version>
<packaging>jar</packaging>
<name>MateClaw Server</name>
@ -15,15 +15,15 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
<version>3.5.14</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.4 正式版 -->
<spring-ai.version>1.1.4</spring-ai.version>
<!-- Spring AI 1.1.5 正式版patch upgrade from 1.1.4 -->
<spring-ai.version>1.1.5</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
@ -381,6 +381,38 @@
<version>1.3.0</version>
<scope>test</scope>
</dependency>
<!-- ShedLock: distributed lock for the cron scheduler so a
multi-instance deployment doesn't fire the same job N times.
JDBC mode reuses the existing DataSource — no Redis dependency
on the desktop / single-node footprint. -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.16.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.16.0</version>
</dependency>
<!-- Graph algorithms (community detection, shortest path, centrality)
used by the wiki page-to-page relevance and insights features. -->
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>
<version>1.5.2</version>
</dependency>
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
Used to walk PDPage resources and pull out PDImageXObject instances
for downstream captioning. -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
</dependencies>
<build>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,6 +59,7 @@ import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.planning.service.PlanningService;
@ -100,6 +101,8 @@ public class AgentGraphBuilder {
private final ConversationService conversationService;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter;
private final PlanningService planningService;
private final ToolGuardService toolGuardService;
private final vip.mate.tool.guard.service.ToolGuardConfigService toolGuardConfigService;
@ -131,6 +134,19 @@ public class AgentGraphBuilder {
/** PR-0b: DashScope-specific construction lives here now; we only call into it for the search-on log. */
private final vip.mate.agent.chatmodel.AgentDashScopeChatModelBuilder dashScopeBuilder;
/**
* Optional audit pipeline. Setter injection (rather than a constructor
* parameter) keeps existing constructor-based wiring + tests intact.
* When present, the executor receives it so child-agent denied-tool
* attempts can be recorded.
*/
private vip.mate.audit.service.AuditEventService auditEventService;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
this.auditEventService = s;
}
/**
* 根据 AgentEntity 构建完整的 Agent 实例
*/
@ -140,17 +156,45 @@ public class AgentGraphBuilder {
// 过滤掉 denied 工具使模型完全看不到它们防止 prompt injection 利用 schema
toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools());
// Per-agent tool 绑定过滤如果 agent 有自定义 tool 绑定则只保留绑定的工具
Set<String> boundTools = agentBindingService.getBoundToolNames(entity.getId());
// RFC-090 §14.2 single entry point that merges:
// (a) tools expanded from bound skills' active features, and
// (b) directly bound atomic tools (the Advanced bypass, §9.2 调整 B).
// Three-state semantics: null = no agent-level restriction (use
// global default); non-null (possibly empty) = explicit allowlist.
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
// 统一使用全局默认模型AgentEntity.modelName 为历史残留字段不参与运行时选择
ModelConfigEntity runtimeModel;
// RFC-090 §9.2 调整 C pick a primary model that satisfies
// the agent's bound-skill requires-model. Falls back to the
// global default when no preferred provider satisfies, so the
// existing "no default model" error path stays intact.
// Honor per-Agent model override when set.
// resolveModel() looks up entity.modelName in enabled-only models;
// null / blank / unmatched silently fall back to getDefaultModel(),
// preserving the legacy behavior for Agents without an override.
ModelConfigEntity globalDefault;
try {
runtimeModel = modelConfigService.getDefaultModel();
globalDefault = modelConfigService.resolveModel(entity.getModelName());
} catch (Exception e) {
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent请先在「设置 → 模型」中配置并启用默认模型");
}
ModelConfigEntity runtimeModel;
try {
runtimeModel = providerRouter.selectPrimary(entity.getId(), globalDefault);
if (runtimeModel == null) runtimeModel = globalDefault;
} catch (Exception e) {
log.debug("[ProviderRouter] primary selection failed, falling back to global default: {}",
e.getMessage());
runtimeModel = globalDefault;
}
// Even after the upgrade, log a WARN when the chosen primary
// still doesn't satisfy needs (e.g. no preferred provider was
// capable). The diagnostic is observability-only.
try {
providerRouter.diagnosePrimary(entity.getId(), runtimeModel);
} catch (Exception e) {
log.debug("[ProviderRouter] diagnostic failed: {}", e.getMessage());
}
ModelProviderEntity provider;
try {
@ -196,17 +240,28 @@ public class AgentGraphBuilder {
// 内置搜索作为首选search 工具作为补充/兜底
log.info("内置搜索已开启 (provider={})search 工具保留作为补充通道", provider.getProviderId());
}
// Default 100 if DB row leaves max_iterations null; clamp per-agent overrides
// to the hard ceiling (BaseAgent.MAX_ITERATIONS_HARD_CEILING) so a misconfigured
// row can never push an unbounded loop. Aligned with QwenPaw's 1..100 range.
// Default 100 if DB row leaves max_iterations null. Negative or zero is an
// explicit opt-in to "no soft cap" ObservationDispatcher already treats
// maxIterations<=0 as "do not enforce", so the agent runs until the LLM
// emits a final answer (or returnDirect short-circuits). Positive values
// are clamped to the hard ceiling so a misconfigured row can't skip the
// safety net unintentionally.
int rawMaxIter = entity.getMaxIterations() != null ? entity.getMaxIterations() : 100;
int maxIter = Math.max(1, Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING));
if (maxIter != rawMaxIter) {
log.warn("Agent {} max_iterations={} clamped to {} (1..{})",
entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
int maxIter;
if (rawMaxIter <= 0) {
maxIter = 0;
log.info("Agent {} max_iterations={} → unlimited soft cap (LLM controls termination)",
entity.getId(), rawMaxIter);
} else {
maxIter = Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
if (maxIter != rawMaxIter) {
log.warn("Agent {} max_iterations={} clamped to {} (1..{})",
entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
}
}
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled);
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled,
boundTools, runtimeModel.getMaxInputTokens());
// 当前仅支持 DashScope OpenAI-compatible其他协议直接拒绝
if (!supportsStateGraph(protocol)) {
@ -235,6 +290,8 @@ public class AgentGraphBuilder {
agent.systemPrompt = enhancedPrompt;
agent.maxIterations = maxIter;
agent.modelName = runtimeModel.getModelName();
agent.modelCapabilities = modelCapabilityService.resolve(
runtimeModel.getModelName(), runtimeModel.getModalities());
agent.runtimeProviderId = provider != null ? provider.getProviderId() : "";
agent.temperature = runtimeModel.getTemperature();
agent.maxTokens = runtimeModel.getMaxTokens();
@ -274,7 +331,7 @@ public class AgentGraphBuilder {
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager);
chatModel, conversationWindowManager, toolSet);
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
@ -288,7 +345,7 @@ public class AgentGraphBuilder {
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager);
chatModel, conversationWindowManager, toolSet);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
@ -310,6 +367,15 @@ public class AgentGraphBuilder {
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
// Issue #46: enable skill-aware "Tool not found" hint so when the
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
executor.setAuditEventService(auditEventService);
}
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
@ -377,6 +443,13 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger
// 后续 ReasoningNode / FinalAnswerNode validateAnswer 校验
// 模型引用是否有真实证据漏注册时框架在多 node merge 时会偶发
// 丢这个键evidence_insufficient 检查会"静默地不生效"
// StateKeyRegistrationCoverageTest 专门兜这条
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.build();
// Graph 拓扑
@ -411,13 +484,37 @@ public class AgentGraphBuilder {
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
.recursionLimit(frameworkRecursionLimit())
.build());
} catch (Exception e) {
throw new MateClawException("err.agent.plan_compile_failed", "Plan-Execute StateGraph 编译失败: " + e.getMessage());
}
}
/**
* Hard ceiling for the underlying graph framework's recursion guard.
* <p>
* The framework treats "recursion limit reached" as a normal completion
* it emits a {@code done} signal with no exception and no log. That makes
* it indistinguishable from a real final answer downstream, and is the
* mechanism by which a turn can silently stop mid-execution and persist
* only whatever partial content the accumulator happened to hold.
* <p>
* To avoid that class of bug, the recursion limit must be sized so it can
* <em>never</em> trip before the soft cap (ObservationDispatcher
* LimitExceededNode), which is the only path that produces a proper
* {@code finish_reason} and human-facing message. Sized for the maximum
* effective soft cap (DB hard ceiling + thinking-mode bonus) multiplied
* by 4 (each iteration is worst-case reasoning + summarizing + action +
* observation) plus a 100-step buffer for phase nodes, approval replays
* and tool-result chunking. Decoupled from the per-agent value so a small
* {@code max_iterations} can never accidentally re-introduce the silent
* killer.
*/
private static int frameworkRecursionLimit() {
return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100;
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
@ -437,6 +534,15 @@ public class AgentGraphBuilder {
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
// Issue #46: enable skill-aware "Tool not found" hint so when the
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
executor.setAuditEventService(auditEventService);
}
// PR-1.2 (RFC-049 L1-B): propagate the bound model's capability so ReasoningNode
// can gate the ThinkingLevelHolder override explicitly, rather than inferring
// capability from reasoningEffort == null.
@ -523,6 +629,13 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger
// 后续 ReasoningNode / FinalAnswerNode validateAnswer 校验
// 模型引用是否有真实证据漏注册时框架在多 node merge 时会偶发
// 丢这个键evidence_insufficient 检查会"静默地不生效"
// StateKeyRegistrationCoverageTest 专门兜这条
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.build();
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
@ -557,7 +670,7 @@ public class AgentGraphBuilder {
.addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
.recursionLimit(frameworkRecursionLimit())
.withLifecycleListener(new ReActLifecycleListener())
.build());
} catch (Exception e) {
@ -704,6 +817,16 @@ public class AgentGraphBuilder {
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
}
// RFC-090 §9.2 调整 C second-pass reorder: lift providers
// that satisfy the bound-skill capability set (vision / video /
// audio) ahead of those that don't. Stable otherwise so the
// user-preferred order still wins among capable providers.
try {
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
} catch (Exception e) {
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage());
}
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
// Don't put the primary provider's row into the fallback chain same-instance
@ -827,16 +950,33 @@ public class AgentGraphBuilder {
// ==================== Prompt 构建 ====================
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
// 通过 MemoryManager 从所有 MemoryProvider 组装系统提示词快照冻结
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled,
Set<String> boundTools, Integer maxInputTokens) {
// The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
// PROFILE.md, MEMORY.md, ...) augments that identity with durable
// context. Both are independently optional, but when both exist they
// must be joined earlier this branch picked memory and silently
// dropped the identity prompt, so editor-side identity changes never
// reached runtime if the agent had any workspace files.
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : "";
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
String basePrompt = (memoryPrompt != null && !memoryPrompt.isBlank())
? memoryPrompt
: (entity.getSystemPrompt() != null ? entity.getSystemPrompt() : "");
StringBuilder basePromptBuilder = new StringBuilder();
if (!identityPrompt.isEmpty()) {
basePromptBuilder.append(identityPrompt);
}
if (memoryPrompt != null && !memoryPrompt.isBlank()) {
if (basePromptBuilder.length() > 0) {
basePromptBuilder.append("\n\n");
}
basePromptBuilder.append(memoryPrompt);
}
String basePrompt = basePromptBuilder.toString();
// 使用 skill runtime 构建技能增强per-agent 绑定过滤
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(boundSkillIds);
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, entity.getId());
// 工具调用指导
String toolGuidance = """
@ -926,22 +1066,27 @@ public class AgentGraphBuilder {
If you try to read a PDF/Office file with read_file, you will get binary garbage or an error.
""".formatted(entity.getId());
String searchGuidance = "";
if (builtinSearchEnabled) {
searchGuidance = """
// Web-search vs browser_use priority guidance emitted unconditionally so the rule
// also reaches OpenAI-compatible / Anthropic / Gemini / DeepSeek / Ollama agents that
// do not have builtin search. Issue #40: without this rule the model treats
// browser_use as a search tool and gets stuck in a Playwright launch loop on Windows.
String searchGuidance = """
## Web Search Capability
You have **dual search capability**:
1. **Built-in search** (preferred): Your responses automatically incorporate live web search results from the model provider. For most queries, answer directly your response already includes real-time search data.
2. **search tool** (supplementary): Available as a fallback. Supports advanced parameters: `freshness` (day/week/month/year), `language` (zh-CN/en), `count` (1-10).
### Priority Rules
- **Default**: Answer directly using built-in search. Do NOT say you cannot search your replies already include live results.
- **Use search tool** ONLY when: you need precise time filtering (e.g., user asks for "yesterday's news" call search with freshness=day), specific language results, or your built-in results feel insufficient.
- **NEVER** call both browser_use and search tool for the same query.
### Tool Priority
- For plain web search or fetching public page content, call the `search` tool. It supports advanced parameters: `freshness` (day/week/month/year), `language` (zh-CN/en), `count` (1-10).
- Call `browser_use` ONLY when you need to interact with a page (click, fill forms, screenshot, run JS, follow a logged-in flow). Do NOT use `browser_use` as a search alternative.
- **NEVER** call both `browser_use` and `search` for the same query.
- When searching for news, use the standard format: `📰 [Category] Title Source | Time + Summary`, up to 5 results per category.
""";
if (builtinSearchEnabled) {
searchGuidance += """
### Built-in Search (preferred when available)
Your responses automatically incorporate live web search results from the model provider. For most queries, answer directly your reply already includes real-time search data. Do NOT say you cannot search.
Use the `search` tool ONLY when you need precise time filtering (e.g., "yesterday's news" freshness=day), a specific language, or when built-in results feel insufficient.
""";
}
// Wiki 知识库上下文注入
@ -1039,6 +1184,16 @@ public class AgentGraphBuilder {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public OpenAiApi buildOpenAiApi(ModelProviderEntity provider) {
return buildOpenAiApi(provider, null);
}
/**
* Overload that accepts a per-model read-timeout override (seconds).
* Threaded into both the sync RestClient and streaming WebClient so
* timeout behavior is consistent across blocking and streaming chat
* completions. Null falls back to the default 180s.
*/
public OpenAiApi buildOpenAiApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
@ -1060,8 +1215,9 @@ public class AgentGraphBuilder {
MultiValueMap<String, String> headers = buildOpenAiHeaders(kwargs);
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
// Spring AI OpenAiApi 构造函数会先 set User-Agent "spring-ai" addAll 我们的 headers
// 导致自定义 User-Agent 被追加而非覆盖因此对需要伪装客户端身份的 provider kimi-code
@ -1418,14 +1574,55 @@ public class AgentGraphBuilder {
* readTimeout=180s覆盖 nginx 60s 网关超时 + 留足真实长响应余量超时后由上层 retry 接管
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
return applyHttpTimeouts(builder, null);
}
/**
* Overload that accepts a per-model read-timeout override (seconds).
* Null falls back to the default 180s.
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(Duration.ofSeconds(180));
rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
/**
* Apply equivalent timeouts to the WebClient that backs OpenAI-compatible
* STREAMING calls (chat completions with {@code stream:true}). The
* RestClient version above only protects synchronous HTTP without this,
* the streaming code path uses the default {@code WebClient} which has
* neither connect nor read timeout, so a stalled provider can hang the
* call forever (observed: a single volcengine-plan request held the agent
* thread for 9+ minutes with no error, until the user manually pressed
* Stop). That kept the failover chain idle because nothing threw.
* <p>
* Uses {@link JdkClientHttpConnector} with the same {@link HttpClient} we
* already use for the RestClient so the dependency surface stays clean
* (reactor-netty is not on this project's classpath Spring's webflux
* starter is excluded by design).
*/
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
return applyHttpTimeoutsToWebClient(builder, null);
}
/**
* Overload with the same per-model override semantics as
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
*/
private 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);
}
/**
* generateKwargs.headers 中提取需要强制覆盖的 headers
* 用于通过 RestClient/WebClient 拦截器绕过 Spring AI OpenAiApi 的默认 User-Agent

View File

@ -10,6 +10,7 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -18,7 +19,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
/**
@ -46,7 +49,7 @@ public abstract class BaseAgent {
/**
* 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). Aligned with QwenPaw's _MAX_MAX_ITERATIONS.
* overrides cannot exceed it).
*/
public static final int MAX_ITERATIONS_HARD_CEILING = 100;
protected int maxIterations = 100;
@ -57,6 +60,13 @@ public abstract class BaseAgent {
/** 模型名称 */
protected String modelName;
/**
* Modalities the chat model can natively consume (resolved at agent build time
* by {@link vip.mate.llm.service.ModelCapabilityService}). Empty set = unknown model,
* fall back to text-only behavior. See issue #44.
*/
protected Set<ModelCapabilityService.Modality> modelCapabilities = EnumSet.noneOf(ModelCapabilityService.Modality.class);
/** 采样温度 */
protected Double temperature;
@ -228,17 +238,36 @@ public abstract class BaseAgent {
}
}
// Tail guard: a few providers reject prompts whose history ends with an
// assistant message. Anthropic Claude returns 400 "does not support
// assistant message prefill"; DeepSeek thinking mode requires the
// last assistant turn's reasoning_content (which we may not have).
// The trailing-user-dedup above can already produce an assistant tail
// when the immediately-prior turn was an error / placeholder that got
// dropped by stage 1 / 1.5 of sanitizeForLlm. Strip remaining assistant
// tails defensively the current user message is fed in separately as
// the final prompt by the caller, so dropping these assistant entries
// never loses information the LLM needs.
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof AssistantMessage) {
// Tail guard orphan-user strip (issue #47).
//
// Invariant: every caller of buildConversationHistory appends the
// current user message AFTER this history (BaseAgent.buildClient
// via .user(), StateGraphReActAgent / StateGraphPlanExecuteAgent
// via messages.add(buildCurrentUserMessage)). So the final prompt is
// [system, ...history, current_user]
// and is *always* terminated by a user message. That means a trailing
// assistant in history is FINE for every provider we support it
// produces the correct [..., user, assistant, current_user] alternation
// (OpenAI, Anthropic, DeepSeek regular/thinking, Gemini, Qwen, ).
//
// The actual hazard is the opposite: a trailing USER in history.
// That happens when the immediately-prior turn's assistant message
// was dropped by Stage 1 (approval placeholder) or Stage 1.5 (errored
// turn / "[错误] " row), or never persisted at all (turn interrupted
// before doOnComplete saved the assistant). In that case the history
// ends with an orphan unanswered user, and appending the current user
// produces TWO consecutive user messages. Most providers concatenate
// those and answer both leaking the orphan question's answer
// alongside the current answer. (This was the symptom reported in
// issue #47, originally caused by a tail guard that stripped trailing
// ASSISTANT messages instead of trailing USER ones a direction-
// reversed version of this loop.)
//
// Stripping orphan users is safe: the user re-asked or asked a new
// question; the orphan turn produced no answer the model can build
// on. We lose a small amount of conversational context in exchange
// for clean alternation across every provider.
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) {
messages.remove(messages.size() - 1);
}
return messages;
@ -276,6 +305,19 @@ public abstract class BaseAgent {
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={}",
@ -442,7 +484,12 @@ public abstract class BaseAgent {
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;
};
}
@ -451,15 +498,15 @@ public abstract class BaseAgent {
/**
* 判断当前模型是否支持视频输入
* 仅已知支持视频分析的视觉模型Qwen-VLGPT-4oGemini 才注入视频 Media
* {@link ModelCapabilityService} agent 构建时解析并注入到
* {@link #modelCapabilities}per-model 粒度区分如 glm-4v vs glm-4v-plus
*/
private boolean modelSupportsVideo() {
if (modelName == null) return false;
String n = modelName.toLowerCase();
return (n.contains("qwen") && n.contains("vl"))
|| n.contains("gpt-4o")
|| n.contains("gemini")
|| (n.contains("glm") && n.contains("v"));
return modelCapabilities.contains(ModelCapabilityService.Modality.VIDEO);
}
private boolean modelSupportsVision() {
return modelCapabilities.contains(ModelCapabilityService.Modality.VISION);
}
/**
@ -467,9 +514,27 @@ public abstract class BaseAgent {
* 让模型在 prompt 中直接看到媒体内容不需要再调 MCP read_media_file 工具
*/
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent) {
return buildUserMessage(message, renderedContent, true);
}
/**
* @param injectMedia when {@code false} (history replay), skip the Media-loading
* branch entirely and return text-only providers like Zhipu
* GLM-5V cap at 1 video per request, so re-injecting historical
* attachments on every turn breaks the call.
*/
protected UserMessage buildUserMessage(MessageEntity message, String renderedContent, boolean injectMedia) {
if (!injectMedia) {
return new UserMessage(renderedContent == null ? "" : renderedContent);
}
List<MessageContentPart> parts = conversationService.parseMessageParts(message);
List<Media> mediaList = new ArrayList<>();
// Reasons for attachments that the model cannot consume surfaced to the agent
// via the user message text so it does not hallucinate a tool call to read them.
// See issue #44.
List<String> skippedAttachments = new ArrayList<>();
boolean videoSupported = modelSupportsVideo();
boolean visionSupported = modelSupportsVision();
for (MessageContentPart part : parts) {
if (part == null) continue;
@ -490,6 +555,15 @@ public abstract class BaseAgent {
if (isImage && contentType.contains("svg")) {
log.debug("[{}] Skipping SVG attachment (not supported by multimodal API): {}",
agentName, part.getFileName());
skippedAttachments.add(part.getFileName() + "(SVG 格式,多模态 API 不支持)");
continue;
}
// 图片仅在模型支持视觉时注入纯文本模型 GLM-5-Turbo / DeepSeek-V3会被跳过
if (isImage && !visionSupported) {
log.debug("[{}] Skipping image attachment (model '{}' does not support vision): {}",
agentName, modelName, part.getFileName());
skippedAttachments.add(part.getFileName() + "(当前模型 " + modelName + " 不支持图片输入)");
continue;
}
@ -497,6 +571,7 @@ public abstract class BaseAgent {
if (isVideo && !videoSupported) {
log.debug("[{}] Skipping video attachment (model '{}' does not support video): {}",
agentName, modelName, part.getFileName());
skippedAttachments.add(part.getFileName() + "(当前模型 " + modelName + " 不支持视频输入)");
continue;
}
@ -504,6 +579,7 @@ public abstract class BaseAgent {
if (isVideo && part.getFileSize() != null && part.getFileSize() > MAX_VIDEO_SIZE_BYTES) {
log.warn("[{}] Skipping oversized video attachment ({}MB > 20MB): {}",
agentName, part.getFileSize() / (1024 * 1024), part.getFileName());
skippedAttachments.add(part.getFileName() + "(视频超过 20MB 大小限制)");
continue;
}
@ -515,6 +591,7 @@ public abstract class BaseAgent {
if (mediaPath == null) {
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
skippedAttachments.add(part.getFileName() + "(文件未找到)");
continue;
}
try {
@ -526,14 +603,23 @@ public abstract class BaseAgent {
} catch (Exception e) {
log.warn("[{}] Failed to create Media for {} {}: {}",
agentName, isVideo ? "video" : "image", part.getFileName(), e.getMessage());
skippedAttachments.add(part.getFileName() + "(媒体加载失败)");
}
}
String finalText = renderedContent;
if (!skippedAttachments.isEmpty()) {
finalText = (renderedContent == null ? "" : renderedContent)
+ "\n\n[系统提示] 以下附件未能传入当前模型:" + String.join("", skippedAttachments)
+ "\n请用对话语言清晰、友好地告诉用户当前模型无法处理这类附件建议切换到具备相应能力的多模态模型图片需视觉模型视频需视频理解模型后重新上传。"
+ "不要调用任何工具(包括 ffmpeg、浏览器、文件读取等尝试解析这些附件。";
}
if (mediaList.isEmpty()) {
return new UserMessage(renderedContent);
return new UserMessage(finalText);
}
return UserMessage.builder()
.text(renderedContent)
.text(finalText)
.media(mediaList)
.build();
}

View File

@ -36,6 +36,15 @@ public final class GraphEventPublisher {
*/
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";
/**
* 事件记录
*/
@ -81,10 +90,15 @@ public final class GraphEventPublisher {
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);
@ -110,9 +124,11 @@ public final class GraphEventPublisher {
public static GraphEvent stepCompleted(int index, String result) {
long ts = System.currentTimeMillis();
// Full step result; broadcastChunked splits at the transport layer
// when the payload exceeds the per-event size budget.
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
"index", index,
"result", result != null ? truncateResult(result) : "",
"result", result != null ? result : "",
"timestamp", ts
), ts);
}
@ -123,7 +139,7 @@ public final class GraphEventPublisher {
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"pendingId", pendingId,
"toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? truncateResult(arguments) : "",
"arguments", arguments != null ? arguments : "",
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
@ -140,7 +156,7 @@ public final class GraphEventPublisher {
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("pendingId", pendingId);
data.put("toolName", toolName != null ? toolName : "");
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
data.put("arguments", arguments != null ? arguments : "");
data.put("reason", reason != null ? reason : "");
data.put("summary", summary);
data.put("maxSeverity", maxSeverity);
@ -182,6 +198,25 @@ public final class GraphEventPublisher {
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);
}
// ===== 提取方法 =====
/**
@ -196,14 +231,90 @@ public final class GraphEventPublisher {
.orElse(List.of());
}
/**
* Pass-through; preserved for source/binary compatibility with older callers.
* Truncation at the SSE transport layer is now handled by {@code
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
* ordered chunk events instead of dropping bytes. Logs a one-time
* deprecation hint when invoked.
*
* @deprecated callers should pass full payloads and let the transport layer
* decide whether to chunk.
*/
@Deprecated
private static String truncateResult(String result) {
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
warnTruncateDeprecation();
return result;
}
/**
* 截断字符串用于直推广播公共方法 Node 直接构造广播数据时使用
* Pass-through, kept for source compatibility with code that built broadcast
* payloads directly. Same deprecation reason as {@link #truncateResult}.
*
* @deprecated callers should pass full payloads.
*/
@Deprecated
public static String truncateForBroadcast(String text) {
return truncateResult(text);
warnTruncateDeprecation();
return text;
}
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
new java.util.concurrent.atomic.AtomicBoolean(false);
private static void warnTruncateDeprecation() {
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
"no-op pass-throughs; payloads are no longer truncated here. " +
"Move callers to send the full string and rely on " +
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
}
}
// ===== Iteration lifecycle events =====
public static final String EVENT_ITERATION_START = "iteration_start";
public static final String EVENT_ITERATION_END = "iteration_end";
/**
* Marks the entry of an iteration boundary so consumers can group later
* tool / content / thinking events under a single logical step. The
* {@code scope} field distinguishes the parent agent ("parent") from a
* delegated sub-agent ("subagent"); when scope is "subagent" the
* {@code subagentId} payload field is populated by the producer.
*/
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("index", index);
data.put("reason", reason != null ? reason : "");
data.put("scope", scope != null ? scope : "parent");
if (subagentId != null && !subagentId.isEmpty()) {
data.put("subagentId", subagentId);
}
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
}
/**
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
* and {@code thinkingChars} let consumers render a compact "this turn
* produced X content / Y thinking" header without re-aggregating the
* underlying delta events.
*/
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
int contentChars, int thinkingChars) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("index", index);
data.put("scope", scope != null ? scope : "parent");
if (subagentId != null && !subagentId.isEmpty()) {
data.put("subagentId", subagentId);
}
data.put("contentChars", contentChars);
data.put("thinkingChars", thinkingChars);
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
}
}

View File

@ -3,6 +3,8 @@ 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;
@ -10,8 +12,11 @@ import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@ -27,12 +32,28 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentBindingService {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
private final AgentProviderPreferenceMapper providerPreferenceMapper;
/**
* {@code @Lazy} SkillRuntimeService and AgentBindingService both sit
* near the agent boot path; the lazy proxy avoids a circular bean
* graph when SkillRuntimeService initializes after binding.
*/
private final SkillRuntimeService skillRuntimeService;
@Autowired
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
AgentToolBindingMapper toolBindingMapper,
AgentProviderPreferenceMapper providerPreferenceMapper,
@Lazy SkillRuntimeService skillRuntimeService) {
this.skillBindingMapper = skillBindingMapper;
this.toolBindingMapper = toolBindingMapper;
this.providerPreferenceMapper = providerPreferenceMapper;
this.skillRuntimeService = skillRuntimeService;
}
// ==================== Skill Bindings ====================
@ -128,6 +149,166 @@ public class AgentBindingService {
.collect(Collectors.toSet());
}
/**
* RFC-090 §14.2 single entry point that maps an agent's bindings to
* the set of tool names allowed at runtime.
*
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
* {@link #getBoundToolNames}):
* <ul>
* <li><b>{@code null} bound skills + {@code null} bound tools</b>
* returns {@code null}. Caller treats this as "no agent-level
* restriction; let the upstream {@code ToolSet} pass through
* its global default".</li>
* <li><b>at least one side non-null</b> returns the union, which
* may be empty (= "this agent is explicitly restricted to no
* tools"). The caller must distinguish empty from null.</li>
* </ul>
*
* <p>Skill expansion rules (§14.2):
* <ul>
* <li>Resolved skill found contribute
* {@code ResolvedSkill.getEffectiveAllowedTools()} only tools
* whose owning feature is READY (unavailable features stay
* hidden from the LLM, §10.2 Q8).</li>
* <li>Skill bound but unresolved (e.g. legacy or missing manifest)
* contribute nothing through this path; legacy SKILL.md prompt
* enhancement still runs separately.</li>
* </ul>
*/
public Set<String> getEffectiveToolNames(Long agentId) {
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
Set<String> directTools = getBoundToolNames(agentId);
// (1) null + null no restriction; defer to the global default.
if (boundSkillIds == null && directTools == null) {
return null;
}
Set<String> merged = new LinkedHashSet<>();
if (boundSkillIds != null) {
for (Long skillId : boundSkillIds) {
ResolvedSkill resolved = findResolvedSkillById(skillId);
if (resolved == null) continue;
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
// §14.2 fix: a disabled / security-blocked / setup-needed
// skill must not contribute tools to the LLM
// advertisement even if it's still bound. Without this
// guard, users see ghost tools for skills they thought
// were off.
continue;
}
Set<String> skillTools = resolved.getEffectiveAllowedTools();
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
}
}
if (directTools != null) {
// Advanced 直选的原子 tool§9.2 调整 B
merged.addAll(directTools);
}
// System-level tools that don't belong to any single skill but
// are agent-wide capabilities. Without this carve-out, binding
// any skill silently strips record_lesson / remember / structured-
// memory tools, breaking the §11 self-evolution loop entirely
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS);
return merged;
}
/**
* RFC-090 §11 tools that exist outside the skill scope and must
* survive any agent-level skill binding restriction.
*
* <p>Add new entries here only after verifying the tool is genuinely
* agent-wide, not skill-specific. Tools added here bypass the
* {@link #getEffectiveToolNames} allowlist completely.
*/
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
// Structured memory primitives used by every agent regardless
// of skill bindings, otherwise the self-evolution path collapses
// (§11.3 / §11.4).
"record_lesson",
"remember",
"remember_structured",
"recall_structured",
"forget_structured",
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
// memory/YYYY-MM-DD.md). Prior versions whitelisted
// "read_workspace_file" / "write_workspace_file" /
// "list_workspace_files" those names match no @Tool bean; the
// actual function names carry the "_memory" segment, so the
// earlier carve-out was silently dead.
"list_workspace_memory_files",
"read_workspace_memory_file",
"write_workspace_memory_file",
"edit_workspace_memory_file",
// Skill discovery / dispatch skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts.
"readSkillFile",
"runSkillScript",
"listSkillFiles",
"listAvailableSkills",
// Date / time prior whitelist had a fictional "datetime"; the
// real DateTimeTool exposes three separate methods.
"getCurrentDate",
"getCurrentDateTime",
"getCurrentTime",
// Multi-agent delegation prior whitelist had "delegate_agent",
// but DelegateAgentTool's @Tool methods are delegateToAgent /
// delegateParallel / listAvailableAgents. Same dead-name bug.
"delegateToAgent",
"delegateParallel",
"listAvailableAgents",
// Document / media generation agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever
// any skill is bound, breaking "generate a Word doc / image /
// song / video" intents on agents that happen to have a skill
// on. Regression observed 2026-05-01: a Code Reviewer agent with
// skills bound dropped renderDocx and fell back to dumping the
// markdown body for the user to copy.
"renderDocx",
"renderDocxFromFile",
"renderDocxFromFiles",
"image_generate",
"music_generate",
"video_generate",
// Universal capabilities the global system prompts (SOUL.md /
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
// available; the new gate silently hid them on any agent with
// skills bound, so the prompt promises a tool the registry then
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
// Code Reviewer agent the model called search got
// not-found gave up before ever reaching renderDocx.
"search",
"browser_use",
"read_file",
"write_file",
"edit_file",
"execute_shell_command",
"detect_file_type",
"extract_document_text",
"extract_pdf_text",
"extract_docx_text",
"readMateClawDoc"
);
private ResolvedSkill findResolvedSkillById(Long skillId) {
if (skillId == null || skillRuntimeService == null) return null;
// resolveAllSkillsStatus returns every skill in the catalog, not
// just the active ones, so we still see READY/SETUP_NEEDED status
// for bound but partially-unsatisfied skills.
return skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(s -> s != null && skillId.equals(s.getId()))
.findFirst()
.orElse(null);
}
public AgentToolBinding bindTool(Long agentId, String toolName) {
AgentToolBinding existing = toolBindingMapper.selectOne(
new LambdaQueryWrapper<AgentToolBinding>()

View File

@ -63,7 +63,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
AnthropicApi api = buildAnthropicApi(provider);
AnthropicApi api = buildAnthropicApi(provider, model.getRequestTimeoutSeconds());
AnthropicChatOptions options = buildAnthropicOptions(model);
return AnthropicChatModel.builder()
.anthropicApi(api)
@ -74,6 +74,14 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
}
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");
@ -85,8 +93,9 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
}
String baseUrl = provider.getBaseUrl();
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
AnthropicApi.Builder builder = AnthropicApi.builder()
.apiKey(apiKey.trim())
@ -193,11 +202,49 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
* 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(Duration.ofSeconds(10))
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(Duration.ofSeconds(180));
rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
/**
* Streaming counterpart of {@link #applyHttpTimeouts(RestClient.Builder)}.
* Without this, Spring AI's AnthropicApi would back its streaming chat
* call by a default WebClient with neither connect nor read timeout a
* stalled provider could hang the agent thread indefinitely while the
* failover chain idles (no exception = no signal).
* <p>
* Mirrors AgentGraphBuilder.applyHttpTimeoutsToWebClient: same JDK
* HttpClient + JdkClientHttpConnector path, so the dependency surface
* doesn't pull in reactor-netty (excluded by this project's pom).
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
return applyHttpTimeoutsToWebClient(builder, null);
}
/**
* RFC-03 Lane B1 overload same per-model override semantics as
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
}

View File

@ -96,7 +96,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
String accessToken = oauthService.getValidToken();
// 2) Build the Anthropic API client wired with OAuth headers.
AnthropicApi api = buildOauthAnthropicApi(accessToken);
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.
@ -122,6 +122,15 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
* 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();
@ -135,7 +144,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
// rate-limited harder than spec'd. Reference: openclaw
// anthropic-transport-stream.ts:567-574.
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder))
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
@ -152,7 +161,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
// staring at SDK internals.
.requestInterceptor(new RateLimitDiagnosticInterceptor());
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
WebClient.Builder webClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")

View File

@ -70,18 +70,60 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
}
/**
* DashScope's built-in web search is on by default; only an explicit
* {@code enableSearch=false} in provider kwargs disables it. Public so
* {@code AgentGraphBuilder.build()} can surface the "built-in search
* active" log once per agent.
* 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 true;
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) {

View File

@ -42,7 +42,10 @@ public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider);
// 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)

View File

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

View File

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

View File

@ -42,10 +42,15 @@ public class TemplateController {
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));
return R.ok(templateService.applyTemplate(id, wsId, userId, acceptLanguage));
}
private Long resolveUserId(Authentication auth) {

View File

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

View File

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

View File

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

View File

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

View File

@ -272,6 +272,17 @@ public class NodeStreamingChatHelper {
// ==================== 重试配置 ====================
/**
* Soft upper bound on per-call thinking ({@code reasoning_content}) chars
* with zero visible content and zero tool calls. Beyond this the helper
* disposes the upstream subscription and returns a partial result so the
* graph can advance instead of streaming thinking forever. Calibrated
* against typical Claude/DeepSeek extended-thinking budgets well above
* normal long-form reasoning, low enough to bound a runaway loop in under
* ~30 seconds of wall clock.
*/
private static final int THINKING_ONLY_HARD_CAP_CHARS = 32768;
private static final int MAX_RETRIES = 5;
// RATE_LIMIT: fail fast to failover chain staying on the same
// provider during a rate-limit window wastes time without recovery.
@ -701,11 +712,41 @@ public class NodeStreamingChatHelper {
AtomicInteger cacheReadTokens = new AtomicInteger(0);
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
// 重复检测器检测 LLM 退化输出如不断重复同一句话
RepetitionDetector contentRepDetector = new RepetitionDetector();
RepetitionDetector thinkingRepDetector = new RepetitionDetector();
// 重复检测触发后设为 true外层轮询线程据此 dispose 订阅
AtomicBoolean repetitionTriggered = new AtomicBoolean(false);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除参考 Hermes 思路
// agent 不替模型审核输出退化 max_tokens + max_iterations 兜底
// 仅保留 thinking-only 这条体积兜底处理 volcengine-plan provider
// thinking 通道堆字符不出 content 的死循环生产 trace c1eefa45
AtomicBoolean thinkingOnlyCapTriggered = new AtomicBoolean(false);
// Lifecycle events emitted at most once per call so consumers can
// pivot the UI between "thinking" and "drafting" without inspecting
// delta rates.
AtomicBoolean thinkingStartEmitted = new AtomicBoolean(false);
AtomicBoolean thinkingEndEmitted = new AtomicBoolean(false);
AtomicBoolean firstTokenSignaled = new AtomicBoolean(false);
// Pre-stream lifecycle: tell the front-end how the prompt was sized
// and which provider/model is being asked. These events ride on the
// existing SSE bus, so the heartbeat / first_token signaling stays
// consistent.
if (broadcast && streamTracker != null && conversationId != null && !conversationId.isEmpty()) {
int messageCount = prompt.getInstructions() != null ? prompt.getInstructions().size() : 0;
int contextChars = approximatePromptChars(prompt);
streamTracker.broadcastObject(conversationId, "context_prepared", Map.of(
"messages", messageCount,
"contextChars", contextChars,
"timestamp", System.currentTimeMillis()
));
String modelId = identifyModel(chatModel);
String providerId = primaryProviderId != null ? primaryProviderId : "";
streamTracker.broadcastObject(conversationId, "llm_request_sent", Map.of(
"provider", providerId,
"model", modelId != null ? modelId : "",
"phase", phase != null ? phase : "",
"timestamp", System.currentTimeMillis()
));
}
CountDownLatch latch = new CountDownLatch(1);
@ -718,19 +759,26 @@ public class NodeStreamingChatHelper {
AssistantMessage msg = generation.getOutput();
lastAssistantMessage.set(msg);
// 重复已触发 跳过一切处理等外层 dispose
if (repetitionTriggered.get()) {
// thinking-only soft cap 已触发 跳过一切处理等外层 dispose
if (thinkingOnlyCapTriggered.get()) {
return;
}
// 1. 提取 content delta含重复检测
// 1. 提取 content delta
String contentDelta = msg.getText();
if (contentDelta != null && !contentDelta.isEmpty()) {
if (contentRepDetector.appendAndCheck(contentDelta)) {
log.warn("[{}] Content repetition detected, will cancel stream " +
"for conversation {}", phase, conversationId);
repetitionTriggered.set(true);
return;
// First content delta closes the thinking phase if one
// was open, and arms first-token heartbeat relaxation.
if (broadcast && streamTracker != null
&& firstTokenSignaled.compareAndSet(false, true)) {
streamTracker.markFirstTokenReceived(conversationId);
}
if (broadcast && thinkingAccum.length() > 0
&& thinkingEndEmitted.compareAndSet(false, true)) {
streamTracker.broadcastObject(conversationId, "thinking_end", Map.of(
"thinkingChars", thinkingAccum.length(),
"timestamp", System.currentTimeMillis()
));
}
contentAccum.append(contentDelta);
if (broadcast) {
@ -738,14 +786,27 @@ public class NodeStreamingChatHelper {
}
}
// 2. 提取 thinking delta含重复检测
// 2. 提取 thinking delta. Do not cancel the stream for
// repeated thinking phrases: some models emit repetitive
// internal planning while still making valid tool progress.
String thinkingDelta = extractReasoningContent(msg);
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
if (thinkingRepDetector.appendAndCheck(thinkingDelta)) {
log.warn("[{}] Thinking repetition detected, will cancel stream " +
"for conversation {}", phase, conversationId);
repetitionTriggered.set(true);
return;
// First-token signaling fires for thinking too UI
// shows "thinking" activity before any content streams.
if (broadcast && streamTracker != null
&& firstTokenSignaled.compareAndSet(false, true)) {
streamTracker.markFirstTokenReceived(conversationId);
}
// First thinking delta opens the thinking phase. We
// emit the start lazily (on first delta) rather than
// before subscription so models that never produce
// thinking don't ghost-pair an empty segment.
if (broadcast && thinkingAccum.length() == 0
&& thinkingStartEmitted.compareAndSet(false, true)) {
streamTracker.broadcastObject(conversationId, "thinking_start", Map.of(
"phase", phase != null ? phase : "",
"timestamp", System.currentTimeMillis()
));
}
thinkingAccum.append(thinkingDelta);
// thinkingLevel=off 时不广播 thinking模型仍可能产生但前端不展示
@ -761,6 +822,31 @@ public class NodeStreamingChatHelper {
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
}
// 4. Thinking-only no-progress guard. MUST run after both
// content delta and tool call accumulation, otherwise a
// chunk that carries thinking AND a tool_call together
// (some Anthropic / DeepSeek-thinking responses do this)
// would trip the guard before we observe the tool_call
// the user would see "INCOMPLETE: thinking-only" on a
// request that was actually about to dispatch a tool.
// Pattern-agnostic; fires on volume alone. Outer poll
// tears the subscription down within 500ms once the
// flag flips.
if (thinkingAccum.length() >= THINKING_ONLY_HARD_CAP_CHARS
&& contentAccum.length() == 0
&& toolCallAccumulators.isEmpty()
&& !msg.hasToolCalls()) {
log.warn("[{}] Thinking-only soft cap reached " +
"({} thinking chars, no content/tool yet) " +
"— disposing stream for conversation {}",
phase, thinkingAccum.length(), conversationId);
broadcastContentTruncated(conversationId,
"thinking_only_no_content",
thinkingAccum.length());
thinkingOnlyCapTriggered.set(true);
return;
}
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
@ -786,14 +872,14 @@ public class NodeStreamingChatHelper {
try {
long deadlineMs = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10);
while (!latch.await(500, TimeUnit.MILLISECONDS)) {
// 重复检测触发 立即 dispose 上游订阅停止消耗 tokens
if (repetitionTriggered.get()) {
log.warn("[{}] Repetition detected, disposing upstream subscription " +
"for conversation {}", phase, conversationId);
// thinking-only 软上限触发 立即 dispose 上游订阅停止消耗 tokens
if (thinkingOnlyCapTriggered.get()) {
log.warn("[{}] Stream guard tripped (thinking_only_no_content), disposing " +
"upstream subscription for conversation {}", phase, conversationId);
subscription.dispose();
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("检测到模型输出重复,已自动截断"));
buildDeltaJson("模型在思考阶段停留过久,已自动截断"));
}
// dispose latch 可能不会 countDown直接跳出
break;
@ -893,22 +979,21 @@ public class NodeStreamingChatHelper {
conversationId, phase, errorType);
}
// ===== 成功检查是否因重复被截断 =====
boolean truncatedByRepetition = repetitionTriggered.get();
if (truncatedByRepetition) {
log.warn("[{}] LLM output was truncated due to repetition detection for conversation {}",
// ===== 成功检查是否因 thinking-only 软上限被截断 =====
boolean truncatedByThinkingCap = thinkingOnlyCapTriggered.get();
if (truncatedByThinkingCap) {
log.warn("[{}] LLM stream disposed: thinking-only soft cap reached for conversation {}",
phase, conversationId);
// warning 已在 dispose 时广播无需重复
}
// RFC-009: guard against silent empty responses. Some providers return
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
// capacity, context filter, upstream overload). Treat this as a failure
// signal so streamCallInternal can hand off to the fallback chain.
// Only fire when the primary wasn't truncated by our own repetition
// detector (which deliberately produces short content) and when there
// are no tool calls (tool-only responses are legitimately empty-text).
if (!truncatedByRepetition
// Only fire when the thinking-only cap didn't fire (which deliberately
// produces thinking-only output) and there are no tool calls
// (tool-only responses are legitimately empty-text).
if (!truncatedByThinkingCap
&& contentAccum.length() == 0
&& thinkingAccum.length() == 0
&& toolCallAccumulators.isEmpty()) {
@ -919,7 +1004,8 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
truncatedByRepetition, truncatedByRepetition ? "output_truncated_repetition" : null);
truncatedByThinkingCap,
truncatedByThinkingCap ? "thinking_only_no_content" : null);
}
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
@ -1411,6 +1497,68 @@ public class NodeStreamingChatHelper {
streamTracker.broadcast(conversationId, eventName, json);
}
/**
* Broadcast a {@code content_truncated} lifecycle event so consumers can
* surface when the volume-based thinking-only soft cap stops the stream.
*/
private void broadcastContentTruncated(String conversationId, String reason, int truncatedChars) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;
}
try {
streamTracker.broadcastObject(conversationId, "content_truncated", Map.of(
"reason", reason != null ? reason : "thinking_only_no_content",
"truncatedChars", truncatedChars,
"timestamp", System.currentTimeMillis()
));
} catch (Exception e) {
log.debug("Failed to broadcast content_truncated for {}: {}", conversationId, e.getMessage());
}
}
/**
* Best-effort character count of the outbound prompt for the
* {@code context_prepared} event. Cheaper than tokenizing and only used
* for UI presentation, so an exact figure is unnecessary.
*/
private static int approximatePromptChars(Prompt prompt) {
if (prompt == null || prompt.getInstructions() == null) return 0;
int total = 0;
for (Message m : prompt.getInstructions()) {
String text = m.getText();
if (text != null) total += text.length();
}
return total;
}
/**
* Pick a stable model identifier from whatever {@link ChatModel}
* implementation we received Spring AI doesn't expose a single accessor.
* We try the well-known fields by reflection so this stays decoupled from
* concrete provider classes (Anthropic / OpenAI / DashScope all expose
* {@code defaultOptions.model} or equivalent).
*/
private static String identifyModel(ChatModel chatModel) {
if (chatModel == null) return "";
try {
// Common Spring AI shape: getDefaultOptions().getModel()
java.lang.reflect.Method getDefaultOptions = chatModel.getClass().getMethod("getDefaultOptions");
Object opts = getDefaultOptions.invoke(chatModel);
if (opts != null) {
try {
java.lang.reflect.Method getModel = opts.getClass().getMethod("getModel");
Object model = getModel.invoke(opts);
if (model != null) return model.toString();
} catch (NoSuchMethodException ignored) {
// fall through
}
}
} catch (Exception ignored) {
// fall through to class-name fallback
}
return chatModel.getClass().getSimpleName();
}
/**
* 构建 {"delta":"..."} JSON
*/

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.tool.guard.ToolExecutionGuardHelper;
@ -23,6 +24,7 @@ import vip.mate.tool.guard.service.ToolGuardService;
import java.util.*;
import java.util.Collections;
import java.util.concurrent.*;
import java.util.regex.Pattern;
/**
* 统一工具执行器共享于 ActionNode StepExecutionNode
@ -55,6 +57,23 @@ public class ToolExecutionExecutor {
* method with {@link vip.mate.tool.ConcurrencyUnsafe} instead of editing
* this list.
*/
/**
* Defense against runaway single-response tool floods.
*
* <p>Some models (StreamLake's kat-coder-pro-v1 has been observed
* emitting 50+ in one shot) return huge {@code tool_calls}
* batches in a single response. Without a cap, every call executes, which
* can saturate downstream provider QPS, multiply approval rows, and burn
* tokens. The cap is independent of {@code MAX_ITERATIONS} (which limits
* the loop count, not the per-response batch).
*
* <p>16 covers virtually every legitimate parallel-search / batch-edit
* scenario; truncated calls receive a synthetic {@link ToolResponseMessage}
* in the same turn so the LLM can re-issue the most-important ones in
* its next response rather than hanging on missing tool replies.
*/
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
"browser_use", "BrowserUseTool", "write_file", "edit_file"
);
@ -114,6 +133,16 @@ public class ToolExecutionExecutor {
}
private final Map<String, ToolCallback> toolCallbackMap;
/**
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
* suffixes stripped) to the canonical name registered in {@link #toolCallbackMap}.
* Lets us resolve names the LLM sometimes mangles (e.g. {@code WebSearch},
* {@code web_search_tool}, {@code Read_File}) back to the registered tool
* before guard / lookup / event reporting run, so guard rules keyed on the
* canonical name aren't silently bypassed.
*/
private final Map<String, String> normalizedNameLookup;
private static final Pattern CAMEL_BOUNDARY = Pattern.compile("([a-z0-9])([A-Z])");
private final ToolGuardService toolGuardService;
private final ToolGuard toolGuard; // legacy fallback
private final ApprovalWorkflowService approvalService;
@ -123,6 +152,39 @@ public class ToolExecutionExecutor {
private final ToolResultStorage resultStorage;
/** RFC-008 Phase 4 metadata-driven concurrency classifier; nullable for legacy constructors. */
private final vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry;
/**
* Issue #46: when {@code toolCallbackMap} misses a name that the LLM
* called, we check whether it matches an active skill so we can
* return a precise hint instead of bare "Tool not found". Nullable
* legacy constructors and tests may leave this unset, in which case
* the safety net falls through to the original error string.
*/
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s;
}
/**
* Optional audit sink. When set, child-agent denied-tool attempts are
* recorded so admins can see what children are trying that gets blocked.
* Left optional because legacy constructors and tests run without an
* audit pipeline.
*/
private vip.mate.audit.service.AuditEventService auditEventService;
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
this.auditEventService = s;
}
/**
* Per-turn deduplication key set for child-agent denial audit. Without
* this, a child that retries the same denied tool many times in one
* turn would write one audit row per call. Cleared at the start of
* every {@code execute(...)} so it does not retain entries across turns.
*/
private final ThreadLocal<Set<String>> auditedDenials =
ThreadLocal.withInitial(HashSet::new);
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
@ -153,6 +215,7 @@ public class ToolExecutionExecutor {
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuard toolGuard,
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
this.toolCallbackMap = toolSet.callbackByName();
this.normalizedNameLookup = buildNormalizedLookup(this.toolCallbackMap.keySet());
this.toolGuardService = null;
this.toolGuard = toolGuard;
this.approvalService = approvalService;
@ -169,6 +232,7 @@ public class ToolExecutionExecutor {
ToolResultStorage resultStorage,
vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry) {
this.toolCallbackMap = toolSet.callbackByName();
this.normalizedNameLookup = buildNormalizedLookup(this.toolCallbackMap.keySet());
this.toolGuardService = toolGuardService;
this.toolGuard = toolGuard;
this.approvalService = approvalService;
@ -242,21 +306,58 @@ public class ToolExecutionExecutor {
String workspaceBasePath,
ChatOrigin origin) {
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
// Reset per-turn audit dedupe state. A retried denied tool inside the
// same turn writes a single audit row; the set is repopulated by the
// denial branch below.
auditedDenials.get().clear();
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
// RFC-052: accumulate full-text outputs from returnDirect tools so the
// graph can route to FinalAnswerNode without re-entering the LLM.
List<DirectToolOutput> directOutputs = Collections.synchronizedList(new ArrayList<>());
events.add(GraphEventPublisher.phase("action", Map.of("toolCount", toolCalls.size())));
// RFC-03 Lane A2 cap per-response tool_calls before doing any other
// work. Truncated calls get synthetic responses appended right away so
// the LLM sees the cap on its next turn instead of hanging on missing
// ToolResponseMessages. See MAX_TOOL_CALLS_PER_RESPONSE javadoc.
CappedToolCalls capped = capToolCalls(toolCalls, MAX_TOOL_CALLS_PER_RESPONSE);
if (capped.wasTruncated) {
int requested = toolCalls.size();
log.warn("[ToolExecutor] Model returned {} tool_calls in one response; truncating to {} — see RFC-03 A2",
requested, MAX_TOOL_CALLS_PER_RESPONSE);
events.add(GraphEventPublisher.phase("toolflood", Map.of(
"requested", requested,
"executed", MAX_TOOL_CALLS_PER_RESPONSE,
"dropped", requested - MAX_TOOL_CALLS_PER_RESPONSE)));
allResponses.addAll(capped.truncatedResponses);
}
List<AssistantMessage.ToolCall> effectiveCalls = capped.effective;
events.add(GraphEventPublisher.phase("action", Map.of("toolCount", effectiveCalls.size())));
// Shared collector for raw-stage SourceEvidenceLedger entries. Every
// PreparedToolCall built below points at this same AtomicReference;
// executeSingleTool does an atomic accumulateAndGet(merge) right
// after the raw tool result is in hand and BEFORE spill/truncate
// shrinks it. ActionNode then reads the final ledger off
// ToolExecutionResult.rawEvidenceLedger() instead of rebuilding it
// from the spill-compacted responses (which routinely lose the
// exact lines that mention the cited filenames see #4b38f04f
// production trace, where "ObservationNode.java" only appeared in
// a 30 KB grep result that got head/tail-cut to 4 KB).
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceRef =
new java.util.concurrent.atomic.AtomicReference<>(SourceEvidenceLedger.empty());
// Phase 1: 顺序 Guard + 分段
List<PreparedToolCall> preparedCalls = new ArrayList<>();
ApprovalBarrier barrier = null;
for (int i = 0; i < toolCalls.size(); i++) {
AssistantMessage.ToolCall toolCall = toolCalls.get(i);
String toolName = toolCall.name();
for (int i = 0; i < effectiveCalls.size(); i++) {
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
// bypass guard rules keyed on the canonical name.
String toolName = resolveToolName(toolCall.name());
String arguments = toolCall.arguments();
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments));
@ -267,6 +368,22 @@ public class ToolExecutionExecutor {
if (denied.contains(toolName)) {
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
log.info("[ToolExecutor] Child agent blocked from using tool: {}", toolName);
// Audit per (toolName, conversationId) tuple at most once
// per turn so a child retrying the same denied tool many
// times does not spam the audit table.
if (auditEventService != null && auditedDenials.get().add(toolName)) {
try {
String detail = "{\"toolName\":\"" + toolName + "\",\"conversationId\":\""
+ (conversationId != null ? conversationId : "")
+ "\",\"agentId\":\"" + (agentId != null ? agentId : "")
+ "\"}";
auditEventService.record("subagent.tool.denied", "tool",
toolName, toolName, detail);
} catch (Exception auditEx) {
log.debug("[ToolExecutor] Audit write failed for denied tool {}: {}",
toolName, auditEx.getMessage());
}
}
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, msg));
@ -304,8 +421,8 @@ public class ToolExecutionExecutor {
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, decision.response));
// 标记后续工具为等待审批
for (int j = i + 1; j < toolCalls.size(); j++) {
AssistantMessage.ToolCall remaining = toolCalls.get(j);
for (int j = i + 1; j < effectiveCalls.size(); j++) {
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
allResponses.add(new ToolResponseMessage.ToolResponse(
remaining.id(), remaining.name(),
"[⏳ 等待审批] 前序工具等待审批中,本工具暂缓执行。"));
@ -326,17 +443,18 @@ public class ToolExecutionExecutor {
}
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
log.warn("[ToolExecutor] Tool not found: {}", toolName);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false));
String msg = skillAwareNotFoundMessage(toolName);
log.warn("[ToolExecutor] {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "Tool not found: " + toolName));
toolCall.id(), toolName, msg));
continue;
}
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin));
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -361,7 +479,8 @@ public class ToolExecutionExecutor {
return new ToolExecutionResult(allResponses, events, hasApprovalPending,
barrier != null ? barrier.pendingId : null,
barrier != null ? barrier.toolName : null,
List.copyOf(directOutputs));
List.copyOf(directOutputs),
rawEvidenceRef.get());
}
/**
@ -397,14 +516,15 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs) {
String toolName = toolCall.name();
String toolName = resolveToolName(toolCall.name());
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "Tool not found: " + toolName);
String msg = skillAwareNotFoundMessage(toolName);
log.warn("[ToolExecutor] Pre-approved {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
}
try {
@ -438,15 +558,16 @@ public class ToolExecutionExecutor {
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
// Phase 3 Layer 2: spill before truncation when storage is wired.
// Use the caller-supplied conversationId so spill files inherit the
// same per-conversation directory layout as the non-replay path.
// RFC-008 Layer 1 first, then Layer 2 match the non-replay path
// in executeSingleTool so behavior stays symmetric across approval
// replays. The caller-supplied conversationId scopes spill files
// into the same per-conversation directory layout.
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
if (resultStorage != null && result != null) {
String spillConv = conversationId != null && !conversationId.isEmpty() ? conversationId : "unknown";
result = resultStorage.persistIfOversized(
result, toolName, toolCall.id(), spillConv, workspaceBasePath);
}
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
@ -646,15 +767,36 @@ public class ToolExecutionExecutor {
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
// RFC-008 Phase 3 Layer 2: spill oversized results to disk and replace
// Capture SourceEvidenceLedger from the RAW result, before truncate/
// spill compacts it. Building the ledger from the post-compact
// response (the old ActionNode behaviour) loses any file path that
// happens to fall outside the head/tail kept by truncateToolResult.
// Wrapping the raw string in a synthetic ToolResponseMessage.ToolResponse
// lets us reuse SourceEvidenceLedger.fromToolResponses verbatim no
// new parsing path to keep in sync.
if (result != null && pc.rawEvidenceCollector != null) {
SourceEvidenceLedger rawDelta = SourceEvidenceLedger.fromToolResponses(
java.util.List.of(new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, result)));
if (rawDelta.hasEvidence()) {
pc.rawEvidenceCollector.accumulateAndGet(rawDelta, SourceEvidenceLedger::merge);
}
}
// RFC-008 Layer 1: hard truncation cap to prevent oversized results
// from inflating the prompt. Runs FIRST (before spill) so the spill
// store doesn't need to handle multi-MB writes for run-of-the-mill
// greps that happen to spit out a long stdout.
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
// RFC-008 Layer 2: spill oversized results to disk and replace
// with preview + path. Falls back to truncation when spilling is
// disabled or fails. Spill preserves the full output (read_file can
// retrieve it); truncation discards the tail.
// retrieve it); the Layer 1 truncation above already capped the
// inline portion, so this layer mostly catches near-cap residues.
if (resultStorage != null && result != null) {
result = resultStorage.persistIfOversized(
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
}
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
@ -817,6 +959,96 @@ public class ToolExecutionExecutor {
return approvalResponse;
}
/**
* Issue #46 when a tool callback miss happens, check whether the
* unrecognized name actually matches an active skill. If it does, return
* a precise hint telling the LLM the right invocation pattern instead
* of bare "Tool not found: X". Without this, an LLM that called e.g.
* {@code RedisOps} as a tool gets no recovery signal and either gives
* up or falls back to shell guessing.
*
* <p>Case-insensitive match because LLMs sometimes change the case of
* skill names mid-conversation.
*/
/**
* Resolve the LLM-emitted tool name to a registered canonical name.
* Tries exact match first (the hot path); on miss, normalizes the input
* (camelCasesnake_case, lowercase, strip {@code _tool}/{@code _function}
* suffix) and looks up the canonical equivalent. Returns the original
* string when no match is found, so the caller's downstream "tool not
* found" path still fires.
*/
String resolveToolName(String requested) {
if (requested == null || requested.isBlank()) {
return requested;
}
if (toolCallbackMap.containsKey(requested)) {
return requested;
}
String normalized = normalizeToolName(requested);
String canonical = normalizedNameLookup.get(normalized);
if (canonical != null) {
log.info("[ToolExecutor] Tool name normalized: '{}' -> '{}' (via '{}')",
requested, canonical, normalized);
return canonical;
}
return requested;
}
static String normalizeToolName(String name) {
if (name == null || name.isBlank()) {
return "";
}
String snake = CAMEL_BOUNDARY.matcher(name).replaceAll("$1_$2");
String collapsed = snake.toLowerCase(Locale.ROOT)
.replaceAll("[\\s\\-.]+", "_")
.replaceAll("_+", "_");
if (collapsed.endsWith("_tool")) {
collapsed = collapsed.substring(0, collapsed.length() - 5);
} else if (collapsed.endsWith("_function")) {
collapsed = collapsed.substring(0, collapsed.length() - 9);
}
return collapsed.replaceAll("^_+|_+$", "");
}
private static Map<String, String> buildNormalizedLookup(Set<String> canonicalNames) {
Map<String, String> result = new HashMap<>(canonicalNames.size() * 2);
for (String name : canonicalNames) {
String norm = normalizeToolName(name);
if (norm.isEmpty()) {
continue;
}
String previous = result.putIfAbsent(norm, name);
if (previous != null && !previous.equals(name)) {
log.warn("[ToolExecutor] Two registered tools normalize to the same key '{}': "
+ "'{}' and '{}' — only '{}' will resolve from mangled LLM emissions",
norm, previous, name, previous);
}
}
return Map.copyOf(result);
}
private String skillAwareNotFoundMessage(String toolName) {
if (skillRuntimeService != null && toolName != null && !toolName.isBlank()) {
try {
boolean isSkill = skillRuntimeService.getActiveSkills().stream()
.anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName));
if (isSkill) {
return String.format(
"'%s' is a Skill, not a Tool — calling it as a tool fails. "
+ "To use it, FIRST call readSkillFile(skillName=\"%s\", filePath=\"SKILL.md\") "
+ "to read its instructions, THEN follow what SKILL.md tells you "
+ "(typically runSkillScript with a scripts/<file> path).",
toolName, toolName);
}
} catch (Exception e) {
// Don't let a hint-side failure mask the original error.
log.debug("[ToolExecutor] skill-aware hint check failed: {}", e.getMessage());
}
}
return "Tool not found: " + toolName;
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(
@ -828,7 +1060,26 @@ public class ToolExecutionExecutor {
String conversationId,
String requesterId,
String workspaceBasePath,
ChatOrigin origin
ChatOrigin origin,
/**
* Shared reference (one per execute() invocation) where each
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
* built from the **raw** tool result, before the spill/truncate
* pipeline (Layer 2/Layer 1) shrinks the response that finally
* reaches the LLM.
*
* <p>Why the raw stage: a 30 KB grep output may carry the only
* mention of {@code ObservationNode.java}; once {@code truncateToolResult}
* head/tail-compacts to 4 KB that line is typically dropped. If the
* ledger is built from the compacted response (the old behaviour
* in {@code ActionNode}), the answer's later citation of
* {@code ObservationNode} gets flagged as evidence-insufficient
* even though the model did see the file in its tool result.
*
* <p>Atomic merge via {@code AtomicReference.accumulateAndGet}
* because parallel batches run on {@code TOOL_EXECUTOR}.
*/
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
) {}
private record ApprovalBarrier(String pendingId, String toolName) {}
@ -872,7 +1123,16 @@ public class ToolExecutionExecutor {
* in this batch. Non-empty list graph must short-circuit to
* FinalAnswerNode without re-entering the LLM.
*/
List<DirectToolOutput> directOutputs
List<DirectToolOutput> directOutputs,
/**
* Source evidence accumulated from the RAW (pre-spill, pre-truncate)
* tool results in this batch. ActionNode merges this into the
* graph-level ledger instead of re-parsing the spill-compacted
* {@link #responses} that compaction routinely drops the line
* mentioning a path the model later cites, which would surface
* as a false-positive evidence_insufficient.
*/
SourceEvidenceLedger rawEvidenceLedger
) {
/** Backwards-compatible constructor for callers that don't track direct outputs. */
public ToolExecutionResult(List<ToolResponseMessage.ToolResponse> responses,
@ -880,11 +1140,76 @@ public class ToolExecutionExecutor {
boolean awaitingApproval,
String pendingId,
String barrierToolName) {
this(responses, events, awaitingApproval, pendingId, barrierToolName, List.of());
this(responses, events, awaitingApproval, pendingId, barrierToolName,
List.of(), SourceEvidenceLedger.empty());
}
/** Bridge for callers that pass directOutputs but predate the raw-ledger field. */
public ToolExecutionResult(List<ToolResponseMessage.ToolResponse> responses,
List<GraphEventPublisher.GraphEvent> events,
boolean awaitingApproval,
String pendingId,
String barrierToolName,
List<DirectToolOutput> directOutputs) {
this(responses, events, awaitingApproval, pendingId, barrierToolName,
directOutputs, SourceEvidenceLedger.empty());
}
public boolean hasDirectOutputs() {
return directOutputs != null && !directOutputs.isEmpty();
}
}
/**
* RFC-03 Lane A2 outcome of {@link #capToolCalls(List, int)}. Holds the
* (possibly trimmed) effective list, any synthesized truncation responses
* that should be appended verbatim to the result, and a boolean flag so
* the caller can decide whether to emit an audit event.
*/
record CappedToolCalls(
List<AssistantMessage.ToolCall> effective,
List<ToolResponseMessage.ToolResponse> truncatedResponses,
boolean wasTruncated
) {}
/**
* RFC-03 Lane A2 pure helper used at the top of {@link #execute}.
*
* <p>Returns the input untouched when {@code calls.size() <= maxPerResponse}.
* Otherwise:
* <ul>
* <li>{@link CappedToolCalls#effective} = first {@code maxPerResponse} calls.</li>
* <li>{@link CappedToolCalls#truncatedResponses} = one synthetic
* {@link ToolResponseMessage.ToolResponse} per dropped call,
* so the LLM gets a paired tool response on its next turn instead
* of hanging on missing replies (some providers reject the request
* entirely if any tool_call lacks a response).</li>
* <li>{@link CappedToolCalls#wasTruncated} = true.</li>
* </ul>
*
* <p>Package-private so {@code ToolExecutionExecutorCapToolCallsTest}
* can drive every branch without booting a Spring context.
*/
static CappedToolCalls capToolCalls(List<AssistantMessage.ToolCall> calls, int maxPerResponse) {
if (calls == null || calls.size() <= maxPerResponse) {
return new CappedToolCalls(
calls == null ? List.of() : calls,
List.of(),
false);
}
List<ToolResponseMessage.ToolResponse> truncated = new ArrayList<>(calls.size() - maxPerResponse);
for (int i = maxPerResponse; i < calls.size(); i++) {
AssistantMessage.ToolCall dropped = calls.get(i);
truncated.add(new ToolResponseMessage.ToolResponse(
dropped.id(),
dropped.name(),
"[truncated] tool_call dropped: model returned " + calls.size()
+ " calls in one response but executor cap is " + maxPerResponse
+ "; please reissue the most-important calls first"));
}
return new CappedToolCalls(
calls.subList(0, maxPerResponse),
truncated,
true);
}
}

View File

@ -29,6 +29,7 @@ import java.util.Set;
* per-result-threshold-chars: 16000
* per-turn-budget-chars: 32000
* preview-head-chars: 800
* excluded-tool-inline-chars: 4000
* storage-base-dir:
* </pre>
*/
@ -40,9 +41,9 @@ public class ToolResultProperties {
/**
* Layer 2 a single tool result larger than this is spilled to disk.
* Note: Layer 1 hard truncation ({@code MAX_TOOL_RESULT_CHARS=8000} in
* {@link ToolExecutionExecutor}) runs before this threshold is evaluated,
* so only results that survive Layer 1 can trigger a spill.
* The executor evaluates this against the raw result before applying the
* final inline cap, so oversized content is preserved before it is shortened
* for the model request.
*/
private int perResultThresholdChars = 16000; // was 4000 prevents WebSearch spill-to-disk
@ -56,6 +57,13 @@ public class ToolResultProperties {
/** 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
@ -93,6 +101,11 @@ public class ToolResultProperties {
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;

View File

@ -164,10 +164,12 @@ public class ToolResultStorage {
}
}
if (targetIdx < 0) {
// Nothing left to spill; remaining oversize is from excluded tools or
// already-spilled responses. Accept the over-budget state better than
// breaking the agent's retrieval path.
log.warn("[ToolResultStorage] aggregate still {} chars after spilling everything eligible (excluded tools may push past budget)",
int compactedIdx = compactLargestExcludedResult(mutable);
if (compactedIdx >= 0) {
aggregate = aggregateSize(mutable);
continue;
}
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
aggregate);
break;
}
@ -191,6 +193,46 @@ public class ToolResultStorage {
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) {

View File

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

View File

@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.*;
import java.util.concurrent.CancellationException;
@ -77,11 +78,23 @@ public class ActionNode implements NodeAction {
.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);

View File

@ -3,9 +3,11 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.List;
import java.util.Map;
@ -54,7 +56,9 @@ public class FinalAnswerNode implements NodeAction {
outputs.size(), assembled.length(), preservedThinking.length());
var builder = MateClawStateAccessor.output()
.finalAnswer(assembled)
.finishReason(FinishReason.RETURN_DIRECT);
.finishReason(FinishReason.RETURN_DIRECT)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.RETURN_DIRECT.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
@ -76,7 +80,9 @@ public class FinalAnswerNode implements NodeAction {
.finalAnswer(preservedContent)
.finishReason(FinishReason.NORMAL)
.contentStreamed(true)
.thinkingStreamed(true);
.thinkingStreamed(true)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.NORMAL.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
@ -133,10 +139,28 @@ public class FinalAnswerNode implements NodeAction {
}
}
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
var builder = MateClawStateAccessor.output()
.finalAnswer(finalAnswer)
.finishReason(finishReason);
.finishReason(finishReason)
// Emit the resolved FinishReason as a GraphEvent so it rides
// the PENDING_EVENTS StreamDelta pipeline that the channel-
// side accumulator subscribes to. A sibling SSE broadcast (e.g.
// streamTracker.broadcastObject) reaches the browser but never
// touches the accumulator, so toMetadataJson() would not see
// it and MemorySummarizationGate would lose the structured
// signal. APPEND-strategy on PENDING_EVENTS means this
// composes safely with any earlier events upstream nodes
// attached.
.events(List.of(GraphEventPublisher.finishReason(finishReason.getValue())));
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);
@ -145,6 +169,12 @@ 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

View File

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

View File

@ -25,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;
@ -52,6 +53,10 @@ public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
return MateClawStateAccessor.output();
}
/**
* 单次 LLM 调用的默认最大输出 token 防止退化输出无限生成
* <p>
@ -211,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))
@ -342,6 +347,9 @@ public class ReasoningNode implements NodeAction {
}
}
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
}
promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
@ -364,6 +372,17 @@ public class ReasoningNode implements NodeAction {
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
Map.of("iteration", accessor.iterationCount()));
// Iteration boundary marker for the parent ReAct loop. Reason
// distinguishes the very first turn of the conversation from a
// mid-loop repeat for consumers grouping events into per-turn cards.
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
GraphEventPublisher.GraphEvent iterStartEvent = iterationEventsOn
? GraphEventPublisher.iterationStart(
accessor.iterationCount(),
accessor.iterationCount() == 0 ? "first_turn" : "react_step",
"parent",
null)
: null;
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
"llmCallCount", nextLlmCallCount
@ -403,7 +422,7 @@ public class ReasoningNode implements NodeAction {
// 必须显式清零 needsToolCall/shouldSummarize防止前一轮残留标志导致误路由
log.info("[ReasoningNode] CancellationException during LLM call (user stopped before first token), " +
"returning empty answer with STOPPED, llmCallCount={}", nextLlmCallCount);
return MateClawStateAccessor.output()
return reasonOutput()
.finalAnswer("")
.needsToolCall(false)
.shouldSummarize(false)
@ -422,7 +441,7 @@ public class ReasoningNode implements NodeAction {
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), flushing as final answer",
partialText.length(), partialThinking.length());
var builder = MateClawStateAccessor.output()
var builder = reasonOutput()
.finalAnswer(partialText)
.needsToolCall(false)
.shouldSummarize(false)
@ -437,13 +456,46 @@ public class ReasoningNode implements NodeAction {
return builder.build();
}
// Order matters: the partial-truncation branch MUST sit before
// hasFatalError(). hasFatalError() is "no text + no tool calls + non-
// null errorMessage", which is also the shape of a thinking-only cap
// result (text is empty by definition). Without this ordering the
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
// INCOMPLETE semantics.
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
// Soft thinking-only loop: the helper disposed the upstream stream
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
// reasoning_content without emitting any visible content or tool
// calls. Treat as INCOMPLETE rather than fatal the thinking text
// has already been streamed and is preserved for the UI's collapse
// panel; the user gets a short fallback line they can retry from.
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
"INCOMPLETE",
partialThinking.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
.contentStreamed(false)
.thinkingStreamed(true)
.mergeUsage(state, result);
if (!partialThinking.isEmpty()) {
builder.finalThinking(partialThinking);
}
return builder.build();
}
// Fatal error直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK
// 不走 LimitExceededNode后者会再发一次 LLM 调用语义不对且对认证/配额错误会再失败
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize finalAnswerNode
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用finishReason 保持 ERROR_FALLBACK
if (result.hasFatalError()) {
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("[错误] " + result.errorMessage())
@ -456,7 +508,8 @@ public class ReasoningNode implements NodeAction {
}
if (result.partial()) {
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
}
if (result.hasToolCalls()) {
@ -468,7 +521,7 @@ public class ReasoningNode implements NodeAction {
"toolCount", result.toolCalls().size()
));
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.shouldSummarize(false)
.toolCalls(result.toolCalls())
@ -481,7 +534,7 @@ public class ReasoningNode implements NodeAction {
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.events(buildEvents(phaseEvent, iterStartEvent))
.build();
} else {
String content = result.text();
@ -490,23 +543,49 @@ public class ReasoningNode implements NodeAction {
"iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0
));
SourceEvidenceLedger.Validation validation =
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
boolean evidenceInsufficient = !validation.valid();
String finalAnswer = evidenceInsufficient
? evidenceWarning(validation.unsupportedReferences())
: (content != null ? content : "");
if (evidenceInsufficient) {
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
return MateClawStateAccessor.output()
// Final-answer path: iteration ends in this same node because
// ReAct never re-enters the loop afterwards.
GraphEventPublisher.GraphEvent iterEndEvent = iterationEventsOn
? GraphEventPublisher.iterationEnd(accessor.iterationCount(),
"parent", null,
content != null ? content.length() : 0,
result.thinking() != null ? result.thinking().length() : 0)
: null;
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(content != null ? content : "")
.finalAnswer(finalAnswer)
.finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.contentStreamed(true)
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
.contentStreamed(!evidenceInsufficient)
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.events(buildEvents(phaseEvent, iterStartEvent, iterEndEvent))
.build();
}
}
private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
}
private AssistantMessage.ToolCall deserializeToolCall(String json) {
try {
@SuppressWarnings("unchecked")
@ -523,6 +602,19 @@ public class ReasoningNode implements NodeAction {
}
}
/**
* Compose the per-call event list, dropping any null entries so the
* iteration-boundary toggle ({@code mateclaw.stream.iteration-events})
* works without forcing every caller into branching code.
*/
private static List<GraphEventPublisher.GraphEvent> buildEvents(GraphEventPublisher.GraphEvent... events) {
List<GraphEventPublisher.GraphEvent> out = new ArrayList<>(events.length);
for (GraphEventPublisher.GraphEvent ev : events) {
if (ev != null) out.add(ev);
}
return out;
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
return;

View File

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

View File

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

View File

@ -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
@ -106,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)));
@ -128,8 +177,24 @@ public class StepExecutionNode implements NodeAction {
// 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) {
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
@ -145,6 +210,10 @@ public class StepExecutionNode implements NodeAction {
oaiOpts.setInternalToolExecutionEnabled(false);
ChatOptions options = oaiOpts;
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(messages, options), conversationId,
"step_execution[" + stepIndex + "]");
@ -288,6 +357,10 @@ public class StepExecutionNode implements NodeAction {
"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 收束
@ -305,8 +378,13 @@ public class StepExecutionNode implements NodeAction {
}
if (finalResult == null) {
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
if (wallClockExceeded) {
finalResult = "步骤执行超过最大耗时限制("
+ (stepWallClockTimeoutMs / 1000) + "秒),已中止本步骤";
} else {
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
}
}
} catch (Exception e) {
@ -315,6 +393,10 @@ public class StepExecutionNode implements NodeAction {
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
shortError != null ? shortError.length() : 0, 0));
}
return PlanStateAccessor.output()
.currentStepResult(shortError)
.currentPhase("plan_aborted")
@ -327,6 +409,11 @@ 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(),

View File

@ -19,6 +19,12 @@ public enum FinishReason {
/** 发生错误后降级回答 */
ERROR_FALLBACK("error_fallback"),
/** 响应未完整完成,需要继续生成或重试 */
INCOMPLETE("incomplete"),
/** 最终回答引用了未被工具结果验证的源码事实 */
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
/** 用户主动停止 */
STOPPED("stopped"),

View File

@ -207,6 +207,10 @@ public final class MateClawStateAccessor {
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() {
@ -405,6 +409,10 @@ public final class MateClawStateAccessor {
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);

View File

@ -156,6 +156,9 @@ public final class MateClawStateKeys {
*/
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 =====
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -63,28 +63,52 @@ public class TemplateService {
}
/**
* 应用模板创建 Agent 及其工作区文件
*
* @param templateId 模板 ID
* @param workspaceId 目标工作区 ID来自 X-Workspace-Id header
* @param creatorUserId 当前用户 ID用于 RFC-077 创建者归属
* @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, 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 RFC-077: 显式注入 workspaceId/creatorUserId避免 DB 默认值兜底成 1issue #26 Bug A
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);
@ -113,4 +137,15 @@ public class TemplateService {
return created;
}
/**
* True when the raw Accept-Language header best-matches a Chinese locale.
* Implementation is intentionally simple we only need to disambiguate
* "Chinese vs not" for picking nameZh / descriptionZh.
*/
private boolean isChineseLocale(String acceptLanguage) {
if (acceptLanguage == null || acceptLanguage.isBlank()) return false;
String first = acceptLanguage.split(",")[0].trim().toLowerCase();
return first.startsWith("zh");
}
}

View File

@ -0,0 +1,88 @@
package vip.mate.auth.pat;
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.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* RFC-03 Lane I1 Personal Access Token CRUD endpoints.
*
* <p>Authenticated callers (JWT or another PAT) manage their own tokens
* here. Cross-user access is impossible: every query is scoped to
* {@code Authentication.getName()} server-side, so tampering with the
* {@code X-User-Id} header has no effect.
*
* <p>Plaintext is returned exactly once on {@link #create}; subsequent
* lookups expose only metadata (id, name, scopes, last_used_at,
* expires_at). The DB never stores plaintext at any point.
*/
@Tag(name = "Personal Access Tokens")
@RestController
@RequestMapping("/api/v1/auth/tokens")
@RequiredArgsConstructor
public class PersonalAccessTokenController {
private final PersonalAccessTokenService patService;
private final AuthService authService;
@Operation(summary = "List my PATs (metadata only — plaintext is never returned after creation)")
@GetMapping
public R<List<PersonalAccessTokenEntity>> list(Authentication auth) {
UserEntity user = requireUser(auth);
return R.ok(patService.listByUser(user.getId()));
}
@Operation(summary = "Mint a new PAT — returned plaintext is shown once and cannot be recovered")
@PostMapping
public R<Map<String, Object>> create(@RequestBody CreateRequest req, Authentication auth) {
UserEntity user = requireUser(auth);
PersonalAccessTokenService.CreatedToken created = patService.create(
user.getId(),
req.name(),
req.scopes(),
req.expiresAt());
// Return plaintext + metadata; UI must surface plaintext immediately
// and warn the user it won't be shown again.
return R.ok(Map.of(
"id", created.id(),
"plaintext", created.plaintext(),
"name", req.name() == null ? "" : req.name(),
"scopes", req.scopes() == null ? "" : req.scopes(),
"expiresAt", req.expiresAt() == null ? "" : req.expiresAt()));
}
@Operation(summary = "Revoke a PAT — soft-delete; further auth attempts with this token will fail")
@DeleteMapping("/{id}")
public R<Void> revoke(@PathVariable Long id, Authentication auth) {
UserEntity user = requireUser(auth);
patService.revoke(id, user.getId());
return R.ok();
}
private UserEntity requireUser(Authentication auth) {
if (auth == null || auth.getName() == null) {
throw new MateClawException("err.auth.unauthenticated", "Authentication required");
}
UserEntity user = authService.findByUsername(auth.getName());
if (user == null) {
throw new MateClawException("err.auth.user_not_found",
"Authenticated user not found: " + auth.getName());
}
return user;
}
/** Inbound DTO for {@link #create}. {@code name} and {@code scopes} are
* optional; {@code expiresAt} null means the token never expires until
* manually revoked. */
public record CreateRequest(String name, String scopes, LocalDateTime expiresAt) {}
}

View File

@ -0,0 +1,68 @@
package vip.mate.auth.pat;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
/**
* RFC-03 Lane I1 Personal Access Token entity.
*
* <p>Plaintext tokens are never persisted; only the SHA-256 hash lives in
* {@link #tokenHash}. This way a DB compromise reveals ownership and
* scope but not the secret needed to actually authenticate. The user
* sees the plaintext exactly once at creation time.
*/
@Data
@TableName("mate_personal_access_token")
public class PersonalAccessTokenEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Owner — joined to mate_user.id; one user may own many tokens. */
private Long userId;
/** Human-readable label so the owner can tell tokens apart in the UI. */
private String name;
/**
* SHA-256 hex of the plaintext, lowercase. UNIQUE indexed for O(1) auth lookups.
*
* <p>{@link JsonIgnore} keeps the hash out of every JSON response
* SHA-256 is not directly reversible, but exposing the digest is poor
* hygiene (lets an attacker who already has a candidate plaintext
* verify a match without trying to authenticate, leaks per-user token
* counts to anyone who can read the list endpoint, and would feed
* future rainbow-table attacks if we ever weakened the algorithm).
* Internally readable / writable by the service + mapper as usual.
*/
@JsonIgnore
private String tokenHash;
/**
* Comma-separated scope tokens (e.g. {@code "chat:read,chat:write"}).
* The first version ships with implicit {@code "*"} scope when null,
* matching the user's JWT-equivalent permissions; finer-grained scope
* checking lands in a follow-up RFC.
*/
private String scopes;
/** Updated on each successful auth — debounced to once per minute by the service. */
private LocalDateTime lastUsedAt;
/** Optional hard expiry. Null = never expires (until manually revoked). */
private LocalDateTime expiresAt;
/** Soft revoke — auth filter rejects disabled tokens immediately. */
private Boolean enabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

View File

@ -0,0 +1,218 @@
package vip.mate.auth.pat;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.auth.pat.repository.PersonalAccessTokenMapper;
import vip.mate.exception.MateClawException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
/**
* RFC-03 Lane I1 Personal Access Token service.
*
* <p>Headless / CI / SDK callers can authenticate without the interactive
* JWT login flow. Token plaintext is shown to the user exactly once at
* creation time; the database only stores SHA-256 hashes, so a DB
* compromise can't be used to authenticate as anyone.
*
* <p>Token format: {@code mc_<43 url-safe base64 chars>} = ~32 bytes of
* entropy from {@link SecureRandom}. The {@code mc_} prefix lets the
* auth filter distinguish PAT tokens from JWT tokens by inspection
* (JWTs always start with {@code eyJ}).
*
* <p>{@link #findActiveByPlaintext} is the hot path called on every
* authenticated PAT request kept to a single indexed lookup with no
* JOINs. {@link #recordUse} debounces last-used updates to once per
* minute per token to avoid hammering the row on busy CI loops.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PersonalAccessTokenService {
/** RFC-03 Lane I1 — observable prefix for PAT plaintext. */
public static final String PAT_PREFIX = "mc_";
/** Bytes of entropy in each freshly generated token. 32 bytes = 256 bits. */
private static final int TOKEN_BYTES = 32;
/** Throttle window for last-used writes. Loops at >1Hz still get a
* freshness signal, but we don't write on every single call. */
private static final long LAST_USED_DEBOUNCE_SECONDS = 60;
private final PersonalAccessTokenMapper mapper;
private final SecureRandom secureRandom = new SecureRandom();
/**
* Mint a new PAT for {@code userId}. Returns the plaintext exactly once
* there is no way to recover it later from the database.
*
* @param userId owner id (joined to mate_user.id)
* @param name human-readable label, may be null
* @param scopes comma-separated scope list, null = "*"
* @param expiresAt optional hard expiry, null = never expires
* @return the plaintext token; the caller is responsible for surfacing
* it to the user and never persisting it server-side
*/
public CreatedToken create(Long userId, String name, String scopes, LocalDateTime expiresAt) {
if (userId == null) {
throw new MateClawException("err.auth.pat_user_required",
"PAT requires an owning user");
}
String plaintext = generatePlaintext();
String hash = sha256Hex(plaintext);
PersonalAccessTokenEntity entity = new PersonalAccessTokenEntity();
entity.setUserId(userId);
entity.setName(name);
entity.setTokenHash(hash);
entity.setScopes(scopes);
entity.setExpiresAt(expiresAt);
entity.setEnabled(true);
entity.setDeleted(0);
mapper.insert(entity);
log.info("[PAT] Created token id={} userId={} name={} expiresAt={}",
entity.getId(), userId, name, expiresAt);
return new CreatedToken(entity.getId(), plaintext, entity);
}
/**
* Auth-filter hot path find an enabled, unexpired token whose
* {@link PersonalAccessTokenEntity#getTokenHash()} matches the SHA-256
* of {@code plaintext}.
*
* <p>Returns empty for null / blank input, missing prefix, hash miss,
* disabled flag, or past-expiry auth filter doesn't need to
* distinguish, it just rejects.
*/
public Optional<PersonalAccessTokenEntity> findActiveByPlaintext(String plaintext) {
if (plaintext == null || plaintext.isBlank()) return Optional.empty();
if (!plaintext.startsWith(PAT_PREFIX)) return Optional.empty();
String hash = sha256Hex(plaintext);
PersonalAccessTokenEntity entity = mapper.selectOne(
new LambdaQueryWrapper<PersonalAccessTokenEntity>()
.eq(PersonalAccessTokenEntity::getTokenHash, hash)
.eq(PersonalAccessTokenEntity::getEnabled, true)
.eq(PersonalAccessTokenEntity::getDeleted, 0)
.last("LIMIT 1"));
if (entity == null) return Optional.empty();
if (entity.getExpiresAt() != null && entity.getExpiresAt().isBefore(LocalDateTime.now())) {
return Optional.empty();
}
return Optional.of(entity);
}
/**
* Record last-used timestamp on the token. Debounced so a CI loop at
* 5Hz doesn't write 5x per second; the last-used field is observability,
* not a correctness gate.
*/
public void recordUse(PersonalAccessTokenEntity entity) {
if (entity == null || entity.getId() == null) return;
LocalDateTime now = LocalDateTime.now();
if (!shouldRecordUse(entity.getLastUsedAt(), now)) return;
try {
PersonalAccessTokenEntity update = new PersonalAccessTokenEntity();
update.setId(entity.getId());
update.setLastUsedAt(now);
mapper.updateById(update);
entity.setLastUsedAt(now);
} catch (Exception e) {
// Best-effort never fail an authenticated request because we
// can't write a metadata column.
log.debug("[PAT] last_used_at write failed for token {}: {}",
entity.getId(), e.getMessage());
}
}
/**
* Pure debounce predicate package-private for unit testing without
* the MyBatis-Plus stack. Returns true when the call should write,
* false when it falls inside the {@link #LAST_USED_DEBOUNCE_SECONDS}
* window of the previous write.
*/
static boolean shouldRecordUse(LocalDateTime lastUsedAt, LocalDateTime now) {
if (lastUsedAt == null) return true;
return !lastUsedAt.plusSeconds(LAST_USED_DEBOUNCE_SECONDS).isAfter(now);
}
/**
* List tokens for {@code userId}, ordered most-recently-created first.
* Plaintext is never returned only metadata.
*/
public List<PersonalAccessTokenEntity> listByUser(Long userId) {
if (userId == null) return List.of();
return mapper.selectList(new LambdaQueryWrapper<PersonalAccessTokenEntity>()
.eq(PersonalAccessTokenEntity::getUserId, userId)
.eq(PersonalAccessTokenEntity::getDeleted, 0)
.orderByDesc(PersonalAccessTokenEntity::getCreateTime));
}
/**
* Soft-revoke. Two-step so the owner check is explicit and we don't
* rely on a single composite UPDATE for both the WHERE-userId guard
* and the SET easier to test, and protects against a future
* refactor accidentally dropping the user_id eq.
*
* <p>Throws {@code err.auth.pat_not_found} for both "token doesn't exist"
* and "token belongs to another user" the unified error code keeps
* us from leaking which token ids exist across user boundaries.
*/
public void revoke(Long tokenId, Long ownerUserId) {
if (tokenId == null || ownerUserId == null) return;
PersonalAccessTokenEntity existing = mapper.selectById(tokenId);
if (existing == null
|| existing.getDeleted() != null && existing.getDeleted() == 1
|| !ownerUserId.equals(existing.getUserId())) {
throw new MateClawException("err.auth.pat_not_found",
"PAT not found or not owned by current user: " + tokenId);
}
existing.setEnabled(false);
existing.setDeleted(1);
mapper.updateById(existing);
log.info("[PAT] Revoked token id={} ownerUserId={}", tokenId, ownerUserId);
}
// Internal helpers
/** Package-private so unit tests can compute the hash without going
* through the service. */
static String sha256Hex(String plaintext) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(plaintext.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
}
}
/**
* Generate a fresh PAT plaintext. Format: {@value PAT_PREFIX} +
* URL-safe base64 of {@value TOKEN_BYTES} bytes from {@link SecureRandom}.
* Package-private so tests can verify the prefix + length without
* minting a real token through the public API.
*/
String generatePlaintext() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
String body = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
return PAT_PREFIX + body;
}
/** Return value of {@link #create} entity (sans plaintext) plus the
* one-shot plaintext the user must save now. */
public record CreatedToken(Long id, String plaintext, PersonalAccessTokenEntity entity) {}
}

View File

@ -0,0 +1,21 @@
package vip.mate.auth.pat.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.auth.pat.PersonalAccessTokenEntity;
/**
* RFC-03 Lane I1 MyBatis Plus mapper for {@link PersonalAccessTokenEntity}.
*
* <p>Located under {@code repository} so {@code @MapperScan("vip.mate.**.repository")}
* (declared on {@code MateClawApplication}) discovers it; outside that
* package Spring won't auto-register the mapper bean and constructor
* injection into {@code PersonalAccessTokenService} fails at startup.
*
* <p>Lookup queries live in the service layer via {@code LambdaQueryWrapper};
* the only custom requirement is uniqueness on {@code token_hash}, which is
* enforced by the database (UNIQUE index in V76 migration).
*/
@Mapper
public interface PersonalAccessTokenMapper extends BaseMapper<PersonalAccessTokenEntity> {
}

View File

@ -24,9 +24,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
@ -89,6 +91,30 @@ public class ChannelMessageRouter {
/** 防抖等待时间(毫秒) */
private static final long DEBOUNCE_MS = 500;
/**
* Plan-Execute SSE events that the Web Console mirror needs to see when
* a conversation runs through an IM channel.
* <p>
* The agent emits these via {@code GraphEventPublisher} and they ride on
* the {@code chatStructuredStream} Flux as {@code StreamDelta.event(...)}.
* Web direct chats already broadcast them via the ChatController
* accumulator. IM channels (DingTalk + the seven sync-path adapters)
* historically dropped them DingTalk's {@code processStreamAsText}
* only consumes {@code delta.content()}, and the sync {@code chat()}
* collector explicitly filters {@code delta.isEvent()} out. The whitelist
* is applied in the IM stream path so PlanStepsPanel renders correctly
* when an operator monitors an IM conversation in the Web Console.
* <p>
* Whitelist (not pass-through) so Web-side accumulator-internal events
* like {@code _usage_final} or future agent-internal markers don't leak
* to subscribers.
*/
private static final Set<String> MIRRORED_PLAN_EVENTS = Set.of(
"plan_created",
"plan_step_started",
"plan_step_completed"
);
/** 是否已关闭 */
private volatile boolean shutdown = false;
@ -456,8 +482,31 @@ public class ChannelMessageRouter {
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
} else {
// 同步路径直接获取完整回复
String reply = agentService.chat(agentId, promptText, conversationId, chatOrigin);
// Sync path for non-streaming IM adapters (feishu / wecom / weixin /
// slack / discord / qq / telegram). We can't use agentService.chat()
// because its collector filters out `delta.isEvent()` deltas that
// would silently drop plan_created / plan_step_* events that the Web
// Console mirror needs to render PlanStepsPanel. Instead we consume
// chatStructuredStream directly: content gets accumulated for the IM
// reply, and whitelisted plan events are mirrored to ChatStreamTracker
// for any Web SSE viewer of the same conversationId.
StringBuilder replyAccumulator = new StringBuilder();
final String channelType = adapter.getChannelType();
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
.doOnNext(delta -> {
if (delta.isEvent()) {
mirrorPlanEventToTracker(conversationId, delta, channelType);
} else if (delta.content() != null) {
// Match the legacy agentService.chat() behavior: include
// persistOnly deltas too. DirectAnswerNode-routed answers
// arrive as persistOnly when CONTENT_STREAMED=true and IM
// channels still need the text for the outgoing reply.
replyAccumulator.append(delta.content());
}
})
.blockLast(Duration.ofMinutes(10));
String reply = replyAccumulator.toString();
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
@ -542,6 +591,30 @@ public class ChannelMessageRouter {
* - StreamingChannelAdapter 负责渲染AI Card / 卡片更新 / 文本累积等
* - Router 负责后续的审批检查消息持久化事件发布
*/
/**
* Forward whitelisted Plan-Execute SSE events to ChatStreamTracker so a
* Web Console viewer of an IM-routed conversation sees PlanStepsPanel.
* <p>
* Bounded to {@link #MIRRORED_PLAN_EVENTS} see the constant's javadoc
* for why this is a whitelist rather than a pass-through. Failures here
* are best-effort and never propagate, since dropping a UI update is
* preferable to derailing the channel reply.
*/
private void mirrorPlanEventToTracker(String conversationId,
AgentService.StreamDelta delta,
String channelTypeForLog) {
String eventType = delta.eventType();
if (eventType == null || !MIRRORED_PLAN_EVENTS.contains(eventType)) {
return;
}
try {
streamTracker.broadcastObject(conversationId, eventType, delta.eventData());
} catch (Exception ex) {
log.debug("[{}] Failed to mirror plan event {}: {}",
channelTypeForLog, eventType, ex.getMessage());
}
}
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText,
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
@ -553,8 +626,16 @@ public class ChannelMessageRouter {
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
// Mirror plan-execute SSE events to ChatStreamTracker before the
// adapter consumes the Flux. DingTalkChannelAdapter.processStreamAsText
// only reads `delta.content()` and would otherwise eat plan_created /
// plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations.
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta ->
mirrorPlanEventToTracker(conversationId, delta, channelType));
// Step 2: 委托渠道渲染渠道内部消费 Flux 并处理 UI 更新
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId);
// Step 3: 审批检查 + 持久化渠道无关逻辑 Router 统一处理
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);

View File

@ -0,0 +1,174 @@
package vip.mate.channel;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* RFC-03 Lane K3 centralized validation for media files passing through
* channel adapters (incoming attachments + outgoing replies).
*
* <p>Each {@code *ChannelAdapter} (DingTalk / Feishu / Discord / Telegram /
* QQ / WeCom / Weixin / Slack) historically inlined its own ad-hoc set of
* checks extension allowlist here, size cap there, no path-traversal
* guard at all. The result is 8 places to keep in sync when a new file
* type is supported (or when a new exploit needs a defensive patch).
*
* <p>This guard centralizes four checks that every adapter needs:
* <ol>
* <li><b>Path containment</b> the file resolves under the configured
* workspace root (after symlink resolution). Blocks {@code ../}
* traversal even when the attacker controls just a filename string.</li>
* <li><b>Existence + regular file</b> not a directory, not a symlink
* to a special device.</li>
* <li><b>Extension allowlist</b> case-insensitive, dot-prefix tolerant.</li>
* <li><b>Size cap</b> checked once via {@link Files#size(Path)} so a
* gigabyte-plus file gets rejected before any adapter sends it.</li>
* </ol>
*
* <p>Adapter migration is staged: this utility lands and ships with tests,
* then individual adapters convert their inline checks one PR at a time
* (Phase 1 starts with DingTalk per the RFC). Keeping this class
* single-purpose so it doesn't depend on any adapter-specific service
* means the migration can proceed without ripple-edits.
*/
public final class MediaPathGuard {
private MediaPathGuard() {}
/**
* Lookup-friendly representation of a violation. The {@link #reason}
* codes are stable so audit logs / metrics can group failures without
* parsing the message text.
*/
public enum Reason {
PATH_OUTSIDE_WORKSPACE,
FILE_MISSING,
NOT_A_REGULAR_FILE,
EXTENSION_NOT_ALLOWED,
FILE_TOO_LARGE,
IO_ERROR
}
/** Validation policy — immutable, share across adapter calls. */
public record Policy(
Path workspaceRoot,
Set<String> allowedExtensions,
long maxBytes
) {
public Policy {
Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null");
Objects.requireNonNull(allowedExtensions, "allowedExtensions must not be null");
if (maxBytes <= 0) {
throw new IllegalArgumentException("maxBytes must be positive: " + maxBytes);
}
// Normalize allowed extensions: lowercase, strip leading dots.
allowedExtensions = Set.copyOf(allowedExtensions.stream()
.filter(Objects::nonNull)
.map(e -> e.toLowerCase(Locale.ROOT))
.map(e -> e.startsWith(".") ? e.substring(1) : e)
.toList());
}
}
/** Thrown when validation fails caller decides whether to log + drop
* silently or surface to the user. */
public static final class MediaValidationException extends RuntimeException {
private final Reason reason;
public MediaValidationException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public MediaValidationException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
/**
* Validate {@code file} against {@code policy}. Returns the canonical
* (real, symlink-resolved) path on success callers that need to act
* on the file (read bytes, upload to platform) should use the returned
* value to avoid TOCTOU between validation and use.
*
* @throws MediaValidationException with a {@link Reason} that maps 1:1
* to the four documented checks; the message is human-readable
* and includes the offending path / extension / size.
*/
public static Path validate(Path file, Policy policy) {
Objects.requireNonNull(file, "file must not be null");
Objects.requireNonNull(policy, "policy must not be null");
Path canonicalRoot;
Path canonicalFile;
try {
canonicalRoot = policy.workspaceRoot().toRealPath();
} catch (IOException e) {
throw new MediaValidationException(Reason.IO_ERROR,
"workspace root cannot be resolved: " + policy.workspaceRoot(), e);
}
try {
canonicalFile = file.toRealPath();
} catch (IOException e) {
// toRealPath fails when file does not exist OR when traversal
// hits a non-readable segment. Distinguish the common case
// (missing file) for clearer audit output.
if (!Files.exists(file)) {
throw new MediaValidationException(Reason.FILE_MISSING,
"file does not exist: " + file, e);
}
throw new MediaValidationException(Reason.IO_ERROR,
"file path cannot be resolved: " + file, e);
}
// Containment: canonicalFile must be inside canonicalRoot. startsWith on
// Path does element-wise comparison so "/ws-foo" doesn't mistakenly look
// like a prefix of "/ws-foobar".
if (!canonicalFile.startsWith(canonicalRoot)) {
throw new MediaValidationException(Reason.PATH_OUTSIDE_WORKSPACE,
"file is outside the workspace: " + canonicalFile + " not under " + canonicalRoot);
}
if (!Files.isRegularFile(canonicalFile)) {
throw new MediaValidationException(Reason.NOT_A_REGULAR_FILE,
"not a regular file (directory / device / fifo): " + canonicalFile);
}
String ext = extensionOf(canonicalFile);
if (!policy.allowedExtensions().contains(ext)) {
throw new MediaValidationException(Reason.EXTENSION_NOT_ALLOWED,
"extension '" + ext + "' not in allowlist; got " + canonicalFile);
}
long size;
try {
size = Files.size(canonicalFile);
} catch (IOException e) {
throw new MediaValidationException(Reason.IO_ERROR,
"cannot read file size: " + canonicalFile, e);
}
if (size > policy.maxBytes()) {
throw new MediaValidationException(Reason.FILE_TOO_LARGE,
"file size " + size + " exceeds policy cap " + policy.maxBytes() + ": " + canonicalFile);
}
return canonicalFile;
}
/** Lowercase extension without leading dot; empty string when there is none. */
static String extensionOf(Path file) {
String name = file.getFileName().toString();
int dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length() - 1) return "";
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}

View File

@ -1,17 +1,24 @@
package vip.mate.channel.controller;
import com.fasterxml.jackson.core.type.TypeReference;
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.web.bind.annotation.*;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.verifier.ChannelVerifierRegistry;
import vip.mate.channel.verifier.VerificationRequest;
import vip.mate.channel.verifier.VerificationResult;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@ -23,6 +30,7 @@ import java.util.Map;
*
* @author MateClaw Team
*/
@Slf4j
@Tag(name = "渠道管理")
@RestController
@RequestMapping("/api/v1/channels")
@ -32,6 +40,8 @@ public class ChannelController {
private final ChannelService channelService;
private final ChannelManager channelManager;
private final AuditEventService auditEventService;
private final ChannelVerifierRegistry verifierRegistry;
private final ObjectMapper objectMapper;
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取渠道列表")
@ -174,11 +184,55 @@ public class ChannelController {
});
body.put("name", c.getName());
body.put("enabled", Boolean.TRUE.equals(c.getEnabled()));
body.put("identity", parseIdentity(c.getIdentityJson()));
return body;
})
.toList());
}
/**
* Parse identity_json into a map for the list-page card. Returns an
* empty map for legacy rows that have not been re-verified yet, so the
* frontend can render the type-level description as a fallback.
*/
private Map<String, Object> parseIdentity(String identityJson) {
if (identityJson == null || identityJson.isBlank()) return Collections.emptyMap();
try {
return objectMapper.readValue(identityJson, new TypeReference<>() {});
} catch (Exception e) {
log.debug("identity_json parse failed (treating as empty): {}", e.getMessage());
return Collections.emptyMap();
}
}
@RequireWorkspaceRole("admin")
@Operation(summary = "Pre-flight: validate draft channel config without persisting")
@PostMapping("/preflight")
public R<VerificationResult> preflight(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestBody PreflightRequest body) {
long ws = workspaceId != null ? workspaceId : 1L;
Map<String, Object> config = parseConfigJson(body.configJson());
return verifierRegistry.find(body.channelType())
.map(v -> R.ok(v.verify(new VerificationRequest(body.channelType(), config, ws))))
.orElseGet(() -> R.ok(VerificationResult.skipped(
"No verifier registered for channel type '" + body.channelType()
+ "' — skipping live check.")));
}
private Map<String, Object> parseConfigJson(String json) {
if (json == null || json.isBlank()) return Collections.emptyMap();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.debug("preflight: invalid configJson, treating as empty: {}", e.getMessage());
return Collections.emptyMap();
}
}
/** Wizard Step 2 payload — channel type + draft configJson, no entity yet. */
public record PreflightRequest(String channelType, String configJson) {}
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {

View File

@ -916,9 +916,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
case "image" -> {
String imageKey = (String) contentObj.get("image_key");
if (imageKey != null) {
String localPath = maybeDownloadResource(messageId, imageKey, "image", null);
// Images need bytes for vision: the Feishu CDN URL
// requires tenant_access_token, so a downstream vision
// tool that only sees image_key cannot fetch the
// image. Default-on for images (separate from the
// file/audio/video gate) so vision works out of the
// box; admins can opt out with feishu_image_download_enabled=false.
String localPath = maybeDownloadImage(messageId, imageKey);
MessageContentPart part = MessageContentPart.image(imageKey, null);
if (localPath != null) part.setPath(localPath);
if (localPath != null) {
part.setPath(localPath);
}
parts.add(part);
}
yield "[图片]";
@ -1068,7 +1076,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
case "img" -> {
String imageKey = (String) element.get("image_key");
if (imageKey != null) {
String localPath = mediaDownload ? maybeDownloadResource(messageId, imageKey, "image", null) : null;
// Same reasoning as the standalone image case: vision
// pipelines need bytes; image_key alone is opaque.
String localPath = maybeDownloadImage(messageId, imageKey);
MessageContentPart imgPart = MessageContentPart.image(imageKey, null);
if (localPath != null) imgPart.setPath(localPath);
parts.add(imgPart);
@ -1117,6 +1127,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
return downloadResource(messageId, fileKey, type, fileNameHint);
}
/**
* Image-specific download: default ON so the vision/STT pipeline
* downstream actually has bytes to analyze. Without the local file,
* vision providers see only an opaque {@code image_key} and the
* Feishu CDN URL needs tenant_access_token to fetch neither of
* which the model can resolve. Admins who want to suppress image
* downloads (e.g. tighter privacy, no disk usage) can set
* {@code feishu_image_download_enabled=false} on the channel config.
*/
private String maybeDownloadImage(String messageId, String imageKey) {
if (!getConfigBoolean("feishu_image_download_enabled", true)) {
return null;
}
return downloadResource(messageId, imageKey, "image", null);
}
/**
* 下载飞书消息资源图片/文件到本地
*
@ -1184,16 +1210,38 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
// ==================== 消息发送 ====================
/**
* Conservative per-message char ceiling. Feishu's documented limit is on
* the encoded JSON body (~150KB), but UTF-8 Chinese is 3 bytes/char and
* JSON escape adds further overhead, so a 4000-char chunk is comfortably
* under any realistic limit and also gives readable IM chunking instead
* of one wall of text. Split at paragraph / line boundaries when possible.
*/
static final int MAX_TEXT_MESSAGE_CHARS = 4000;
@Override
public void sendMessage(String targetId, String content) {
if (httpClient == null) {
log.warn("[feishu] Channel not started, cannot send message");
return;
}
if (content == null) {
return;
}
ensureTokenValid();
String apiBase = getApiBaseUrl();
List<String> chunks = splitTextForFeishu(content, MAX_TEXT_MESSAGE_CHARS);
if (chunks.size() > 1) {
log.info("[feishu] Splitting message into {} chunks ({} chars total) before send",
chunks.size(), content.length());
}
for (String chunk : chunks) {
sendOneTextChunk(targetId, chunk);
}
}
private void sendOneTextChunk(String targetId, String content) {
String apiBase = getApiBaseUrl();
try {
String jsonBody = objectMapper.writeValueAsString(Map.of(
"receive_id", targetId,
@ -1212,7 +1260,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
if (response.statusCode() != 200) {
log.warn("[feishu] Send message failed: status={}, body={}", response.statusCode(), response.body());
} else {
log.debug("[feishu] Message sent to chat_id={}", targetId);
log.debug("[feishu] Message sent to chat_id={} ({} chars)", targetId, content.length());
}
} catch (Exception e) {
@ -1220,6 +1268,52 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
}
}
/**
* Split a possibly-oversized message into chunks no larger than
* {@code maxChars}, preferring paragraph (\n\n) then line (\n) then
* whitespace boundaries. Hard-cuts as a last resort so we never silently
* drop content.
*/
static List<String> splitTextForFeishu(String content, int maxChars) {
if (content == null || content.isEmpty()) {
return List.of();
}
if (content.length() <= maxChars) {
return List.of(content);
}
List<String> chunks = new ArrayList<>();
int idx = 0;
int n = content.length();
while (idx < n) {
int end = Math.min(idx + maxChars, n);
if (end < n) {
int boundary = -1;
int paragraphCut = content.lastIndexOf("\n\n", end);
if (paragraphCut > idx + maxChars / 2) {
boundary = paragraphCut + 2;
}
if (boundary < 0) {
int lineCut = content.lastIndexOf('\n', end);
if (lineCut > idx + maxChars / 2) {
boundary = lineCut + 1;
}
}
if (boundary < 0) {
int spaceCut = content.lastIndexOf(' ', end);
if (spaceCut > idx + maxChars / 2) {
boundary = spaceCut + 1;
}
}
if (boundary > idx) {
end = boundary;
}
}
chunks.add(content.substring(idx, end));
idx = end;
}
return chunks;
}
@Override
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
if (httpClient == null) {

View File

@ -34,6 +34,16 @@ public class ChannelEntity {
@TableField(value = "config_json", updateStrategy = FieldStrategy.ALWAYS)
private String configJson;
/**
* Identity snapshot from the most recent successful credential probe
* (RFC-084 follow-up). JSON-encoded {@code accountName / accountId / team /
* region / ...} payload. Surfaced on the list page so cards read
* "Connected as @MyBot" instead of the type-level description. Optional
* legacy rows have it null until the next successful connect.
*/
@TableField(value = "identity_json", updateStrategy = FieldStrategy.ALWAYS)
private String identityJson;
/** 是否启用 */
private Boolean enabled;

View File

@ -0,0 +1,37 @@
package vip.mate.channel.verifier;
/**
* Pre-flight credential verifier for a single channel type.
* <p>
* Implementations validate a draft channel configuration by performing the
* cheapest possible auth probe against the upstream service (e.g. Telegram
* {@code getMe}, Slack {@code auth.test}, Discord {@code users/@me}). They
* MUST be side-effect free: no persistence, no shared connection state, no
* impact on running adapters. Network errors are reported via
* {@link VerificationResult#failed} rather than thrown.
* <p>
* Verifiers are auto-discovered as Spring beans and indexed by
* {@link #getChannelType()} in {@link ChannelVerifierRegistry}. A channel
* type without a verifier degrades to a "skipped" verify step in the
* onboarding wizard the user can still save and start the channel, but
* loses the live connection check.
*
* @author MateClaw Team
* @see ChannelVerifierRegistry
* @see VerificationResult
*/
public interface ChannelVerifier {
/**
* Channel type discriminator, e.g. {@code "telegram"}, {@code "slack"}.
* Must match the {@code channelType} column in {@code mate_channel}.
*/
String getChannelType();
/**
* Validate the draft config. Implementations MUST bound every network
* call by a 5-second timeout and never throw wrap upstream errors in
* {@link VerificationResult#failed}.
*/
VerificationResult verify(VerificationRequest request);
}

View File

@ -0,0 +1,46 @@
package vip.mate.channel.verifier;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Indexes all {@link ChannelVerifier} beans by channel type. Spring injects
* the full list at construction time; one verifier per channel type is the
* contract duplicates log a warning and the last one wins (keeps test
* doubles overridable without crashing the context).
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelVerifierRegistry {
private final List<ChannelVerifier> verifiers;
private Map<String, ChannelVerifier> byType;
@PostConstruct
void index() {
Map<String, ChannelVerifier> map = new HashMap<>();
for (ChannelVerifier v : verifiers) {
ChannelVerifier prev = map.put(v.getChannelType(), v);
if (prev != null) {
log.warn("Duplicate ChannelVerifier for type '{}' — {} replaces {}",
v.getChannelType(), v.getClass().getSimpleName(), prev.getClass().getSimpleName());
}
}
this.byType = Map.copyOf(map);
log.info("ChannelVerifierRegistry indexed {} verifier(s): {}", byType.size(), byType.keySet());
}
public Optional<ChannelVerifier> find(String channelType) {
return Optional.ofNullable(byType.get(channelType));
}
}

View File

@ -0,0 +1,131 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Validates DingTalk app credentials via
* {@code POST /v1.0/oauth2/accessToken} (the same handshake the production
* adapter does to call Robot APIs). Mirrors {@code DingTalkChannelAdapter
* .getDingTalkAccessToken} so a green Step 2 maps to a green channel
* post-save.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DingTalkVerifier implements ChannelVerifier {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final String API_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
private final ObjectMapper objectMapper;
@Override
public String getChannelType() {
return "dingtalk";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String clientId = string(request.config(), "client_id");
String clientSecret = string(request.config(), "client_secret");
if (clientId == null || clientId.isBlank()) {
return VerificationResult.failed(0, "Client ID (AppKey) is required",
"client_id", "Scan the DingTalk QR (one-click bot creation) — Client ID is filled automatically.");
}
if (clientSecret == null || clientSecret.isBlank()) {
return VerificationResult.failed(0, "Client Secret is required",
"client_secret", "Scan the DingTalk QR — Client Secret is filled automatically.");
}
try {
String body = objectMapper.writeValueAsString(Map.of(
"appKey", clientId,
"appSecret", clientSecret));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.timeout(TIMEOUT)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
JsonNode root = objectMapper.readTree(resp.body());
// DingTalk returns {accessToken, expireIn} on success, or
// {code: "InvalidAuthentication"/..., message: "...", requestid: "..."} on failure.
String accessToken = root.path("accessToken").asText("");
if (resp.statusCode() == 200 && !accessToken.isBlank()) {
int expire = root.path("expireIn").asInt(7200);
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", clientId);
identity.put("transport", "DingTalk OAuth2 v1.0");
identity.put("tokenTtl", expire + "s");
return VerificationResult.ok(ms,
"Connected — DingTalk issued an access token",
identity);
}
String code = root.path("code").asText("");
String msg = root.path("message").asText("auth failed");
return VerificationResult.failed(ms,
"DingTalk rejected the credentials (" + code + "): " + msg,
invalidFieldFor(code),
hintFor(code, msg));
} catch (java.net.http.HttpTimeoutException e) {
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Timed out talking to api.dingtalk.com", null,
"Network couldn't reach DingTalk in 5s. Check egress to api.dingtalk.com (port 443).");
} catch (Exception e) {
log.debug("[dingtalk-verify] error: {}", e.getMessage());
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Could not reach DingTalk: " + e.getClass().getSimpleName(), null, e.getMessage());
}
}
private static String invalidFieldFor(String code) {
if (code == null) return null;
return switch (code) {
case "InvalidAuthentication", "AccessKeyError" -> "client_secret";
case "InvalidParameter.AppKey", "AppNotExist" -> "client_id";
default -> null;
};
}
private static String hintFor(String code, String msg) {
if (code == null || code.isBlank()) {
return msg != null && !msg.isBlank() ? msg : "DingTalk auth failed.";
}
return switch (code) {
case "InvalidAuthentication" ->
"Client Secret rejected. Re-scan the QR — DingTalk rotates secrets on app re-publish.";
case "AppNotExist" ->
"App not found in your tenant. Verify the QR was for the right corporation.";
case "AccessKeyError" ->
"Authentication signature mismatch. Re-scan to refresh the credential pair.";
default -> "DingTalk error " + code + "" + msg;
};
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
}

View File

@ -0,0 +1,110 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Validates a Discord bot token via {@code GET /api/v10/users/@me} with
* {@code Authorization: Bot <token>}. Same proxy semantics as
* {@link TelegramVerifier}.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DiscordVerifier implements ChannelVerifier {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final String API_URL = "https://discord.com/api/v10/users/@me";
private final ObjectMapper objectMapper;
@Override
public String getChannelType() {
return "discord";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String botToken = string(request.config(), "bot_token");
if (botToken == null || botToken.isBlank()) {
return VerificationResult.failed(0, "Bot Token is required",
"bot_token", "Get one from the Discord Developer Portal under Bot → Token.");
}
HttpClient.Builder cb = HttpClient.newBuilder().connectTimeout(TIMEOUT);
applyProxy(cb, string(request.config(), "http_proxy"));
HttpClient client = cb.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.timeout(TIMEOUT)
.header("Authorization", "Bot " + botToken)
.header("User-Agent", "MateClaw-Verifier/1.0 (+https://claw.mate.vip)")
.GET()
.build();
try {
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
if (resp.statusCode() == 200) {
JsonNode body = objectMapper.readTree(resp.body());
String username = body.path("username").asText("");
String discriminator = body.path("discriminator").asText("0");
String displayName = "0".equals(discriminator) ? username : username + "#" + discriminator;
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", body.path("id").asText());
identity.put("accountName", displayName);
identity.put("isBot", body.path("bot").asBoolean(true));
identity.put("verified", body.path("verified").asBoolean(false));
return VerificationResult.ok(ms, "Connected as " + displayName, identity);
}
if (resp.statusCode() == 401) {
return VerificationResult.failed(ms, "Discord rejected the bot token (401 Unauthorized)",
"bot_token", "Bot Token is invalid. Regenerate it in the Discord Developer Portal.");
}
return VerificationResult.failed(ms, "Discord returned HTTP " + resp.statusCode(),
null, "Unexpected response from Discord. Check the bot exists and the token has not been revoked.");
} catch (java.net.http.HttpTimeoutException e) {
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Timed out talking to discord.com", null,
"Network couldn't reach Discord in 5s. Set HTTP Proxy in advanced settings if needed.");
} catch (Exception e) {
log.debug("[discord-verify] error: {}", e.getMessage());
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Could not reach Discord: " + e.getClass().getSimpleName(), null, e.getMessage());
}
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
private static void applyProxy(HttpClient.Builder cb, String httpProxy) {
if (httpProxy == null || httpProxy.isBlank()) return;
try {
URI uri = URI.create(httpProxy);
if (uri.getHost() != null && uri.getPort() > 0) {
cb.proxy(ProxySelector.of(new InetSocketAddress(uri.getHost(), uri.getPort())));
}
} catch (Exception ignored) {
// best effort invalid proxy falls back to direct
}
}
}

View File

@ -0,0 +1,128 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Validates Feishu / Lark app credentials via the
* {@code /open-apis/auth/v3/tenant_access_token/internal} endpoint. Mirrors
* the same exchange that {@code FeishuChannelAdapter.refreshTenantAccessToken}
* does on real startup, so a green Step 2 is a strong predictor of a green
* channel post-save.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FeishuVerifier implements ChannelVerifier {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private final ObjectMapper objectMapper;
@Override
public String getChannelType() {
return "feishu";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String appId = string(request.config(), "app_id");
String appSecret = string(request.config(), "app_secret");
String domain = string(request.config(), "domain");
if (domain == null || domain.isBlank()) domain = "feishu";
if (appId == null || appId.isBlank()) {
return VerificationResult.failed(0, "App ID is required",
"app_id", "Scan the QR (one-click app creation) — App ID is filled automatically.");
}
if (appSecret == null || appSecret.isBlank()) {
return VerificationResult.failed(0, "App Secret is required",
"app_secret", "Scan the QR (one-click app creation) — App Secret is filled automatically.");
}
String apiBase = "lark".equalsIgnoreCase(domain)
? "https://open.larksuite.com"
: "https://open.feishu.cn";
try {
String body = objectMapper.writeValueAsString(Map.of(
"app_id", appId,
"app_secret", appSecret));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(apiBase + "/open-apis/auth/v3/tenant_access_token/internal"))
.timeout(TIMEOUT)
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
JsonNode root = objectMapper.readTree(resp.body());
int code = root.path("code").asInt(-1);
if (code == 0) {
int expire = root.path("expire").asInt(7200);
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", appId);
identity.put("region", "lark".equalsIgnoreCase(domain) ? "Lark (international)" : "Feishu (China)");
identity.put("tokenTtl", expire + "s");
String regionLabel = "lark".equalsIgnoreCase(domain) ? "Lark" : "Feishu";
return VerificationResult.ok(ms,
"Connected to " + regionLabel + " — tenant_access_token issued",
identity);
}
String msg = root.path("msg").asText("auth failed");
return VerificationResult.failed(ms,
"Feishu rejected the credentials (code " + code + "): " + msg,
invalidFieldFor(code),
hintFor(code, msg));
} catch (java.net.http.HttpTimeoutException e) {
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Timed out talking to " + apiBase, null,
"Network couldn't reach Feishu in 5s. If you're on a corporate network, check egress to *.feishu.cn / *.larksuite.com.");
} catch (Exception e) {
log.debug("[feishu-verify] error: {}", e.getMessage());
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Could not reach Feishu: " + e.getClass().getSimpleName(), null, e.getMessage());
}
}
private static String invalidFieldFor(int code) {
// 10003 / 99991663 / 99991664 family: app credential / signature errors
return switch (code) {
case 10003 -> "app_secret";
case 10012 -> "app_id";
default -> code >= 10000 && code < 20000 ? "app_secret" : null;
};
}
private static String hintFor(int code, String msg) {
return switch (code) {
case 10003 -> "App Secret rejected. Re-scan the QR — Feishu may have rotated the secret on app re-publish.";
case 10012 -> "App ID not recognized. Verify you scanned the QR for the right tenant.";
case 99991663 -> "Token cache stale. Re-scan to force a fresh credential pair.";
default -> msg != null && !msg.isBlank()
? "Feishu code " + code + "" + msg
: "Feishu code " + code + ". Re-scanning the QR usually resolves credential drift.";
};
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
}

View File

@ -0,0 +1,91 @@
package vip.mate.channel.verifier;
import com.slack.api.Slack;
import com.slack.api.methods.response.auth.AuthTestResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Validates a Slack bot token via {@code auth.test}. The {@code app_token}
* (used for Socket Mode) is intentionally not probed here because Slack
* does not expose a no-side-effect endpoint for it {@code apps.connections.open}
* actually opens a WSS, which we do not want during a wizard step. We
* surface a hint when {@code app_token} is missing so the user knows Socket
* Mode won't work yet.
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class SlackVerifier implements ChannelVerifier {
@Override
public String getChannelType() {
return "slack";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String botToken = string(request.config(), "bot_token");
String appToken = string(request.config(), "app_token");
if (botToken == null || botToken.isBlank()) {
return VerificationResult.failed(0, "Bot Token is required",
"bot_token", "Get one from your Slack App → OAuth & Permissions → Bot User OAuth Token (xoxb-…).");
}
try {
AuthTestResponse resp = Slack.getInstance().methods(botToken).authTest(r -> r);
long ms = System.currentTimeMillis() - t0;
if (resp.isOk()) {
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", resp.getUserId());
identity.put("accountName", resp.getUser());
identity.put("team", resp.getTeam());
identity.put("teamId", resp.getTeamId());
identity.put("botId", resp.getBotId());
String headline = "Connected to " + resp.getTeam() + " as " + resp.getUser();
if (appToken == null || appToken.isBlank()) {
return VerificationResult.failedWithIdentity(ms,
headline + " — but App Token missing for Socket Mode",
identity, "app_token",
"Bot Token is valid. Add an App-Level Token (xapp-…) with connections:write to enable Socket Mode.");
}
if (!appToken.startsWith("xapp-")) {
return VerificationResult.failedWithIdentity(ms,
headline + " — but App Token has wrong prefix",
identity, "app_token",
"App Token must start with xapp- (App-Level Token), not xoxb- (Bot Token).");
}
return VerificationResult.ok(ms, headline, identity);
}
String error = resp.getError() != null ? resp.getError() : "auth_failed";
return VerificationResult.failed(ms, "Slack says: " + error,
"bot_token", hintForSlackError(error));
} catch (Exception e) {
log.debug("[slack-verify] error: {}", e.getMessage());
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Could not reach Slack: " + e.getClass().getSimpleName(), null, e.getMessage());
}
}
private static String hintForSlackError(String code) {
return switch (code) {
case "invalid_auth", "not_authed" ->
"Bot Token is invalid. Verify you copied the full xoxb- string from OAuth & Permissions.";
case "account_inactive" -> "The Slack workspace or user is deactivated.";
case "token_revoked" -> "Bot Token has been revoked. Reinstall the app to get a fresh token.";
case "token_expired" -> "Bot Token has expired. Generate a new one in Slack App settings.";
default -> "Slack rejected the request: " + code;
};
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
}

View File

@ -0,0 +1,106 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Validates a Telegram bot token via {@code GET /bot{token}/getMe}. Honors
* the {@code http_proxy} field so Chinese users who almost always need a
* proxy to reach api.telegram.org get a representative result instead of
* a misleading timeout.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TelegramVerifier implements ChannelVerifier {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private final ObjectMapper objectMapper;
@Override
public String getChannelType() {
return "telegram";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String botToken = string(request.config(), "bot_token");
if (botToken == null || botToken.isBlank()) {
return VerificationResult.failed(0, "Bot Token is required",
"bot_token", "Get one from @BotFather and paste it here.");
}
HttpClient.Builder cb = HttpClient.newBuilder().connectTimeout(TIMEOUT);
applyProxy(cb, string(request.config(), "http_proxy"));
HttpClient client = cb.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.telegram.org/bot" + botToken + "/getMe"))
.timeout(TIMEOUT)
.GET()
.build();
try {
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
JsonNode body = objectMapper.readTree(resp.body());
if (resp.statusCode() == 200 && body.path("ok").asBoolean(false)) {
JsonNode r = body.path("result");
String username = r.path("username").asText("");
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", r.path("id").asLong());
identity.put("accountName", "@" + username);
identity.put("firstName", r.path("first_name").asText(""));
identity.put("canJoinGroups", r.path("can_join_groups").asBoolean());
identity.put("canReadAllGroupMessages", r.path("can_read_all_group_messages").asBoolean());
return VerificationResult.ok(ms, "Connected as @" + username, identity);
}
// Telegram returns 401/404 for bad tokens with a JSON body containing "description"
String description = body.path("description").asText("Telegram rejected the token");
return VerificationResult.failed(ms, "Telegram says: " + description,
"bot_token", "Bot Token rejected. Check the full string from @BotFather, including the colon.");
} catch (java.net.http.HttpTimeoutException e) {
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Timed out talking to api.telegram.org", null,
"Network couldn't reach Telegram in 5s. If you're in mainland China, set HTTP Proxy in advanced settings.");
} catch (Exception e) {
log.debug("[telegram-verify] error: {}", e.getMessage());
return VerificationResult.failed(System.currentTimeMillis() - t0,
"Could not reach Telegram: " + e.getClass().getSimpleName(), null, e.getMessage());
}
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
private static void applyProxy(HttpClient.Builder cb, String httpProxy) {
if (httpProxy == null || httpProxy.isBlank()) return;
try {
URI uri = URI.create(httpProxy);
if (uri.getHost() != null && uri.getPort() > 0) {
cb.proxy(ProxySelector.of(new InetSocketAddress(uri.getHost(), uri.getPort())));
}
} catch (Exception ignored) {
// best effort invalid proxy falls back to direct
}
}
}

View File

@ -0,0 +1,17 @@
package vip.mate.channel.verifier;
import java.util.Map;
/**
* Draft channel config submitted to the wizard's Verify step. Carries only
* what a verifier needs no entity ID, no audit context because preflight
* runs before the row exists in {@code mate_channel}.
*
* @author MateClaw Team
*/
public record VerificationRequest(
String channelType,
Map<String, Object> config,
Long workspaceId
) {
}

View File

@ -0,0 +1,54 @@
package vip.mate.channel.verifier;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Outcome of a {@link ChannelVerifier#verify} probe. Designed so the
* onboarding wizard's Step 2 can render success and failure with no extra
* round-trips:
* <ul>
* <li>{@code headline} one-line status shown in the verify card</li>
* <li>{@code identity} account display fields piped into Step 3 ("Connected as ...")</li>
* <li>{@code invalidField} when failed, the form key Step 1 should highlight on "Fix it"</li>
* <li>{@code hint} actionable next step, surfaced under the failure headline</li>
* </ul>
* The {@code skipped} variant lets channel types with no verifier (web,
* webchat, webhook) fast-forward through Step 2 without showing an error.
*
* @author MateClaw Team
*/
public record VerificationResult(
boolean ok,
boolean skipped,
long durationMs,
String headline,
Map<String, Object> identity,
String invalidField,
String hint
) {
public static VerificationResult ok(long durationMs, String headline, Map<String, Object> identity) {
return new VerificationResult(true, false, durationMs, headline,
identity != null ? identity : Collections.emptyMap(), null, null);
}
public static VerificationResult failed(long durationMs, String headline, String invalidField, String hint) {
return new VerificationResult(false, false, durationMs, headline,
Collections.emptyMap(), invalidField, hint);
}
public static VerificationResult skipped(String headline) {
return new VerificationResult(true, true, 0L, headline,
Collections.emptyMap(), null, null);
}
/** Convenience: rich failure with structured identity (for partial-success scenarios). */
public static VerificationResult failedWithIdentity(long durationMs, String headline,
Map<String, Object> identity,
String invalidField, String hint) {
Map<String, Object> id = identity != null ? identity : new LinkedHashMap<>();
return new VerificationResult(false, false, durationMs, headline, id, invalidField, hint);
}
}

View File

@ -0,0 +1,252 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
/**
* Validates WeCom (企业微信) smart-bot credentials by performing a real
* {@code aibot_subscribe} handshake against {@code wss://openws.work.weixin.qq.com}.
* <p>
* Why a full WS handshake instead of a cheaper REST probe: WeCom's smart-bot
* API has no REST equivalent the long-connection subscribe is the only way
* to find out whether a {@code (bot_id, secret)} pair will actually connect.
* Skimping here would let bad credentials through Step 2 and surface as a red
* dot in production, which is exactly the failure mode the wizard exists to
* eliminate.
* <p>
* The probe is short-lived: connect send subscribe wait 5s for ack
* close. No heartbeat, no message handling, no retained state.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WeComVerifier implements ChannelVerifier {
private static final String WS_URL = "wss://openws.work.weixin.qq.com";
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final long ACK_TIMEOUT_MS = 5_000L;
private final ObjectMapper objectMapper;
@Override
public String getChannelType() {
return "wecom";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String botId = string(request.config(), "bot_id");
String secret = string(request.config(), "secret");
if (botId == null || botId.isBlank()) {
return VerificationResult.failed(0, "Bot ID is required",
"bot_id", "Scan the QR in WeCom to fetch the Bot ID and Secret automatically.");
}
if (secret == null || secret.isBlank()) {
return VerificationResult.failed(0, "Secret is required",
"secret", "Scan the QR in WeCom to fetch the Bot ID and Secret automatically.");
}
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
AckWaiter waiter = new AckWaiter();
WebSocket ws = null;
try {
ws = httpClient.newWebSocketBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.buildAsync(URI.create(WS_URL), waiter)
.get(7, TimeUnit.SECONDS);
String reqId = "aibot_subscribe-" + UUID.randomUUID();
Map<String, Object> frame = Map.of(
"cmd", "aibot_subscribe",
"headers", Map.of("req_id", reqId),
"body", Map.of("bot_id", botId, "secret", secret)
);
ws.sendText(objectMapper.writeValueAsString(frame), true)
.orTimeout(2, TimeUnit.SECONDS)
.join();
Map<String, Object> ack = waiter.awaitAck(ACK_TIMEOUT_MS);
long ms = System.currentTimeMillis() - t0;
Object errcodeObj = ack.get("errcode");
int errcode = errcodeObj instanceof Number n ? n.intValue() : 0;
if (errcode == 0) {
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", maskBotId(botId));
identity.put("transport", "WebSocket (openws.work.weixin.qq.com)");
return VerificationResult.ok(ms, "Connected — WeCom accepted the bot credentials", identity);
}
String errmsg = String.valueOf(ack.getOrDefault("errmsg", "unknown error"));
return VerificationResult.failed(ms,
"WeCom rejected the credentials: " + errmsg,
invalidFieldFor(errcode),
hintFor(errcode, errmsg));
} catch (TimeoutException e) {
long ms = System.currentTimeMillis() - t0;
// Timed out either on connect (handled by buildAsync) or on the ack wait.
String headline = waiter.opened()
? "WeCom did not respond to the subscribe frame in 5s"
: "Could not reach openws.work.weixin.qq.com in 5s";
return VerificationResult.failed(ms, headline, null,
"Check network egress to *.work.weixin.qq.com (port 443). If you are behind a corporate proxy, the WebSocket upgrade may be blocked.");
} catch (Exception e) {
log.debug("[wecom-verify] error: {}", e.getMessage());
long ms = System.currentTimeMillis() - t0;
Throwable cause = e.getCause() != null ? e.getCause() : e;
return VerificationResult.failed(ms,
"Could not reach WeCom: " + cause.getClass().getSimpleName(),
null, cause.getMessage());
} finally {
if (ws != null) {
try {
ws.sendClose(WebSocket.NORMAL_CLOSURE, "verify done")
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> null)
.join();
} catch (Exception ignored) {
// best-effort close
}
}
}
}
/** Map common WeCom errcodes to the form field most likely to fix them. */
private static String invalidFieldFor(int errcode) {
// 40001..40015 family is "invalid credentials / signature" the
// smart-bot API does not document a stable taxonomy, so we apply a
// conservative bucket: everything not network-class points at secret,
// because bot_id format errors are rare (the QR flow guarantees it).
return errcode >= 40000 && errcode < 50000 ? "secret" : null;
}
private static String hintFor(int errcode, String errmsg) {
return switch (errcode) {
case 40001, 40014 -> "Secret rejected. Re-scan the QR in WeCom — your Secret may have been rotated.";
case 40013 -> "Bot ID is malformed. Re-scan the QR to refresh it.";
case 41001 -> "Authorization expired. Re-scan to obtain a fresh token.";
default -> errmsg != null && !errmsg.isBlank()
? "WeCom errcode " + errcode + "" + errmsg
: "WeCom errcode " + errcode + ". Re-scanning the QR usually resolves transient signature issues.";
};
}
private static String maskBotId(String botId) {
if (botId.length() <= 8) return botId;
return botId.substring(0, 6) + "" + botId.substring(botId.length() - 4);
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
/**
* Minimal WebSocket listener that buffers text frames and resolves the
* first JSON frame whose {@code req_id} starts with {@code aibot_subscribe}.
* Other frames are ignored the verifier never enters the message loop.
*/
private final class AckWaiter implements WebSocket.Listener {
private final StringBuilder buf = new StringBuilder();
private final CompletableFuture<Map<String, Object>> ackFuture = new CompletableFuture<>();
private final AtomicReference<Boolean> openedFlag = new AtomicReference<>(false);
@Override
public void onOpen(WebSocket webSocket) {
openedFlag.set(true);
webSocket.request(1);
}
@Override
public java.util.concurrent.CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
buf.append(data);
if (last) {
String full = buf.toString();
buf.setLength(0);
tryResolve(full);
}
webSocket.request(1);
return null;
}
@Override
public java.util.concurrent.CompletionStage<?> onBinary(WebSocket webSocket, java.nio.ByteBuffer data, boolean last) {
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
buf.append(new String(bytes));
if (last) {
String full = buf.toString();
buf.setLength(0);
tryResolve(full);
}
webSocket.request(1);
return null;
}
@Override
public java.util.concurrent.CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!ackFuture.isDone()) {
ackFuture.completeExceptionally(new RuntimeException(
"WebSocket closed before ack: code=" + statusCode + ", reason=" + reason));
}
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!ackFuture.isDone()) ackFuture.completeExceptionally(error);
}
@SuppressWarnings("unchecked")
private void tryResolve(String json) {
try {
Map<String, Object> frame = objectMapper.readValue(json, Map.class);
Map<String, Object> headers = (Map<String, Object>) frame.getOrDefault("headers", Map.of());
String reqId = String.valueOf(headers.getOrDefault("req_id", ""));
if (reqId.startsWith("aibot_subscribe")) {
ackFuture.complete(frame);
}
// Other frames (heartbeat ack, server-pushed events) ignored.
} catch (Exception e) {
// Malformed frame is not fatal keep waiting until the timeout fires.
log.debug("[wecom-verify] non-JSON or unparseable frame ignored: {}", e.getMessage());
}
}
Map<String, Object> awaitAck(long timeoutMs) throws Exception {
try {
return ackFuture.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
if (cause instanceof Exception ex) throw ex;
throw new RuntimeException(cause);
}
}
boolean opened() {
return Boolean.TRUE.equals(openedFlag.get());
}
}
}

View File

@ -0,0 +1,154 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
/**
* Validates a WeChat iLink Bot token by hitting
* {@code GET /ilink/bot/getupdates} on the configured base URL.
* <p>
* iLink's getupdates is a long-poll (server holds the connection up to ~35s
* waiting for a message). For the verify probe we set a short HTTP request
* timeout if the server reaches the long-poll wait, that already proves
* the bearer token was accepted, so a clean {@link java.net.http.HttpTimeoutException}
* is treated as success. 401/403 means the token is dead.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WeixinVerifier implements ChannelVerifier {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3);
private static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
private final ObjectMapper objectMapper;
@Override
public String getChannelType() {
return "weixin";
}
@Override
public VerificationResult verify(VerificationRequest request) {
long t0 = System.currentTimeMillis();
String botToken = string(request.config(), "bot_token");
String baseUrl = string(request.config(), "base_url");
if (baseUrl == null || baseUrl.isBlank()) baseUrl = DEFAULT_BASE_URL;
baseUrl = baseUrl.replaceAll("/+$", "");
if (botToken == null || botToken.isBlank()) {
return VerificationResult.failed(0, "Bot Token is required",
"bot_token", "Scan the WeChat QR — Bot Token is filled automatically once you confirm in WeChat.");
}
try {
// X-WECHAT-UIN: base64(str(random_uint32)) iLink's anti-replay header.
// Mirrors what ILinkClient.makeHeaders does on every real request.
long uinVal = new Random().nextLong(0, 0xFFFFFFFFL + 1);
String uin = Base64.getEncoder().encodeToString(
String.valueOf(uinVal).getBytes(java.nio.charset.StandardCharsets.UTF_8));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/getupdates"))
.timeout(REQUEST_TIMEOUT)
.header("Content-Type", "application/json")
.header("AuthorizationType", "ilink_bot_token")
.header("Authorization", "Bearer " + botToken)
.header("X-WECHAT-UIN", uin)
.POST(HttpRequest.BodyPublishers.ofString("{\"cursor\":\"\"}"))
.build();
HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
return interpretStatus(resp, ms, botToken, baseUrl);
} catch (java.net.http.HttpTimeoutException e) {
// Long-poll held the connection implies the bearer token was
// accepted (the server only enters the poll loop after auth).
long ms = System.currentTimeMillis() - t0;
return VerificationResult.ok(ms,
"Connected — WeChat iLink accepted the bot token",
identityFor(botToken, baseUrl));
} catch (Exception e) {
log.debug("[weixin-verify] error: {}", e.getMessage());
long ms = System.currentTimeMillis() - t0;
return VerificationResult.failed(ms,
"Could not reach iLink: " + e.getClass().getSimpleName(),
null, e.getMessage());
}
}
private VerificationResult interpretStatus(HttpResponse<String> resp, long ms,
String botToken, String baseUrl) {
int status = resp.statusCode();
if (status == 200) {
// Server returned an immediate update before our timeout token is fine.
return VerificationResult.ok(ms,
"Connected — WeChat iLink accepted the bot token",
identityFor(botToken, baseUrl));
}
if (status == 401 || status == 403) {
return VerificationResult.failed(ms,
"iLink rejected the Bot Token (HTTP " + status + ")",
"bot_token",
"The token has been revoked or expired. Re-scan the QR to obtain a fresh one.");
}
// Other 4xx/5xx surface the body snippet for debugging without exposing the token.
String snippet = resp.body() != null && !resp.body().isBlank()
? truncate(resp.body(), 160) : "(empty response body)";
// Try to parse iLink JSON error envelope.
try {
JsonNode root = objectMapper.readTree(resp.body());
int errcode = root.path("errcode").asInt(0);
String errmsg = root.path("errmsg").asText("");
if (errcode != 0 && !errmsg.isBlank()) {
return VerificationResult.failed(ms,
"iLink errcode " + errcode + ": " + errmsg,
errcode == 401 || errcode == 403 ? "bot_token" : null,
"If this persists, re-scan the QR to refresh credentials.");
}
} catch (Exception ignored) {
// Non-JSON body fall through to generic message.
}
return VerificationResult.failed(ms,
"iLink returned HTTP " + status, null, snippet);
}
private static Map<String, Object> identityFor(String botToken, String baseUrl) {
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("accountId", maskToken(botToken));
identity.put("baseUrl", baseUrl);
identity.put("transport", "iLink long-polling");
return identity;
}
private static String maskToken(String token) {
if (token.length() <= 8) return token;
return token.substring(0, 4) + "" + token.substring(token.length() - 4);
}
private static String truncate(String s, int max) {
return s.length() <= max ? s : s.substring(0, max) + "";
}
private static String string(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? v.toString() : null;
}
}

View File

@ -107,7 +107,8 @@ public class ChatController {
// without sticky session)". They look identical from attach()'s
// boolean return, but the user-facing remediation is different.
boolean existsLocally = streamTracker.streamExistsOnThisNode(conversationId);
boolean attached = streamTracker.attach(conversationId, emitter);
long lastEventId = request.getLastEventId() == null ? 0L : request.getLastEventId();
boolean attached = streamTracker.attach(conversationId, emitter, lastEventId);
if (!attached) {
try {
if (existsLocally) {
@ -225,6 +226,8 @@ public class ChatController {
final String decision = isApprovalCommand ? "approved" : "denied";
streamTracker.register(conversationId);
Long approvalAgentId = parseLongOrNull(pending.getAgentId());
streamTracker.bindRunMeta(conversationId, approvalAgentId, username);
registerEmitterCallbacks(emitter, conversationId);
streamTracker.attach(conversationId, emitter);
AtomicBoolean approvalEmitterDone = new AtomicBoolean(false);
@ -248,7 +251,8 @@ public class ChatController {
broadcastEvent(conversationId, "content_delta", Map.of("delta", denyMsg));
broadcastEvent(conversationId, "message_complete", Map.of("status", "completed"));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "completed", savedAssistant, 0, 0, true,
conversationId, "completed", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant),
conversationService.getMessageCount(conversationId)));
// deny 是正常 turn 终结用户可能在 awaiting_approval 阶段排了消息
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
@ -305,7 +309,22 @@ public class ChatController {
})
.doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return;
// RFC-067 §4.6: replay can re-trigger an approval (the approved tool
// Force-recycle short-circuit: see main doOnComplete below.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE replay doOnComplete skipped for force-recycled conversation: {}", conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, e.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, approvalEmitterDone);
}
return;
}
// Replay can re-trigger an approval (the approved tool
// call may chain into another guarded tool). Derive status the same
// way as the normal stream so awaiting_approval doesn't get masked
// as completed.
@ -328,6 +347,13 @@ public class ChatController {
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); // includes toolCalls metadata
} else if (replayWasStopped) {
boolean replayIsFollowup = replayInterrupt == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
replayIsFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus);
} else {
savedAssistant = saveEmptyAssistantPlaceholder(
conversationId, persistStatus, accumulator, "SSE replay doOnComplete");
}
broadcastEvent(conversationId, "message_complete", Map.of(
"status", persistStatus,
@ -336,7 +362,8 @@ public class ChatController {
));
int msgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, persistStatus, savedAssistant, 0, 0, true, msgCount));
conversationId, persistStatus, savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), msgCount));
} catch (Exception e) {
log.warn("SSE replay complete error: {}", e.getMessage());
} finally {
@ -353,6 +380,22 @@ public class ChatController {
})
.doOnError(e -> {
if (!finalized.compareAndSet(false, true)) return;
// Force-recycle short-circuit: see main doOnComplete below.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE replay doOnError skipped for force-recycled conversation: {}, cause={}",
conversationId, e.getMessage());
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception ex) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, ex.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, approvalEmitterDone);
}
return;
}
boolean isUserStop = e instanceof java.util.concurrent.CancellationException
|| (e.getCause() instanceof java.util.concurrent.CancellationException);
@ -386,6 +429,10 @@ public class ChatController {
} else if (isUserStop) {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
replayIsFollowup ? "[已中断]" : "[已停止生成]", null, errStatus);
} else {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
"[错误] " + (e.getMessage() != null ? e.getMessage() : "replay error"),
null, "failed");
}
if (replayIsFollowup) {
@ -406,7 +453,8 @@ public class ChatController {
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0, true, stoppedMsgCount));
conversationId, "stopped", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), stoppedMsgCount));
} else {
broadcastEvent(conversationId, "error", buildErrorPayload(
conversationId,
@ -433,6 +481,8 @@ public class ChatController {
() -> log.debug("SSE replay subscription completed: conversationId={}", conversationId));
streamTracker.setDisposable(conversationId, disposable);
streamTracker.setEmergencySaveCallback(conversationId,
() -> emergencySaveAccumulator(conversationId, accumulator));
} catch (Exception e) {
log.error("SSE approval replay setup error: {}", e.getMessage());
@ -445,9 +495,23 @@ public class ChatController {
// ---- 正常请求注册流状态并附着首个订阅者 ----
streamTracker.register(conversationId);
streamTracker.bindRunMeta(conversationId, agentId, username);
registerEmitterCallbacks(emitter, conversationId);
streamTracker.attach(conversationId, emitter);
// Per-emitter "the SSE channel is open and you should reset any
// pending placeholder UI". Sent directly to the emitter rather than
// broadcast so reconnecting subscribers don't see a duplicate marker
// for an already-open conversation.
try {
sendEvent(emitter, "stream_started", Map.of(
"conversationId", conversationId,
"timestamp", System.currentTimeMillis()
));
} catch (IOException e) {
log.debug("Failed to send stream_started event for {}: {}", conversationId, e.getMessage());
}
// 标记 emitter 是否已结束防止 Flux 回调再次写入已关闭的 emitter
AtomicBoolean emitterDone = new AtomicBoolean(false);
@ -486,6 +550,36 @@ public class ChatController {
})
.doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return;
// Force-recycle: the recycle path already wrote a
// "[已被用户中止]" placeholder (or the partial
// content via emergencySave). The agent's flux may
// have completed the same millisecond skip its
// save + broadcast so we don't append a duplicate
// assistant row below the placeholder. Cleanup
// still runs so queue draining + emitter close
// happen normally.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE doOnComplete skipped for force-recycled conversation: {}", conversationId);
streamTracker.clearInterruptState(conversationId);
// Defensive: keep DB stream_status consistent with the
// "this turn is over" reality even when we skip the
// save. Force-recycle's controller path already wrote
// 'idle' for the recycled run, so this is normally a
// no-op but if a register() ever fails to clear the
// marker (e.g. a different turn snuck through), this
// prevents the row leaking at 'running' across refresh.
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, e.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, emitterDone);
}
return;
}
// 区分四种完成语义
// 1. 正常完成stopRequested=false completed
// 2. 用户主动停止 stopped
@ -522,6 +616,9 @@ public class ChatController {
} else if (wasStopped) {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus);
} else {
savedAssistant = saveEmptyAssistantPlaceholder(
conversationId, persistStatus, accumulator, "SSE doOnComplete");
}
// 发布对话完成事件仅正常完成时停止/中断/错误均不触发记忆提取
// RFC-049 follow-up: also skip on isError error turns persist
@ -550,7 +647,8 @@ public class ChatController {
int msgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, persistStatus, savedAssistant,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(), true, msgCount));
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
isAssistantPersisted(savedAssistant), msgCount));
}
} catch (Exception e) {
log.warn("SSE complete error: {}", e.getMessage());
@ -592,6 +690,22 @@ public class ChatController {
boolean wasFirst = finalized.compareAndSet(false, true);
log.info("SSE doOnCancel fired: conversationId={}, wasFirst={}", conversationId, wasFirst);
if (!wasFirst) return;
// Force-recycle short-circuit: see doOnComplete above.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE doOnCancel skipped for force-recycled conversation: {}", conversationId);
streamTracker.clearInterruptState(conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, e.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, emitterDone);
}
return;
}
// 区分用户主动停止和 interrupt-with-followup
ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId);
boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
@ -635,7 +749,8 @@ public class ChatController {
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0, true, stoppedMsgCount));
conversationId, "stopped", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), stoppedMsgCount));
}
} catch (Exception e) {
log.warn("SSE stop finalize error: {}", e.getMessage());
@ -659,6 +774,23 @@ public class ChatController {
log.info("SSE doOnError skipped (finalized by doOnCancel): conversationId={}", conversationId);
return;
}
// Force-recycle short-circuit: see doOnComplete above.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE doOnError skipped for force-recycled conversation: {}, cause={}",
conversationId, e.getMessage());
streamTracker.clearInterruptState(conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception ex) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, ex.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, emitterDone);
}
return;
}
// CancellationException = 用户主动停止或中断续跑
boolean isUserStop = e instanceof java.util.concurrent.CancellationException
@ -721,7 +853,8 @@ public class ChatController {
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0, true, stoppedMsgCount));
conversationId, "stopped", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), stoppedMsgCount));
} else {
broadcastEvent(conversationId, "error", buildErrorPayload(conversationId, errorMsg, savedAssistant));
}
@ -1008,6 +1141,14 @@ public class ChatController {
private List<MessageContentPart> contentParts;
/** true 表示断线重连,不发送新消息,只附着到已有的流 */
private Boolean reconnect;
/**
* Last SSE event id the client has already processed. Only meaningful
* when {@link #reconnect} is true the server skips events with
* id &le; this value during buffer replay so the client doesn't
* see them twice. 0 (or null) means "replay everything", matching
* the legacy attach behavior for backwards compatibility.
*/
private Long lastEventId;
/** 思考深度off / low / medium / high / maxnull 表示跟随 Agent 默认 */
private String thinkingLevel;
}
@ -1115,6 +1256,13 @@ public class ChatController {
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (queuedWasStopped) {
boolean queuedIsFollowup = queuedInterrupt == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
queuedIsFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus);
} else {
savedAssistant = saveEmptyAssistantPlaceholder(
conversationId, persistStatus, accumulator, "SSE queued doOnComplete");
}
broadcastEvent(conversationId, "message_complete", Map.of(
"status", persistStatus,
@ -1123,7 +1271,8 @@ public class ChatController {
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, persistStatus, savedAssistant,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(), true,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
isAssistantPersisted(savedAssistant),
conversationService.getMessageCount(conversationId)));
} catch (Exception e) {
log.warn("SSE queued complete error: {}", e.getMessage());
@ -1236,6 +1385,29 @@ public class ChatController {
? "interrupted" : "stopped";
}
static String emptyAssistantPlaceholder(String status) {
if ("awaiting_approval".equals(status)) return "[等待审批]";
return "[本次没有输出]";
}
static boolean isAssistantPersisted(MessageEntity savedAssistant) {
return savedAssistant != null;
}
private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status,
StreamAccumulator accumulator, String source) {
log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}",
source, conversationId, status, accumulator.getFinishReason(),
accumulator.getCurrentPhase(), !accumulator.segmentsEmpty());
return conversationService.saveMessage(conversationId, "assistant",
emptyAssistantPlaceholder(status), null, status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
}
private Map<String, Object> buildDonePayload(String conversationId, String status, MessageEntity savedAssistant,
int promptTokens, int completionTokens,
boolean persisted, Integer messageCount) {
@ -1287,6 +1459,9 @@ public class ChatController {
String text = accumulator.getContent();
List<MessageContentPart> parts = accumulator.toAssistantParts();
if (text.isBlank() && parts.isEmpty()) {
log.warn("[ChatController] Emergency save skipped (empty accumulator): conversationId={}, finishReason={}, phase={}, hasSegments={}",
conversationId, accumulator.getFinishReason(), accumulator.getCurrentPhase(),
!accumulator.segmentsEmpty());
return;
}
boolean awaitingApproval = accumulator.isAwaitingApproval();
@ -1374,8 +1549,13 @@ public class ChatController {
* 注册 SseEmitter 的完整生命周期回调
*/
private void registerEmitterCallbacks(SseEmitter emitter, String conversationId) {
emitter.onCompletion(() ->
log.debug("SSE emitter completed: conversationId={}", conversationId));
emitter.onCompletion(() -> {
log.debug("SSE emitter completed: conversationId={}", conversationId);
// Detach immediately so a subsequent broadcast (heartbeat / async_task_*)
// doesn't waste a send call on the zombie emitter and emit
// "Removing dead subscriber ... ResponseBodyEmitter has already completed".
streamTracker.detach(conversationId, emitter);
});
emitter.onTimeout(() -> {
log.debug("SSE emitter timeout: conversationId={}", conversationId);
streamTracker.detach(conversationId, emitter);
@ -1445,6 +1625,19 @@ public class ChatController {
private String runtimeProviderId = "";
private boolean awaitingApproval = false;
private String currentPhase = "";
/**
* Graph-emitted FinishReason for the turn (e.g. {@code "incomplete"},
* {@code "stopped"}, {@code "evidence_insufficient"}). Sourced from
* the {@code finish_reason} {@link vip.mate.agent.GraphEventPublisher}
* event that {@code FinalAnswerNode} attaches to its PENDING_EVENTS
* output same pipeline the SSE accumulator already drains, so the
* value is delivered alongside the assistant content (not via a
* sibling SSE-only broadcast that would bypass this accumulator).
* Persisted into message metadata so downstream filters
* (memory promotion gate) see a machine-readable status instead of
* having to guess from text. Empty string until the event arrives.
*/
private String finishReason = "";
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
@ -1471,6 +1664,17 @@ public class ChatController {
finalizeRunningSegments("content", "thinking");
}
}
if ("finish_reason".equals(delta.eventType())) {
Object reason = delta.eventData().get("reason");
if (reason != null) {
// Last-write-wins: graph normally fires this exactly once
// at FinalAnswerNode completion. Replay paths that re-enter
// the graph after approval will emit a fresh value, which
// is the correct behavior the latest reason is what gets
// persisted with the assistant message.
finishReason = String.valueOf(reason);
}
}
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
broadcastEvent(conversationId, delta.eventType(), delta.eventData());
@ -1669,6 +1873,9 @@ public class ChatController {
int getCompletionTokens() { return completionTokens; }
String getRuntimeModelName() { return runtimeModelName; }
String getRuntimeProviderId() { return runtimeProviderId; }
String getCurrentPhase() { return currentPhase; }
String getFinishReason() { return finishReason; }
boolean segmentsEmpty() { return segments.isEmpty(); }
synchronized List<MessageContentPart> toAssistantParts() {
List<MessageContentPart> parts = new ArrayList<>();
@ -1744,6 +1951,14 @@ public class ChatController {
// historical messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!finishReason.isEmpty()) {
// Surface graph FinishReason so MemorySummarizationGate and
// any other downstream consumer can branch on a structured
// status (e.g. skip INCOMPLETE / STOPPED / ERROR_FALLBACK
// turns from long-term memory promotion) instead of doing
// brittle text matching on the assistant content.
metadata.put("finishReason", finishReason);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());
@ -1751,4 +1966,9 @@ public class ChatController {
}
}
}
private static Long parseLongOrNull(String s) {
if (s == null || s.isBlank()) return null;
try { return Long.parseLong(s.trim()); } catch (NumberFormatException e) { return null; }
}
}

View File

@ -337,14 +337,40 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
* Called when WeCom auth_succeed frame is received the only point at
* which the connection is genuinely usable. Resets backoff and clears
* the failure dedup flag so the next outage (if any) can register.
*
* <p>RFC-080 follow-up: also cancels {@code reconnectFuture}. A stale
* failure signal (e.g. the previous socket's async onError fired AFTER
* connectWebSocket reset the dedup flag) can schedule a ghost reconnect
* while this attempt is still in flight. Without canceling, that ghost
* fires 2s later and tears down the freshly-authenticated connection,
* producing the self-sustaining loop. Per-listener identity dedup catches
* most cases; this is the belt-and-suspenders for any signal that still
* gets through.
*/
private void markReady() {
super.onReconnectSuccess();
if (reconnectFuture != null) {
reconnectFuture.cancel(false);
reconnectFuture = null;
}
disconnectInflight.set(false);
}
/**
* WebSocket 监听器接收消息帧并分发处理
* WebSocket 监听器接收消息帧并分发处理
*
* <p>RFC-080 follow-up: dedup by socket identity. The {@code disconnectInflight}
* flag is per-channel and gets reset by {@link #connectWebSocket} as soon as a
* new attempt starts. But the previous socket's onClose/onError can fire
* asynchronously on a JDK HttpClient worker tens of milliseconds AFTER we have
* already called sendClose+rebuilt and reset the flag. Without per-socket
* dedup that stale signal slips through, schedules a ghost reconnect, and
* tears down the freshly-established healthy connection 2s later producing
* the self-sustaining 2-second loop observed in the field.
*
* <p>Each listener instance compares the {@code WebSocket} argument against
* {@link #webSocket} and ignores callbacks for sockets that have already
* been replaced or released.
*/
private class WeComWebSocketListener implements WebSocket.Listener {
@ -356,6 +382,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
return null;
}
wsBuffer.append(data);
if (last) {
String fullMessage = wsBuffer.toString();
@ -368,6 +397,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
return null;
}
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
wsBuffer.append(new String(bytes));
@ -382,6 +414,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
log.debug("[wecom] stale onClose ignored: code={}, reason={}", statusCode, reason);
return null;
}
log.warn("[wecom] WebSocket closed: code={}, reason={}", statusCode, reason);
handleFailure("WebSocket closed: code=" + statusCode + ", reason=" + reason);
return null;
@ -389,6 +425,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
log.debug("[wecom] stale onError ignored: {}", error.getMessage());
return;
}
log.error("[wecom] WebSocket error: {}", error.getMessage());
handleFailure("WebSocket error: " + error.getMessage());
}

View File

@ -13,10 +13,13 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.pat.PersonalAccessTokenEntity;
import vip.mate.auth.pat.PersonalAccessTokenService;
import vip.mate.auth.service.AuthService;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
/**
* JWT 认证过滤器
@ -31,42 +34,86 @@ import java.util.List;
public class JwtAuthFilter extends OncePerRequestFilter {
private final AuthService authService;
/**
* RFC-03 Lane I1 Personal Access Token service for the headless /
* CI / SDK auth path. Optional in the constructor sense but Spring
* always injects since the bean is auto-discovered; declared as a
* required dependency so unit tests of this filter must wire it.
*/
private final PersonalAccessTokenService patService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (StringUtils.hasText(token)) {
try {
Claims claims = authService.parseClaims(token);
if (claims != null && SecurityContextHolder.getContext().getAuthentication() == null) {
String username = claims.getSubject();
UserEntity user = authService.findByUsername(username);
if (user != null && Boolean.TRUE.equals(user.getEnabled())) {
var auth = new UsernamePasswordAuthenticationToken(
username, null,
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
);
SecurityContextHolder.getContext().setAuthentication(auth);
// 滑动窗口续期Token 接近过期时自动签发新 Token
if (authService.isNearExpiry(claims)) {
String newToken = authService.renewToken(username);
if (newToken != null) {
response.setHeader("X-New-Token", newToken);
response.setHeader("Access-Control-Expose-Headers", "X-New-Token");
}
}
}
}
} catch (Exception ignored) {
// Token 解析失败继续匿名访问
if (StringUtils.hasText(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
// RFC-03 Lane I1: prefix-based dispatch PAT plaintext is observably
// "mc_*", JWTs always start with "eyJ" (header b64). Cheap O(1) check
// before the heavier work of parsing the token.
if (token.startsWith(PersonalAccessTokenService.PAT_PREFIX)) {
authenticateWithPat(token);
} else {
authenticateWithJwt(token, response);
}
}
filterChain.doFilter(request, response);
}
/**
* RFC-03 Lane I1 PAT auth path. Looks up the token by SHA-256 hash,
* loads the owning user, and stamps the SecurityContext identically to
* the JWT path so downstream {@code @PreAuthorize} / {@code Authentication}
* usages don't need to special-case PAT vs JWT auth.
*/
private void authenticateWithPat(String plaintext) {
try {
Optional<PersonalAccessTokenEntity> maybe = patService.findActiveByPlaintext(plaintext);
if (maybe.isEmpty()) return;
PersonalAccessTokenEntity pat = maybe.get();
UserEntity user = authService.findById(pat.getUserId());
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return;
var auth = new UsernamePasswordAuthenticationToken(
user.getUsername(), null,
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
);
SecurityContextHolder.getContext().setAuthentication(auth);
patService.recordUse(pat); // debounced inside the service
} catch (Exception ignored) {
// Anonymous fall-through same behavior as JWT parse failure.
}
}
/** Original JWT auth path, factored out to keep doFilterInternal flat. */
private void authenticateWithJwt(String token, HttpServletResponse response) {
try {
Claims claims = authService.parseClaims(token);
if (claims == null) return;
String username = claims.getSubject();
UserEntity user = authService.findByUsername(username);
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return;
var auth = new UsernamePasswordAuthenticationToken(
username, null,
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
);
SecurityContextHolder.getContext().setAuthentication(auth);
// 滑动窗口续期Token 接近过期时自动签发新 Token
if (authService.isNearExpiry(claims)) {
String newToken = authService.renewToken(username);
if (newToken != null) {
response.setHeader("X-New-Token", newToken);
response.setHeader("Access-Control-Expose-Headers", "X-New-Token");
}
}
} catch (Exception ignored) {
// Token 解析失败继续匿名访问
}
}
/**
* 从请求中提取 Token
* 优先从 Authorization Header 读取其次从 query param 读取用于 SSE

View File

@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
@ -39,4 +40,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
.allowCredentials(true)
.maxAge(3600);
}
/**
* Expose per-skill bundled assets (logos, screenshots, ...) at
* {@code /skill-assets/<skillName>/...}.
*
* <p>Source layout: {@code src/main/resources/skills/<name>/assets/<file>}.
* Built-in skills can ship icons / hero images alongside SKILL.md without
* polluting {@code src/main/resources/static/} (gitignored that path is
* the mateclaw-ui Vite build output).
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/skill-assets/**")
.addResourceLocations("classpath:/skills/")
.setCachePeriod(86400);
}
}

View File

@ -0,0 +1,123 @@
package vip.mate.cron;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.ChannelSessionStore;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.model.DeliveryConfig;
import java.util.List;
import java.util.Optional;
/**
* Single source of truth for the {@code conversationId} a cron run writes to.
* <p>
* Cron used to write every run to a per-job orphan conversation
* ({@code "cron_" + job.getId()}). Those rows existed in {@code mate_conversation}
* but had no entry in any sidebar the user had no way to reach them. The
* delivery pipeline ({@code CronResultDelivery}) covered the IM case (push
* back to DingTalk / Feishu / etc.) but Web-origin cron jobs ended up with
* {@code delivery_status='NONE'} and silent results.
* <p>
* The new policy:
* <ul>
* <li>Web-origin cron (no {@code channelId}) {@code "tasks_" + workspaceId}.
* A single workspace-scoped conversation pre-seeded as "📋 定时任务"
* (V65 migration). All Web cron output lands here so the user has one
* reliable place to look.</li>
* <li>IM-bound cron with an existing channel session that matches the
* delivery target the session's conversationId. This makes the cron
* output appear inline in the IM mirror conversation when the user
* opens the channel in Web Console, they see cron history alongside
* chat history, no separate inbox to check.</li>
* <li>IM-bound cron without a matching session yet (e.g. first run before
* the user has interacted with the channel) fall back to the old
* per-job {@code cron_<id>} conversation so push delivery still works
* and the run isn't lost.</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CronConversationResolver {
private final ChannelSessionStore channelSessionStore;
public String resolve(CronJobEntity job) {
if (job == null) return "tasks_1";
// IM-bound cron: try to thread output into the existing channel session
// so the IM mirror in Web Console shows it inline with regular chat.
if (job.getChannelId() != null) {
String sessionConvId = findChannelSessionConvId(job);
if (sessionConvId != null) return sessionConvId;
// No session yet keep the legacy per-job conversation so push
// delivery to the IM still works and the run isn't dropped.
return "cron_" + job.getId();
}
// Web-origin cron: unified per-workspace tasks conversation.
Long ws = job.getWorkspaceId() != null ? job.getWorkspaceId() : 1L;
return "tasks_" + ws;
}
/**
* Find the existing channel session for the cron's creator. Match
* priority most specific first:
* <ol>
* <li>{@code (channelId, dc.userId)} the senderId of who created
* this cron, captured by {@code CronJobTool.propagateChannelBinding}.
* This is the stable identifier across replyToken rotations.</li>
* <li>{@code (channelId, dc.targetId)} fallback for legacy rows that
* were written before the {@code userId} field was added (V62
* baseline / older). Matches when the channel adapter happens to
* use the same value for {@code session.targetId} and
* {@code dc.targetId} (Slack / Discord / Telegram). Will miss for
* DingTalk-style replyToken adapters but those rows will never
* have written a useful targetId match either, so behavior is
* no worse than before.</li>
* <li>If both miss, return null and fall back to {@code cron_<id>}.</li>
* </ol>
*/
private String findChannelSessionConvId(CronJobEntity job) {
DeliveryConfig dc = job.getDeliveryConfig();
if (dc == null) return null;
try {
List<ChannelSessionEntity> sessions = channelSessionStore.listByChannelId(job.getChannelId());
if (sessions.isEmpty()) return null;
// Preferred: match by creator's senderId (V63+ rows).
if (dc.userId() != null && !dc.userId().isBlank()) {
String byUser = sessions.stream()
.filter(s -> dc.userId().equals(s.getSenderId()))
.map(ChannelSessionEntity::getConversationId)
.findFirst()
.orElse(null);
if (byUser != null) return byUser;
}
// Fallback: legacy targetId match (works for non-replyToken adapters).
if (dc.targetId() != null && !dc.targetId().isBlank()) {
return sessions.stream()
.filter(s -> dc.targetId().equals(s.getTargetId()))
.map(ChannelSessionEntity::getConversationId)
.findFirst()
.orElse(null);
}
return null;
} catch (Exception e) {
log.debug("[CronConvResolver] session lookup failed for job {}: {}",
job.getId(), e.getMessage());
return null;
}
}
/** Reused by header insertion to know whether we are in the unified tasks view. */
public boolean isWebOriginTasksConv(String conversationId) {
return conversationId != null && conversationId.startsWith("tasks_");
}
}

View File

@ -0,0 +1,49 @@
package vip.mate.cron.config;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
/**
* RFC-03 Lane G2 distributed lock provider for the cron scheduler.
*
* <p>{@link CronJobService} runs in every node of a multi-instance
* deployment; without coordination, every node fires every {@code CronTrigger}
* tick, multiplying invocations and downstream side effects (channel
* messages, LLM calls, approval rows). ShedLock's JDBC mode reuses the
* existing application DataSource so we don't pull in Redis just for this
* one purpose.
*
* <p>Schema lives in {@code db/migration/{h2,mysql}/V74__shedlock_table.sql}.
*
* <p>Default {@code lockAtMostFor=PT30M} on the {@link EnableSchedulerLock}
* annotation is the safety net for a node that dies mid-execution after
* 30 min any other node can take the lock. Per-call {@code @SchedulerLock}
* annotations may shorten this for predictable workloads.
*
* <p>Single-node deployments (desktop / single docker container) are
* unaffected: the lock is acquired on the same node trivially.
*/
@Slf4j
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
log.info("[ShedLock] Initializing JDBC LockProvider for cron scheduling");
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.withTableName("shedlock")
.usingDbTime() // server-side NOW() avoids node clock drift
.build()
);
}
}

View File

@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.service.CronJobService;
import vip.mate.dashboard.model.ActiveCronRunVO;
import vip.mate.dashboard.service.CronJobRunService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
@ -23,6 +25,7 @@ import java.util.List;
public class CronJobController {
private final CronJobService cronJobService;
private final CronJobRunService cronJobRunService;
/**
* RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend
@ -92,6 +95,20 @@ public class CronJobController {
return R.ok();
}
/**
* Lightweight poll target for the chat console. Returns the cron job runs
* currently in {@code running} state for the given conversation, so the
* UI can render a "executing…" placeholder bubble between T1 (run row
* inserted) and T2 (assistant message persisted).
*/
@Operation(summary = "查询会话下正在执行的定时任务运行")
@GetMapping("/active-runs")
@RequireWorkspaceRole("viewer")
public R<List<ActiveCronRunVO>> activeRuns(
@RequestParam("conversationId") String conversationId) {
return R.ok(cronJobRunService.listActiveByConversation(conversationId));
}
private static long resolve(Long headerWorkspaceId) {
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
}

View File

@ -8,6 +8,7 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import vip.mate.audit.service.AuditEventService;
import vip.mate.cron.model.DeliveryConfig;
import java.util.List;
import java.util.Optional;
@ -40,6 +41,18 @@ public class CronDeliveryListener {
@Async("cronDeliveryExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onCompleted(CronJobCompletedEvent ev) {
// RFC-03 Lane C1: explicit silent mode short-circuits delivery
// resolution. Tools already executed, the run row is already
// persisted only the agent's narrative reply is withheld from
// the channel. Status row stays NONE (same as "no strategy
// matched") so dashboards keep one canonical "not delivered"
// bucket.
DeliveryConfig dc = ev.job() != null ? ev.job().getDeliveryConfig() : null;
if (dc != null && dc.isAgentReplySuppressed()) {
log.debug("[CronDelivery] Job {} suppressAgentReply=true; skipping delivery",
ev.job().getId());
return;
}
Optional<CronResultDelivery> strategy = deliveries.stream()
.filter(d -> d.supports(ev.job()))
.findFirst();

View File

@ -3,6 +3,7 @@ package vip.mate.cron.delivery;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import vip.mate.dashboard.model.CronJobRunEntity;
@ -39,7 +40,17 @@ public class CronRunStaleCleanup {
private static final Duration DELIVERY_STALE = Duration.ofMinutes(15);
private static final Duration RUN_STALE = Duration.ofMinutes(30);
/**
* RFC-03 Lane G2: in a multi-instance deployment, the sweep is purely
* idempotent (UPDATE with predicates) so duplicates would be harmless,
* but locking still saves N-1 nodes the DB roundtrips and keeps the
* dashboard counters honest. {@code lockAtMostFor} comfortably exceeds
* the worst-case sweep latency we have seen (1s).
*/
@Scheduled(fixedDelay = 5 * 60 * 1000L, initialDelay = 60 * 1000L)
@SchedulerLock(name = "cronRunStaleCleanup",
lockAtMostFor = "PT2M",
lockAtLeastFor = "PT30S")
public void sweep() {
LocalDateTime now = LocalDateTime.now();

View File

@ -33,10 +33,17 @@ public class CronJobEntity {
/** 关联 Agent ID */
private Long agentId;
/** 任务类型text | agent */
/**
* 任务类型
* <ul>
* <li>{@code text} single-turn LLM chat (uses {@code triggerMessage})</li>
* <li>{@code agent} Plan-Execute (uses {@code requestBody})</li>
* <li>{@code reminder} direct push of {@code triggerMessage}, no LLM call</li>
* </ul>
*/
private String taskType;
/** 触发消息task_type=text 时使用) */
/** 触发消息task_type=text 或 reminder 时使用) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String triggerMessage;

View File

@ -18,17 +18,81 @@ import vip.mate.agent.context.ChannelTarget;
public record DeliveryConfig(
@Nullable String targetId,
@Nullable String threadId,
@Nullable String accountId
@Nullable String accountId,
/**
* The IM senderId of the user who created this cron job. Used by
* {@code CronConversationResolver} to find that user's existing
* channel session matching by {@code targetId} alone is fragile
* because adapters that use a {@code replyToken} (DingTalk
* sessionWebhook etc.) store the token as session.targetId while
* the cron tool captures the message {@code chatId/senderId} as
* deliveryConfig.targetId. The two never match, the lookup always
* misses, and IM cron output never reaches the channel mirror
* conversation. Carrying senderId fixes that without coupling the
* resolver to per-channel reply-target conventions.
* <p>Nullable for backwards compat with rows written before this
* field was added (V62 baseline).
*/
@Nullable String userId,
/**
* RFC-03 Lane C1 when {@code TRUE}, {@code CronDeliveryListener}
* skips strategy resolution entirely and the run completes with
* {@code delivery_status='NONE'}. Tools still execute, the run row
* is still persisted, audit + token-usage all work only the
* agent's narrative reply is suppressed from the channel.
*
* <p>Use cases: noon health-check cron that just
* pokes a database and writes structured output, project-weekly
* report jobs that drop a file into a knowledge base, internal
* pipelines that don't need an IM-visible "I did the thing"
* trailing message.
*
* <p>{@code Boolean} (not {@code boolean}) so JSON deserialization
* of pre-V75 rows leaves the field {@code null} the listener
* treats {@code null} and {@code FALSE} identically (deliver as
* usual), preserving every existing job's behavior.
*/
@Nullable Boolean suppressAgentReply
) {
/** 3-arg legacy constructor preserved so older deserialized rows still work. */
public DeliveryConfig(@Nullable String targetId,
@Nullable String threadId,
@Nullable String accountId) {
this(targetId, threadId, accountId, null, null);
}
/** 4-arg legacy constructor — pre-RFC-03 callers that already carry userId. */
public DeliveryConfig(@Nullable String targetId,
@Nullable String threadId,
@Nullable String accountId,
@Nullable String userId) {
this(targetId, threadId, accountId, userId, null);
}
/** Convert from the {@link ChannelTarget} carried on a {@code ChatOrigin}. */
public static DeliveryConfig from(@Nullable ChannelTarget t) {
if (t == null) return null;
return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId());
return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId(), null, null);
}
/** Convert from {@link ChannelTarget} + the requester's senderId. */
public static DeliveryConfig from(@Nullable ChannelTarget t, @Nullable String userId) {
if (t == null) return null;
return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId(), userId, null);
}
/** Convert back to a {@link ChannelTarget} for ChatOrigin reconstruction. */
public ChannelTarget toChannelTarget() {
return new ChannelTarget(targetId, threadId, accountId);
}
/**
* RFC-03 Lane C1 convenience predicate so listeners can short-circuit
* delivery resolution without unwrapping the {@code Boolean}. Treats
* {@code null} as {@code false} (the historical default).
*/
public boolean isAgentReplySuppressed() {
return Boolean.TRUE.equals(suppressAgentReply);
}
}

View File

@ -13,6 +13,7 @@ import vip.mate.cron.delivery.CronJobCompletedEvent;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import vip.mate.i18n.I18nService;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.workspace.conversation.ConversationService;
@ -47,6 +48,7 @@ public class CronJobLifecycleService {
private final ConversationService conversationService;
private final ConversationCompletionPublisher completionPublisher;
private final ApplicationEventPublisher events;
private final I18nService i18n;
/**
* T1 short transaction: persist a run row in {@code running} state,
@ -57,25 +59,59 @@ public class CronJobLifecycleService {
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType) {
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType,
String conversationId) {
CronJobRunEntity run = new CronJobRunEntity();
run.setCronJobId(job.getId());
run.setConversationId("cron_" + job.getId());
run.setConversationId(conversationId);
run.setStatus("running");
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
run.setStartedAt(LocalDateTime.now());
run.setDeliveryStatus("NONE");
runMapper.insert(run);
// Self-heal the parent conversation row before saving messages.
// saveMessage only inserts message rows; if the conversation row is
// missing (e.g. tasks_<wsId> seed never ran on this DB, or was
// deleted manually) the messages become orphans the sidebar cannot
// surface. getOrCreateSharedConversation makes the row land with
// username=system so every workspace member sees it.
conversationService.getOrCreateSharedConversation(
conversationId, job.getAgentId(), job.getWorkspaceId());
// Cron-run header (system role) so users browsing the unified
// tasks_<wsId> conversation can tell which job's run starts here.
// Renderable as a divider card on the frontend; LLM history reads
// skip system messages so this doesn't pollute future prompts.
conversationService.saveMessage(conversationId, "system",
buildHeader(job, run));
// Persist the user message before the LLM call so history reads
// see a coherent (user assistant) ordering even if the agent
// throws mid-run.
if (userMessage != null && !userMessage.isBlank()) {
conversationService.saveMessage(run.getConversationId(), "user", userMessage);
conversationService.saveMessage(conversationId, "user", userMessage);
}
return run;
}
/**
* Format a cron-run header row. Pattern is parsed by the frontend
* MessageBubble which renders it as a labeled divider when role=system.
* Format: "📋 [{jobName}] {triggerType} · {timestamp}"
*/
private String buildHeader(CronJobEntity job, CronJobRunEntity run) {
String triggerKey = "manual".equalsIgnoreCase(run.getTriggerType())
? "cron.run_header.manual" : "cron.run_header.scheduled";
String triggerLabel = i18n != null ? i18n.msg(triggerKey)
: ("manual".equalsIgnoreCase(run.getTriggerType()) ? "manual" : "scheduled");
String fallbackTitle = i18n != null ? i18n.msg("cron.tasks_conversation.title") : "Scheduled Tasks";
return String.format("📋 %s · %s · %s",
job.getName() != null ? job.getName() : fallbackTitle,
triggerLabel,
run.getStartedAt());
}
/**
* T-fail short transaction: flag the run row as failed when the agent
* throws. Always-best-effort policy: delivery_status stays NONE; nothing
@ -100,8 +136,9 @@ public class CronJobLifecycleService {
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
String userMessage, AssistantMessage result) {
String convId = "cron_" + job.getId();
String userMessage, AssistantMessage result,
String conversationId) {
String convId = conversationId != null ? conversationId : run.getConversationId();
String text = result != null && result.getText() != null ? result.getText() : "";
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()

View File

@ -41,6 +41,7 @@ public class CronJobRunner {
private final CronJobLifecycleService lifecycle;
private final AgentService agentService;
private final CronChatOriginFactory originFactory;
private final vip.mate.cron.CronConversationResolver conversationResolver;
/**
* Scheduler-facing entry. Runs three logical segments:
@ -69,21 +70,54 @@ public class CronJobRunner {
? job.getRequestBody()
: job.getTriggerMessage();
// Resolve once and pass through the lifecycle. CronConversationResolver
// routes Web-origin jobs to tasks_<wsId> (single visible conversation
// per workspace) and IM-bound jobs to the channel session conversation
// when one already exists, so cron output appears where the user
// naturally looks rather than in an orphan cron_<id> row.
String conversationId = conversationResolver.resolve(job);
// T1 short tx
CronJobRunEntity run;
try {
run = lifecycle.startRun(job, userMessage, triggerType);
run = lifecycle.startRun(job, userMessage, triggerType, conversationId);
} catch (Exception e) {
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
return;
}
// task_type='reminder' pure notification, no LLM call. The user
// (or the create_reminder tool on their behalf) supplied the exact
// text they want pushed; running it through chat() only echoes /
// rephrases it ("收到提醒,请立即前往…"). Hand the trigger_message
// straight to T2 so the recipient sees the literal reminder.
// 'text' jobs still go through the LLM path below (they may need
// tool use, e.g. weather lookup, news search).
if ("reminder".equals(job.getTaskType())
&& job.getTriggerMessage() != null
&& !job.getTriggerMessage().isBlank()) {
try {
AssistantMessage direct = new AssistantMessage(job.getTriggerMessage());
lifecycle.finishRunAndPublish(job, run, userMessage, direct, conversationId);
} catch (Exception e) {
log.error("[CronRunner] reminder direct-push failed for job {}: {}",
job.getId(), e.getMessage(), e);
try {
lifecycle.markRunFailed(run, e);
} catch (Exception markErr) {
log.warn("[CronRunner] markRunFailed after reminder failure also failed for run {}: {}",
run.getId(), markErr.getMessage());
}
}
return;
}
// No-tx segment long LLM call. RFC §5.2 hard rule: must not hold
// any DB connection during this call.
AssistantMessage result;
try {
ChatOrigin origin = originFactory.from(job, "cron_" + job.getId());
result = runAgent(job, userMessage, origin);
ChatOrigin origin = originFactory.from(job, conversationId);
result = runAgent(job, userMessage, origin, conversationId);
} catch (Exception e) {
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
try {
@ -98,7 +132,7 @@ public class CronJobRunner {
// T2 short tx
try {
lifecycle.finishRunAndPublish(job, run, userMessage, result);
lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId);
} catch (Exception e) {
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
try {
@ -117,11 +151,12 @@ public class CronJobRunner {
* mateclaw cli to send to wechat") by telling the model that delivery is
* framework-handled.
*/
private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin) {
private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin,
String conversationId) {
String guarded = wrapWithDeliveryGuard(userMessage, origin);
String text = "agent".equals(job.getTaskType())
? agentService.execute(job.getAgentId(), guarded, "cron_" + job.getId(), origin)
: agentService.chat(job.getAgentId(), guarded, "cron_" + job.getId(), origin);
? agentService.execute(job.getAgentId(), guarded, conversationId, origin)
: agentService.chat(job.getAgentId(), guarded, conversationId, origin);
return new AssistantMessage(text != null ? text : "");
}

View File

@ -5,9 +5,13 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.scheduling.support.CronTrigger;
@ -21,16 +25,20 @@ import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
import vip.mate.exception.MateClawException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@ -48,6 +56,13 @@ public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper;
private final ChannelMapper channelMapper;
/**
* RFC-03 Lane G2: distributed lock for fire-time execution. ShedLock's
* JDBC provider is configured in {@link vip.mate.cron.config.ShedLockConfig}
* see also {@link #LOCK_AT_MOST_FOR} / {@link #LOCK_AT_LEAST_FOR}
* tuning notes on {@link #register}.
*/
private final LockProvider lockProvider;
/**
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
* (separate bean) so the three-segment transactional model in
@ -79,6 +94,36 @@ public class CronJobService implements ApplicationRunner {
private final ExecutorService cronExecutor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("cron-execute-", 0).factory());
/**
* Issue #50: cap concurrent cron run executions so that hundreds of jobs
* firing at the same minute boundary cannot exhaust the JDBC pool. Each
* run holds 3-4 connections in sequence (three REQUIRES_NEW segments in
* {@link CronJobLifecycleService} plus {@link #updateRunTimes}); without
* a limit, virtual threads launch unboundedly and starve the channel
* monitor / web traffic. Tuned alongside Hikari maximum-pool-size keep
* this value well below the pool size so non-cron paths still get
* connections.
*/
private static final int MAX_CONCURRENT_CRON_RUNS = 8;
private final Semaphore cronConcurrencyLimiter = new Semaphore(MAX_CONCURRENT_CRON_RUNS);
/**
* RFC-03 Lane G2: ShedLock duration tuning.
*
* <p>{@code lockAtMostFor} is the safety net for a node that crashes
* mid-execution after this window, any other node may take over the
* job's next tick. 30 minutes covers all observed cron run times
* (longest LLM-driven jobs in production sit around p99 8 min).
*
* <p>{@code lockAtLeastFor} prevents thundering-herd when a fast job
* (e.g. trivial SQL query that completes in <1s) finishes before its
* own next tick without it, the same node could fire twice per tick
* if its clock is slightly ahead. 30s gives every other node a chance
* to see the lock as held even for instant-finishing jobs.
*/
private static final Duration LOCK_AT_MOST_FOR = Duration.ofMinutes(30);
private static final Duration LOCK_AT_LEAST_FOR = Duration.ofSeconds(30);
// ==================== 初始化与销毁 ====================
/**
@ -181,6 +226,18 @@ public class CronJobService implements ApplicationRunner {
// toSpringCron 校验表达式合法性结果复用于后续 calcNextRunTime register
String springCron = toSpringCron(dto.getCronExpression());
// Issue #50: dedup by (workspace_id, agent_id, name). LLM-driven
// creators (CronJobTool) call this on every retry; without this guard
// a single instruction can produce N identical rows that all fire on
// the same tick. App-level check covers the common case; the unique
// index added in V67 protects against races (handled below).
CronJobEntity duplicate = findActiveDuplicate(workspaceId, dto.getAgentId(), dto.getName());
if (duplicate != null) {
log.info("[CronJob] create dedup hit: ws={} agent={} name={} → returning existing id={}",
workspaceId, dto.getAgentId(), dto.getName(), duplicate.getId());
return getById(duplicate.getId(), workspaceId);
}
CronJobEntity entity = dto.toEntity();
// RFC-083: workspace stamped server-side from X-Workspace-Id; never
// trust a client-supplied value (DTO.toEntity intentionally drops it).
@ -190,7 +247,18 @@ public class CronJobService implements ApplicationRunner {
if (entity.getEnabled() == null) entity.setEnabled(true);
entity.setNextRunTime(calcNextRunTime(springCron, entity.getTimezone()));
cronJobMapper.insert(entity);
try {
cronJobMapper.insert(entity);
} catch (DuplicateKeyException e) {
// Race: another concurrent create won. Re-fetch and return that one.
CronJobEntity raced = findActiveDuplicate(workspaceId, dto.getAgentId(), dto.getName());
if (raced != null) {
log.info("[CronJob] create race resolved: ws={} agent={} name={} → existing id={}",
workspaceId, dto.getAgentId(), dto.getName(), raced.getId());
return getById(raced.getId(), workspaceId);
}
throw e;
}
if (Boolean.TRUE.equals(entity.getEnabled())) {
// register() 内部会再次调用 toSpringCron但表达式已校验过不会抛异常
@ -200,6 +268,39 @@ public class CronJobService implements ApplicationRunner {
return getById(entity.getId(), workspaceId);
}
/**
* Issue #50: lookup existing active row for the dedup natural key.
* No {@code @TableLogic} on this entity {@code deleted=0} must be
* filtered explicitly.
*/
/**
* Issue #50 review #6 excluding-self variant for the update path.
* Same lookup as {@link #findActiveDuplicate} but skips the row
* currently being edited so a no-op save (same name, same agent)
* doesn't false-positive as a duplicate.
*/
private CronJobEntity findActiveDuplicateExcluding(Long workspaceId, Long agentId, String name, Long excludeId) {
if (workspaceId == null || agentId == null || name == null) return null;
LambdaQueryWrapper<CronJobEntity> q = new LambdaQueryWrapper<CronJobEntity>()
.eq(CronJobEntity::getWorkspaceId, workspaceId)
.eq(CronJobEntity::getAgentId, agentId)
.eq(CronJobEntity::getName, name)
.eq(CronJobEntity::getDeleted, 0);
if (excludeId != null) q.ne(CronJobEntity::getId, excludeId);
return cronJobMapper.selectOne(q);
}
private CronJobEntity findActiveDuplicate(Long workspaceId, Long agentId, String name) {
if (workspaceId == null || agentId == null || name == null) return null;
return cronJobMapper.selectOne(
new LambdaQueryWrapper<CronJobEntity>()
.eq(CronJobEntity::getWorkspaceId, workspaceId)
.eq(CronJobEntity::getAgentId, agentId)
.eq(CronJobEntity::getName, name)
.eq(CronJobEntity::getDeleted, 0)
.last("LIMIT 1"));
}
public CronJobDTO update(Long id, CronJobDTO dto, Long workspaceId) {
// RFC-083: scoped lookup cross-workspace updates 404 the same as
// deleted rows.
@ -210,6 +311,22 @@ public class CronJobService implements ApplicationRunner {
validateDto(dto);
String springCron = toSpringCron(dto.getCronExpression());
// Issue #50 review #6: excluding-self duplicate check. Without
// this, renaming a job onto an existing (workspace, agent, name)
// tuple surfaces as a raw DataIntegrityViolationException from
// the V69 unique index instead of a controlled validation
// error. Try app-level check first; the catch below is the
// race-protection net.
Long newAgentId = dto.getAgentId();
String newName = dto.getName();
if (newName != null && newAgentId != null) {
CronJobEntity collision = findActiveDuplicateExcluding(workspaceId, newAgentId, newName, id);
if (collision != null) {
throw new MateClawException("err.cron.duplicate_name",
"已存在同名定时任务: name=" + newName + ", agentId=" + newAgentId);
}
}
existing.setName(dto.getName());
existing.setCronExpression(dto.getCronExpression());
existing.setTimezone(dto.getTimezone() != null ? dto.getTimezone() : "Asia/Shanghai");
@ -222,7 +339,16 @@ public class CronJobService implements ApplicationRunner {
}
existing.setNextRunTime(calcNextRunTime(springCron, existing.getTimezone()));
cronJobMapper.updateById(existing);
try {
cronJobMapper.updateById(existing);
} catch (DuplicateKeyException e) {
// Race: another concurrent rename grabbed the natural key
// between our app-level check and the UPDATE. Translate to
// a clean validation error so the controller can 4xx instead
// of a 500 leaking the unique-key constraint name.
throw new MateClawException("err.cron.duplicate_name",
"已存在同名定时任务: name=" + dto.getName() + ", agentId=" + dto.getAgentId());
}
// 加锁保证 cancel + register 的原子性ReentrantLock 支持同线程重入
schedulerLock.lock();
@ -290,13 +416,7 @@ public class CronJobService implements ApplicationRunner {
// CronJobLifecycleService work as advertised. "manual" trigger type
// distinguishes this from scheduler-driven runs in mate_cron_job_run.
// Run on the virtual-thread cronExecutor never block the scheduler.
cronExecutor.submit(() -> {
try {
cronJobRunner.executeJob(entity, "manual");
} finally {
updateRunTimes(entity.getId(), entity.getCronExpression(), entity.getTimezone());
}
});
cronExecutor.submit(() -> runWithBackpressure(entity, "manual"));
}
// ==================== 调度器管理 ====================
@ -313,14 +433,12 @@ public class CronJobService implements ApplicationRunner {
// cronExecutor the LLM call must NOT run on a scheduler
// worker (4 concurrent long crons would otherwise saturate the
// pool and the 5th would miss its tick).
ScheduledFuture<?> future = scheduler.schedule(() ->
cronExecutor.submit(() -> {
try {
cronJobRunner.executeJob(job, "scheduled");
} finally {
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
}
}), trigger);
//
// RFC-03 Lane G2: tickWithDistributedLock wraps the offload in
// a ShedLock acquire/release so a multi-instance deployment
// fires each tick exactly once. See javadoc on that method.
ScheduledFuture<?> future = scheduler.schedule(
() -> tickWithDistributedLock(job), trigger);
scheduledTasks.put(job.getId(), future);
log.info("[CronJob] Registered job {} ({}) ws={}, cron={}, tz={}",
job.getId(), job.getName(), job.getWorkspaceId(),
@ -337,6 +455,48 @@ public class CronJobService implements ApplicationRunner {
}
}
/**
* RFC-03 Lane G2 distributed-lock-guarded tick fire.
*
* <p>Called on the scheduler thread when {@link CronTrigger} fires.
* Tries to acquire a ShedLock entry keyed by {@code "cron-job-{jobId}"};
* if another node holds it, returns immediately (silent skip siblings
* always see this for every tick, which is by design). On success,
* passes lock ownership into the virtual-thread executor so the work
* proceeds on {@link #cronExecutor} and the lock releases only after
* {@code runWithBackpressure} completes. {@link #LOCK_AT_MOST_FOR} is
* the safety net for a node that crashes mid-execution.
*
* <p>Package-private so unit tests can drive lock-acquisition outcomes
* without booting the full Spring context.
*/
void tickWithDistributedLock(CronJobEntity job) {
Long jobId = job.getId();
Optional<SimpleLock> maybeLock = lockProvider.lock(new LockConfiguration(
Instant.now(),
"cron-job-" + jobId,
LOCK_AT_MOST_FOR,
LOCK_AT_LEAST_FOR));
if (maybeLock.isEmpty()) {
log.debug("[CronJob] {} skipped — another node holds the tick lock", jobId);
return;
}
SimpleLock lock = maybeLock.get();
cronExecutor.submit(() -> {
try {
runWithBackpressure(job, "scheduled");
} finally {
try {
lock.unlock();
} catch (Exception unlockEx) {
// Lock will expire after LOCK_AT_MOST_FOR anyway; log + continue
// so a transient unlock failure doesn't taint the cron worker.
log.warn("[CronJob] {} lock release failed: {}", jobId, unlockEx.getMessage());
}
}
});
}
// ==================== 任务执行 ====================
//
// RFC-063r §2.7.1: the executeJob body moved to CronJobRunner so the
@ -351,6 +511,37 @@ public class CronJobService implements ApplicationRunner {
// see those methods above. The wrap below ensures next-run rolls forward
// regardless of run outcome.
/**
* Issue #50: gate every run on {@link #cronConcurrencyLimiter} so that a
* minute-boundary stampede of N enabled jobs cannot fan out into N
* simultaneous JDBC connection acquisitions. Excess runs queue on the
* virtual thread (cheap) instead of competing for the pool.
*
* <p>The {@code updateRunTimes} write is intentionally inside the
* permit's hold so the next-run pointer advances under the same
* backpressure budget otherwise a tail of bookkeeping writes could
* still pile up after the executor "finishes".
*/
private void runWithBackpressure(CronJobEntity job, String triggerType) {
try {
cronConcurrencyLimiter.acquire();
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("[CronJob] Interrupted while waiting to run job {} ({})",
job.getId(), triggerType);
return;
}
try {
cronJobRunner.executeJob(job, triggerType);
} finally {
try {
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
} finally {
cronConcurrencyLimiter.release();
}
}
}
/**
* 合并更新 lastRunTime nextRunTime单次 DB 写入替代原来的 4 selectById + updateById
*/
@ -467,7 +658,9 @@ public class CronJobService implements ApplicationRunner {
throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空");
}
String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text";
if ("text".equals(taskType) && (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) {
// 'text' (LLM chat) and 'reminder' (direct push) both rely on triggerMessage.
if (("text".equals(taskType) || "reminder".equals(taskType))
&& (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) {
throw new MateClawException("err.cron.trigger_required", "触发消息不能为空");
}
if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) {

View File

@ -0,0 +1,22 @@
package vip.mate.dashboard.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Read-model for the "is a cron job currently running in this conversation?"
* UI poll. Returned by {@code GET /api/v1/cron-jobs/active-runs} so the
* chat console can render a placeholder bubble between T1 (run row inserted)
* and T2 (assistant message persisted) see CronJobLifecycleService for the
* three-segment transactional flow.
*/
@Data
public class ActiveCronRunVO {
private Long runId;
private Long jobId;
private String jobName;
private String triggerType;
private String conversationId;
private LocalDateTime startedAt;
}

View File

@ -7,12 +7,15 @@ import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
import vip.mate.dashboard.model.ActiveCronRunVO;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@ -118,4 +121,47 @@ public class CronJobRunService {
.orderByDesc(CronJobRunEntity::getStartedAt)
.last("LIMIT " + limit));
}
/**
* Active runs (status='running') for one conversation, joined with the
* cron job name. Used by the chat console to render a placeholder bubble
* while the LLM is still thinking covers the gap between T1 (run row
* inserted, user message visible) and T2 (assistant message persisted),
* which can be 15 minutes when the agent does multi-iteration tool use.
*/
public List<ActiveCronRunVO> listActiveByConversation(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return Collections.emptyList();
}
List<CronJobRunEntity> runs = runMapper.selectList(
new LambdaQueryWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getConversationId, conversationId)
.eq(CronJobRunEntity::getStatus, "running")
.orderByAsc(CronJobRunEntity::getStartedAt));
if (runs.isEmpty()) return Collections.emptyList();
Set<Long> jobIds = runs.stream()
.map(CronJobRunEntity::getCronJobId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> jobNameById = new HashMap<>();
if (!jobIds.isEmpty()) {
cronJobMapper.selectList(
new LambdaQueryWrapper<CronJobEntity>()
.in(CronJobEntity::getId, jobIds)
.select(CronJobEntity::getId, CronJobEntity::getName))
.forEach(j -> jobNameById.put(j.getId(), j.getName()));
}
return runs.stream().map(r -> {
ActiveCronRunVO vo = new ActiveCronRunVO();
vo.setRunId(r.getId());
vo.setJobId(r.getCronJobId());
vo.setJobName(jobNameById.getOrDefault(r.getCronJobId(), ""));
vo.setTriggerType(r.getTriggerType());
vo.setConversationId(r.getConversationId());
vo.setStartedAt(r.getStartedAt());
return vo;
}).toList();
}
}

View File

@ -39,13 +39,18 @@ public class HookActionFactory {
case BUILTIN -> new BuiltinAction(
text(cfg, "op", "log.info"),
text(cfg, "arg", ""));
// RFC-03 Lane H1 hmacSecret + signatureHeader are optional; null /
// blank disables signing (legacy behavior). Receivers that need
// origin-verification re-compute SHA-256 HMAC on the raw body.
case HTTP -> new HttpAction(
sharedRestClient(),
text(cfg, "method", "POST"),
URI.create(required(cfg, "url")),
text(cfg, "body", null),
props.getTrustedDomains(),
timeoutMs);
timeoutMs,
text(cfg, "hmacSecret", null),
text(cfg, "signatureHeader", null));
case SHELL -> new ShellAction(required(cfg, "command"));
case CHANNEL_MESSAGE -> new ChannelMessageAction(
required(cfg, "channelType"),

View File

@ -7,7 +7,11 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import vip.mate.hook.event.MateHookEvent;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;
/**
@ -27,21 +31,43 @@ import java.util.List;
@Slf4j
public final class HttpAction implements HookAction {
/** RFC-03 Lane H1 default header name; configurable per hook so receivers
* with existing conventions (X-Hub-Signature-256, etc.) can be served
* without code changes. */
public static final String DEFAULT_SIGNATURE_HEADER = "X-MateClaw-Signature";
private final RestClient restClient;
private final String method; // GET | POST
private final URI url;
private final String bodyTemplate; // 可含 {{event.xxx}} 占位
private final List<String> trustedDomains;
private final long timeoutMs;
/** RFC-03 Lane H1 when set, the rendered body is signed with HMAC-SHA-256
* and the resulting hex digest is placed in the header named by
* {@link #signatureHeader}, prefixed with {@code "sha256="}. Receivers
* validate by re-computing the same digest from the raw body and a
* shared secret. Null / blank disables signing (the previous behavior). */
private final String hmacSecret;
private final String signatureHeader;
/** Legacy constructor — preserved for callers that don't sign. */
public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate,
List<String> trustedDomains, long timeoutMs) {
this(restClient, method, url, bodyTemplate, trustedDomains, timeoutMs, null, null);
}
public HttpAction(RestClient restClient, String method, URI url, String bodyTemplate,
List<String> trustedDomains, long timeoutMs,
String hmacSecret, String signatureHeader) {
this.restClient = restClient;
this.method = (method == null) ? "POST" : method.toUpperCase();
this.url = url;
this.bodyTemplate = bodyTemplate;
this.trustedDomains = List.copyOf(trustedDomains == null ? List.of() : trustedDomains);
this.timeoutMs = Math.max(100L, timeoutMs);
this.hmacSecret = (hmacSecret == null || hmacSecret.isBlank()) ? null : hmacSecret;
this.signatureHeader = (signatureHeader == null || signatureHeader.isBlank())
? DEFAULT_SIGNATURE_HEADER : signatureHeader;
}
@Override
@ -69,12 +95,24 @@ public final class HttpAction implements HookAction {
long start = System.nanoTime();
try {
String body = renderBody(event, ctx);
String renderedBody = body == null ? "" : body;
// RFC-03 Lane H1 sign the rendered body if a secret is configured.
// Computed once on the agreed-upon byte representation; receivers
// validate by re-computing on raw bytes before any JSON parsing.
String signature = (hmacSecret == null) ? null : hmacSign(renderedBody);
HttpStatusCode status = switch (method) {
case "GET" -> restClient.get().uri(url).retrieve().toBodilessEntity().getStatusCode();
case "POST" -> restClient.post().uri(url)
.contentType(MediaType.APPLICATION_JSON)
.body(body == null ? "" : body)
.retrieve().toBodilessEntity().getStatusCode();
case "GET" -> {
var spec = restClient.get().uri(url);
if (signature != null) spec.header(signatureHeader, signature);
yield spec.retrieve().toBodilessEntity().getStatusCode();
}
case "POST" -> {
var spec = restClient.post().uri(url)
.contentType(MediaType.APPLICATION_JSON)
.body(renderedBody);
if (signature != null) spec.header(signatureHeader, signature);
yield spec.retrieve().toBodilessEntity().getStatusCode();
}
default -> throw new IllegalStateException("unreachable");
};
long ms = (System.nanoTime() - start) / 1_000_000L;
@ -86,6 +124,26 @@ public final class HttpAction implements HookAction {
}
}
/**
* RFC-03 Lane H1 compute {@code "sha256=<hex>"} where {@code hex} is the
* lowercase HMAC-SHA-256 of {@code body} keyed by {@link #hmacSecret}.
* Format matches the GitHub / Stripe webhook convention so receivers can
* reuse off-the-shelf validators. Package-private for unit tests.
*/
String hmacSign(String body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
return "sha256=" + HexFormat.of().formatHex(digest);
} catch (Exception e) {
// HmacSHA256 is mandatory in every JDK; failure here is unrecoverable
// and points at JVM corruption rethrow as runtime so the action
// factory's validate() can surface a clear error before scheduling.
throw new IllegalStateException("HMAC-SHA-256 unavailable on this JVM", e);
}
}
private String renderBody(MateHookEvent event, HookContext ctx) {
if (bodyTemplate == null || bodyTemplate.isEmpty()) return null;
// 极简占位替换仅支持 {{event.type}} / {{event.timestamp}} + ctx.templateVars

View File

@ -5,6 +5,8 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
@ -110,7 +112,22 @@ public class ChatGPTChatModel implements ChatModel {
return null;
}
case "done" -> {
// 流结束如果没有任何 tool call 产生过 done event 但有未完成的忽略
// Emit a final empty-content ChatResponse carrying the
// usage metadata so NodeStreamingChatHelper can record
// per-turn token counts. Without this the OAuth path's
// turns log 0/0/0 (the client only knows about Usage
// when it's attached to a ChatResponse.metadata).
if (event.inputTokens() != null || event.outputTokens() != null
|| event.totalTokens() != null) {
int in = event.inputTokens() != null ? event.inputTokens() : 0;
int out = event.outputTokens() != null ? event.outputTokens() : 0;
int total = event.totalTokens() != null ? event.totalTokens() : (in + out);
Usage usage = new DefaultUsage(in, out, total);
Generation usageGen = new Generation(new AssistantMessage(""),
ChatGenerationMetadata.builder().finishReason("stop").build());
return new ChatResponse(List.of(usageGen),
ChatResponseMetadata.builder().model(model).usage(usage).build());
}
return null;
}
default -> { return null; }

View File

@ -37,12 +37,17 @@ public class ChatGPTResponsesClient {
/**
* 流式调用结果 包含文本增量和 tool call 事件
*/
public record StreamEvent(String type, String content, String toolCallId, String toolName, String toolArgsDelta) {
public static StreamEvent text(String delta) { return new StreamEvent("text", delta, null, null, null); }
public static StreamEvent toolCallStart(String callId, String name) { return new StreamEvent("tool_call_start", null, callId, name, null); }
public static StreamEvent toolCallArgsDelta(String callId, String delta) { return new StreamEvent("tool_call_args_delta", null, callId, null, delta); }
public static StreamEvent toolCallDone(String callId, String args) { return new StreamEvent("tool_call_done", null, callId, null, args); }
public static StreamEvent done() { return new StreamEvent("done", null, null, null, null); }
public record StreamEvent(String type, String content, String toolCallId, String toolName, String toolArgsDelta,
Integer inputTokens, Integer outputTokens, Integer totalTokens) {
public static StreamEvent text(String delta) { return new StreamEvent("text", delta, null, null, null, null, null, null); }
public static StreamEvent toolCallStart(String callId, String name) { return new StreamEvent("tool_call_start", null, callId, name, null, null, null, null); }
public static StreamEvent toolCallArgsDelta(String callId, String delta) { return new StreamEvent("tool_call_args_delta", null, callId, null, delta, null, null, null); }
public static StreamEvent toolCallDone(String callId, String args) { return new StreamEvent("tool_call_done", null, callId, null, args, null, null, null); }
public static StreamEvent done() { return new StreamEvent("done", null, null, null, null, null, null, null); }
/** Done event carrying token usage extracted from {@code response.completed.response.usage}. */
public static StreamEvent done(Integer in, Integer out, Integer total) {
return new StreamEvent("done", null, null, null, null, in, out, total);
}
}
/**
@ -281,8 +286,25 @@ public class ChatGPTResponsesClient {
return StreamEvent.toolCallDone(callId, args);
}
// 完成/结束
// 完成/结束 extract usage from response.completed so the agent
// pipeline can record per-turn token counts (otherwise OAuth turns
// get logged as 0/0/0). Field shape mirrors the upstream client:
// input_tokens prompt, output_tokens completion, total_tokens
// falls back to (input + output) when the API omits it.
if (type.startsWith("response.completed") || type.startsWith("response.done")) {
JsonNode usage = node.path("response").path("usage");
if (!usage.isMissingNode() && !usage.isNull()) {
Integer in = usage.has("input_tokens") ? usage.get("input_tokens").asInt() : null;
Integer out = usage.has("output_tokens") ? usage.get("output_tokens").asInt() : null;
Integer total = usage.has("total_tokens")
? usage.get("total_tokens").asInt()
: (in != null && out != null ? in + out : null);
if (in != null || out != null || total != null) {
log.debug("[ChatGPT] Usage in response.completed: input={}, output={}, total={}",
in, out, total);
return StreamEvent.done(in, out, total);
}
}
return StreamEvent.done();
}

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