release: v1.2.0
3
.gitignore
vendored
@ -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
|
||||
|
||||
34
README.md
@ -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 · $20–200/mo | Proprietary · $0–200/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
|
||||
|
||||
|
||||
34
README_zh.md
@ -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 |
|
||||
| **技术栈** | **Java(Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20–200/月 | 闭源 · $0–200/月 |
|
||||
|
||||
**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 上游集成。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
|
||||
@ -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 & 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 |
@ -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 |
@ -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 |
@ -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 |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 897 KiB |
@ -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
|
||||
#
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -0,0 +1,368 @@
|
||||
package vip.mate.acp.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — minimal Java ACP (Agent Communication Protocol)
|
||||
* client over stdio.
|
||||
*
|
||||
* <p>Implements just enough of the JSON-RPC 2.0 framing to:
|
||||
* <ol>
|
||||
* <li>Spawn the agent process ({@code command} + {@code args}).</li>
|
||||
* <li>Send {@code initialize} and capture {@code agentCapabilities}
|
||||
* / {@code protocolVersion}.</li>
|
||||
* <li>Optionally open a {@code session/new} handshake.</li>
|
||||
* <li>Tear the process down cleanly.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>This is intentionally a one-shot connection tester (RFC §10.2 Q3
|
||||
* recommended starting order: codex → claude → opencode → qwen). Full
|
||||
* bidirectional session prompting / streaming / permission requests is
|
||||
* a future increment — that needs a proper async bus and ties into the
|
||||
* agent graph layer.
|
||||
*
|
||||
* <p>Why not the official {@code acp} Python SDK: MateClaw runs on the
|
||||
* JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio; the
|
||||
* surface we need for "test connection" is small enough to implement
|
||||
* directly.
|
||||
*
|
||||
* <p>Each {@link AcpStdioClient} instance owns one Process. Use
|
||||
* try-with-resources or call {@link #close()} explicitly.
|
||||
*/
|
||||
@Slf4j
|
||||
public class AcpStdioClient implements AutoCloseable {
|
||||
|
||||
/** ACP protocol version we advertise (matches v1 ACP-compatible agents). */
|
||||
public static final int PROTOCOL_VERSION = 1;
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final Process process;
|
||||
private final Writer stdin;
|
||||
private final BufferedReader stdout;
|
||||
private final Thread readerThread;
|
||||
private final AtomicLong nextRequestId = new AtomicLong(1);
|
||||
private final Map<Long, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed = false;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC
|
||||
* notification (no id). Notification objects passed in have shape
|
||||
* {@code {jsonrpc, method, params}}; the most common is
|
||||
* {@code session/update} carrying agent message chunks.
|
||||
*
|
||||
* <p>Default no-op so existing test-only callers don't need to set
|
||||
* a handler. {@link AcpDelegationService} installs an accumulator
|
||||
* that scrapes {@code agent_message_chunk} text into a
|
||||
* {@code StringBuilder}.
|
||||
*/
|
||||
private volatile Consumer<JsonNode> notificationHandler = msg -> { /* drop */ };
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7b — invoked when the agent sends a JSON-RPC
|
||||
* request (has id). The handler returns the JSON-RPC
|
||||
* {@code result} object (or null to send back -32601 method-not-
|
||||
* implemented). Used for {@code session/request_permission};
|
||||
* trusted endpoints auto-allow, untrusted ones cancel.
|
||||
*/
|
||||
private volatile Function<JsonNode, JsonNode> requestHandler = msg -> null;
|
||||
|
||||
private AcpStdioClient(ObjectMapper mapper, Process process) {
|
||||
this.mapper = mapper;
|
||||
this.process = process;
|
||||
this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
|
||||
this.stdout = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
|
||||
this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
|
||||
this.readerThread.setDaemon(true);
|
||||
this.readerThread.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the configured agent process. Caller is responsible for
|
||||
* closing the returned client; failure to do so leaks a child
|
||||
* process.
|
||||
*/
|
||||
public static AcpStdioClient spawn(ObjectMapper mapper,
|
||||
String command,
|
||||
List<String> args,
|
||||
Map<String, String> envOverrides,
|
||||
String cwd)
|
||||
throws IOException {
|
||||
if (command == null || command.isBlank()) {
|
||||
throw new IllegalArgumentException("ACP command is required");
|
||||
}
|
||||
java.util.List<String> cmdline = new java.util.ArrayList<>();
|
||||
cmdline.add(command);
|
||||
if (args != null) cmdline.addAll(args);
|
||||
ProcessBuilder pb = new ProcessBuilder(cmdline);
|
||||
Map<String, String> env = pb.environment();
|
||||
if (envOverrides != null) env.putAll(envOverrides);
|
||||
if (cwd != null && !cwd.isBlank()) {
|
||||
pb.directory(new java.io.File(cwd));
|
||||
}
|
||||
// Keep stderr separate from stdout so we don't poison JSON-RPC
|
||||
// framing when the child agent writes a banner / log line.
|
||||
pb.redirectErrorStream(false);
|
||||
Process proc = pb.start();
|
||||
// Drain stderr in the background — many CLIs print diagnostics
|
||||
// there (e.g. Zed agents print version on startup).
|
||||
Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
|
||||
errDrain.setDaemon(true);
|
||||
errDrain.start();
|
||||
return new AcpStdioClient(mapper, proc);
|
||||
}
|
||||
|
||||
private static void drainStream(java.io.InputStream in) {
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
|
||||
}
|
||||
} catch (IOException ignore) {
|
||||
// Process exited; nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send {@code initialize} and wait for the response. Returns the
|
||||
* response payload's {@code result} object, or throws on protocol
|
||||
* mismatch / timeout.
|
||||
*/
|
||||
public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
|
||||
ObjectNode params = mapper.createObjectNode();
|
||||
params.put("protocolVersion", PROTOCOL_VERSION);
|
||||
// ClientCapabilities — we don't yet implement any client-side
|
||||
// optional features. Send an empty object so strict agents
|
||||
// don't reject the request.
|
||||
params.set("clientCapabilities", mapper.createObjectNode());
|
||||
ObjectNode info = mapper.createObjectNode();
|
||||
info.put("name", "mateclaw-acp-client");
|
||||
info.put("version", "1.0.0");
|
||||
params.set("clientInfo", info);
|
||||
return sendRequest("initialize", params, timeoutMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send {@code session/new} — establishes a session for prompting.
|
||||
* For the connection-test path we don't actually prompt, just
|
||||
* verify the server accepts the handshake.
|
||||
*
|
||||
* <p>The {@code cwd} parameter is always written into the request
|
||||
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
|
||||
* agent-acp} and the codex variant) marks {@code cwd} as a required
|
||||
* string and returns {@code -32602 Invalid params} when it's
|
||||
* missing. If the caller passes null/blank we substitute the JVM
|
||||
* working directory — a workspace-aware default lives in
|
||||
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
|
||||
* the protocol never sees {@code undefined} regardless of caller.
|
||||
*/
|
||||
public JsonNode newSession(String cwd, long timeoutMillis)
|
||||
throws IOException, InterruptedException {
|
||||
ObjectNode params = mapper.createObjectNode();
|
||||
String safeCwd = (cwd == null || cwd.isBlank())
|
||||
? System.getProperty("user.dir", ".")
|
||||
: cwd;
|
||||
params.put("cwd", safeCwd);
|
||||
params.set("mcpServers", mapper.createArrayNode());
|
||||
return sendRequest("session/new", params, timeoutMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-level request helper. Synchronously awaits the response
|
||||
* matching the request id. Server-pushed requests (e.g. permission
|
||||
* prompts) are dropped — the test-only connection path doesn't need
|
||||
* to handle them.
|
||||
*/
|
||||
public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
|
||||
throws IOException, InterruptedException {
|
||||
if (closed) throw new IOException("ACP client is closed");
|
||||
long id = nextRequestId.getAndIncrement();
|
||||
CompletableFuture<JsonNode> future = new CompletableFuture<>();
|
||||
pending.put(id, future);
|
||||
|
||||
ObjectNode envelope = mapper.createObjectNode();
|
||||
envelope.put("jsonrpc", "2.0");
|
||||
envelope.put("id", id);
|
||||
envelope.put("method", method);
|
||||
envelope.set("params", params);
|
||||
|
||||
synchronized (stdin) {
|
||||
stdin.write(mapper.writeValueAsString(envelope));
|
||||
stdin.write('\n');
|
||||
stdin.flush();
|
||||
}
|
||||
|
||||
try {
|
||||
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
|
||||
} catch (java.util.concurrent.ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof IOException io) throw io;
|
||||
throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
pending.remove(id);
|
||||
throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
private void readLoop() {
|
||||
try {
|
||||
String line;
|
||||
while (!closed && (line = stdout.readLine()) != null) {
|
||||
if (line.isEmpty()) continue;
|
||||
try {
|
||||
JsonNode msg = mapper.readTree(line);
|
||||
routeMessage(msg);
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP malformed line, skipping: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (!closed) {
|
||||
log.debug("ACP stdio reader closed: {}", e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
// If the process exited mid-await, fail every pending future.
|
||||
for (Map.Entry<Long, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
|
||||
entry.getValue().completeExceptionally(
|
||||
new IOException("ACP process exited before responding"));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void routeMessage(JsonNode msg) {
|
||||
JsonNode idNode = msg.get("id");
|
||||
boolean hasId = idNode != null && !idNode.isNull();
|
||||
boolean hasMethod = msg.has("method");
|
||||
|
||||
// (1) Response to one of *our* outbound requests.
|
||||
if (hasId && idNode.isNumber() && !hasMethod) {
|
||||
long id = idNode.asLong();
|
||||
CompletableFuture<JsonNode> future = pending.remove(id);
|
||||
if (future != null) {
|
||||
JsonNode error = msg.get("error");
|
||||
if (error != null && !error.isNull()) {
|
||||
future.completeExceptionally(
|
||||
new IOException("ACP error: " + error.toString()));
|
||||
} else {
|
||||
future.complete(msg.get("result"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Server-initiated request — has both method and id.
|
||||
if (hasMethod && hasId) {
|
||||
JsonNode result = null;
|
||||
try {
|
||||
result = requestHandler.apply(msg);
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP requestHandler threw on method '{}': {}",
|
||||
msg.path("method").asText(""), e.getMessage());
|
||||
}
|
||||
sendReplyTo(idNode, result, msg.path("method").asText(""));
|
||||
return;
|
||||
}
|
||||
|
||||
// (3) Notification — has method but no id.
|
||||
if (hasMethod) {
|
||||
try {
|
||||
notificationHandler.accept(msg);
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP notificationHandler threw on method '{}': {}",
|
||||
msg.path("method").asText(""), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendReplyTo(JsonNode idNode, JsonNode result, String method) {
|
||||
try {
|
||||
ObjectNode reply = mapper.createObjectNode();
|
||||
reply.put("jsonrpc", "2.0");
|
||||
reply.set("id", idNode);
|
||||
if (result != null) {
|
||||
reply.set("result", result);
|
||||
} else {
|
||||
ObjectNode error = mapper.createObjectNode();
|
||||
error.put("code", -32601);
|
||||
error.put("message", "Method not implemented: " + method);
|
||||
reply.set("error", error);
|
||||
}
|
||||
synchronized (stdin) {
|
||||
stdin.write(mapper.writeValueAsString(reply));
|
||||
stdin.write('\n');
|
||||
stdin.flush();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("ACP failed to reply to server-initiated request '{}': {}", method, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the notification handler. Pass {@code null} to fall back
|
||||
* to the no-op default.
|
||||
*/
|
||||
public void setNotificationHandler(Consumer<JsonNode> handler) {
|
||||
this.notificationHandler = handler != null ? handler : msg -> {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the server-request handler. Pass {@code null} to fall
|
||||
* back to the default which returns -32601 for every method.
|
||||
*/
|
||||
public void setRequestHandler(Function<JsonNode, JsonNode> handler) {
|
||||
this.requestHandler = handler != null ? handler : msg -> null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
try {
|
||||
stdin.close();
|
||||
} catch (IOException ignore) {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
// Give the agent ~1s to exit gracefully after EOF on stdin.
|
||||
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||
process.destroy();
|
||||
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
process.destroyForcibly();
|
||||
}
|
||||
try {
|
||||
stdout.close();
|
||||
} catch (IOException ignore) {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for callers that just want a fresh empty env map. */
|
||||
public static Map<String, String> emptyEnv() {
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.acp.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.service.AcpConnectionTester;
|
||||
import vip.mate.acp.service.AcpEndpointService;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — REST surface for managing ACP endpoints.
|
||||
*
|
||||
* <p>Mirrors the McpServers controller so the frontend page can be a
|
||||
* close cousin of {@code McpServers.vue}.
|
||||
*/
|
||||
@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acp/endpoints")
|
||||
@RequiredArgsConstructor
|
||||
public class AcpEndpointController {
|
||||
|
||||
private final AcpEndpointService service;
|
||||
private final AcpConnectionTester tester;
|
||||
|
||||
@Operation(summary = "List ACP endpoints")
|
||||
@GetMapping
|
||||
public R<List<AcpEndpointEntity>> list() {
|
||||
return R.ok(service.list());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get ACP endpoint by id")
|
||||
@GetMapping("/{id}")
|
||||
public R<AcpEndpointEntity> get(@PathVariable Long id) {
|
||||
return R.ok(service.get(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Create a custom ACP endpoint")
|
||||
@PostMapping
|
||||
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.create(body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Update an ACP endpoint")
|
||||
@PutMapping("/{id}")
|
||||
public R<AcpEndpointEntity> update(@PathVariable Long id,
|
||||
@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.update(id, body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
|
||||
@DeleteMapping("/{id}")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "Enable / disable an ACP endpoint")
|
||||
@PutMapping("/{id}/toggle")
|
||||
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
|
||||
@RequestParam boolean enabled) {
|
||||
return R.ok(service.toggle(id, enabled));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the configured CLI, run {@code initialize} + {@code
|
||||
* session/new}, persist the outcome, and return diagnostics.
|
||||
*/
|
||||
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
|
||||
@PostMapping("/{id}/test")
|
||||
public R<Map<String, Object>> test(@PathVariable Long id) {
|
||||
AcpEndpointEntity endpoint = service.get(id);
|
||||
return R.ok(tester.testEndpoint(endpoint));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package vip.mate.acp.event;
|
||||
|
||||
/**
|
||||
* Lifecycle event for ACP endpoint rows.
|
||||
*
|
||||
* <p>Published by {@code AcpEndpointService} whenever a row is created,
|
||||
* updated, toggled, or deleted. Listened to by
|
||||
* {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
|
||||
* skill cards and their wrapper tool registrations without a full
|
||||
* application restart.
|
||||
*
|
||||
* <p>Mirrors the {@code SkillWorkspaceEvent} pattern — a small immutable
|
||||
* record carrying just enough context for listeners to fan out.
|
||||
*/
|
||||
public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
|
||||
|
||||
public enum Type {
|
||||
/** Row inserted. */
|
||||
CREATED,
|
||||
/** Row attributes updated (command/args/env/etc.). */
|
||||
UPDATED,
|
||||
/** {@code enabled} flag flipped. */
|
||||
TOGGLED,
|
||||
/** Row deleted. */
|
||||
DELETED
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.acp.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — ACP (Agent Communication Protocol) endpoint registry.
|
||||
*
|
||||
* <p>Each row describes one external coding agent that MateClaw can
|
||||
* delegate to over stdio (codex / claude-code / opencode / qwen-code by
|
||||
* default). Bundled via Flyway V68 so the user only has to enable the
|
||||
* row once the matching CLI is on their PATH.
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_acp_endpoint")
|
||||
public class AcpEndpointEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** Stable slug, lowercase. Referenced by skill manifests via {@code type: acp} + {@code endpoint:}. */
|
||||
private String name;
|
||||
|
||||
private String displayName;
|
||||
private String description;
|
||||
|
||||
/** Process command, e.g. {@code npx} or {@code codex}. */
|
||||
private String command;
|
||||
|
||||
/**
|
||||
* JSON array of CLI args, e.g. {@code ["-y","@zed-industries/codex-acp"]}.
|
||||
* MyBatis Plus stores it as a string; the service layer parses on read.
|
||||
*/
|
||||
@TableField(value = "args_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String argsJson;
|
||||
|
||||
/** JSON object of environment variables to inject (merged onto System.getenv()). */
|
||||
@TableField(value = "env_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String envJson;
|
||||
|
||||
/**
|
||||
* call_title | call_detail | update_detail (mirrors the ACP
|
||||
* {@code tool_parse_mode} convention). Drives how the wrapper
|
||||
* renders ACP tool-call events into MateClaw's stream protocol.
|
||||
*/
|
||||
private String toolParseMode;
|
||||
|
||||
private Boolean builtin;
|
||||
/** When true, accept the agent's tool calls without re-prompting the user. */
|
||||
private Boolean trusted;
|
||||
private Boolean enabled;
|
||||
|
||||
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
|
||||
private Long stdioBufferLimitBytes;
|
||||
|
||||
/** UNKNOWN / OK / ERROR — last test result. */
|
||||
private String lastStatus;
|
||||
|
||||
private LocalDateTime lastTestedAt;
|
||||
@TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String lastError;
|
||||
|
||||
private Long workspaceId;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package vip.mate.acp.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — MyBatis Plus mapper for {@link AcpEndpointEntity}.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AcpEndpointMapper extends BaseMapper<AcpEndpointEntity> {
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.client.AcpStdioClient;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — connection tester for ACP endpoints.
|
||||
*
|
||||
* <p>Runs the {@code initialize} + {@code session/new} handshake, with
|
||||
* a generous-but-bounded timeout, and persists the outcome on the row.
|
||||
* The wired CLI doesn't have to be installed for the user to add a row;
|
||||
* they can install it later and re-run the test.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AcpConnectionTester {
|
||||
|
||||
/** Hard cap so a hung CLI doesn't block the request thread forever. */
|
||||
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AcpEndpointService endpointService;
|
||||
private final AcpRuntimeSupport runtimeSupport;
|
||||
|
||||
/**
|
||||
* Spawn the configured agent, exchange initialize + session/new,
|
||||
* tear it down, and return a structured result. The endpoint row
|
||||
* is updated with {@code last_status / last_tested_at / last_error}.
|
||||
*/
|
||||
public Map<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
|
||||
long started = System.currentTimeMillis();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("name", endpoint.getName());
|
||||
result.put("command", endpoint.getCommand());
|
||||
|
||||
List<String> args = endpointService.parseArgs(endpoint);
|
||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||
result.put("args", args);
|
||||
// Same as AcpDelegationService — Zed's ACP server requires a
|
||||
// non-blank cwd at session/new, so the connection test must
|
||||
// also default it. The "Test" button used to fail at session/new
|
||||
// with -32602 even when the CLI itself was healthy.
|
||||
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
|
||||
|
||||
AcpStdioClient client;
|
||||
try {
|
||||
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||
args, env, resolvedCwd);
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Spawn failed: " + e.getMessage(), started);
|
||||
}
|
||||
|
||||
try (AcpStdioClient autoClose = client) {
|
||||
JsonNode initResp;
|
||||
try {
|
||||
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Initialize failed: " + e.getMessage(), started);
|
||||
}
|
||||
if (initResp == null) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Initialize returned no result", started);
|
||||
}
|
||||
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
|
||||
result.put("protocolVersion", agentProtocolVersion);
|
||||
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
|
||||
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
|
||||
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
|
||||
return persistAndReturn(endpoint, result, "ERROR", msg, started);
|
||||
}
|
||||
// Capture agent capabilities for diagnostics — the UI can
|
||||
// surface this as "supports: file_system, terminal, …".
|
||||
JsonNode agentCaps = initResp.path("agentCapabilities");
|
||||
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
|
||||
result.put("agentCapabilities", agentCaps);
|
||||
}
|
||||
|
||||
// session/new validates that the agent really stands up a
|
||||
// working session, not just initialize handshake.
|
||||
try {
|
||||
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
|
||||
if (sessionResp != null && sessionResp.has("sessionId")) {
|
||||
result.put("sessionId", sessionResp.path("sessionId").asText(""));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// session/new may fail for legitimate reasons (e.g. agent
|
||||
// requires auth flow first). Still report OK on initialize
|
||||
// but flag in the message — translated when it smells
|
||||
// like an auth error so the test page UI shows actionable
|
||||
// text instead of raw JSON-RPC.
|
||||
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
|
||||
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Connection test crashed: " + e.getMessage(), started);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - started;
|
||||
result.put("elapsedMs", elapsed);
|
||||
return persistAndReturn(endpoint, result, "OK", null, started);
|
||||
}
|
||||
|
||||
private Map<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
|
||||
Map<String, Object> result,
|
||||
String status,
|
||||
String error,
|
||||
long started) {
|
||||
endpointService.recordTestResult(endpoint.getId(), status, error);
|
||||
result.put("status", status);
|
||||
if (error != null) result.put("error", error);
|
||||
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,241 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.client.AcpStdioClient;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7b — fire-and-forget delegation to an external ACP
|
||||
* agent.
|
||||
*
|
||||
* <p>One {@link #prompt(String, String, String)} call:
|
||||
* <ol>
|
||||
* <li>Looks up the endpoint row, refuses if disabled or undefined.</li>
|
||||
* <li>Spawns a fresh {@link AcpStdioClient} (no session caching in
|
||||
* v1 — stateless tool calls keep failure surface small;
|
||||
* multi-turn caching can be a follow-up RFC).</li>
|
||||
* <li>Runs {@code initialize → session/new → session/prompt}.</li>
|
||||
* <li>Accumulates {@code agent_message_chunk} text from
|
||||
* {@code session/update} notifications into the response.</li>
|
||||
* <li>Auto-allows or cancels {@code session/request_permission}
|
||||
* based on the endpoint's {@code trusted} flag — untrusted
|
||||
* endpoints reject every permission request, surfacing a
|
||||
* transparent "this endpoint can't be used non-interactively"
|
||||
* error to the LLM caller.</li>
|
||||
* <li>Returns the accumulated text or a JSON error blob on failure.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The streaming surface (chunk-by-chunk relay back through MateClaw's
|
||||
* own SSE stream) is intentionally not done yet — the wrapper tool is
|
||||
* synchronous so it composes cleanly with the existing ReAct graph.
|
||||
* When we want native streaming, we'll add a second method that takes
|
||||
* an {@code Sinks.Many<String>}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AcpDelegationService {
|
||||
|
||||
/** Hard ceiling on a single ACP delegation. Long enough for a
|
||||
* multi-turn coding session, short enough that a hung agent can't
|
||||
* permanently block an LLM tool call. */
|
||||
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
|
||||
|
||||
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AcpEndpointService endpointService;
|
||||
private final AcpRuntimeSupport runtimeSupport;
|
||||
|
||||
/**
|
||||
* Run a one-shot ACP prompt against {@code endpointName}. Returns
|
||||
* the agent's accumulated reply text. Throws
|
||||
* {@link MateClawException} for configuration / runtime errors so
|
||||
* the caller (typically a wrapper tool) can serialize a friendly
|
||||
* JSON error.
|
||||
*/
|
||||
public String prompt(String endpointName, String userPrompt, String cwdHint) {
|
||||
if (endpointName == null || endpointName.isBlank()) {
|
||||
throw new MateClawException("err.acp.endpoint_required",
|
||||
"ACP endpoint name is required");
|
||||
}
|
||||
if (userPrompt == null || userPrompt.isBlank()) {
|
||||
throw new MateClawException("err.acp.prompt_required",
|
||||
"ACP prompt is required");
|
||||
}
|
||||
|
||||
AcpEndpointEntity endpoint = endpointService.findByName(endpointName);
|
||||
if (endpoint == null) {
|
||||
throw new MateClawException("err.acp.endpoint_not_found",
|
||||
"ACP endpoint not found: " + endpointName);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(endpoint.getEnabled())) {
|
||||
throw new MateClawException("err.acp.endpoint_disabled",
|
||||
"ACP endpoint '" + endpointName + "' is disabled — enable it in Settings ▸ ACP Endpoints");
|
||||
}
|
||||
|
||||
List<String> args = endpointService.parseArgs(endpoint);
|
||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
|
||||
// Always resolve cwd to a real directory: Zed's ACP Zod schema
|
||||
// marks cwd as a required string and rejects {@code undefined}
|
||||
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
|
||||
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
|
||||
|
||||
StringBuilder accumulator = new StringBuilder();
|
||||
AcpStdioClient client;
|
||||
try {
|
||||
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||
args, env, resolvedCwd);
|
||||
} catch (IOException e) {
|
||||
throw new MateClawException("err.acp.spawn_failed",
|
||||
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
|
||||
}
|
||||
|
||||
try (AcpStdioClient autoClose = client) {
|
||||
wireHandlers(autoClose, accumulator, trusted, endpointName);
|
||||
|
||||
JsonNode initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
|
||||
if (initResp == null || initResp.path("protocolVersion").asInt(-1)
|
||||
!= AcpStdioClient.PROTOCOL_VERSION) {
|
||||
throw new MateClawException("err.acp.protocol_mismatch",
|
||||
"ACP protocol mismatch with endpoint '" + endpointName + "'");
|
||||
}
|
||||
|
||||
JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
|
||||
String sessionId = session == null ? null : session.path("sessionId").asText("");
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new MateClawException("err.acp.session_failed",
|
||||
"ACP session/new returned no sessionId for '" + endpointName + "'");
|
||||
}
|
||||
|
||||
ObjectNode promptParams = objectMapper.createObjectNode();
|
||||
promptParams.put("sessionId", sessionId);
|
||||
promptParams.set("prompt", buildPromptArray(userPrompt));
|
||||
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
|
||||
} catch (IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
|
||||
// Upstream CLIs (claude-code / codex / qwen-code) wrap their
|
||||
// own auth failures in opaque JSON-RPC noise. Recognise the
|
||||
// 401/403/forbidden/unauthorized fingerprints and rewrite
|
||||
// the message into something the user can act on, with the
|
||||
// exact env var name they need to set.
|
||||
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
|
||||
if (authHint != null) {
|
||||
throw new MateClawException("err.acp.auth_failed", authHint);
|
||||
}
|
||||
throw new MateClawException("err.acp.delegation_failed",
|
||||
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
return accumulator.toString().trim();
|
||||
}
|
||||
|
||||
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
|
||||
boolean trusted, String endpointName) {
|
||||
// Notifications carry session/update messages; agent_message_chunk
|
||||
// is what we accumulate. Other update kinds (tool_call_*, plan,
|
||||
// current_mode) are observed but not relayed in v1.
|
||||
client.setNotificationHandler(msg -> {
|
||||
String method = msg.path("method").asText("");
|
||||
if (!"session/update".equals(method)) return;
|
||||
JsonNode update = msg.path("params").path("update");
|
||||
if (update.isMissingNode() || update.isNull()) return;
|
||||
String type = update.path("sessionUpdate").asText(
|
||||
update.path("type").asText(""));
|
||||
if ("agent_message_chunk".equals(type) || "agent-message-chunk".equals(type)) {
|
||||
String text = extractText(update.path("content"));
|
||||
if (!text.isEmpty()) buf.append(text);
|
||||
}
|
||||
});
|
||||
|
||||
// Permission requests: trusted endpoints auto-allow the FIRST
|
||||
// option (which Zed-style agents make the "allow" choice);
|
||||
// untrusted refuse every request explicitly so the agent
|
||||
// exits cleanly instead of hanging.
|
||||
client.setRequestHandler(msg -> {
|
||||
String method = msg.path("method").asText("");
|
||||
if (!"session/request_permission".equals(method)) return null;
|
||||
JsonNode params = msg.path("params");
|
||||
if (!trusted) {
|
||||
log.info("[ACP] declining permission for untrusted endpoint '{}'", endpointName);
|
||||
return cancelledOutcome();
|
||||
}
|
||||
JsonNode options = params.path("options");
|
||||
String optionId = "";
|
||||
if (options.isArray() && options.size() > 0) {
|
||||
JsonNode first = options.get(0);
|
||||
optionId = first.path("optionId").asText(first.path("id").asText(""));
|
||||
}
|
||||
if (optionId.isEmpty()) {
|
||||
return cancelledOutcome();
|
||||
}
|
||||
return selectedOutcome(optionId);
|
||||
});
|
||||
}
|
||||
|
||||
private JsonNode buildPromptArray(String text) {
|
||||
// Spring AI / Zed ACP prompt format: array of content blocks.
|
||||
// For now we only emit a single text block; future iterations
|
||||
// can attach images / file references via additional blocks.
|
||||
var arr = objectMapper.createArrayNode();
|
||||
ObjectNode block = objectMapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", text);
|
||||
arr.add(block);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from an ACP {@code content} field. The shape
|
||||
* varies between agents — Zed uses {@code [{type:"text",text:"..."}]},
|
||||
* some emit a single object, others nest in {@code resource.text}.
|
||||
* Tolerant extractor that handles all known shapes.
|
||||
*/
|
||||
private String extractText(JsonNode content) {
|
||||
if (content == null || content.isNull()) return "";
|
||||
if (content.isArray()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (JsonNode item : content) sb.append(extractText(item));
|
||||
return sb.toString();
|
||||
}
|
||||
JsonNode text = content.get("text");
|
||||
if (text != null && text.isTextual()) return text.asText("");
|
||||
JsonNode resource = content.get("resource");
|
||||
if (resource != null) {
|
||||
JsonNode rt = resource.get("text");
|
||||
if (rt != null && rt.isTextual()) return rt.asText("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private ObjectNode selectedOutcome(String optionId) {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ObjectNode outcome = objectMapper.createObjectNode();
|
||||
outcome.put("outcome", "selected");
|
||||
outcome.put("optionId", optionId);
|
||||
result.set("outcome", outcome);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode cancelledOutcome() {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ObjectNode outcome = objectMapper.createObjectNode();
|
||||
outcome.put("outcome", "cancelled");
|
||||
result.set("outcome", outcome);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.event.AcpEndpointChangedEvent;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.repository.AcpEndpointMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — CRUD layer for {@link AcpEndpointEntity}.
|
||||
*
|
||||
* <p>Keeps three guarantees:
|
||||
* <ol>
|
||||
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted —
|
||||
* the user can only disable them. Mirrors {@code SkillService}.</li>
|
||||
* <li>Names are unique; {@code create} validates against the live
|
||||
* (non-deleted) set.</li>
|
||||
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
|
||||
* so the controller can hand structured data to the UI without
|
||||
* leaking string-encoded JSON.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AcpEndpointService {
|
||||
|
||||
private final AcpEndpointMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public List<AcpEndpointEntity> list() {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
.orderByDesc(AcpEndpointEntity::getBuiltin)
|
||||
.orderByAsc(AcpEndpointEntity::getName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of {@link #list()} that returns only enabled rows.
|
||||
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
|
||||
* (one per enabled endpoint).
|
||||
*/
|
||||
public List<AcpEndpointEntity> listEnabled() {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
.eq(AcpEndpointEntity::getEnabled, true)
|
||||
.orderByAsc(AcpEndpointEntity::getName));
|
||||
}
|
||||
|
||||
public AcpEndpointEntity get(Long id) {
|
||||
AcpEndpointEntity ep = mapper.selectById(id);
|
||||
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
|
||||
"ACP endpoint not found: " + id);
|
||||
return ep;
|
||||
}
|
||||
|
||||
public AcpEndpointEntity findByName(String name) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
.eq(AcpEndpointEntity::getName, name));
|
||||
}
|
||||
|
||||
public AcpEndpointEntity create(AcpEndpointEntity input) {
|
||||
if (input.getName() == null || input.getName().isBlank()) {
|
||||
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
|
||||
}
|
||||
if (input.getCommand() == null || input.getCommand().isBlank()) {
|
||||
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
|
||||
}
|
||||
if (findByName(input.getName()) != null) {
|
||||
throw new MateClawException("err.acp.name_exists",
|
||||
"ACP endpoint name already exists: " + input.getName());
|
||||
}
|
||||
// User-created rows are never builtin; default-enable false so a
|
||||
// misconfigured row can't auto-spawn a process at startup.
|
||||
input.setBuiltin(false);
|
||||
if (input.getEnabled() == null) input.setEnabled(false);
|
||||
if (input.getTrusted() == null) input.setTrusted(true);
|
||||
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
|
||||
input.setToolParseMode("call_title");
|
||||
}
|
||||
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
||||
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
||||
}
|
||||
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
||||
mapper.insert(input);
|
||||
log.info("Created ACP endpoint: {}", input.getName());
|
||||
publish(input, AcpEndpointChangedEvent.Type.CREATED);
|
||||
return input;
|
||||
}
|
||||
|
||||
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())
|
||||
&& patch.getCommand() != null
|
||||
&& !patch.getCommand().equals(existing.getCommand())) {
|
||||
throw new MateClawException("err.acp.builtin_command_locked",
|
||||
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
|
||||
}
|
||||
// Allow surgical updates: only fields the caller actually set.
|
||||
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
|
||||
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
|
||||
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
|
||||
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
|
||||
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
|
||||
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
|
||||
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
|
||||
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
|
||||
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
||||
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
||||
}
|
||||
mapper.updateById(existing);
|
||||
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
||||
return existing;
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||
throw new MateClawException("err.acp.builtin_readonly",
|
||||
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
|
||||
}
|
||||
mapper.deleteById(id);
|
||||
log.info("Deleted ACP endpoint: {}", existing.getName());
|
||||
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
|
||||
}
|
||||
|
||||
public AcpEndpointEntity toggle(Long id, boolean enabled) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
existing.setEnabled(enabled);
|
||||
mapper.updateById(existing);
|
||||
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
|
||||
return existing;
|
||||
}
|
||||
|
||||
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
|
||||
try {
|
||||
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
|
||||
ep.getId(), ep.getName(), type));
|
||||
} catch (Exception e) {
|
||||
// Listener failures must not break the CRUD path. The bridge
|
||||
// will resync on the next ApplicationReady tick anyway.
|
||||
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
|
||||
ep.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a connection-test outcome on the row. */
|
||||
public void recordTestResult(Long id, String status, String error) {
|
||||
AcpEndpointEntity existing = mapper.selectById(id);
|
||||
if (existing == null) return;
|
||||
existing.setLastStatus(status);
|
||||
existing.setLastTestedAt(LocalDateTime.now());
|
||||
existing.setLastError(error);
|
||||
mapper.updateById(existing);
|
||||
}
|
||||
|
||||
public List<String> parseArgs(AcpEndpointEntity ep) {
|
||||
return parseStringList(ep.getArgsJson());
|
||||
}
|
||||
|
||||
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
|
||||
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
|
||||
try {
|
||||
return objectMapper.readValue(ep.getEnvJson(),
|
||||
new TypeReference<Map<String, String>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
|
||||
ep.getName(), e.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseStringList(String json) {
|
||||
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse args_json: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.workspace.core.model.WorkspaceEntity;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Shared runtime helpers for ACP code paths.
|
||||
*
|
||||
* <p>Two responsibilities, both motivated by upstream ACP servers
|
||||
* (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
|
||||
* inputs and noisy in failure modes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #resolveCwd} — pick a non-blank cwd for {@code session/new}.
|
||||
* Zed's ACP Zod schema marks {@code cwd} as a required string and
|
||||
* returns {@code -32602 Invalid params} when it's missing. We
|
||||
* prefer the endpoint's bound workspace {@code base_path} (per-
|
||||
* workspace context) and fall back to the JVM working directory
|
||||
* only as a last resort. Never returns null/blank.</li>
|
||||
*
|
||||
* <li>{@link #translateAuthError} — turn upstream JSON-RPC noise like
|
||||
* {@code "API Error: 403 {...forbidden...}"} into an actionable
|
||||
* hint that names the env var the user actually has to set in
|
||||
* Settings ▸ ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
|
||||
* claude-code, {@code OPENAI_API_KEY} for codex). Returns
|
||||
* {@code null} when the error doesn't smell like an auth failure.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AcpRuntimeSupport {
|
||||
|
||||
private final WorkspaceService workspaceService;
|
||||
|
||||
/**
|
||||
* Resolution order (first non-blank wins):
|
||||
* <ol>
|
||||
* <li>Caller-provided hint (skill manifest's {@code acp.cwd},
|
||||
* wrapper tool {@code cwd} arg, or explicit override).</li>
|
||||
* <li>Workspace {@code base_path} when the endpoint is bound to a
|
||||
* workspace and the workspace declares one.</li>
|
||||
* <li>{@code System.getProperty("user.dir")} — the JVM working
|
||||
* directory at server launch. Reasonable for a single-user
|
||||
* desktop install, but exposes the server's launch dir to the
|
||||
* upstream agent, which is why it's last.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
|
||||
if (callerHint != null && !callerHint.isBlank()) {
|
||||
return callerHint;
|
||||
}
|
||||
if (endpoint != null && endpoint.getWorkspaceId() != null) {
|
||||
try {
|
||||
WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
|
||||
if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
|
||||
File f = new File(ws.getBasePath());
|
||||
if (f.isDirectory()) return f.getAbsolutePath();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
|
||||
endpoint.getWorkspaceId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return System.getProperty("user.dir", ".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect upstream auth errors and emit an actionable hint string.
|
||||
* Returns null when the message doesn't match — caller should keep
|
||||
* the original error as-is.
|
||||
*
|
||||
* <p>Heuristic: looks for HTTP-like 401/403 markers OR the words
|
||||
* {@code forbidden / unauthorized / not allowed / api key / token}
|
||||
* in the original message (case-insensitive). The patterns are loose
|
||||
* on purpose — different ACP CLIs phrase auth errors differently
|
||||
* and the cost of a false positive (a slightly more verbose error
|
||||
* banner) is much smaller than a false negative (user staring at a
|
||||
* raw JSON-RPC blob).
|
||||
*
|
||||
* <p>Special case: a claude-code endpoint returning {@code 403
|
||||
* "Request not allowed"} is almost always the keychain-hijack
|
||||
* scenario rather than a wrong API key. The third-party
|
||||
* {@code @zed-industries/claude-agent-acp} package wraps
|
||||
* {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
|
||||
* checks the macOS keychain ({@code Claude Code-credentials}) /
|
||||
* {@code ~/.claude/credentials.json} BEFORE the
|
||||
* {@code ANTHROPIC_API_KEY} env var. So a host that's done
|
||||
* {@code claude login} silently shadows whatever API key the user
|
||||
* configured in the endpoint env, and Anthropic's API rejects the
|
||||
* subscription OAuth token (first-party-only) with the very
|
||||
* specific {@code "Request not allowed"} error string. We detect
|
||||
* that exact combination and surface the keychain-clearing remedy
|
||||
* instead of the generic "set ANTHROPIC_API_KEY" hint, which
|
||||
* doesn't apply here.
|
||||
*/
|
||||
public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
|
||||
if (originalMessage == null) return null;
|
||||
String lower = originalMessage.toLowerCase(Locale.ROOT);
|
||||
boolean looksLikeAuth =
|
||||
lower.contains("403")
|
||||
|| lower.contains("401")
|
||||
|| lower.contains("forbidden")
|
||||
|| lower.contains("unauthorized")
|
||||
|| lower.contains("not allowed")
|
||||
|| lower.contains("invalid api key")
|
||||
|| lower.contains("invalid token")
|
||||
|| lower.contains("authenticate");
|
||||
if (!looksLikeAuth) return null;
|
||||
|
||||
String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
|
||||
String slug = lower(name);
|
||||
String command = endpoint != null ? lower(endpoint.getCommand()) : "";
|
||||
|
||||
// Keychain-hijack detection — must come before the generic env-
|
||||
// missing branch because both would superficially match.
|
||||
boolean keychainHijack = lower.contains("request not allowed")
|
||||
&& (slug.contains("claude") || command.contains("claude-agent-acp"));
|
||||
if (keychainHijack) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
|
||||
sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
|
||||
sb.append("credentials are hijacking the SDK auth path. ");
|
||||
sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
|
||||
sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
|
||||
sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
|
||||
sb.append("third-party processes aren't allowed to use it. ");
|
||||
sb.append("To fix: ");
|
||||
sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
|
||||
sb.append("-s \"Claude Code-credentials\"`; ");
|
||||
sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
|
||||
sb.append("Then click Test connection again. Original: ").append(originalMessage);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String envVar = expectedAuthEnvVar(endpoint);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
|
||||
sb.append("Most likely the endpoint env has no API key. ");
|
||||
sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
|
||||
if (envVar != null) {
|
||||
sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
|
||||
} else {
|
||||
sb.append("add the appropriate API key for this CLI");
|
||||
}
|
||||
sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
|
||||
sb.append("so a real API key is required. Original: ").append(originalMessage);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort guess of the API key env var the upstream CLI expects.
|
||||
* Returns null when we don't recognise the endpoint — caller emits a
|
||||
* generic "appropriate API key" hint instead.
|
||||
*/
|
||||
public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
|
||||
if (endpoint == null) return null;
|
||||
String name = lower(endpoint.getName());
|
||||
String command = lower(endpoint.getCommand());
|
||||
// Match by name first (slug is the stable identifier); fall back
|
||||
// to command keywords for user-defined rows.
|
||||
if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
|
||||
return "ANTHROPIC_API_KEY";
|
||||
}
|
||||
if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
|
||||
return "OPENAI_API_KEY";
|
||||
}
|
||||
if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
|
||||
return "DASHSCOPE_API_KEY";
|
||||
}
|
||||
if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
|
||||
return "GOOGLE_API_KEY";
|
||||
}
|
||||
// opencode multi-model — no single canonical env var.
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String lower(String s) {
|
||||
return s == null ? "" : s.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package vip.mate.activity;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.audit.model.AuditEventEntity;
|
||||
import vip.mate.audit.repository.AuditEventMapper;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 §4.5 / §7 — unified Activity feed.
|
||||
*
|
||||
* <p>Merges three sources into one chronologically-ordered stream:
|
||||
* <ul>
|
||||
* <li>{@code audit_event} — CRUD-style events on agents / channels /
|
||||
* skills / wiki / workspace (the existing audit log)</li>
|
||||
* <li>{@code tool_approval} — approval requests + their resolution
|
||||
* (granted / denied / expired). Ties tool gating decisions
|
||||
* directly to the audit timeline.</li>
|
||||
* <li>Successful tool calls — RFC §4.5 mentions these, but the
|
||||
* runtime doesn't yet persist a row per successful call.
|
||||
* Returning an empty bucket keeps the API contract stable so
|
||||
* the UI can light up automatically once a future commit adds
|
||||
* persistence.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Pagination is best-effort: each source is paged from index 0
|
||||
* up to {@code size * 2}, then the merged list is trimmed and offset
|
||||
* in-memory. For workspaces with >>1k events / day a follow-up should
|
||||
* push merging into SQL; this is good enough for v1.
|
||||
*/
|
||||
@Tag(name = "Activity Feed (RFC-090)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/activity")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityFeedController {
|
||||
|
||||
private final AuditEventService auditEventService;
|
||||
private final AuditEventMapper auditEventMapper;
|
||||
private final ToolApprovalMapper toolApprovalMapper;
|
||||
|
||||
/**
|
||||
* RFC-090 §4.5 — paginated activity feed.
|
||||
*
|
||||
* <p>Pagination strategy:
|
||||
* <ul>
|
||||
* <li><b>Single-source filter</b> (source=audit | approval) →
|
||||
* direct {@code BaseMapper.selectPage(...)} on the matching
|
||||
* table. Both total and records are SQL-accurate.</li>
|
||||
* <li><b>Combined feed</b> (source unset) → fetch
|
||||
* {@code page*size} rows from each side, merge by time-desc,
|
||||
* slice to the requested window. {@code total} is the sum
|
||||
* of {@code selectCount} across both tables — exact for
|
||||
* count, best-effort for time-merge ordering at very deep
|
||||
* page numbers (the merge buffer is bounded but typical
|
||||
* use stays within a few hundred rows).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Caps: {@code size} clamped to [1, 200]; {@code page} ≥ 1.
|
||||
*/
|
||||
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
|
||||
@GetMapping("/feed")
|
||||
public R<Map<String, Object>> feed(
|
||||
@RequestParam(required = false) Long workspaceId,
|
||||
@RequestParam(required = false) String source,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
if (size <= 0) size = 20;
|
||||
if (size > 200) size = 200;
|
||||
if (page <= 0) page = 1;
|
||||
|
||||
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
|
||||
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
|
||||
|
||||
// ───── Single-source path: direct SQL pagination ─────
|
||||
if (wantAudit && !wantApproval) {
|
||||
return R.ok(pageAuditOnly(workspaceId, page, size));
|
||||
}
|
||||
if (wantApproval && !wantAudit) {
|
||||
return R.ok(pageApprovalOnly(page, size));
|
||||
}
|
||||
|
||||
// ───── Combined path: per-source paginate + merge ─────
|
||||
// Fetch page*size from each side so the merged window contains
|
||||
// the requested slice even in the worst case where one source
|
||||
// dominates the timeline. This is wasteful at very deep pages
|
||||
// but bounded — a follow-up can push merging into SQL via a
|
||||
// UNION ALL view if event volume gets into 10k+/day territory.
|
||||
int bufferSize = Math.max(size * page, 50);
|
||||
|
||||
LambdaQueryWrapper<AuditEventEntity> auditQ = new LambdaQueryWrapper<AuditEventEntity>()
|
||||
.orderByDesc(AuditEventEntity::getCreateTime);
|
||||
if (workspaceId != null) auditQ.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||
IPage<AuditEventEntity> auditPage = auditEventMapper.selectPage(new Page<>(1, bufferSize), auditQ);
|
||||
|
||||
LambdaQueryWrapper<ToolApprovalEntity> approvalQ = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||
IPage<ToolApprovalEntity> approvalPage = toolApprovalMapper.selectPage(new Page<>(1, bufferSize), approvalQ);
|
||||
|
||||
List<ActivityRow> rows = new ArrayList<>();
|
||||
for (AuditEventEntity ev : auditPage.getRecords()) rows.add(fromAuditEvent(ev));
|
||||
for (ToolApprovalEntity ap : approvalPage.getRecords()) rows.add(fromApproval(ap));
|
||||
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
|
||||
long total = auditPage.getTotal() + approvalPage.getTotal();
|
||||
int from = Math.min((page - 1) * size, rows.size());
|
||||
int to = Math.min(from + size, rows.size());
|
||||
List<ActivityRow> sliced = rows.subList(from, to);
|
||||
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", total);
|
||||
resp.put("records", sliced);
|
||||
return R.ok(resp);
|
||||
}
|
||||
|
||||
/** Pure SQL pagination on the audit_event table; total + records both
|
||||
* come from the underlying {@link Page} object. */
|
||||
private Map<String, Object> pageAuditOnly(Long workspaceId, int page, int size) {
|
||||
LambdaQueryWrapper<AuditEventEntity> q = new LambdaQueryWrapper<AuditEventEntity>()
|
||||
.orderByDesc(AuditEventEntity::getCreateTime);
|
||||
if (workspaceId != null) q.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||
IPage<AuditEventEntity> p = auditEventMapper.selectPage(new Page<>(page, size), q);
|
||||
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
|
||||
for (AuditEventEntity ev : p.getRecords()) records.add(fromAuditEvent(ev));
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", p.getTotal());
|
||||
resp.put("records", records);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Pure SQL pagination on the tool_approval table. */
|
||||
private Map<String, Object> pageApprovalOnly(int page, int size) {
|
||||
LambdaQueryWrapper<ToolApprovalEntity> q = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||
IPage<ToolApprovalEntity> p = toolApprovalMapper.selectPage(new Page<>(page, size), q);
|
||||
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
|
||||
for (ToolApprovalEntity ap : p.getRecords()) records.add(fromApproval(ap));
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", p.getTotal());
|
||||
resp.put("records", records);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("detailJson", ev.getDetailJson());
|
||||
detail.put("userAgent", ev.getUserAgent());
|
||||
detail.put("workspaceId", ev.getWorkspaceId());
|
||||
return new ActivityRow(
|
||||
"audit-" + ev.getId(),
|
||||
"audit",
|
||||
ev.getCreateTime(),
|
||||
ev.getUsername(),
|
||||
ev.getAction(),
|
||||
ev.getResourceType(),
|
||||
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
|
||||
ev.getIpAddress(),
|
||||
detail);
|
||||
}
|
||||
|
||||
private ActivityRow fromApproval(ToolApprovalEntity ap) {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("toolArguments", ap.getToolArguments());
|
||||
detail.put("summary", ap.getSummary());
|
||||
detail.put("maxSeverity", ap.getMaxSeverity());
|
||||
detail.put("status", ap.getStatus());
|
||||
detail.put("resolvedAt", ap.getResolvedAt());
|
||||
// Map approval status onto an audit-style action so the UI's
|
||||
// existing action coloring (CREATE / DELETE / etc.) keeps
|
||||
// working without a special case.
|
||||
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
|
||||
return new ActivityRow(
|
||||
"approval-" + ap.getId(),
|
||||
"approval",
|
||||
ap.getCreatedAt(),
|
||||
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
|
||||
action,
|
||||
"TOOL_APPROVAL",
|
||||
ap.getToolName(),
|
||||
null,
|
||||
detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format row. Public record so Jackson serializes it directly
|
||||
* without needing a separate DTO.
|
||||
*/
|
||||
public record ActivityRow(
|
||||
String id,
|
||||
String source,
|
||||
LocalDateTime time,
|
||||
String username,
|
||||
String action,
|
||||
String resourceType,
|
||||
String resourceName,
|
||||
String ipAddress,
|
||||
Map<String, Object> detail
|
||||
) {}
|
||||
}
|
||||
@ -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。
|
||||
|
||||
@ -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-VL、GPT-4o、Gemini 等)才注入视频 Media。
|
||||
* 由 {@link ModelCapabilityService} 在 agent 构建时解析并注入到
|
||||
* {@link #modelCapabilities},per-model 粒度(区分如 glm-4v vs glm-4v-plus)。
|
||||
*/
|
||||
private boolean modelSupportsVideo() {
|
||||
if (modelName == null) return false;
|
||||
String n = modelName.toLowerCase();
|
||||
return (n.contains("qwen") && n.contains("vl"))
|
||||
|| n.contains("gpt-4o")
|
||||
|| n.contains("gemini")
|
||||
|| (n.contains("glm") && n.contains("v"));
|
||||
return modelCapabilities.contains(ModelCapabilityService.Modality.VIDEO);
|
||||
}
|
||||
|
||||
private boolean modelSupportsVision() {
|
||||
return modelCapabilities.contains(ModelCapabilityService.Modality.VISION);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -11,6 +11,7 @@ import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
@ -71,6 +72,21 @@ public class ConversationWindowManager {
|
||||
private static final int CONTENT_MAX = 6000;
|
||||
private static final int CONTENT_HEAD = 4000;
|
||||
private static final int CONTENT_TAIL = 1500;
|
||||
private static final int OLD_TOOL_RESULT_SUMMARY_THRESHOLD = 500;
|
||||
|
||||
/**
|
||||
* Tool names whose results must never be compacted into a one-line
|
||||
* summary. Sub-agent delegations are irreplaceable: the child runs an
|
||||
* independent LLM session that the parent cannot reproduce, so dropping
|
||||
* earlier batches forces the parent to re-dispatch the same children to
|
||||
* recover what was lost. Every other tool (read_file, shell, search,
|
||||
* memory) can be re-invoked cheaply if the parent decides it needs
|
||||
* the data again.
|
||||
*/
|
||||
private static final java.util.Set<String> PRUNE_EXEMPT_TOOLS = java.util.Set.of(
|
||||
"delegateToAgent",
|
||||
"delegateParallel"
|
||||
);
|
||||
|
||||
// ==================== 冷却机制 ====================
|
||||
|
||||
@ -116,9 +132,27 @@ public class ConversationWindowManager {
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId, Long agentId) {
|
||||
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||
maxInputTokens, chatModel, conversationId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as the 7-arg overload but additionally accounts for the tool
|
||||
* definitions sent on every LLM call. Without {@code toolCallbacks},
|
||||
* the budget calculation underestimates the actual request size by the
|
||||
* full size of the tools schema (often several thousand tokens for
|
||||
* agents bound to multiple MCP servers), making compression fire too
|
||||
* late and producing HTTP 400 once the request hits the model.
|
||||
*/
|
||||
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId, Long agentId,
|
||||
java.util.Collection<ToolCallback> toolCallbacks) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
messages = pruneOldToolResultsForModelInput(messages);
|
||||
|
||||
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
|
||||
? maxInputTokens : properties.getDefaultMaxInputTokens();
|
||||
@ -127,20 +161,21 @@ public class ConversationWindowManager {
|
||||
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
|
||||
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
|
||||
int historyTokens = TokenEstimator.estimateTokens(messages);
|
||||
int totalTokens = systemTokens + currentMsgTokens + historyTokens;
|
||||
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
|
||||
int totalTokens = systemTokens + currentMsgTokens + historyTokens + toolsTokens;
|
||||
|
||||
if (totalTokens <= triggerThreshold) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conv={}",
|
||||
totalTokens, systemTokens, currentMsgTokens, historyTokens,
|
||||
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}, tools={}) > {} 触发阈值 (max={}), conv={}",
|
||||
totalTokens, systemTokens, currentMsgTokens, historyTokens, toolsTokens,
|
||||
triggerThreshold, effectiveMax, conversationId);
|
||||
|
||||
evictExpiredEntries();
|
||||
|
||||
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
|
||||
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
|
||||
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
|
||||
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
|
||||
// RFC-025 Change 1: reserve 硬封顶到 effectiveMax 的 50%。
|
||||
// 小上下文模型(Ollama 16K、本地 8K)下,systemTokens + currentMsgTokens 很容易
|
||||
// 接近或超过 effectiveMax,不封顶会让 historyBudget 变负数导致死循环压缩
|
||||
@ -339,6 +374,85 @@ public class ConversationWindowManager {
|
||||
|
||||
// ==================== 工具结果处理 ====================
|
||||
|
||||
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
|
||||
int latestToolResponseIndex = -1;
|
||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage) {
|
||||
latestToolResponseIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (latestToolResponseIndex <= 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
List<Message> pruned = new ArrayList<>(messages);
|
||||
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
|
||||
int changed = 0;
|
||||
for (int i = pruned.size() - 1; i >= 0; i--) {
|
||||
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
|
||||
continue;
|
||||
}
|
||||
boolean keepFull = i == latestToolResponseIndex;
|
||||
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
|
||||
boolean messageChanged = false;
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
String data = r.responseData();
|
||||
boolean exempt = r.name() != null && PRUNE_EXEMPT_TOOLS.contains(r.name());
|
||||
if (keepFull || exempt || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
|
||||
newResponses.add(r);
|
||||
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
|
||||
seenLargeOutputs.add(data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String replacement;
|
||||
if (seenLargeOutputs.contains(data)) {
|
||||
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
|
||||
} else {
|
||||
replacement = summarizeToolResponse(r.name(), data);
|
||||
seenLargeOutputs.add(data);
|
||||
}
|
||||
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
|
||||
messageChanged = true;
|
||||
}
|
||||
if (messageChanged) {
|
||||
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (changed > 0) {
|
||||
log.info("[ConversationWindow] Pruned {} older tool response message(s) before model request", changed);
|
||||
}
|
||||
return changed > 0 ? pruned : messages;
|
||||
}
|
||||
|
||||
private static String summarizeToolResponse(String toolName, String data) {
|
||||
int chars = data.length();
|
||||
int lines = data.isBlank() ? 0 : data.split("\\R", -1).length;
|
||||
String firstLine = firstNonBlankLine(data);
|
||||
if (firstLine.length() > 160) {
|
||||
firstLine = firstLine.substring(0, 160) + "...";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append('[').append(toolName).append("] previous tool output summarized for model context: ")
|
||||
.append(chars).append(" chars, ").append(lines).append(" lines");
|
||||
if (!firstLine.isBlank()) {
|
||||
sb.append(". First line: ").append(firstLine);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String firstNonBlankLine(String data) {
|
||||
for (String line : data.split("\\R")) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isBlank()) {
|
||||
return trimmed.replace('|', '/');
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
*/
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -21,6 +24,13 @@ public final class TokenEstimator {
|
||||
/** 每条消息的固定开销 token(role 标记、分隔符等) */
|
||||
static final int PER_MESSAGE_OVERHEAD = 4;
|
||||
|
||||
/**
|
||||
* Per-tool wrapper overhead: function/type:object boilerplate, name and
|
||||
* description framing, parameters key, and JSON braces around the schema.
|
||||
* Conservative — slightly overestimates so budget guards don't underrun.
|
||||
*/
|
||||
static final int PER_TOOL_OVERHEAD = 12;
|
||||
|
||||
private TokenEstimator() {
|
||||
}
|
||||
|
||||
@ -71,6 +81,38 @@ public final class TokenEstimator {
|
||||
.sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the token cost of the tool definitions sent on every LLM call
|
||||
* (name + description + JSON inputSchema, plus per-tool wrapper overhead).
|
||||
* <p>
|
||||
* A heavily-bound agent (multiple MCP servers, many built-ins) can carry
|
||||
* several thousand tokens of tool schema on every request — leaving them
|
||||
* out of the context-window budget makes compression decisions fire too
|
||||
* late and on small models triggers HTTP 400 once the request actually
|
||||
* goes out.
|
||||
*/
|
||||
public static int estimateToolsTokens(Collection<ToolCallback> callbacks) {
|
||||
if (callbacks == null || callbacks.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (ToolCallback cb : callbacks) {
|
||||
if (cb == null) continue;
|
||||
ToolDefinition def;
|
||||
try {
|
||||
def = cb.getToolDefinition();
|
||||
} catch (Exception e) {
|
||||
continue;
|
||||
}
|
||||
if (def == null) continue;
|
||||
total += estimateTokens(def.name())
|
||||
+ estimateTokens(def.description())
|
||||
+ estimateTokens(def.inputSchema())
|
||||
+ PER_TOOL_OVERHEAD;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CJK 字符(中日韩统一表意文字 + 常用标点)
|
||||
*/
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* REST surface for managing live sub-agents:
|
||||
* <ul>
|
||||
* <li>{@code POST /interrupt} — stop a running sub-agent.</li>
|
||||
* <li>{@code POST /spawn-pause} — toggle the per-parent spawn-pause flag.</li>
|
||||
* <li>{@code GET /active} — list sub-agents under one parent conversation.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Every endpoint authorizes the caller against the parent conversation's
|
||||
* owner before mutating or revealing anything; the {@code parentConversationId}
|
||||
* query parameter on {@code /active} is mandatory so the route cannot be used
|
||||
* to enumerate cross-tenant sub-agents.
|
||||
*
|
||||
* <p>Authorization mirrors the {@link vip.mate.workspace.conversation.ConversationService#isConversationOwner}
|
||||
* pattern used by the chat stop / fork routes — usernames are the principal
|
||||
* identity carried on {@link Authentication#getName()}, and shared "system"
|
||||
* conversations are accessible to all logged-in users (matches the existing
|
||||
* cron-job convention).
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Sub-agents")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/subagents")
|
||||
@RequiredArgsConstructor
|
||||
public class SubagentController {
|
||||
|
||||
private final SubagentRegistry registry;
|
||||
private final ConversationService conversationService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Resolve the record and verify the caller owns its parent conversation.
|
||||
* Throws a 403-coded exception when ownership fails so the global handler
|
||||
* can render a uniform JSON error envelope.
|
||||
*/
|
||||
private SubagentRegistry.SubagentRecord requireOwnership(String subagentId, Authentication auth) {
|
||||
Optional<SubagentRegistry.SubagentRecord> opt = registry.get(subagentId);
|
||||
if (opt.isEmpty()) {
|
||||
throw new MateClawException(404, "subagent " + subagentId + " not found");
|
||||
}
|
||||
SubagentRegistry.SubagentRecord rec = opt.get();
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(rec.parentConversationId(), username)) {
|
||||
// Audit denial separately from the operation itself so admins can
|
||||
// see what cross-tenant attempts hit the registry. Best-effort
|
||||
// serialization — the audit insert is async on the service side.
|
||||
auditEventService.record("subagent.interrupt.denied", "subagent",
|
||||
subagentId, rec.subagentId(),
|
||||
safeJson(Map.of(
|
||||
"callerUsername", username,
|
||||
"parent", rec.parentConversationId(),
|
||||
"agentId", rec.agentId() == null ? -1L : rec.agentId()
|
||||
)));
|
||||
throw new MateClawException(403, "not the owner of subagent's parent conversation");
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running sub-agent. The registry flips status to {@code interrupted}
|
||||
* and disposes the streaming subscription if one was registered. Returns
|
||||
* the {@code interrupted} flag so the caller can distinguish "we did stop
|
||||
* something" from "the subagent was already finished" (404 case is handled
|
||||
* separately by {@link #requireOwnership}).
|
||||
*/
|
||||
@Operation(summary = "Interrupt a running sub-agent")
|
||||
@PostMapping("/{subagentId}/interrupt")
|
||||
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
|
||||
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
|
||||
boolean ok = registry.interrupt(subagentId);
|
||||
auditEventService.record("subagent.interrupt", "subagent",
|
||||
subagentId, rec.subagentId(),
|
||||
safeJson(Map.of(
|
||||
"by", currentUsername(auth),
|
||||
"parent", rec.parentConversationId(),
|
||||
"result", ok
|
||||
)));
|
||||
return R.ok(Map.of("interrupted", ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether new sub-agent spawns are accepted under a parent
|
||||
* conversation. Used by the operator UI to halt runaway parent agents
|
||||
* mid-turn without killing the parent's own LLM call.
|
||||
*/
|
||||
@Operation(summary = "Set sub-agent spawn-pause for a conversation")
|
||||
@PostMapping("/spawn-pause")
|
||||
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
|
||||
Object parentObj = body == null ? null : body.get("parentConversationId");
|
||||
String parent = parentObj == null ? null : parentObj.toString();
|
||||
if (parent == null || parent.isBlank()) {
|
||||
throw new MateClawException(400, "parentConversationId required");
|
||||
}
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(parent, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parent);
|
||||
}
|
||||
boolean paused = Boolean.TRUE.equals(body.get("paused"));
|
||||
registry.setSpawnPaused(parent, paused);
|
||||
auditEventService.record("subagent.spawn-pause", "conversation",
|
||||
parent, parent,
|
||||
safeJson(Map.of(
|
||||
"paused", paused,
|
||||
"by", username
|
||||
)));
|
||||
return R.ok(Map.of("paused", paused));
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sub-agents currently active under {@code parentConversationId}.
|
||||
* The query parameter is mandatory: returning all subagents process-wide
|
||||
* would let any logged-in user enumerate other tenants' delegation trees.
|
||||
*/
|
||||
@Operation(summary = "List active sub-agents under a parent conversation")
|
||||
@GetMapping("/active")
|
||||
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
|
||||
Authentication auth) {
|
||||
if (parentConversationId == null || parentConversationId.isBlank()) {
|
||||
throw new MateClawException(400, "parentConversationId required");
|
||||
}
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(parentConversationId, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
|
||||
}
|
||||
List<Map<String, Object>> snapshot = registry.snapshot(parentConversationId).stream()
|
||||
.map(this::toResponseDto)
|
||||
.toList();
|
||||
return R.ok(Map.of("subagents", snapshot));
|
||||
}
|
||||
|
||||
/** Username from auth context; falls back to "anonymous" only when null. */
|
||||
private String currentUsername(Authentication auth) {
|
||||
return auth != null ? auth.getName() : "anonymous";
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO projection that drops the {@link reactor.core.Disposable} (not
|
||||
* serializable to the wire) and exposes only the user-facing fields.
|
||||
*/
|
||||
private Map<String, Object> toResponseDto(SubagentRegistry.SubagentRecord rec) {
|
||||
Map<String, Object> dto = new LinkedHashMap<>();
|
||||
dto.put("subagentId", rec.subagentId());
|
||||
dto.put("parentConversationId", rec.parentConversationId());
|
||||
dto.put("childConversationId", rec.childConversationId());
|
||||
dto.put("agentId", rec.agentId());
|
||||
dto.put("goal", rec.goal());
|
||||
dto.put("startedAt", rec.startedAt());
|
||||
dto.put("status", rec.status().get());
|
||||
dto.put("toolCount", rec.toolCount().get());
|
||||
dto.put("lastTool", rec.lastTool().get());
|
||||
dto.put("currentPhase", rec.currentPhase().get());
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON serialization for audit detail. Falling back to a
|
||||
* marker string keeps the audit row insertable when payload contains
|
||||
* a non-serializable value — the alternative (throwing) would lose the
|
||||
* audit record entirely.
|
||||
*/
|
||||
private String safeJson(Map<String, Object> payload) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{\"error\":\"audit_serialization_failed\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Periodic watchdog that flips a sub-agent's status to {@code stale} when its
|
||||
* child stream stops making observable progress.
|
||||
*
|
||||
* <p>Progress is probed via {@link ChatStreamTracker#getRunningToolName} and
|
||||
* {@link ChatStreamTracker#getCurrentPhase}. When neither has changed across
|
||||
* the configured number of cycles, the record is marked stale and a
|
||||
* {@code subagent_stale} event is broadcast on the parent conversation so the
|
||||
* UI can surface the issue. Cycle count uses two separate thresholds — one
|
||||
* for idle children and one for children mid-tool — because legitimately slow
|
||||
* tools (large file scans, slow LLM calls) need a longer window than an idle
|
||||
* model that has simply gone quiet.
|
||||
*
|
||||
* <p>The runtime tool name + phase combination is a deliberately coarse
|
||||
* progress signal: it does not require introspecting LLM token deltas, which
|
||||
* keeps the watchdog cheap and avoids racing with the streaming hot path.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SubagentHeartbeat {
|
||||
|
||||
private final SubagentRegistry registry;
|
||||
private final SubagentHeartbeatConfig cfg;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
|
||||
/**
|
||||
* Scheduled tick. Defaults to every 30 s; controlled by
|
||||
* {@code mateclaw.delegation.heartbeat.intervalSec}.
|
||||
*/
|
||||
@Scheduled(fixedRateString = "#{@subagentHeartbeatConfig.intervalSec * 1000L}")
|
||||
public void check() {
|
||||
for (var rec : registry.allActive()) {
|
||||
if (!"running".equals(rec.status().get())) {
|
||||
continue;
|
||||
}
|
||||
evaluate(rec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible for testing — apply one heartbeat tick to a single record so
|
||||
* tests can drive the watchdog deterministically without scheduling.
|
||||
*/
|
||||
void evaluate(SubagentRegistry.SubagentRecord rec) {
|
||||
// Probe child progress. We use (currentTool, currentPhase) as the
|
||||
// monotonic-progress signal: any change in either implies the child
|
||||
// advanced at least one observable step. We deliberately do NOT
|
||||
// depend on a private apiCallCount field — the RunState does not
|
||||
// expose one, and counting deltas across the streaming hot path
|
||||
// would race with token emission. Tool/phase ticks are atomic
|
||||
// volatile writes from the streaming layer, so reading them here
|
||||
// is cheap and correct.
|
||||
String currentTool = streamTracker.getRunningToolName(rec.childConversationId());
|
||||
String currentPhase = streamTracker.getCurrentPhase(rec.childConversationId());
|
||||
int phaseHash = currentPhase != null ? currentPhase.hashCode() : 0;
|
||||
|
||||
boolean toolChanged = !Objects.equals(currentTool, rec.lastSeenTool().get());
|
||||
boolean phaseChanged = phaseHash != rec.lastSeenIter().get();
|
||||
|
||||
if (toolChanged || phaseChanged) {
|
||||
rec.lastSeenTool().set(currentTool);
|
||||
rec.lastSeenIter().set(phaseHash);
|
||||
rec.staleCount().set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
int sc = rec.staleCount().incrementAndGet();
|
||||
int limit = (currentTool != null && !currentTool.isEmpty())
|
||||
? cfg.getStaleCyclesInTool()
|
||||
: cfg.getStaleCyclesIdle();
|
||||
|
||||
if (sc >= limit) {
|
||||
// Atomic transition: only the first thread to flip running -> stale
|
||||
// emits the event. Subsequent ticks fall through the running guard
|
||||
// in check().
|
||||
if (rec.status().compareAndSet("running", "stale")) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("subagentId", rec.subagentId());
|
||||
payload.put("cycles", sc);
|
||||
payload.put("lastTool", currentTool != null ? currentTool : "");
|
||||
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
|
||||
streamTracker.broadcastObject(rec.parentConversationId(), "subagent_stale", payload);
|
||||
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
|
||||
rec.subagentId(), sc, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Configuration knobs for {@link SubagentHeartbeat}.
|
||||
*
|
||||
* <p>Defaults are tuned so a wedged child surfaces visibly to the parent UI
|
||||
* without firing on legitimately slow tool runs:
|
||||
* <ul>
|
||||
* <li>Idle (no tool running) → 5 cycles × 30 s = 150 s before stale.</li>
|
||||
* <li>In a tool → 20 cycles × 30 s = 600 s before stale.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The in-tool threshold MUST stay greater than or equal to
|
||||
* {@code child_hard_timeout / intervalSec}. If it fires before the per-child
|
||||
* hard cap then the cap stops being the source of truth for "this child is
|
||||
* dead" and operators see ambiguous telemetry.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties("mateclaw.delegation.heartbeat")
|
||||
public class SubagentHeartbeatConfig {
|
||||
|
||||
/**
|
||||
* Heartbeat check interval in seconds. Lower values make the parent
|
||||
* transcript more responsive at the cost of scheduler overhead.
|
||||
*/
|
||||
private int intervalSec = 30;
|
||||
|
||||
/**
|
||||
* Stale threshold (in heartbeat cycles) when the child has no current
|
||||
* tool in flight. With the default 30 s interval this is 150 s, tight
|
||||
* enough that a wedged child does not mask a legitimate gateway timeout.
|
||||
*/
|
||||
private int staleCyclesIdle = 5;
|
||||
|
||||
/**
|
||||
* Stale threshold (in heartbeat cycles) while the child is inside a
|
||||
* tool. Generous enough to tolerate slow tools (large file reads, slow
|
||||
* LLM calls). Must be at least the per-child hard timeout divided by
|
||||
* {@link #intervalSec}, otherwise stale fires before the hard cap and
|
||||
* obscures fallback semantics.
|
||||
*/
|
||||
private int staleCyclesInTool = 20;
|
||||
|
||||
public int getIntervalSec() {
|
||||
return intervalSec;
|
||||
}
|
||||
|
||||
public void setIntervalSec(int intervalSec) {
|
||||
this.intervalSec = intervalSec;
|
||||
}
|
||||
|
||||
public int getStaleCyclesIdle() {
|
||||
return staleCyclesIdle;
|
||||
}
|
||||
|
||||
public void setStaleCyclesIdle(int staleCyclesIdle) {
|
||||
this.staleCyclesIdle = staleCyclesIdle;
|
||||
}
|
||||
|
||||
public int getStaleCyclesInTool() {
|
||||
return staleCyclesInTool;
|
||||
}
|
||||
|
||||
public void setStaleCyclesInTool(int staleCyclesInTool) {
|
||||
this.staleCyclesInTool = staleCyclesInTool;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Process-wide registry of live sub-agents spawned through the delegation flow.
|
||||
*
|
||||
* <p>Holds the in-memory subagent tree so the parent transcript, the heartbeat
|
||||
* watcher, and the operator UI can observe / interrupt children that the parent
|
||||
* conversation spawned. Records use atomic accessors throughout because the
|
||||
* heartbeat thread may mutate {@code staleCount} / {@code status} concurrently
|
||||
* with the spawning thread that registered the record.
|
||||
*
|
||||
* <p>The pause flag is keyed per parent conversation so two unrelated users
|
||||
* cannot freeze each other's spawning by toggling a global switch.
|
||||
*/
|
||||
@Component
|
||||
public class SubagentRegistry {
|
||||
|
||||
/**
|
||||
* Single live sub-agent.
|
||||
*
|
||||
* <p>Mutable counters are atomics so the heartbeat scheduler and the
|
||||
* spawn / completion thread can update them without locking. Status is
|
||||
* driven by external lifecycle events; allowed values are
|
||||
* {@code running} / {@code completed} / {@code interrupted} / {@code stale}
|
||||
* / {@code timeout}.
|
||||
*/
|
||||
public record SubagentRecord(
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
Long agentId,
|
||||
String goal,
|
||||
long startedAt,
|
||||
AtomicReference<String> status,
|
||||
AtomicInteger toolCount,
|
||||
AtomicReference<String> lastTool,
|
||||
AtomicReference<String> currentPhase,
|
||||
AtomicInteger lastSeenIter,
|
||||
AtomicReference<String> lastSeenTool,
|
||||
AtomicInteger staleCount,
|
||||
AtomicLong firstApiCallAt,
|
||||
Disposable disposable
|
||||
) {}
|
||||
|
||||
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Per-parent pause flag set: scoping prevents one user from freezing
|
||||
* another user's spawning. A parent conversation appears in this set iff
|
||||
* spawning is currently paused for it.
|
||||
*/
|
||||
private final Set<String> pausedParents = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final SecureRandom rng = new SecureRandom();
|
||||
|
||||
/**
|
||||
* Register a freshly spawned sub-agent. Returns the assigned subagentId
|
||||
* which the caller must thread through to {@link #unregister(String)} on
|
||||
* completion (success / failure / timeout) so the registry does not leak.
|
||||
*
|
||||
* <p>ID format {@code sa-<epoch_ms>-<8 hex chars>} keeps IDs sortable by
|
||||
* spawn time while the random suffix prevents collisions when many
|
||||
* children spawn within the same millisecond.
|
||||
*/
|
||||
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
|
||||
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
|
||||
active.put(sid, new SubagentRecord(
|
||||
sid,
|
||||
parentConvId,
|
||||
childConvId,
|
||||
agentId,
|
||||
goal,
|
||||
System.currentTimeMillis(),
|
||||
new AtomicReference<>("running"),
|
||||
new AtomicInteger(0),
|
||||
new AtomicReference<>(""),
|
||||
new AtomicReference<>("starting"),
|
||||
new AtomicInteger(0),
|
||||
new AtomicReference<>(null),
|
||||
new AtomicInteger(0),
|
||||
new AtomicLong(0),
|
||||
d));
|
||||
return sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a sub-agent as interrupted and dispose its underlying stream
|
||||
* subscription if one was registered. Returns {@code false} when the
|
||||
* subagentId is unknown (already cleaned up or never registered) so
|
||||
* callers can distinguish "not running anymore" from "interrupted".
|
||||
*/
|
||||
public boolean interrupt(String subagentId) {
|
||||
if (subagentId == null) return false;
|
||||
SubagentRecord r = active.get(subagentId);
|
||||
if (r == null) return false;
|
||||
r.status().set("interrupted");
|
||||
Disposable d = r.disposable();
|
||||
if (d != null && !d.isDisposed()) {
|
||||
d.dispose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<SubagentRecord> get(String subagentId) {
|
||||
return subagentId == null ? Optional.empty() : Optional.ofNullable(active.get(subagentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all sub-agents whose parent matches {@code parentConvId}.
|
||||
* Filtering at the registry boundary prevents callers from accidentally
|
||||
* surfacing other tenants' subagents in API responses.
|
||||
*/
|
||||
public List<SubagentRecord> snapshot(String parentConvId) {
|
||||
if (parentConvId == null) return List.of();
|
||||
return active.values().stream()
|
||||
.filter(r -> parentConvId.equals(r.parentConversationId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void unregister(String subagentId) {
|
||||
if (subagentId == null) return;
|
||||
active.remove(subagentId);
|
||||
}
|
||||
|
||||
public boolean isSpawnPaused(String parentConvId) {
|
||||
if (parentConvId == null) return false;
|
||||
return pausedParents.contains(parentConvId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the pause flag for one parent conversation. Returns the new
|
||||
* paused state so the caller can echo the resulting flag without an
|
||||
* extra read.
|
||||
*/
|
||||
public boolean setSpawnPaused(String parentConvId, boolean paused) {
|
||||
if (parentConvId == null) return false;
|
||||
if (paused) {
|
||||
pausedParents.add(parentConvId);
|
||||
} else {
|
||||
pausedParents.remove(parentConvId);
|
||||
}
|
||||
return paused;
|
||||
}
|
||||
|
||||
public Collection<SubagentRecord> allActive() {
|
||||
return active.values();
|
||||
}
|
||||
|
||||
/** Lowercase 8-hex-char suffix sourced from a SecureRandom. */
|
||||
private String nextHexSuffix() {
|
||||
byte[] bytes = new byte[4];
|
||||
rng.nextBytes(bytes);
|
||||
StringBuilder sb = new StringBuilder(8);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 流式输出重复检测器
|
||||
* <p>
|
||||
* 检测 LLM 流式输出中的退化重复模式(degenerate repetition),
|
||||
* 当检测到内容在滑动窗口内高度重复时返回 true,调用方应截断 LLM 流。
|
||||
* <p>
|
||||
* 算法:维护一个滑动窗口缓冲区,每次追加新 delta 后,
|
||||
* 检查窗口尾部是否存在连续重复的 n-gram 模式。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
public class RepetitionDetector {
|
||||
|
||||
/** 滑动窗口大小(字符数) */
|
||||
private static final int WINDOW_SIZE = 1024;
|
||||
|
||||
/** 最小重复片段长度 */
|
||||
private static final int MIN_PATTERN_LEN = 8;
|
||||
|
||||
/** 最大检测的模式长度 */
|
||||
private static final int MAX_PATTERN_LEN = 200;
|
||||
|
||||
/** 模式需要连续出现的最小次数才判定为重复 */
|
||||
private static final int MIN_REPEATS = 4;
|
||||
|
||||
/** 已累积内容的最小长度才开始检测(避免误判短内容) */
|
||||
private static final int MIN_CONTENT_LEN = 200;
|
||||
|
||||
private final StringBuilder buffer = new StringBuilder();
|
||||
private boolean repetitionDetected = false;
|
||||
|
||||
/**
|
||||
* 追加新的 delta 并检测是否存在重复
|
||||
*
|
||||
* @param delta 新增的文本片段
|
||||
* @return true 表示检测到退化重复,调用方应截断流
|
||||
*/
|
||||
public boolean appendAndCheck(String delta) {
|
||||
if (delta == null || delta.isEmpty() || repetitionDetected) {
|
||||
return repetitionDetected;
|
||||
}
|
||||
|
||||
buffer.append(delta);
|
||||
|
||||
// 内容太短,不检测
|
||||
if (buffer.length() < MIN_CONTENT_LEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保持窗口大小
|
||||
if (buffer.length() > WINDOW_SIZE * 2) {
|
||||
buffer.delete(0, buffer.length() - WINDOW_SIZE);
|
||||
}
|
||||
|
||||
// 在窗口尾部检测重复模式
|
||||
String window = buffer.toString();
|
||||
int windowLen = window.length();
|
||||
|
||||
// 从短模式到长模式扫描
|
||||
for (int patternLen = MIN_PATTERN_LEN;
|
||||
patternLen <= Math.min(MAX_PATTERN_LEN, windowLen / MIN_REPEATS);
|
||||
patternLen++) {
|
||||
|
||||
// 取窗口末尾的 pattern
|
||||
String pattern = window.substring(windowLen - patternLen);
|
||||
|
||||
// 向前数这个 pattern 连续出现了几次
|
||||
int count = 1;
|
||||
int pos = windowLen - patternLen * 2;
|
||||
while (pos >= 0) {
|
||||
String segment = window.substring(pos, pos + patternLen);
|
||||
if (segment.equals(pattern)) {
|
||||
count++;
|
||||
pos -= patternLen;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (count >= MIN_REPEATS) {
|
||||
// 排除装饰性重复(代码缩进、ASCII 图表、Markdown 分隔线常见)
|
||||
if (isDecorativePattern(pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
repetitionDetected = true;
|
||||
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
|
||||
"pattern length={}, repeats={}, pattern preview=\"{}\"",
|
||||
patternLen, count,
|
||||
pattern.length() > 50 ? pattern.substring(0, 50) + "..." : pattern);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 pattern 是否为装饰性字符(不应判定为退化重复)。
|
||||
* <p>
|
||||
* 排除场景:
|
||||
* <ul>
|
||||
* <li>纯空白/缩进:{@code " "}(代码缩进)</li>
|
||||
* <li>单一重复字符:{@code "────────"} {@code "════════"} {@code "--------"} {@code "********"}(分隔线、表格边框)</li>
|
||||
* <li>Box Drawing 字符族:{@code "┌──────┐"} {@code "│ │"}(ASCII 图表)</li>
|
||||
* </ul>
|
||||
*/
|
||||
private boolean isDecorativePattern(String pattern) {
|
||||
if (pattern.isBlank()) {
|
||||
return true; // 纯空白
|
||||
}
|
||||
|
||||
// 统计不同的非空白字符种类
|
||||
long distinctNonWhitespace = pattern.chars()
|
||||
.filter(c -> !Character.isWhitespace(c))
|
||||
.distinct()
|
||||
.count();
|
||||
|
||||
// 只有 1-2 种不同的非空白字符 → 装饰性(如 "────────" 或 "│ │")
|
||||
if (distinctNonWhitespace <= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否全部是 Box Drawing / 装饰字符
|
||||
boolean allDecorative = pattern.chars().allMatch(c ->
|
||||
Character.isWhitespace(c)
|
||||
|| isBoxDrawing(c)
|
||||
|| "─━│┃┄┅┆┇┈┉┊┋═║╌╍╎╏╔╗╚╝╠╣╦╩╬├┤┬┴┼┌┐└┘".indexOf(c) >= 0
|
||||
|| "-=_*+|#~<>".indexOf(c) >= 0);
|
||||
return allDecorative;
|
||||
}
|
||||
|
||||
private boolean isBoxDrawing(int codePoint) {
|
||||
// Unicode Box Drawing block: U+2500 – U+257F
|
||||
return codePoint >= 0x2500 && codePoint <= 0x257F;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置检测器状态
|
||||
*/
|
||||
public void reset() {
|
||||
buffer.setLength(0);
|
||||
repetitionDetected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已检测到重复
|
||||
*/
|
||||
public boolean isRepetitionDetected() {
|
||||
return repetitionDetected;
|
||||
}
|
||||
}
|
||||
@ -53,15 +53,32 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
private final CompiledGraph compiledGraph;
|
||||
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
/**
|
||||
* Held only so {@link #buildInitialState} can include the tools schema in
|
||||
* the context-window budget — those bytes ride along on every LLM call
|
||||
* and were previously ignored, making compression decisions fire late.
|
||||
* Nullable for the legacy 5-arg constructor used by older tests.
|
||||
*/
|
||||
private final vip.mate.agent.AgentToolSet toolSet;
|
||||
|
||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
ConversationWindowManager conversationWindowManager) {
|
||||
this(chatClient, conversationService, compiledGraph, chatModel,
|
||||
conversationWindowManager, null);
|
||||
}
|
||||
|
||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
vip.mate.agent.AgentToolSet toolSet) {
|
||||
super(chatClient, conversationService);
|
||||
this.compiledGraph = compiledGraph;
|
||||
this.chatModel = chatModel;
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.toolSet = toolSet;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -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);
|
||||
// 初始化新字段
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
* (camelCase→snake_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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -24,6 +24,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
|
||||
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
|
||||
* </pre>
|
||||
* <p>
|
||||
* Note: this listener is intentionally read-only / log-only. Surfacing
|
||||
* graph state to channel-side accumulators (e.g. publishing the resolved
|
||||
* {@code FinishReason} to message metadata) lives in {@code FinalAnswerNode}
|
||||
* via a {@code finish_reason} GraphEvent — that path goes through the
|
||||
* PENDING_EVENTS → StreamDelta pipeline that {@code ChatController.StreamAccumulator}
|
||||
* actually consumes. A sibling sink that called {@code streamTracker.broadcastObject}
|
||||
* here would only reach the browser SSE bus and bypass the accumulator entirely.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
|
||||
@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 次工具调用返回相同结果,已强制终止循环");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -19,6 +19,12 @@ public enum FinishReason {
|
||||
/** 发生错误后降级回答 */
|
||||
ERROR_FALLBACK("error_fallback"),
|
||||
|
||||
/** 响应未完整完成,需要继续生成或重试 */
|
||||
INCOMPLETE("incomplete"),
|
||||
|
||||
/** 最终回答引用了未被工具结果验证的源码事实 */
|
||||
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
|
||||
|
||||
/** 用户主动停止 */
|
||||
STOPPED("stopped"),
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 =====
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,220 @@
|
||||
package vip.mate.agent.graph.state;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Tracks source references that were actually observed through tool results.
|
||||
*/
|
||||
public record SourceEvidenceLedger(
|
||||
Set<String> sourcePaths,
|
||||
Set<String> sourceSymbols,
|
||||
Set<String> failedPaths
|
||||
) implements Serializable {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Pattern JAVA_PATH = Pattern.compile(
|
||||
"(?:[A-Za-z]:)?[A-Za-z0-9_./\\\\-]+\\.java\\b");
|
||||
private static final Pattern JAVA_FILE_REF = Pattern.compile("\\b[A-Za-z][A-Za-z0-9_]*\\.java\\b");
|
||||
private static final Pattern JAVA_SYMBOL_REF = Pattern.compile(
|
||||
"\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b");
|
||||
private static final Pattern DECLARED_TYPE = Pattern.compile(
|
||||
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
|
||||
|
||||
public SourceEvidenceLedger {
|
||||
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
|
||||
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
|
||||
failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths);
|
||||
}
|
||||
|
||||
public static SourceEvidenceLedger empty() {
|
||||
return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of());
|
||||
}
|
||||
|
||||
public static SourceEvidenceLedger fromToolResponses(List<ToolResponseMessage.ToolResponse> responses) {
|
||||
if (responses == null || responses.isEmpty()) {
|
||||
return empty();
|
||||
}
|
||||
Builder builder = new Builder();
|
||||
for (ToolResponseMessage.ToolResponse response : responses) {
|
||||
String data = response.responseData();
|
||||
if (data == null || data.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (isReadFileTool(response.name())) {
|
||||
recordReadFile(data, builder);
|
||||
} else {
|
||||
recordPlainTextEvidence(data, builder);
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public SourceEvidenceLedger merge(SourceEvidenceLedger other) {
|
||||
if (other == null || !other.hasEvidence()) {
|
||||
return this;
|
||||
}
|
||||
Builder builder = new Builder();
|
||||
sourcePaths.forEach(builder::sourcePath);
|
||||
sourceSymbols.forEach(builder::symbol);
|
||||
failedPaths.forEach(builder::failedPath);
|
||||
other.sourcePaths.forEach(builder::sourcePath);
|
||||
other.sourceSymbols.forEach(builder::symbol);
|
||||
other.failedPaths.forEach(builder::failedPath);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public SourceEvidenceLedger withSourcePath(String path) {
|
||||
Builder builder = new Builder();
|
||||
sourcePaths.forEach(builder::sourcePath);
|
||||
sourceSymbols.forEach(builder::symbol);
|
||||
failedPaths.forEach(builder::failedPath);
|
||||
builder.sourcePath(path);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public boolean hasEvidence() {
|
||||
return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasPath(String path) {
|
||||
String normalized = normalizePath(path);
|
||||
return sourcePaths.contains(normalized) || sourcePaths.stream().anyMatch(p -> p.endsWith("/" + normalized));
|
||||
}
|
||||
|
||||
public boolean hasSymbol(String symbol) {
|
||||
return sourceSymbols.contains(symbol);
|
||||
}
|
||||
|
||||
public Validation validateAnswer(String answer) {
|
||||
if (answer == null || answer.isBlank() || !hasEvidence()) {
|
||||
return Validation.ok();
|
||||
}
|
||||
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
|
||||
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
|
||||
while (fileMatcher.find()) {
|
||||
String ref = fileMatcher.group();
|
||||
if (!hasFileName(ref)) {
|
||||
unsupported.add(ref);
|
||||
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
|
||||
}
|
||||
}
|
||||
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
|
||||
while (symbolMatcher.find()) {
|
||||
String ref = symbolMatcher.group();
|
||||
if (!unsupportedFileStems.contains(ref) && !sourceSymbols.contains(ref) && !hasFileName(ref + ".java")) {
|
||||
unsupported.add(ref);
|
||||
}
|
||||
}
|
||||
return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported));
|
||||
}
|
||||
|
||||
private boolean hasFileName(String fileName) {
|
||||
String normalized = normalizePath(fileName);
|
||||
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
||||
}
|
||||
|
||||
private static boolean isReadFileTool(String name) {
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String normalized = name.toLowerCase(Locale.ROOT).replace("-", "_");
|
||||
return normalized.equals("read_file");
|
||||
}
|
||||
|
||||
private static void recordReadFile(String data, Builder builder) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(data);
|
||||
String filePath = root.path("filePath").asText("");
|
||||
if (root.path("error").asBoolean(false)) {
|
||||
builder.failedPath(filePath);
|
||||
return;
|
||||
}
|
||||
builder.sourcePath(filePath);
|
||||
String content = root.path("content").asText("");
|
||||
recordSymbols(content, builder);
|
||||
} catch (Exception ignored) {
|
||||
recordPlainTextEvidence(data, builder);
|
||||
}
|
||||
}
|
||||
|
||||
private static void recordPlainTextEvidence(String text, Builder builder) {
|
||||
Matcher matcher = JAVA_PATH.matcher(text);
|
||||
while (matcher.find()) {
|
||||
builder.sourcePath(matcher.group());
|
||||
}
|
||||
recordSymbols(text, builder);
|
||||
}
|
||||
|
||||
private static void recordSymbols(String text, Builder builder) {
|
||||
Matcher matcher = DECLARED_TYPE.matcher(text);
|
||||
while (matcher.find()) {
|
||||
builder.symbol(matcher.group(1));
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizePath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String normalized = path.replace('\\', '/').trim();
|
||||
while (normalized.contains("//")) {
|
||||
normalized = normalized.replace("//", "/");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static final class Builder {
|
||||
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
|
||||
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
|
||||
private final LinkedHashSet<String> failedPaths = new LinkedHashSet<>();
|
||||
|
||||
void sourcePath(String path) {
|
||||
String normalized = normalizePath(path);
|
||||
if (normalized.isBlank()) {
|
||||
return;
|
||||
}
|
||||
sourcePaths.add(normalized);
|
||||
String fileName = Path.of(normalized).getFileName() != null
|
||||
? Path.of(normalized).getFileName().toString() : normalized;
|
||||
if (fileName.endsWith(".java")) {
|
||||
sourceSymbols.add(fileName.substring(0, fileName.length() - ".java".length()));
|
||||
}
|
||||
}
|
||||
|
||||
void symbol(String symbol) {
|
||||
if (symbol != null && !symbol.isBlank()) {
|
||||
sourceSymbols.add(symbol.trim());
|
||||
}
|
||||
}
|
||||
|
||||
void failedPath(String path) {
|
||||
String normalized = normalizePath(path);
|
||||
if (!normalized.isBlank()) {
|
||||
failedPaths.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
SourceEvidenceLedger build() {
|
||||
return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
public record Validation(boolean valid, List<String> unsupportedReferences) {
|
||||
public static Validation ok() {
|
||||
return new Validation(true, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,10 +31,30 @@ public class AgentEntity {
|
||||
private String systemPrompt;
|
||||
|
||||
/**
|
||||
* 保留但不再生效:运行时统一使用全局默认模型(ModelConfigService.getDefaultModel())。
|
||||
* 该字段为历史残留,仅保留以避免数据库迁移。
|
||||
* Per-Agent model override.
|
||||
*
|
||||
* <p>When non-blank, the runtime resolves this value via
|
||||
* {@code ModelConfigService.resolveModel(...)} — a case-sensitive,
|
||||
* enabled-only lookup against {@code mate_model_config.model_name}.
|
||||
* On match, the resolved entity is used as the primary model in
|
||||
* place of {@code getDefaultModel()}.
|
||||
*
|
||||
* <p>Null / blank → fall back to the global default (preserves the
|
||||
* original behavior). Stale rows whose named model has been removed
|
||||
* or disabled also fall back, since {@code resolveModel} returns the
|
||||
* default when no enabled match is found.
|
||||
*
|
||||
* <p>{@link FieldStrategy#ALWAYS} so a {@code PUT} with explicit null
|
||||
* actually clears the column — the MyBatis-Plus default {@code NOT_NULL}
|
||||
* strategy silently drops null fields from UPDATE, which means a user
|
||||
* who once picked a model could never revert back to "use global default"
|
||||
* via the UI (only by directly editing the DB). Smoke test on 2026-05-02
|
||||
* caught it.
|
||||
*
|
||||
* <p>RFC-03 Lane G1 — re-enables this field after it was silently
|
||||
* deprecated in earlier work; the database column is unchanged.
|
||||
*/
|
||||
@Deprecated
|
||||
@TableField(value = "model_name", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String modelName;
|
||||
|
||||
/** 最大迭代次数 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
package vip.mate.agent.runtime;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.delegation.SubagentRegistry;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry})
|
||||
* with agent metadata so the admin Backstage UI can render one card per
|
||||
* working agent without making the frontend traverse three independent
|
||||
* services.
|
||||
*
|
||||
* <p>The "stuck" verdict is computed here rather than persisted on
|
||||
* {@link RunSnapshot} so the thresholds can be tuned at runtime without
|
||||
* touching every producer.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentRuntimeAggregator {
|
||||
|
||||
/**
|
||||
* Idle threshold: a run with no events for this long while NOT inside a
|
||||
* tool call is treated as wedged. Aligns with the upstream cli reference
|
||||
* (5 × 30s heartbeat cycles) so pre-token latency does not false-alarm.
|
||||
*/
|
||||
private static final long STUCK_IDLE_MS = 150_000L;
|
||||
|
||||
/**
|
||||
* In-tool threshold: a run with a {@code runningToolName} but no events
|
||||
* for this long. Looser than idle because slow tool calls (LLM-backed
|
||||
* tools, long-running shell commands) routinely sit silent for minutes.
|
||||
*/
|
||||
private static final long STUCK_TOOL_MS = 600_000L;
|
||||
|
||||
/**
|
||||
* Hard cap regardless of activity. A run older than this is suspicious
|
||||
* even when the bytes are flowing: the user has likely walked away and
|
||||
* the model is in a feedback loop.
|
||||
*/
|
||||
private static final long STUCK_HARD_CAP_MS = 1_800_000L;
|
||||
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final SubagentRegistry subagentRegistry;
|
||||
private final AgentService agentService;
|
||||
|
||||
/** One in-flight run, enriched with agent label and stuck verdict. */
|
||||
public record RunCard(
|
||||
String conversationId,
|
||||
Long agentId,
|
||||
String agentName,
|
||||
String agentIcon,
|
||||
String username,
|
||||
String currentPhase,
|
||||
String runningToolName,
|
||||
String waitingReason,
|
||||
boolean done,
|
||||
boolean stopRequested,
|
||||
boolean firstTokenReceived,
|
||||
int subscriberCount,
|
||||
int queueLen,
|
||||
long ageMs,
|
||||
long msSinceLastEvent,
|
||||
String stuckReason,
|
||||
boolean orphan,
|
||||
int subagentCount
|
||||
) {}
|
||||
|
||||
/** One sub-agent under a parent run, ready for tree rendering. */
|
||||
public record SubagentCard(
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
Long agentId,
|
||||
String agentName,
|
||||
String agentIcon,
|
||||
String goal,
|
||||
String status,
|
||||
String currentPhase,
|
||||
String lastTool,
|
||||
int toolCount,
|
||||
long ageMs
|
||||
) {}
|
||||
|
||||
/** Top-level summary used to drive the breathing sidebar dot. */
|
||||
public record Summary(
|
||||
int running,
|
||||
int stuck,
|
||||
int orphan,
|
||||
int queued,
|
||||
int subagentsActive
|
||||
) {}
|
||||
|
||||
/** Full snapshot envelope returned to the admin UI. */
|
||||
public record RuntimeSnapshot(
|
||||
Summary summary,
|
||||
List<RunCard> runs,
|
||||
List<SubagentCard> subagents,
|
||||
long timestamp
|
||||
) {}
|
||||
|
||||
public RuntimeSnapshot snapshot() {
|
||||
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
||||
Set<Long> agentIds = rawRuns.stream()
|
||||
.map(RunSnapshot::agentId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
for (var rec : subagentRegistry.allActive()) {
|
||||
if (rec.agentId() != null) agentIds.add(rec.agentId());
|
||||
}
|
||||
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
|
||||
|
||||
Map<String, Long> subagentCountByParent = new HashMap<>();
|
||||
for (var rec : subagentRegistry.allActive()) {
|
||||
String parent = rec.parentConversationId();
|
||||
if (parent != null) {
|
||||
subagentCountByParent.merge(parent, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
List<RunCard> cards = new ArrayList<>(rawRuns.size());
|
||||
int stuckCount = 0;
|
||||
int orphanCount = 0;
|
||||
int queuedTotal = 0;
|
||||
int runningCount = 0;
|
||||
for (RunSnapshot s : rawRuns) {
|
||||
if (s.done()) continue;
|
||||
runningCount++;
|
||||
String stuckReason = computeStuckReason(s);
|
||||
boolean orphan = s.subscriberCount() == 0;
|
||||
if (stuckReason != null) stuckCount++;
|
||||
if (orphan) orphanCount++;
|
||||
queuedTotal += s.queueLen();
|
||||
int subCount = subagentCountByParent.getOrDefault(s.conversationId(), 0L).intValue();
|
||||
AgentEntity ag = s.agentId() == null ? null : agentInfo.get(s.agentId());
|
||||
cards.add(new RunCard(
|
||||
s.conversationId(),
|
||||
s.agentId(),
|
||||
ag == null ? null : ag.getName(),
|
||||
ag == null ? null : ag.getIcon(),
|
||||
s.username(),
|
||||
s.currentPhase(),
|
||||
s.runningToolName(),
|
||||
s.waitingReason(),
|
||||
s.done(),
|
||||
s.stopRequested(),
|
||||
s.firstTokenReceived(),
|
||||
s.subscriberCount(),
|
||||
s.queueLen(),
|
||||
s.ageMs(),
|
||||
s.msSinceLastEvent(),
|
||||
stuckReason,
|
||||
orphan,
|
||||
subCount
|
||||
));
|
||||
}
|
||||
// Sort: stuck first (loudest first), then orphan, then by lastEventAt asc
|
||||
cards.sort((a, b) -> {
|
||||
int aStuck = a.stuckReason() != null ? 1 : 0;
|
||||
int bStuck = b.stuckReason() != null ? 1 : 0;
|
||||
if (aStuck != bStuck) return bStuck - aStuck;
|
||||
int aOrph = a.orphan() ? 1 : 0;
|
||||
int bOrph = b.orphan() ? 1 : 0;
|
||||
if (aOrph != bOrph) return bOrph - aOrph;
|
||||
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
|
||||
});
|
||||
|
||||
List<SubagentCard> subCards = subagentRegistry.allActive().stream()
|
||||
.map(rec -> {
|
||||
long now = System.currentTimeMillis();
|
||||
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
|
||||
return new SubagentCard(
|
||||
rec.subagentId(),
|
||||
rec.parentConversationId(),
|
||||
rec.childConversationId(),
|
||||
rec.agentId(),
|
||||
ag == null ? null : ag.getName(),
|
||||
ag == null ? null : ag.getIcon(),
|
||||
rec.goal(),
|
||||
rec.status() != null ? rec.status().get() : null,
|
||||
rec.currentPhase() != null ? rec.currentPhase().get() : null,
|
||||
rec.lastTool() != null ? rec.lastTool().get() : null,
|
||||
rec.toolCount() != null ? rec.toolCount().get() : 0,
|
||||
now - rec.startedAt()
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
Summary summary = new Summary(
|
||||
runningCount,
|
||||
stuckCount,
|
||||
orphanCount,
|
||||
queuedTotal,
|
||||
subCards.size()
|
||||
);
|
||||
|
||||
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when the run looks healthy. The returned tag is a stable
|
||||
* machine-readable code (not a translated label) so the frontend can
|
||||
* decide presentation: {@code idle_silent} / {@code tool_silent} /
|
||||
* {@code hard_cap}.
|
||||
*/
|
||||
private String computeStuckReason(RunSnapshot s) {
|
||||
if (s.ageMs() > STUCK_HARD_CAP_MS) return "hard_cap";
|
||||
boolean inTool = s.runningToolName() != null && !s.runningToolName().isBlank();
|
||||
long since = s.msSinceLastEvent();
|
||||
if (inTool && since > STUCK_TOOL_MS) return "tool_silent";
|
||||
if (!inTool && since > STUCK_IDLE_MS) return "idle_silent";
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<Long, AgentEntity> resolveAgents(Set<Long> ids) {
|
||||
Map<Long, AgentEntity> out = new LinkedHashMap<>();
|
||||
for (Long id : ids) {
|
||||
if (id == null) continue;
|
||||
try {
|
||||
AgentEntity a = agentService.getAgent(id);
|
||||
if (a != null) out.put(id, a);
|
||||
} catch (Exception e) {
|
||||
log.debug("agent lookup failed for id={}: {}", id, e.getMessage());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
package vip.mate.agent.runtime;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.agent.delegation.SubagentRegistry;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.i18n.I18nService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Admin-only Backstage surface: the global view of every in-flight agent
|
||||
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
|
||||
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
|
||||
* owner-scoped — this controller is intentionally cross-tenant for the
|
||||
* operator role.
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Agent Runtime (Backstage)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/agent-runtime")
|
||||
@RequiredArgsConstructor
|
||||
public class AgentRuntimeController {
|
||||
|
||||
private final AgentRuntimeAggregator aggregator;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final SubagentRegistry subagentRegistry;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ConversationService conversationService;
|
||||
private final I18nService i18nService;
|
||||
|
||||
@Operation(summary = "Snapshot of every in-flight agent turn")
|
||||
@GetMapping("/snapshot")
|
||||
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
return R.ok(aggregator.snapshot());
|
||||
}
|
||||
|
||||
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
|
||||
@PostMapping("/runs/{conversationId}/stop")
|
||||
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
boolean ok = streamTracker.requestStop(conversationId);
|
||||
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
|
||||
return R.ok(Map.of("stopped", ok));
|
||||
}
|
||||
|
||||
@Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored")
|
||||
@PostMapping("/runs/{conversationId}/recycle")
|
||||
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
boolean ok = streamTracker.forceRecycle(conversationId);
|
||||
if (ok) {
|
||||
finalizeRecycledConversation(conversationId);
|
||||
}
|
||||
recordAudit(auth, "agent-runtime.recycle", conversationId, Map.of("result", ok));
|
||||
return R.ok(Map.of("recycled", ok));
|
||||
}
|
||||
|
||||
@Operation(summary = "Interrupt one sub-agent (admin override of ownership check)")
|
||||
@PostMapping("/subagents/{subagentId}/interrupt")
|
||||
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
boolean ok = subagentRegistry.interrupt(subagentId);
|
||||
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
|
||||
return R.ok(Map.of("interrupted", ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk recycle every run that the aggregator currently flags as stuck.
|
||||
* Returns the conversationIds that were touched so the caller can render
|
||||
* a confirmation toast without re-fetching.
|
||||
*/
|
||||
@Operation(summary = "Recycle every run currently flagged as stuck")
|
||||
@PostMapping("/sweep")
|
||||
public R<Map<String, Object>> sweep(Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
|
||||
List<String> ids = snap.runs().stream()
|
||||
.filter(r -> r.stuckReason() != null)
|
||||
.map(AgentRuntimeAggregator.RunCard::conversationId)
|
||||
.toList();
|
||||
int recycled = 0;
|
||||
for (String cid : ids) {
|
||||
if (streamTracker.forceRecycle(cid)) {
|
||||
recycled++;
|
||||
finalizeRecycledConversation(cid);
|
||||
}
|
||||
}
|
||||
recordAudit(auth, "agent-runtime.sweep", "all",
|
||||
Map.of("targets", ids, "recycled", recycled));
|
||||
return R.ok(Map.of("recycled", recycled, "ids", ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Common DB-side cleanup after a successful {@code forceRecycle}:
|
||||
* <ol>
|
||||
* <li>Flip {@code stream_status} off 'running' so the sidebar drops the
|
||||
* 生成中 badge immediately. (The late doOnCancel / doOnComplete may
|
||||
* re-set this to 'idle' when the agent finally yields — same value,
|
||||
* no-op.)</li>
|
||||
* <li>If the conversation's last message is still a user turn — i.e.
|
||||
* the agent was disposed before any text streamed and the
|
||||
* emergencySaveCallback found nothing to persist — write a
|
||||
* "已被用户中止" assistant marker so the UI shows what happened
|
||||
* instead of a blank reply.</li>
|
||||
* </ol>
|
||||
*/
|
||||
private void finalizeRecycledConversation(String conversationId) {
|
||||
try {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
} catch (Exception e) {
|
||||
log.warn("recycle: failed to reset stream_status for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
}
|
||||
try {
|
||||
conversationService.saveStopMarkerIfDangling(
|
||||
conversationId, i18nService.msg("chat.stopMarker.userAborted"), "stopped");
|
||||
} catch (Exception e) {
|
||||
log.warn("recycle: failed to save stop marker for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdmin(Authentication auth) {
|
||||
if (auth == null) {
|
||||
throw new MateClawException(401, "authentication required");
|
||||
}
|
||||
boolean isAdmin = auth.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch("ROLE_ADMIN"::equals);
|
||||
if (!isAdmin) {
|
||||
throw new MateClawException(403, "admin role required");
|
||||
}
|
||||
}
|
||||
|
||||
private void recordAudit(Authentication auth, String action,
|
||||
String resourceId, Map<String, Object> detail) {
|
||||
try {
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("by", username);
|
||||
payload.putAll(detail);
|
||||
auditEventService.record(action, "agent-runtime", resourceId, resourceId,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload));
|
||||
} catch (Exception e) {
|
||||
log.warn("audit serialization failed for {}: {}", action, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,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 默认值兜底成 1(issue #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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 ≤ 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 / max,null 表示跟随 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_");
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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 : "");
|
||||
}
|
||||
|
||||
|
||||
@ -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())) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 1–5 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||