release: v2.0.0

This commit is contained in:
matevip 2026-07-31 03:54:21 -04:00
parent c6f6f10fd0
commit 5fa6706496
344 changed files with 26458 additions and 1873 deletions

19
.gitattributes vendored Normal file
View File

@ -0,0 +1,19 @@
# Line-ending policy.
#
# Shell scripts are bind-mounted into Linux containers (e.g.
# docker/postgres/init/ -> /docker-entrypoint-initdb.d) and executed there.
# A CRLF checkout on Windows (core.autocrlf=true is the Git for Windows
# default) turns the shebang into "#!/bin/sh\r", which fails with
# "cannot execute: required file not found". Pin them to LF everywhere.
#
# SQL files are pinned to LF too so Flyway migration checksums stay
# identical across platforms.
*.sh text eol=lf
*.bash text eol=lf
*.sql text eol=lf
# Windows-native scripts keep CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

9
.gitignore vendored
View File

@ -94,8 +94,13 @@ deploy/nginx/ssl/*.crt
deploy/nginx/ssl/*.key
deploy/nginx/ssl/*.pem
# Deploy env
deploy/.env
# Env files (real secrets - do not commit)
# .env matches any level; .env.example / *.env.example are templates and stay tracked.
.env
.env.local
.env.*.local
!.env.example
!**/.env.example
# Claude Code local settings
CLAUDE.md

View File

@ -81,6 +81,9 @@ Same brain. Same memory. Same tools. Different doors.
### 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.
### Agent Teams (2.0.0+)
One lead, a crew of employees, one **shared task board**. Tell the lead a goal and it breaks the goal into tasks on the board (`blockedBy` declares dependencies); the dispatch engine hands tasks to members in parallel, prerequisite results hand off to downstream tasks automatically, and settled results are announced back to the lead for synthesis. Execution leases + heartbeats eliminate double execution, **cancel actually interrupts** a running member session, and sensitive tasks park at `in_review` for a human. Deliverables (docx / pptx / xlsx / pdf) register on tasks for download, timelines record everything, and you can jump into any member's child conversation to watch it execute word by word. A Plan-Execute lead hands its **whole plan over to the board** — a lead that can plan turns planning into orchestration.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
@ -203,7 +206,7 @@ Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution · Team task board (2.0.0+) |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
@ -220,6 +223,19 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
**v2.0.0 (shipped 2026-07-26)** — from "one person who gets things done" to "a team that collaborates": **Agent Teams** become a standing roster around a shared task board:
- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation
- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale
- **Plan-Execute plans hand over to the board** — steps become tasks, dependencies become parallelism, a parked-plan resume gate synthesizes deterministically
- **Workspace isolation fully sealed** — channel-scoped conversation ids; same-named skills coexist per workspace with conversation-scoped runtime resolution
- **Channel experience** — magic commands on every channel (`/new` `/clear` `/status` `/stop` `/model` `/help`), WeCom's event-driven progress bubble (live tool trace + per-stage rolling narration)
- **Server-side rewind / regenerate** · **explainable auto-approval misses** (reason codes on audit rows + one-click grant creation) · **policy-driven LLM error recovery** (overload vs rate-limit split · `Retry-After`-aware backoff · provider TTL readmission)
Plus: in-chat attachment preview (pdf / docx / xlsx / html / text), single-source SKILL.md + console bundle-file management, the optional Mem0 plugin memory provider, and the knowledge-graph relation schema whitelist.
Full story in the [v2.0.0 release notes](https://claw.mate.vip/docs/en/releases/2.0.0).
**v1.8.0 (shipped 2026-07-12)** — the employee turns *outward and does a whole job*: **Content Studio**, the first flagship scene built end-to-end on MateClaw's own primitives:
- **Content Studio — one sentence to a publishable post** — a seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver. **WeChat Official Account (公众号)** image-text articles (inline-style HTML → draft box) and **Xiaohongshu (小红书)** image-first notes (≥3 vertical 3:4 cards + online preview) ship first-class

View File

@ -81,6 +81,9 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 团队协作2.0.0+
一个 Lead 带一群员工,围着一块**共享任务板**干活。你对 Lead 说一句目标,它拆成任务上板(`blockedBy` 声明依赖);派发引擎把任务并行分给成员,前置结果自动传给下游,完成结果自动通报回 Lead 汇总。执行租约 + 心跳杜绝双重执行,**取消即中断**正在跑的成员会话,敏感任务停在 `in_review` 等人批。交付物docx / pptx / xlsx / pdf登记到任务可下载任务时间线记录全程还能跳进任意成员的子会话看它逐字执行。Plan-Execute 型 Lead 的计划**整体移交任务板**——会规划的 Lead规划能力就是编排能力。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
- **工作区记忆**`AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
@ -203,7 +206,7 @@ mateclaw/
| 层次 | 技术 |
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 · 团队任务板2.0.0+|
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· MySQL 8.0+(生产)|
@ -220,6 +223,19 @@ mateclaw/
## 路线图
**v2.0.02026-07-26 发布)** — 从"一个能干活的人"到"一支能协作的队伍"**Agent 团队**成为常设编制,围着一块共享任务板干活:
- **Agent 团队与共享任务板** — 团队 / 角色lead · member · reviewer、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 LeadTeams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
- **Plan-Execute 计划整体移交任务板** — 步骤变任务、依赖变并行、停靠恢复门确定性汇总
- **工作空间隔离全面收口** — 渠道会话 id 编入渠道标识、同名技能跨工作空间共存且运行时按会话工作空间解析
- **渠道体验** — 全渠道魔法命令(`/new` `/clear` `/status` `/stop` `/model` `/help`)、企业微信事件驱动进度气泡(实时工具轨迹 + 分阶段滚动叙述)
- **会话回退 / 重新生成服务端语义** · **自动批准未命中可解释**(原因码落审计行 + 一键补策略) · **LLM 错误恢复策略化**(过载/限流分治 · `Retry-After` 回馈退避 · provider TTL 回收)
外加聊天附件在线预览pdf / docx / xlsx / html / 文本、SKILL.md 单一事实源 + 捆绑文件控制台管理、Mem0 可选插件记忆 provider、知识图谱关系模式白名单。
完整故事见 [v2.0.0 release notes](https://claw.mate.vip/docs/zh/releases/2.0.0)。
**v1.8.02026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景:
- **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民

View File

@ -98,10 +98,10 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
<!-- ===== Orchestration tier: Team board + Workflow + Trigger ===== -->
<rect x="300" y="358" width="360" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Team Board (2.0.0+) + Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Lead decomposes → members run in parallel → approve / deliver</text>
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -105,10 +105,10 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
<!-- ===== Orchestration tier: Team board + Workflow + Trigger ===== -->
<rect x="310" y="358" width="340" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 团队任务板2.0.0++ 工作流 + 触发器</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Lead 拆解派发 → 成员并行执行 → 审批 / 交付物 / 分发 / 写记忆</text>
<!-- ===== Bottom Center: Models + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -80,9 +80,9 @@
</g>
<g transform="translate(244, 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">Workflow + Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Team · Workflow · Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Task-board dispatch (2.0.0+)</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step modes · 6 patterns</text>
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -83,9 +83,9 @@
</g>
<g transform="translate(244, 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">7 step mode · 6 pattern</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排1.3.0+</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">任务板派发 + 并行2.0.0+</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step mode · 6 pattern</text>
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -855,12 +855,17 @@ async function showLocalToolsSettings(): Promise<void> {
].join('\n')
const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
const hasDirs = cfg.allowedDirs.length > 0
const buttons = hasDirs
? ['关闭', '添加目录…', '移除目录…', cfg.enabled ? '停用' : '启用']
: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用']
const toggleId = buttons.length - 1
const opts = {
type: 'info' as const,
title: '本地工具设置',
message: '本地文件/命令工具',
detail,
buttons: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'],
buttons,
defaultId: 0,
cancelId: 0,
noLink: true,
@ -871,13 +876,39 @@ async function showLocalToolsSettings(): Promise<void> {
if (res.response === 1) {
await pickAllowedDirectory()
} else if (res.response === 2) {
} else if (hasDirs && res.response === 2) {
await pickDirectoryToRemove(cfg.allowedDirs)
} else if (res.response === toggleId) {
const saved = saveLocalToolsConfig({ enabled: !cfg.enabled })
if (saved.enabled && backendReady) localBridge.start()
else if (!saved.enabled) localBridge.stop()
}
}
// Second-level picker for removing a whitelisted directory: native dialogs
// cannot render per-item delete controls, so each directory becomes a button.
async function pickDirectoryToRemove(dirs: string[]): Promise<void> {
const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
const opts = {
type: 'question' as const,
title: '移除目录',
message: '选择要从白名单移除的目录',
detail: '移除后,本地文件/命令工具将无法再访问该目录。',
buttons: ['取消', ...dirs],
defaultId: 0,
cancelId: 0,
noLink: true,
}
const res = parent
? await dialog.showMessageBox(parent, opts)
: await dialog.showMessageBox(opts)
if (res.response === 0) return
const dir = dirs[res.response - 1]
const cfg = loadLocalToolsConfig()
saveLocalToolsConfig({ allowedDirs: cfg.allowedDirs.filter((d) => d !== dir) })
}
async function menuCheckForUpdates(): Promise<void> {
if (!app.isPackaged) {
dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' })

View File

@ -1,6 +1,6 @@
{
"name": "mateclaw-desktop",
"version": "1.8.0",
"version": "2.0.0",
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
"author": "MateClaw Team",
"license": "Apache-2.0",

View File

@ -54,6 +54,24 @@ public interface PluginMemoryProvider {
return "";
}
/**
* Pre-turn context recall with per-owner isolation. Called by the platform
* when an owner key (e.g. {@code "user:42"}, {@code "feishu:sender_abc"})
* is resolved for the current conversation.
* <p>
* Default implementation degrades to the two-arg variant, dropping the
* owner key. External providers that need per-owner recall (e.g. Mem0)
* should override this to use {@code ownerKey} as their per-user identifier.
*
* @param agentId the agent ID
* @param userQuery the current user message
* @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown
* @return context text to inject, or empty string
*/
default String prefetch(Long agentId, String userQuery, String ownerKey) {
return prefetch(agentId, userQuery);
}
/**
* Post-turn sync. Called after LLM response is available.
* Should be non-blocking (async).
@ -62,6 +80,28 @@ public interface PluginMemoryProvider {
String userMessage, String assistantReply) {
}
/**
* Post-turn sync with per-owner isolation. Called by the platform with the
* same {@code ownerKey} that was resolved for this turn's prefetch, so
* providers can persist the turn under the same per-user identifier they
* recall by.
* <p>
* Default implementation degrades to the four-arg variant, dropping the
* owner key. External providers that isolate memory per end-user should
* override this so that written memories stay reachable by owner-scoped
* recall.
*
* @param agentId the agent ID
* @param conversationId the conversation ID
* @param userMessage user's message text
* @param assistantReply assistant's reply text
* @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown
*/
default void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply, String ownerKey) {
syncTurn(agentId, conversationId, userMessage, assistantReply);
}
/**
* Tool beans this provider wants to expose to the agent.
*/

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-mem0</artifactId>
<packaging>jar</packaging>
<name>MateClaw Mem0 Memory Provider Plugin</name>
<description>
Optional community plugin that bridges MateClaw's memory system to a self-hosted
Mem0 service (FastAPI + pgvector + Neo4j). Provides semantic recall via Mem0's
REST API alongside the built-in local memory providers. Not in the default stack;
users must deploy Mem0 separately and install this JAR into the plugins/ directory.
</description>
<dependencies>
<!-- MateClaw Plugin API -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Spring AI (provided by the platform) — PluginContext method signatures
reference ToolCallback/ChatModel, so it must be resolvable at compile time -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<scope>provided</scope>
</dependency>
<!-- Jackson (provided by the platform parent classloader) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
<!-- SLF4J (provided by the platform) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Test only -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- slf4j-simple: gives the plugin a real logger during tests so
LoggerFactory.getLogger doesn't fall back to NOP silently -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,182 @@
package vip.mate.plugin.mem0;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import 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.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Thin HTTP client for a self-hosted Mem0 REST API.
* <p>
* Covers the two endpoints used by {@link Mem0Provider}:
* <ul>
* <li>{@code POST /memories/} add a turn (user + assistant message) for extraction</li>
* <li>{@code POST /memories/search/} semantic recall by query + user_id</li>
* </ul>
*
* <p>Failure semantics: every call either returns a parsed result or throws
* {@link Mem0Exception}. Callers are expected to catch and degrade gracefully
* (return empty recall / log sync failures).
*
* @author MateClaw Team
*/
class Mem0Client {
private final Mem0Config config;
private final HttpClient http;
private final ObjectMapper mapper = new ObjectMapper();
Mem0Client(Mem0Config config) {
this.config = config;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(config.timeoutMs()))
.build();
}
/**
* Push a conversation turn to Mem0 for extraction.
*
* @param userId Mem0 user_id, typically MateClaw's ownerKey
* @param agentId Mem0 agent_id, typically MateClaw's agentId
* @param conversationId optional conversation identifier (stored as metadata)
* @param userMessage user's message text
* @param assistantReply assistant's reply text
*/
void addMemories(String userId, String agentId, String conversationId,
String userMessage, String assistantReply) {
ObjectNode body = mapper.createObjectNode();
body.put("user_id", userId);
if (agentId != null && !agentId.isBlank()) {
body.put("agent_id", agentId);
}
ArrayNode messages = body.putArray("messages");
if (userMessage != null && !userMessage.isBlank()) {
ObjectNode m = messages.addObject();
m.put("role", "user");
m.put("content", userMessage);
}
if (assistantReply != null && !assistantReply.isBlank()) {
ObjectNode m = messages.addObject();
m.put("role", "assistant");
m.put("content", assistantReply);
}
if (conversationId != null && !conversationId.isBlank()) {
ObjectNode meta = body.putObject("metadata");
meta.put("conversation_id", conversationId);
}
post("/memories/", body);
}
/**
* Semantic recall.
*
* @param userId Mem0 user_id (ownerKey)
* @param agentId Mem0 agent_id
* @param query user query text
* @return list of memory strings, possibly empty; never null
*/
List<String> searchMemories(String userId, String agentId, String query) {
ObjectNode body = mapper.createObjectNode();
body.put("query", query);
body.put("user_id", userId);
if (agentId != null && !agentId.isBlank()) {
body.put("agent_id", agentId);
}
body.put("limit", config.maxResults());
JsonNode resp = post("/memories/search/", body);
JsonNode results = resp.path("results");
List<String> out = new ArrayList<>();
if (results.isArray()) {
for (JsonNode r : results) {
String mem = r.path("memory").asText("");
if (!mem.isBlank()) {
out.add(mem);
}
}
}
return out;
}
/**
* Shared POST helper. Returns the parsed JSON body on 2xx.
*
* @throws Mem0Exception on non-2xx response or IO error
*/
private JsonNode post(String path, ObjectNode body) {
String url = config.normalizedBaseUrl() + path;
try {
String payload = mapper.writeValueAsString(body);
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofMillis(config.timeoutMs()))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload));
if (config.apiKey() != null && !config.apiKey().isBlank()) {
req.header("Authorization", "Bearer " + config.apiKey());
}
HttpResponse<String> resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
int code = resp.statusCode();
if (code < 200 || code >= 300) {
throw new Mem0Exception("Mem0 " + path + " returned HTTP " + code
+ ": " + truncate(resp.body(), 500));
}
return mapper.readTree(resp.body() == null ? "{}" : resp.body());
} catch (Mem0Exception e) {
throw e;
} catch (Exception e) {
throw new Mem0Exception("Mem0 " + path + " request failed: " + e.getMessage(), e);
}
}
private static String truncate(String s, int max) {
if (s == null) return "";
return s.length() > max ? s.substring(0, max) + "..." : s;
}
/**
* Test-only accessor for verifying configuration wiring.
*/
Mem0Config config() {
return config;
}
/**
* Test-only helper to inspect what would be POSTed without sending.
* Builds the same payload as {@link #addMemories} and returns it as a Map.
*/
Map<String, Object> buildAddPayload(String userId, String agentId, String conversationId,
String userMessage, String assistantReply) {
ObjectNode body = mapper.createObjectNode();
body.put("user_id", userId);
if (agentId != null && !agentId.isBlank()) {
body.put("agent_id", agentId);
}
ArrayNode messages = body.putArray("messages");
if (userMessage != null && !userMessage.isBlank()) {
ObjectNode m = messages.addObject();
m.put("role", "user");
m.put("content", userMessage);
}
if (assistantReply != null && !assistantReply.isBlank()) {
ObjectNode m = messages.addObject();
m.put("role", "assistant");
m.put("content", assistantReply);
}
if (conversationId != null && !conversationId.isBlank()) {
ObjectNode meta = body.putObject("metadata");
meta.put("conversation_id", conversationId);
}
return mapper.convertValue(body, Map.class);
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.plugin.mem0;
/**
* Mem0 plugin configuration snapshot.
* <p>
* Read once from {@link vip.mate.plugin.api.PluginContext#getConfig} at plugin
* load time and passed to {@link Mem0Client} / {@link Mem0Provider}. Snapshot
* semantics config changes require a plugin reload.
*
* @param baseUrl Mem0 REST API base URL, e.g. {@code http://localhost:8080}
* @param apiKey optional bearer token; null/blank means no Authorization header
* @param searchEnabled whether prefetch should query Mem0 /memories/search/
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
* @param maxResults cap on memories returned per recall
* @param timeoutMs HTTP timeout for both recall and sync
* @author MateClaw Team
*/
record Mem0Config(
String baseUrl,
String apiKey,
boolean searchEnabled,
boolean syncEnabled,
int maxResults,
int timeoutMs
) {
static final int DEFAULT_MAX_RESULTS = 5;
static final int DEFAULT_TIMEOUT_MS = 3000;
/**
* Whether this provider should participate at all.
* Mem0 without a base URL is unusable; treat as unavailable.
*/
boolean isUsable() {
return baseUrl != null && !baseUrl.isBlank();
}
/**
* Strip trailing slashes from the base URL to avoid double-slash in path joins.
*/
String normalizedBaseUrl() {
String url = baseUrl;
while (url.endsWith("/")) {
url = url.substring(0, url.length() - 1);
}
return url;
}
}

View File

@ -0,0 +1,21 @@
package vip.mate.plugin.mem0;
/**
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
* <p>
* Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade
* gracefully (empty recall / dropped sync) without affecting the agent's
* response path.
*
* @author MateClaw Team
*/
class Mem0Exception extends RuntimeException {
Mem0Exception(String message) {
super(message);
}
Mem0Exception(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,107 @@
package vip.mate.plugin.mem0;
import org.slf4j.Logger;
import vip.mate.plugin.api.MateClawPlugin;
import vip.mate.plugin.api.PluginContext;
import java.net.URI;
/**
* MateClaw plugin entrypoint that registers {@link Mem0Provider} with the
* platform's memory subsystem.
* <p>
* Lifecycle:
* <ol>
* <li>{@code onLoad} read config from {@link PluginContext}, build
* {@link Mem0Config} {@link Mem0Client} {@link Mem0Provider},
* then {@code context.registerMemoryProvider(provider)}.
* If the config is incomplete (no baseUrl), the provider is registered
* but reports {@code isAvailable()=false} the platform silently
* skips it.</li>
* <li>{@code onEnable} / {@code onDisable} lifecycle log only.</li>
* </ol>
*
* <p>This plugin is NOT part of the default stack. Users must:
* <ol>
* <li>Self-host a Mem0 service (FastAPI + pgvector + optional Neo4j)</li>
* <li>Drop the built JAR into the platform's {@code plugins/} directory</li>
* <li>Configure {@code baseUrl} (and optionally {@code apiKey}) via the
* plugin admin UI</li>
* </ol>
*
* @author MateClaw Team
*/
public class Mem0Plugin implements MateClawPlugin {
private static final String CONFIG_BASE_URL = "baseUrl";
private static final String CONFIG_API_KEY = "apiKey";
private static final String CONFIG_SEARCH_ENABLED = "searchEnabled";
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
private static final String CONFIG_MAX_RESULTS = "maxResults";
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
private Logger log;
@Override
public void onLoad(PluginContext context) {
this.log = context.getLogger();
Mem0Config config = readConfig(context);
if (!config.isUsable()) {
log.warn("Mem0 plugin loaded without baseUrl — provider will stay unavailable. "
+ "Configure 'baseUrl' in the plugin config to enable.");
}
Mem0Client client = new Mem0Client(config);
Mem0Provider provider = new Mem0Provider(config, client, log);
context.registerMemoryProvider(provider);
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}",
maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(),
config.maxResults(), config.timeoutMs());
}
@Override
public void onEnable() {
if (log != null) log.info("Mem0 plugin enabled");
}
@Override
public void onDisable() {
if (log != null) log.info("Mem0 plugin disabled");
}
private Mem0Config readConfig(PluginContext ctx) {
String baseUrl = ctx.getConfig(CONFIG_BASE_URL, String.class);
String apiKey = ctx.getConfig(CONFIG_API_KEY, String.class);
Boolean searchEnabled = ctx.getConfig(CONFIG_SEARCH_ENABLED, Boolean.class);
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
return new Mem0Config(
baseUrl,
apiKey,
searchEnabled == null ? true : searchEnabled,
syncEnabled == null ? true : syncEnabled,
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs
);
}
/**
* Mask credentials in the URL when logging. Keeps the scheme + host,
* strips any user info and path.
*/
private static String maskUrl(String url) {
if (url == null || url.isBlank()) return "(unset)";
try {
URI u = URI.create(url);
String host = u.getHost();
int port = u.getPort();
return u.getScheme() + "://" + host + (port > 0 ? ":" + port : "");
} catch (Exception e) {
return "(malformed)";
}
}
}

View File

@ -0,0 +1,177 @@
package vip.mate.plugin.mem0;
import org.slf4j.Logger;
import vip.mate.plugin.api.memory.PluginMemoryProvider;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
* Mem0 service.
* <p>
* Behavior matrix:
* <ul>
* <li>{@code systemPromptBlock} no-op (returns ""), aligns with SessionSearchProvider</li>
* <li>{@code prefetch(agentId, query, ownerKey)} when {@code searchEnabled}
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
* and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
* or when disabled.</li>
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} when
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
* pushes the turn to {@code POST /memories/} under {@code user_id =
* ownerKey}, the same identifier prefetch recalls by. Failures are
* logged and swallowed; never blocks the response path. The four-arg
* variant (no ownerKey) skips writing under any other identifier
* would produce memories that owner-scoped recall can never surface.</li>
* <li>{@code getToolBeans} empty (no agent-facing tools in v1)</li>
* </ul>
*
* <p>Per-owner isolation: {@code ownerKey} (e.g. {@code "user:42"}) is passed
* verbatim as Mem0's {@code user_id}; {@code agentId} as Mem0's {@code agent_id}.
* When {@code ownerKey} is null/blank, both recall and sync are skipped Mem0
* requires {@code user_id}.
*
* <p>Asynchronous sync: a single-thread daemon executor is used
* so that bursts of turns don't pile up on the platform's request thread.
*
* @author MateClaw Team
*/
class Mem0Provider implements PluginMemoryProvider {
static final String ID = "mem0";
private final Mem0Config config;
private final Mem0Client client;
private final Logger log;
private final Executor async;
Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
this.config = config;
this.client = client;
this.log = log;
// Single-thread executor is enough syncTurn calls are sequential per
// agent and not latency-sensitive; the platform's request thread must
// not be blocked. A bounded single-thread queue keeps memory footprint
// predictable even under burst load.
this.async = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "mem0-sync");
t.setDaemon(true);
return t;
});
}
@Override
public String id() {
return ID;
}
@Override
public int order() {
// Same as the SPI default; declared explicitly for clarity.
return 200;
}
@Override
public boolean isAvailable() {
// Provider is "available" if at least one of recall/sync can fire.
return config.isUsable() && (config.searchEnabled() || config.syncEnabled());
}
@Override
public String systemPromptBlock(Long agentId) {
return "";
}
@Override
public String prefetch(Long agentId, String userQuery) {
// Two-arg variant: no owner key cannot isolate per-user skip.
// Mem0 requires user_id; without it the call would either fail or
// return global memories breaking per-owner isolation.
return "";
}
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
if (!config.searchEnabled()) {
return "";
}
if (ownerKey == null || ownerKey.isBlank()) {
return "";
}
if (userQuery == null || userQuery.isBlank()) {
return "";
}
try {
List<String> memories = client.searchMemories(
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
if (memories.isEmpty()) {
return "";
}
return formatRecallBlock(memories);
} catch (Exception e) {
// Fault isolation: log and return empty so the platform falls back
// to the other (local) providers without affecting the response.
log.warn("[Mem0] prefetch failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
return "";
}
}
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply) {
// Four-arg variant: no owner key skip. Mem0 keys memories by user_id;
// writing under any fallback identifier (e.g. agentId) would store
// memories that owner-scoped prefetch can never recall.
}
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply, String ownerKey) {
if (!config.syncEnabled()) {
return;
}
if (ownerKey == null || ownerKey.isBlank()) {
// Same guard as prefetch: Mem0 requires user_id; without the owner
// key the write would break per-owner isolation.
return;
}
if ((userMessage == null || userMessage.isBlank())
&& (assistantReply == null || assistantReply.isBlank())) {
return;
}
CompletableFuture.runAsync(() -> {
try {
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
conversationId, userMessage, assistantReply);
} catch (Exception e) {
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
}
}, async);
}
@Override
public void onSessionEnd(Long agentId, String conversationId) {
// No Mem0-specific session cleanup needed in v1.
}
/**
* Format the recalled memories into a labeled block.
* <p>
* The {@code [Mem0 Recall]} label is intentional: it lets the LLM
* distinguish this block from the local providers' output and avoid
* treating it as authoritative PROFILE.md content.
*/
private String formatRecallBlock(List<String> memories) {
StringBuilder sb = new StringBuilder();
sb.append("[Mem0 Recall — semantic matches from external service, treat as hints]\n");
for (int i = 0; i < memories.size(); i++) {
sb.append(i + 1).append(". ").append(memories.get(i)).append('\n');
}
return sb.toString();
}
}

View File

@ -0,0 +1,48 @@
{
"name": "mateclaw-plugin-mem0",
"version": "1.0.0",
"type": "memory",
"displayName": "Mem0 Memory Provider (Optional)",
"description": "Bridges MateClaw's memory system to a self-hosted Mem0 service. Adds semantic recall from Mem0 alongside the built-in local memory providers. Requires a separately deployed Mem0 service (FastAPI + pgvector). Not part of the default stack.",
"entrypoint": "vip.mate.plugin.mem0.Mem0Plugin",
"minPlatformVersion": "2.0.0",
"author": "MateClaw Team",
"config": {
"baseUrl": {
"type": "string",
"required": true,
"secret": false,
"description": "Mem0 REST API base URL, e.g. http://localhost:8080"
},
"apiKey": {
"type": "string",
"required": false,
"secret": true,
"description": "Optional bearer token sent as Authorization header to Mem0"
},
"searchEnabled": {
"type": "boolean",
"required": false,
"secret": false,
"description": "Enable semantic recall via Mem0 /memories/search/. Default true."
},
"syncEnabled": {
"type": "boolean",
"required": false,
"secret": false,
"description": "Enable pushing each turn to Mem0 /memories/. Default true."
},
"maxResults": {
"type": "integer",
"required": false,
"secret": false,
"description": "Max number of memories returned per recall. Default 5."
},
"timeoutMs": {
"type": "integer",
"required": false,
"secret": false,
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
}
}
}

View File

@ -0,0 +1,163 @@
package vip.mate.plugin.mem0;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Mem0ClientTest {
private HttpServer server;
private Mem0Client client;
private final AtomicReference<String> lastPath = new AtomicReference<>();
private final AtomicReference<String> lastBody = new AtomicReference<>();
private final AtomicReference<String> lastAuthHeader = new AtomicReference<>();
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
void setUp() throws IOException {
// Capture request details so each test can assert what was sent.
HttpHandler handler = this::handle;
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", handler);
server.start();
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
Mem0Config config = new Mem0Config(baseUrl, "test-token", true, true, 5, 3000);
client = new Mem0Client(config);
}
@AfterEach
void tearDown() {
if (server != null) server.stop(0);
}
private void handle(HttpExchange exchange) throws IOException {
lastPath.set(exchange.getRequestURI().getPath());
lastAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization"));
try (InputStream in = exchange.getRequestBody()) {
lastBody.set(new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
String path = exchange.getRequestURI().getPath();
if ("/memories/".equals(path) || "/memories/search/".equals(path)) {
byte[] resp;
if ("/memories/".equals(path)) {
resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"x\",\"event\":\"ADD\"}]}".getBytes(StandardCharsets.UTF_8);
} else {
resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes Go\",\"score\":0.9},{\"id\":\"m2\",\"memory\":\"works at Acme\",\"score\":0.7}]}".getBytes(StandardCharsets.UTF_8);
}
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, resp.length);
exchange.getResponseBody().write(resp);
} else {
byte[] resp = "{\"error\":\"not found\"}".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(404, resp.length);
exchange.getResponseBody().write(resp);
}
exchange.close();
}
@Test
void addMemories_postsToMemoriesEndpointWithCorrectPayload() throws Exception {
client.addMemories("user:42", "1", "conv-abc", "hello", "world");
assertThat(lastPath.get()).isEqualTo("/memories/");
assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token");
JsonNode body = mapper.readTree(lastBody.get());
assertThat(body.get("user_id").asText()).isEqualTo("user:42");
assertThat(body.get("agent_id").asText()).isEqualTo("1");
assertThat(body.get("metadata").get("conversation_id").asText()).isEqualTo("conv-abc");
assertThat(body.get("messages").size()).isEqualTo(2);
assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("user");
assertThat(body.get("messages").get(0).get("content").asText()).isEqualTo("hello");
assertThat(body.get("messages").get(1).get("role").asText()).isEqualTo("assistant");
assertThat(body.get("messages").get(1).get("content").asText()).isEqualTo("world");
}
@Test
void addMemories_omitsBlankMessages() throws Exception {
client.addMemories("user:42", "1", null, " ", "reply");
JsonNode body = mapper.readTree(lastBody.get());
assertThat(body.get("messages").size()).isEqualTo(1);
assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("assistant");
// metadata should be absent since conversationId is null
assertThat(body.has("metadata")).isFalse();
}
@Test
void searchMemories_returnsParsedMemoryStrings() {
List<String> results = client.searchMemories("user:42", "1", "what language");
assertThat(results).containsExactly("likes Go", "works at Acme");
assertThat(lastPath.get()).isEqualTo("/memories/search/");
assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token");
}
@Test
void searchMemories_includesQueryUserIdAndLimitInBody() throws Exception {
client.searchMemories("user:42", "1", "query text");
JsonNode body = mapper.readTree(lastBody.get());
assertThat(body.get("query").asText()).isEqualTo("query text");
assertThat(body.get("user_id").asText()).isEqualTo("user:42");
assertThat(body.get("agent_id").asText()).isEqualTo("1");
assertThat(body.get("limit").asInt()).isEqualTo(5); // from Mem0Config in setUp
}
@Test
void non2xxResponseThrowsMem0Exception() {
// Use a client pointed at a non-existent path on the running server.
// Reconfigure handler to return 500 for the next call.
server.removeContext("/");
server.createContext("/", ex -> {
ex.sendResponseHeaders(500, 0);
ex.close();
});
assertThatThrownBy(() -> client.searchMemories("user:42", "1", "q"))
.isInstanceOf(Mem0Exception.class)
.hasMessageContaining("HTTP 500");
}
@Test
void connectionFailureThrowsMem0Exception() {
// Stop the server, then call should fail with connection refused.
int port = server.getAddress().getPort();
server.stop(0);
Mem0Config cfg = new Mem0Config("http://127.0.0.1:" + port, null, true, true, 5, 500);
Mem0Client deadClient = new Mem0Client(cfg);
assertThatThrownBy(() -> deadClient.searchMemories("user:42", "1", "q"))
.isInstanceOf(Mem0Exception.class)
.hasMessageContaining("request failed");
}
@Test
void buildAddPayload_isConsistentWithAddMemories() {
// buildAddPayload is a test helper used to inspect payload structure
// without sending; verify it matches what addMemories would send.
Map<String, Object> payload = client.buildAddPayload("user:42", "1", "conv-x", "hi", "there");
assertThat(payload).containsEntry("user_id", "user:42");
assertThat(payload).containsEntry("agent_id", "1");
assertThat(payload).containsKey("messages");
assertThat(payload).containsKey("metadata");
}
}

View File

@ -0,0 +1,38 @@
package vip.mate.plugin.mem0;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class Mem0ConfigTest {
@Test
void isUsable_false_whenBaseUrlNull() {
Mem0Config c = new Mem0Config(null, null, true, true, 5, 1000);
assertThat(c.isUsable()).isFalse();
}
@Test
void isUsable_false_whenBaseUrlBlank() {
Mem0Config c = new Mem0Config(" ", null, true, true, 5, 1000);
assertThat(c.isUsable()).isFalse();
}
@Test
void isUsable_true_whenBaseUrlSet() {
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
assertThat(c.isUsable()).isTrue();
}
@Test
void normalizedBaseUrl_stripsTrailingSlashes() {
Mem0Config c = new Mem0Config("http://localhost:8080///", null, true, true, 5, 1000);
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
}
@Test
void normalizedBaseUrl_keepsUrlWithoutTrailingSlash() {
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
}
}

View File

@ -0,0 +1,146 @@
package vip.mate.plugin.mem0;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.plugin.api.PluginContext;
import vip.mate.plugin.api.PluginException;
import vip.mate.plugin.api.channel.PluginChannelAdapter;
import vip.mate.plugin.api.memory.PluginMemoryProvider;
import vip.mate.plugin.api.search.PluginSearchProvider;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Mem0PluginTest {
@Test
void onLoad_readsConfigAndRegistersProvider() {
Map<String, Object> config = new HashMap<>();
config.put("baseUrl", "http://localhost:8080");
config.put("apiKey", "secret");
config.put("searchEnabled", true);
config.put("syncEnabled", false);
config.put("maxResults", 7);
config.put("timeoutMs", 5000);
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
PluginContext ctx = new StubContext(config, registered);
Mem0Plugin plugin = new Mem0Plugin();
plugin.onLoad(ctx);
plugin.onEnable();
PluginMemoryProvider p = registered.get();
assertThat(p).isNotNull();
assertThat(p.id()).isEqualTo("mem0");
assertThat(p.isAvailable()).isTrue(); // baseUrl set + searchEnabled true
plugin.onDisable();
}
@Test
void onLoad_withMissingBaseUrl_stillRegistersButUnavailable() {
// No baseUrl configured plugin should register but report unavailable
// rather than throwing.
Map<String, Object> config = new HashMap<>(); // empty
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
PluginContext ctx = new StubContext(config, registered);
Mem0Plugin plugin = new Mem0Plugin();
plugin.onLoad(ctx);
PluginMemoryProvider p = registered.get();
assertThat(p).isNotNull();
assertThat(p.isAvailable()).isFalse();
}
@Test
void onLoad_appliesDefaultsToOptionalConfig() {
// Only baseUrl set searchEnabled/syncEnabled/maxResults/timeoutMs
// should default.
Map<String, Object> config = new HashMap<>();
config.put("baseUrl", "http://localhost:8080");
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
PluginContext ctx = new StubContext(config, registered);
Mem0Plugin plugin = new Mem0Plugin();
plugin.onLoad(ctx);
// Verify defaults indirectly: searchEnabled and syncEnabled both default
// to true isAvailable() must be true.
assertThat(registered.get().isAvailable()).isTrue();
}
@Test
void onLoad_throwsWhenContextRejectsSecondProvider() {
// Simulate the platform's single-select constraint by throwing from
// registerMemoryProvider.
Map<String, Object> config = new HashMap<>();
config.put("baseUrl", "http://localhost:8080");
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
PluginContext ctx = new StubContext(config, registered) {
@Override
public void registerMemoryProvider(PluginMemoryProvider provider) {
throw new PluginException("Only one external memory provider allowed");
}
};
Mem0Plugin plugin = new Mem0Plugin();
assertThatThrownBy(() -> plugin.onLoad(ctx))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Only one");
}
/**
* Minimal PluginContext stub: only getConfig / registerMemoryProvider /
* getLogger are exercised by Mem0Plugin; everything else throws.
*/
static class StubContext implements PluginContext {
private final Map<String, Object> config;
private final AtomicReference<PluginMemoryProvider> registered;
StubContext(Map<String, Object> config, AtomicReference<PluginMemoryProvider> registered) {
this.config = config;
this.registered = registered;
}
@Override
@SuppressWarnings("unchecked")
public <T> T getConfig(String key, Class<T> type) {
Object v = config.get(key);
if (v == null) return null;
if (type.isInstance(v)) return (T) v;
// Best-effort scalar coercion for Integer/Boolean from String/Number
if (type == Integer.class && v instanceof Number n) return (T) (Integer) n.intValue();
if (type == Boolean.class && v instanceof Boolean b) return (T) b;
return null;
}
@Override
public Logger getLogger() {
return LoggerFactory.getLogger("test.Mem0Plugin");
}
@Override
public void registerMemoryProvider(PluginMemoryProvider provider) {
registered.set(provider);
}
// The remaining methods are not used by Mem0Plugin; stub them out.
@Override public void registerTool(ToolCallback tool) { throw new UnsupportedOperationException(); }
@Override public void registerTool(ToolCallback tool, Supplier<Boolean> availabilityCheck) { throw new UnsupportedOperationException(); }
@Override public void registerProvider(String providerId, ChatModel chatModel) { throw new UnsupportedOperationException(); }
@Override public void registerChannel(PluginChannelAdapter channel) { throw new UnsupportedOperationException(); }
@Override public void registerSearchProvider(PluginSearchProvider provider) { throw new UnsupportedOperationException(); }
}
}

View File

@ -0,0 +1,219 @@
package vip.mate.plugin.mem0;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class Mem0ProviderTest {
private HttpServer server;
private Mem0Provider provider;
private final AtomicInteger addCount = new AtomicInteger();
private final AtomicInteger searchCount = new AtomicInteger();
private final AtomicReference<String> lastAddBody = new AtomicReference<>();
@BeforeEach
void setUp() throws IOException {
addCount.set(0);
searchCount.set(0);
lastAddBody.set(null);
HttpHandler handler = this::handle;
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", handler);
server.start();
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
Mem0Config config = new Mem0Config(baseUrl, null, true, true, 3, 3000);
Mem0Client client = new Mem0Client(config);
provider = new Mem0Provider(config, client, LoggerFactory.getLogger("test"));
}
@AfterEach
void tearDown() {
if (server != null) server.stop(0);
}
private void handle(HttpExchange exchange) throws IOException {
String body;
try (InputStream in = exchange.getRequestBody()) {
body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
String path = exchange.getRequestURI().getPath();
byte[] resp;
if ("/memories/".equals(path)) {
addCount.incrementAndGet();
lastAddBody.set(body);
resp = "{\"results\":[]}".getBytes(StandardCharsets.UTF_8);
} else if ("/memories/search/".equals(path)) {
searchCount.incrementAndGet();
resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes PostgreSQL\",\"score\":0.9}]}".getBytes(StandardCharsets.UTF_8);
} else {
resp = "{}".getBytes(StandardCharsets.UTF_8);
}
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, resp.length);
exchange.getResponseBody().write(resp);
exchange.close();
}
@Test
void id_isMem0() {
assertThat(provider.id()).isEqualTo("mem0");
}
@Test
void isAvailable_true_whenConfigUsableAndAtLeastOneFeatureEnabled() {
assertThat(provider.isAvailable()).isTrue();
}
@Test
void isAvailable_false_whenBaseUrlMissing() {
Mem0Config cfg = new Mem0Config(null, null, true, true, 5, 1000);
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
assertThat(p.isAvailable()).isFalse();
}
@Test
void isAvailable_false_whenBothFeaturesDisabled() {
Mem0Config cfg = new Mem0Config("http://localhost:8080", null, false, false, 5, 1000);
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
assertThat(p.isAvailable()).isFalse();
}
@Test
void systemPromptBlock_isEmpty() {
assertThat(provider.systemPromptBlock(1L)).isEmpty();
}
@Test
void twoArgPrefetch_returnsEmptyBecauseNoOwnerKey() {
// Without ownerKey, Mem0 cannot isolate per-user; provider skips.
assertThat(provider.prefetch(1L, "hello")).isEmpty();
assertThat(searchCount.get()).isZero();
}
@Test
void threeArgPrefetch_returnsRecallBlock() {
String result = provider.prefetch(1L, "what database", "user:42");
assertThat(result).startsWith("[Mem0 Recall");
assertThat(result).contains("likes PostgreSQL");
assertThat(searchCount.get()).isEqualTo(1);
}
@Test
void threeArgPrefetch_returnsEmptyWhenOwnerKeyBlank() {
assertThat(provider.prefetch(1L, "query", "")).isEmpty();
assertThat(provider.prefetch(1L, "query", null)).isEmpty();
assertThat(searchCount.get()).isZero();
}
@Test
void threeArgPrefetch_returnsEmptyWhenQueryBlank() {
assertThat(provider.prefetch(1L, "", "user:42")).isEmpty();
assertThat(provider.prefetch(1L, null, "user:42")).isEmpty();
assertThat(searchCount.get()).isZero();
}
@Test
void threeArgPrefetch_returnsEmptyOnServerError() {
// Replace handler to fail; the provider should swallow and return "".
server.removeContext("/");
server.createContext("/", ex -> {
ex.sendResponseHeaders(500, 0);
ex.close();
});
String result = provider.prefetch(1L, "q", "user:42");
assertThat(result).isEmpty();
}
@Test
void syncTurn_pushesAsynchronouslyWithOwnerKeyAsUserId() throws Exception {
provider.syncTurn(1L, "conv-1", "hello", "world", "user:42");
// Wait briefly for the async executor to fire the POST.
long deadline = System.currentTimeMillis() + 2000;
while (addCount.get() == 0 && System.currentTimeMillis() < deadline) {
Thread.sleep(20);
}
assertThat(addCount.get()).isEqualTo(1);
// The write must land under the same user_id that prefetch recalls by.
assertThat(lastAddBody.get()).contains("\"user_id\":\"user:42\"");
assertThat(lastAddBody.get()).contains("\"agent_id\":\"1\"");
}
@Test
void fourArgSyncTurn_skipsBecauseNoOwnerKey() throws Exception {
// Without ownerKey, a write would be keyed by an identifier that
// owner-scoped prefetch never queries; the provider must skip.
provider.syncTurn(1L, "conv-1", "hello", "world");
Thread.sleep(200); // give async a chance to (not) fire
assertThat(addCount.get()).isZero();
}
@Test
void syncTurn_skipsWhenOwnerKeyBlank() throws Exception {
provider.syncTurn(1L, "conv-1", "hello", "world", "");
provider.syncTurn(1L, "conv-1", "hello", "world", null);
Thread.sleep(200);
assertThat(addCount.get()).isZero();
}
@Test
void syncTurn_skipsWhenBothMessagesBlank() throws Exception {
provider.syncTurn(1L, "conv-1", " ", "", "user:42");
Thread.sleep(200); // give async a chance to (not) fire
assertThat(addCount.get()).isZero();
}
@Test
void syncTurn_failureIsSwallowedAndDoesNotThrow() throws Exception {
// Stop the server so the async POST fails; provider must not propagate.
server.stop(0);
// Re-create a stub server just so tearDown doesn't NPE; not listening
// on the original port anymore the client will get connection refused.
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", ex -> { ex.sendResponseHeaders(200, 0); ex.close(); });
// Note: client still points at the old port connection refused.
provider.syncTurn(1L, "conv-1", "hi", "there", "user:42");
Thread.sleep(500);
// No exception thrown; nothing to assert beyond "test didn't blow up".
}
@Test
void syncTurn_skippedWhenSyncDisabled() throws Exception {
// Build a provider with sync disabled.
Mem0Config cfg = new Mem0Config(
"http://127.0.0.1:" + server.getAddress().getPort(),
null, true, false, 3, 3000);
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
p.syncTurn(1L, "conv-1", "hi", "there", "user:42");
Thread.sleep(200);
assertThat(addCount.get()).isZero();
}
@Test
void prefetch_skippedWhenSearchDisabled() {
Mem0Config cfg = new Mem0Config(
"http://127.0.0.1:" + server.getAddress().getPort(),
null, false, true, 3, 3000);
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
assertThat(searchCount.get()).isZero();
}
}

View File

@ -42,12 +42,19 @@ ARG MAVEN_FLAGS=""
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
# Copy the root parent plus module POMs first for Docker layer caching.
#
# This list MUST mirror <modules> in the root pom.xml, even for modules this
# image never builds. Maven fails while constructing the reactor if a declared
# module directory is missing ("Child module /build/<name> does not exist"),
# so `-pl mateclaw-server -am` aborts before it ever gets to dependency
# resolution. When a module is added to the root POM, add its pom.xml here too.
WORKDIR /build
COPY pom.xml ./pom.xml
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml
COPY mateclaw-plugin-mem0/pom.xml mateclaw-plugin-mem0/pom.xml
# Pre-fetch backend dependencies through the reactor so the parent POM,
# dependencyManagement, and internal module versions all resolve consistently.

View File

@ -60,6 +60,8 @@ import vip.mate.tool.guard.service.ToolGuardService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamContextBuilder;
import vip.mate.team.service.TeamPlanBridge;
import vip.mate.wiki.service.WikiContextService;
import java.lang.reflect.Field;
@ -101,6 +103,8 @@ public class AgentGraphBuilder {
"${mate.agent.markdown-normalize-enabled:true}")
private boolean markdownNormalizeEnabled;
private final ConversationService conversationService;
private final TeamContextBuilder teamContextBuilder;
private final TeamPlanBridge teamPlanBridge;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver;
@ -641,6 +645,9 @@ public class AgentGraphBuilder {
executor.setAuditEventService(auditEventService);
}
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
// the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
// Per-step delegation: route a step assigned to a specialist agent
// through DelegateAgentTool (null when delegation deps aren't wired).
@ -785,7 +792,10 @@ public class AgentGraphBuilder {
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE))
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE,
// Board-delegated plan settled: step results were
// rebuilt from team tasks summarize directly.
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
Map.of(
@ -960,6 +970,9 @@ public class AgentGraphBuilder {
// C4: wire the environment-notification registry so ReasoningNode
// can drain pending MCP/skill events and inject them as a SystemMessage.
reasoningNode.setRunningConversationRegistry(runningConversationRegistry);
// Live team-board snapshot for leads, injected per turn as a meta
// user message; no-op for agents outside any team.
reasoningNode.setTeamContextBuilder(teamContextBuilder);
ActionNode actionNode = new ActionNode(executor, streamTracker);
// B2/B5: wire optional collaborators so ActionNode can pin skill
// constraints and auto-record tool completions into ProgressLedger.
@ -1743,10 +1756,20 @@ public class AgentGraphBuilder {
""";
}
// Wiki 知识库上下文注入
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
// Wiki 知识库上下文注入Share the same prefix budget as the memory
// block so a large KB's page listing can't consume a fixed
// maxContextChars-sized slice of a small model's window every turn
// (issue #521). Integer.MAX_VALUE (the unbudgeted default path) keeps
// the legacy chars-only cap for large cloud models.
Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens;
String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens);
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext;
// Team context (role-specific board playbook, or a negative notice for
// agents outside any team). Baked here so it shares the prompt-cache
// prefix; TeamChangedEvent evicts the cached agent on composition changes.
String teamContext = teamContextBuilder.buildTeamContext(entity.getId());
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext;
}
/**
@ -1779,7 +1802,7 @@ public class AgentGraphBuilder {
boolean anyHasConstraints = false;
for (String skillName : loaded) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName);
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName, workspaceId);
if (skill != null && skill.getManifest() != null) {
List<String> constraints = skill.getManifest().getConstraints();
if (constraints != null && !constraints.isEmpty()) {

View File

@ -13,6 +13,7 @@ import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.progress.ProgressLedgerService;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
@ -21,6 +22,7 @@ import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.team.event.TeamChangedEvent;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
@ -68,6 +70,14 @@ public class AgentService {
@Autowired(required = false)
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
/**
* Optional clears leftover auto-recorded ledger entries when a new
* user turn starts. Field-injected so existing test constructors of
* {@code AgentService} don't need to supply it.
*/
@Autowired(required = false)
private ProgressLedgerService progressLedgerService;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
@ -239,8 +249,46 @@ public class AgentService {
}
}
/**
* Invalidate cached agents whenever their team's composition or settings
* change. The team context block is baked into the system prompt at build
* time, so membership edits would otherwise stay invisible until restart.
*/
@EventListener
public void onTeamChanged(TeamChangedEvent event) {
if (event.agentIds() != null) {
event.agentIds().forEach(agentInstances::remove);
}
}
// ==================== 运行时入口 ====================
/**
* New-user-turn housekeeping: drop auto-recorded ledger entries left
* over from the previous turn. They mark past tool calls as DONE, and
* the ledger snapshot's "已完成的步骤不要重复执行" instruction would
* otherwise stop the agent from re-running status-query tools when the
* user repeats a question that needs fresh data.
*
* <p>Only the fresh-turn entries ({@code chat} / {@code chatStream} /
* {@code chatStructuredStream} / {@code execute}) call this. The
* approval-replay entries ({@code chatWithReplay*}) resume the SAME
* logical turn after a tool approval and must keep the safety net for
* work already done before the pause.
*/
private void clearAutoRecordedForNewTurn(String conversationId) {
if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
return;
}
try {
progressLedgerService.clearAutoRecorded(conversationId);
} catch (Exception e) {
// Ledger housekeeping must never block the chat itself.
log.warn("Failed to clear auto-recorded ledger entries for {}: {}",
conversationId, e.getMessage());
}
}
public String chat(Long agentId, String message, String conversationId) {
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
}
@ -251,6 +299,7 @@ public class AgentService {
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
@ -287,6 +336,7 @@ public class AgentService {
}
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux
@ -323,6 +373,7 @@ public class AgentService {
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId, String thinkingLevel,
ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
@ -369,6 +420,7 @@ public class AgentService {
}
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);

View File

@ -0,0 +1,38 @@
package vip.mate.agent.context;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.approval.grant.WorkspaceLookupCache;
/**
* Resolves the workspace of the currently-executing conversation for the agent
* runtime skill-resolution path.
* <p>
* The {@link ChatOrigin} carried in a tool's {@code ToolContext} usually already
* holds the workspaceId (populated at the web / channel entry point). Some paths
* notably approval replay carry a conversationId but a {@code null}
* workspaceId; there we fall back to {@link WorkspaceLookupCache}, which maps a
* conversationId to its owning workspace. When neither yields a workspace, the
* result is {@code null}: callers must treat that conservatively (resolve only
* builtin / global skills, never another workspace's skill).
*/
@Component
@RequiredArgsConstructor
public class AgentWorkspaceResolver {
private final WorkspaceLookupCache workspaceLookupCache;
/** Best-effort workspace id for the given origin; {@code null} if unresolved. */
@Nullable
public Long resolve(@Nullable ChatOrigin origin) {
if (origin == null) {
return null;
}
if (origin.workspaceId() != null) {
return origin.workspaceId();
}
String conversationId = origin.conversationId();
return conversationId != null ? workspaceLookupCache.resolveByConversation(conversationId) : null;
}
}

View File

@ -8,6 +8,8 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
@ -15,6 +17,10 @@ import vip.mate.llm.chatmodel.ReasoningContentCache;
import reactor.core.Disposable;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@ -193,6 +199,16 @@ public class NodeStreamingChatHelper {
else healthTracker.recordFailure(primaryProviderId);
}
/**
* Record a primary failure carrying a provider-stated retry window so the
* health tracker can start a cooldown of exactly that length. No-op under
* the same conditions as {@link #recordPrimary}.
*/
private void recordPrimaryFailure(long cooldownOverrideMs) {
if (healthTracker == null || primaryProviderId == null) return;
healthTracker.recordFailure(primaryProviderId, cooldownOverrideMs);
}
/**
* Map an {@link ErrorType} to the matching pool
* {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for
@ -207,7 +223,11 @@ public class NodeStreamingChatHelper {
* {@link vip.mate.llm.failover.ProviderHealthTracker}'s cooldown instead.</p>
*/
private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) {
if (type == null) return null;
// Policy lives on the enum ({@code evictsProvider}); this switch is
// only the name mapping to the pool's RemovalSource. A type marked
// evicting but missing here falls through to null (fail-open, logged
// nowhere) extend the switch when adding a new evicting type.
if (type == null || !type.evictsProvider()) return null;
return switch (type) {
case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR;
case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING;
@ -224,11 +244,7 @@ public class NodeStreamingChatHelper {
* says nothing about whether the provider's other models still work.
*/
private static boolean isProviderLevelFailure(ErrorType type) {
if (type == null) return false;
return switch (type) {
case NONE, PROMPT_TOO_LONG, CLIENT_ERROR, THINKING_BLOCK_ERROR, MODEL_NOT_FOUND -> false;
default -> true;
};
return type != null && type.countsHealth();
}
/** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */
@ -373,6 +389,21 @@ public class NodeStreamingChatHelper {
// avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the
// ultimate safety net.
static final int MAX_RETRIES_UNKNOWN = 5;
// OVERLOADED: the provider's serving capacity is saturated (Anthropic 529,
// "engine_overloaded", "model is overloaded"). Unlike RATE_LIMIT this says
// nothing about the caller's key, so waiting on the same provider is the
// productive move recovery periods are typically tens of seconds, hence
// the dedicated long backoff table below instead of the generic 3s-based
// exponential.
static final int MAX_RETRIES_OVERLOADED = 5;
/**
* Backoff table for {@link ErrorType#OVERLOADED} retries, indexed by
* {@code attempt - 1} (attempts past the table reuse the last entry).
* A ±30% jitter is applied on top so concurrent conversations don't
* re-hit a saturated provider in lockstep. The 3-minute wall-clock
* budget still bounds the total wait.
*/
static final long[] OVERLOADED_BACKOFF_MS = {10_000, 20_000, 40_000, 60_000, 60_000};
// Hard time budget for the primary retry loop (3 min). Prevents
// retries from stalling a single conversation turn indefinitely.
// Aligned with WikiProcessingService.llmMaxTotalDurationMs.
@ -455,9 +486,24 @@ public class NodeStreamingChatHelper {
|| msg.contains("authentication") || msg.contains("AuthenticationError")) {
return ErrorType.AUTH_ERROR;
}
// Overloaded the provider's serving capacity is saturated. Checked
// BEFORE the rate-limit patterns: providers commonly surface overload
// through a reused 429 status ("engine_overloaded" arrives alongside
// "429" in the same chain), and the more specific semantic must win
// an overloaded provider deserves patient same-provider backoff, not
// the rate-limit fast-failover path.
if (msg.contains("engine_overloaded")
|| msg.contains("overloaded_error") // Anthropic 529 body type
|| msg.contains("Overloaded") // Anthropic 529 message
|| msg.contains("model is overloaded") // Gemini / OpenAI-compatible
|| msg.contains("529")
|| msg.contains("server is busy")
|| msg.contains("当前分组上游负载已饱和")) { // SiliconFlow group saturation
return ErrorType.OVERLOADED;
}
// Rate limit
if (msg.contains("429") || msg.contains("rate_limit") || msg.contains("RateLimitError")
|| msg.contains("Too Many Requests") || msg.contains("engine_overloaded")) {
|| msg.contains("Too Many Requests")) {
return ErrorType.RATE_LIMIT;
}
// Thinking block errors (Anthropic: old thinking blocks cannot be modified)
@ -549,8 +595,7 @@ public class NodeStreamingChatHelper {
// surfaced as HTTP 400 with a body that describes the upstream
// outage. These are transient server-side failures retryable.
|| msg.contains("temporarily unavailable")
|| msg.contains("service unavailable")
|| msg.contains("model is overloaded")) {
|| msg.contains("service unavailable")) {
return ErrorType.SERVER_ERROR;
}
// Client errors (400 Bad Request unsupported format, invalid params, etc.) NOT retryable.
@ -568,6 +613,111 @@ public class NodeStreamingChatHelper {
return ErrorType.UNKNOWN;
}
/**
* Ceiling for honoring a provider-stated retry window as an in-loop
* backoff sleep. Longer windows (quota resets measured in minutes or
* hours) are not worth blocking a conversation turn for the call fails
* over instead, and the window is honored as a
* {@code ProviderHealthTracker} cooldown override so later turns skip
* the provider without re-probing it.
*/
static final long HINTED_BACKOFF_CAP_MS = 90_000;
/** Floor / ceiling for any parsed retry-window hint (guards absurd values). */
private static final long MIN_HINT_MS = 1_000;
private static final long MAX_HINT_MS = 2 * 60 * 60 * 1000L;
private static final List<String> ANTHROPIC_RESET_HEADERS = List.of(
"anthropic-ratelimit-requests-reset",
"anthropic-ratelimit-tokens-reset",
"anthropic-ratelimit-input-tokens-reset",
"anthropic-ratelimit-output-tokens-reset");
private static final List<String> OPENAI_RESET_HEADERS = List.of(
"x-ratelimit-reset-requests",
"x-ratelimit-reset-tokens");
/** Matches Go-style duration strings ("1s", "6m0s", "120ms", "1h2m"). */
private static final java.util.regex.Pattern GO_DURATION = java.util.regex.Pattern.compile(
"^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?(?:(\\d+)ms)?$");
/**
* Walk the error chain for an HTTP response exception and parse the
* provider-stated retry window from its headers. Returns milliseconds
* clamped to {@code [MIN_HINT_MS, MAX_HINT_MS]}, or {@code 0} when no
* usable hint is present.
*
* <p>Priority: {@code Retry-After} (delta-seconds or HTTP-date)
* Anthropic RFC-3339 reset instants OpenAI-style duration resets. For
* multi-bucket reset headers the <b>earliest</b> future instant wins
* optimistic, because a premature retry just re-records the hint, while
* over-waiting silently costs the user the whole window.</p>
*/
static long extractRetryAfterMs(Throwable error) {
for (Throwable cur = error; cur != null; cur = cur.getCause()) {
HttpHeaders headers = null;
if (cur instanceof WebClientResponseException wre) {
headers = wre.getHeaders();
} else if (cur instanceof RestClientResponseException rre) {
headers = rre.getResponseHeaders();
}
if (headers == null) continue;
long ms = parseRetryWindowMs(headers);
if (ms > 0) return ms;
}
return 0;
}
private static long parseRetryWindowMs(HttpHeaders headers) {
String retryAfter = headers.getFirst("retry-after");
if (retryAfter != null && !retryAfter.isBlank()) {
String v = retryAfter.trim();
if (v.chars().allMatch(Character::isDigit)) {
return clampHint(Long.parseLong(v) * 1000);
}
try {
long epochMs = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant().toEpochMilli();
return clampHint(epochMs - System.currentTimeMillis());
} catch (DateTimeParseException ignored) {
// fall through to the reset headers
}
}
long best = 0;
for (String name : ANTHROPIC_RESET_HEADERS) {
String v = headers.getFirst(name);
if (v == null || v.isBlank()) continue;
try {
long delta = Instant.parse(v.trim()).toEpochMilli() - System.currentTimeMillis();
if (delta > 0 && (best == 0 || delta < best)) best = delta;
} catch (DateTimeParseException ignored) {
}
}
if (best > 0) return clampHint(best);
for (String name : OPENAI_RESET_HEADERS) {
long ms = parseGoDurationMs(headers.getFirst(name));
if (ms > 0 && (best == 0 || ms < best)) best = ms;
}
return best > 0 ? clampHint(best) : 0;
}
private static long parseGoDurationMs(String value) {
if (value == null || value.isBlank()) return 0;
java.util.regex.Matcher m = GO_DURATION.matcher(value.trim());
if (!m.matches()) return 0;
long ms = 0;
if (m.group(1) != null) ms += Long.parseLong(m.group(1)) * 3_600_000L;
if (m.group(2) != null) ms += Long.parseLong(m.group(2)) * 60_000L;
if (m.group(3) != null) ms += (long) (Double.parseDouble(m.group(3)) * 1000);
if (m.group(4) != null) ms += Long.parseLong(m.group(4));
return ms;
}
private static long clampHint(long ms) {
if (ms <= 0) return 0;
return Math.max(MIN_HINT_MS, Math.min(ms, MAX_HINT_MS));
}
/** 提取完整异常链信息用于关键字匹配 */
private static String extractFullErrorChain(Throwable error) {
StringBuilder sb = new StringBuilder();
@ -640,6 +790,17 @@ public class NodeStreamingChatHelper {
int failoverCount = 0;
int llmCallCount = 0;
long callStartMs = System.currentTimeMillis();
// True once the generic routing below has already recorded a health
// failure for this incident stops the post-loop fallback record from
// double-counting it.
boolean healthRecorded = false;
// Carries the ErrorType behind each null-return retry so the next
// attempt's backoff can be type-aware (see doStreamCall).
AtomicReference<ErrorType> retryType = new AtomicReference<>();
// Provider-stated retry window (ms) parsed from the latest 429/529
// response headers; 0 when absent. Consumed by the next attempt's
// backoff and by the health-cooldown override on failover.
AtomicReference<Long> retryHint = new AtomicReference<>(0L);
// 主模型重试循环
StreamResult lastResult = null;
@ -655,111 +816,82 @@ public class NodeStreamingChatHelper {
}
llmCallCount++;
if (attempt > 0) retryCount++;
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true);
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true, retryType, retryHint);
if (lastResult != null) {
// PTL: 不重试直接返回给上层 Node 处理
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
return lastResult;
}
// AUTH: primary key 失效不会自愈跳过同模型重试交给 fallback chain
// 其它 provider key 可能仍然可用 BILLING / MODEL_NOT_FOUND 同策略
// recordPrimary(false) 仍记一次失败用于 healthTracker 冷却累计
// fallback chain 全部 401walker 末尾会把最后一次 AUTH_ERROR 透出
// 不会静默吞错
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
log.warn("[{}] Primary auth failed — skipping same-model retries, handing off to fallback chain", phase);
recordPrimary(false);
removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage());
break;
}
// BILLING provider-side hard failure (out of credit). Won't change
// on retry and affects every model on the provider, so evict it and
// hand off to the fallback chain (a different provider may have credits).
if (lastResult.errorType() == ErrorType.BILLING) {
log.warn("[{}] Primary billing failure — skipping same-model retries, handing off to fallback chain", phase);
recordPrimary(false);
removeFromPool(primaryProviderId, ErrorType.BILLING, lastResult.errorMessage());
break;
}
// MODEL_NOT_FOUND the provider rejected this specific model id. The
// provider itself is healthy, so do NOT evict it from the pool or
// record a provider-level failure: that would take its sibling models
// down too. Just skip same-model retries and hand off to the fallback
// chain a different provider may recognize the model name.
if (lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) {
log.warn("[{}] Primary model not found — handing off to fallback chain "
+ "(provider kept available for its other models)", phase);
break;
}
// CLIENT_ERROR (400 Bad Request): 不重试参数/格式错误重试也不会变
if (lastResult.errorType() == ErrorType.CLIENT_ERROR) {
return lastResult;
}
// THINKING_BLOCK_ERROR: 剥离旧 thinking 块后单次重试
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR && attempt == 0) {
log.warn("[{}] Thinking block error detected, stripping old thinking and retrying once", phase);
prompt = stripThinkingFromPrompt(prompt);
continue; // 重试一次
}
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
return lastResult; // 已经重试过了
}
// EMPTY_RESPONSE transient gateway blip often resolves on same-model
// retry (e.g., proxy timeout returns HTTP 200 with empty body).
// Retry up to MAX_RETRIES_EMPTY_RESPONSE before handing off to the
// fallback chain. A different provider has a better chance of
// succeeding if the same model repeatedly returns nothing.
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
if (attempt < MAX_RETRIES_EMPTY_RESPONSE) {
log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
phase, attempt + 1, MAX_RETRIES_EMPTY_RESPONSE + 1);
continue;
}
log.warn("[{}] Primary exhausted empty-response retries — handing off to fallback chain", phase);
recordPrimary(false);
break;
}
// 成功
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
ErrorType errType = lastResult.errorType();
// Success reaffirm health / pool membership and return.
if (lastResult.errorMessage() == null || errType == ErrorType.NONE) {
recordPrimary(true);
addToPool(primaryProviderId);
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return lastResult;
}
// RATE_LIMIT / SERVER_ERROR / UNKNOWN past their retry budget are
// provider-level failures: the same model will not recover within
// this turn, but a different provider can. Break to the fallback
// chain instead of returning recordPrimary(false) runs once at
// the post-loop provider health check below, and if every fallback
// also fails the chain walker re-surfaces this same error to the
// caller.
// UNKNOWN errors are included defensively: an error we can't
// classify may be a transient (mis-classified by our keyword
// patterns) or a fatal (truly new error shape). Retrying with a
// smaller budget (MAX_RETRIES_UNKNOWN=5 vs MAX_RETRIES=10) is
// safer than immediate termination MAX_TOTAL_DURATION_MS provides
// the ultimate safety net.
if (lastResult.errorType() == ErrorType.RATE_LIMIT
|| lastResult.errorType() == ErrorType.SERVER_ERROR
|| lastResult.errorType() == ErrorType.UNKNOWN) {
log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain",
phase, lastResult.errorType());
break;
// PROMPT_TOO_LONG side-effectful recovery owned by the caller:
// the node runs structured compaction and retries by itself, so
// the error must surface unchanged (never routed to fallback
// a different provider has a different window and the caller
// would lose the compaction signal).
if (errType == ErrorType.PROMPT_TOO_LONG) {
return lastResult;
}
// Any truly unhandled error type safety net. Prefer falling back
// over terminating the entire call. If this branch is ever hit in
// production, the type should be added explicitly above.
recordPrimary(false);
log.warn("[{}] Primary returned unhandled error type={} — handing off to fallback chain",
phase, lastResult.errorType());
// THINKING_BLOCK_ERROR side-effectful recovery: strip stale
// thinking blocks from the prompt, then retry once. Kept as an
// explicit branch because the generic path cannot mutate the
// outgoing prompt.
if (errType == ErrorType.THINKING_BLOCK_ERROR) {
if (attempt == 0) {
log.warn("[{}] Thinking block error detected, stripping old thinking and retrying once", phase);
prompt = stripThinkingFromPrompt(prompt);
continue;
}
return lastResult;
}
// EMPTY_RESPONSE retries here in the outer loop it is a
// result (HTTP 200 with an empty body), not an exception, so
// the inner retry gate never sees it. Same-model retry often
// resolves the transient gateway blip.
if (errType == ErrorType.EMPTY_RESPONSE && attempt < errType.retryBudget()) {
log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
phase, attempt + 1, errType.retryBudget() + 1);
continue;
}
// Generic routing driven entirely by the ErrorType policy
// attributes. By the time a typed error result surfaces here
// the type's same-model retry budget is already exhausted
// (enforced inside the call for exception-path types).
if (!errType.failsOver()) {
// Fails identically everywhere (e.g. CLIENT_ERROR)
// surface to the caller instead of burning the chain.
return lastResult;
}
if (errType.countsHealth()) {
// A rate-limit response carrying an explicit retry window
// becomes a health-cooldown override: later turns skip the
// provider until the stated instant instead of re-probing
// it every ~5 minutes and re-collecting the same 429.
Long hintMs = retryHint.get();
if (errType == ErrorType.RATE_LIMIT && hintMs != null && hintMs > 0) {
recordPrimaryFailure(hintMs);
} else {
recordPrimary(false);
}
healthRecorded = true;
}
if (errType.evictsProvider()) {
removeFromPool(primaryProviderId, errType, lastResult.errorMessage());
}
log.warn("[{}] Primary failed (type={}) — handing off to fallback chain", phase, errType);
break;
}
// lastResult == null 表示需要重试
}
// If we exhausted the retry loop without a verdict, primary effectively
// failed. Only count it against provider health for provider-level errors
// a MODEL_NOT_FOUND break above must not nudge the provider toward cooldown.
if (!primarySkipped && lastResult != null && isProviderLevelFailure(lastResult.errorType())) {
// Exits that bypassed the generic routing (time-budget break, an
// EMPTY_RESPONSE retry cut short by the loop bound) still count one
// health failure for provider-level errors. healthRecorded guards
// against double-counting the generic-path breaks; model-scoped
// errors (MODEL_NOT_FOUND et al.) never dent provider health.
if (!primarySkipped && !healthRecorded && lastResult != null
&& isProviderLevelFailure(lastResult.errorType())) {
recordPrimary(false);
}
@ -797,7 +929,8 @@ public class NodeStreamingChatHelper {
failoverCount++;
llmCallCount++;
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
phase + "_fallback_" + (i + 1), broadcast, 0, false);
phase + "_fallback_" + (i + 1), broadcast, 0, false,
new AtomicReference<>(), new AtomicReference<>(0L));
// Accept only fully successful fallbacks. Non-successful results (auth
// error, client error, still-rate-limited) propagate to the next
// fallback instead of being surfaced as the final result.
@ -840,11 +973,17 @@ public class NodeStreamingChatHelper {
/**
* 单次流式调用尝试
* @param retryTypeRef carries the {@link ErrorType} that caused the
* previous attempt's retry (set on every
* {@code return null}) so the next attempt's backoff
* can be type-aware (OVERLOADED uses the long table).
* @return StreamResult 如果成功/降级/不可重试null 如果应该重试
*/
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt, boolean primaryCall) {
boolean broadcast, int attempt, boolean primaryCall,
AtomicReference<ErrorType> retryTypeRef,
AtomicReference<Long> retryHintRef) {
// Collapse every SystemMessage in the prompt into a single SystemMessage
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
// server, certain strict vLLM / SGLang deployments) reject 400
@ -897,7 +1036,7 @@ public class NodeStreamingChatHelper {
}
try {
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall);
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall, retryTypeRef, retryHintRef);
} finally {
// Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) {
@ -929,18 +1068,42 @@ public class NodeStreamingChatHelper {
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt, boolean primaryCall) {
boolean broadcast, int attempt, boolean primaryCall,
AtomicReference<ErrorType> retryTypeRef,
AtomicReference<Long> retryHintRef) {
if (attempt > 0) {
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, backoffCapMs);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
phase, attempt, MAX_RETRIES, delay, conversationId);
boolean overloaded = retryTypeRef.get() == ErrorType.OVERLOADED;
Long hintedMs = retryHintRef.get();
long delay;
if (hintedMs != null && hintedMs > 0) {
// The provider stated exactly when to come back honor it
// (capped: longer windows are handled by failover + the
// health-cooldown override, not by blocking this turn), with
// a small additive jitter so concurrent sessions don't retry
// in lockstep at the stated instant.
delay = Math.min(hintedMs, HINTED_BACKOFF_CAP_MS)
+ ThreadLocalRandom.current().nextLong(0, 1_000);
} else if (overloaded) {
// Saturated provider: recovery periods run tens of seconds, so
// the generic 3s-based exponential would burn attempts before
// capacity returns. Table lookup + ±30% jitter (decorrelates
// concurrent conversations re-hitting the same provider).
int idx = Math.min(attempt - 1, OVERLOADED_BACKOFF_MS.length - 1);
long base = OVERLOADED_BACKOFF_MS[idx];
delay = base * (70 + ThreadLocalRandom.current().nextLong(61)) / 100;
} else {
delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, backoffCapMs);
}
log.warn("[{}] Retry attempt {}/{} after {}ms (prev type={}) for conversation {}",
phase, attempt, MAX_RETRIES, delay, retryTypeRef.get(), conversationId);
// 广播给前端用户可见的重试倒计时
if (broadcast) {
String cause = overloaded ? "模型服务繁忙" : "请求频率受限";
broadcastDelta(conversationId, "warning",
buildDeltaJson("⏱️ 请求频率受限,等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
}
// Poll stop flag every 100ms so user Stop is honored mid-backoff.
long remaining = delay;
@ -1256,6 +1419,14 @@ public class NodeStreamingChatHelper {
// ===== 无内容分类错误并决定是否重试 =====
ErrorType errorType = classifyError(error);
// Extract the provider-stated retry window once per failure and
// publish it for both consumers (next attempt's backoff; health
// cooldown override on failover). Non-throttling types clear the
// slot so a stale hint from an earlier attempt can't leak into an
// unrelated retry's backoff.
retryHintRef.set(errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.OVERLOADED
? extractRetryAfterMs(error) : 0L);
// PTL: 不重试返回给上层 Node 处理压缩
if (errorType == ErrorType.PROMPT_TOO_LONG) {
log.warn("[{}] Prompt too long error, returning to node for compaction: {}",
@ -1275,46 +1446,30 @@ public class NodeStreamingChatHelper {
conversationId, phase, errorType);
}
// Auth: 不重试
if (errorType == ErrorType.AUTH_ERROR) {
log.error("[{}] Authentication error, not retrying: {}", phase, error.getMessage());
return buildErrorResultWithType("认证失败: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
// Generic retry gate the ErrorType's own budget decides whether
// this attempt returns null (outer loop retries with backoff) or
// surfaces a typed terminal result for the routing skeleton.
// THINKING_BLOCK_ERROR is excluded: its retry needs the prompt
// mutation (strip thinking) that only the outer loop can do, so it
// always surfaces immediately despite a non-zero budget.
if (errorType != ErrorType.THINKING_BLOCK_ERROR && attempt < errorType.retryBudget()) {
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
phase, attempt, errorType.retryBudget(), errorType, error.getMessage());
retryTypeRef.set(errorType);
return null;
}
// Client error (400): 不重试参数/格式错误重试也不会变
if (errorType == ErrorType.CLIENT_ERROR) {
log.error("[{}] Client error (400), not retrying: {}", phase, error.getMessage());
return buildErrorResultWithType("Bad request: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
}
// Rate limit / Server error / Unknown: retryable, but with different budgets.
// RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2).
// SERVER_ERROR: keep full MAX_RETRIES upstream flaps often self-heal.
// UNKNOWN: conservative cap (5 vs 10). Defensive: retry what we can't
// classify, but with a smaller budget to avoid masking truly fatal
// errors. MAX_TOTAL_DURATION_MS provides the ultimate safety net.
if (errorType == ErrorType.RATE_LIMIT
|| errorType == ErrorType.SERVER_ERROR
|| errorType == ErrorType.UNKNOWN) {
int effectiveMaxRetries = switch (errorType) {
case RATE_LIMIT -> MAX_RETRIES_RATE_LIMIT;
case UNKNOWN -> MAX_RETRIES_UNKNOWN;
default -> MAX_RETRIES;
};
if (attempt < effectiveMaxRetries) {
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
phase, attempt, effectiveMaxRetries, errorType, error.getMessage());
return null; // 返回 null 触发重试
}
}
// 不可重试或已耗尽重试
log.error("[{}] LLM call failed after {} attempts for conversation {}: {}",
phase, attempt + 1, conversationId, error.getMessage());
return buildErrorResultWithType("LLM 调用失败: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
// Not retryable, or retry budget exhausted surface with a
// type-appropriate user-facing prefix.
String friendly = extractUserFriendlyError(error);
String message = switch (errorType) {
case AUTH_ERROR -> "认证失败: " + friendly;
case CLIENT_ERROR -> "Bad request: " + friendly;
default -> "LLM 调用失败: " + friendly;
};
log.error("[{}] LLM call failed (type={}) after {} attempts for conversation {}: {}",
phase, errorType, attempt + 1, conversationId, error.getMessage());
return buildErrorResultWithType(message, conversationId, phase, errorType);
}
// ===== 成功检查是否因 thinking-only 软上限或内容重复被截断 =====
@ -1786,46 +1941,123 @@ public class NodeStreamingChatHelper {
/**
* LLM 调用错误类型分类
*/
/**
* Error classification with the recovery policy attached to each type.
*
* <p>Each constant carries four policy attributes so the retry loop, the
* fallback-chain router, the pool eviction hook, and the health tracker
* all read <b>one</b> source of truth instead of maintaining parallel
* per-type branch chains:</p>
* <ul>
* <li>{@link #retryBudget()} same-model retry attempts before the
* type is considered exhausted (0 = never retried).</li>
* <li>{@link #failsOver()} whether an exhausted failure of this type
* hands off to the fallback chain (vs. returning the error to the
* caller, for errors that would fail identically on every provider
* or that the caller must handle, e.g. prompt compaction).</li>
* <li>{@link #evictsProvider()} provider-wide HARD failure: remove
* the provider from {@code AvailableProviderPool} so later walks
* skip it entirely.</li>
* <li>{@link #countsHealth()} whether the failure reflects the
* <i>provider's own health</i> and feeds the consecutive-failure
* cooldown in {@code ProviderHealthTracker}. Model-scoped and
* request-scoped errors must not penalise a healthy provider.</li>
* </ul>
*
* <p>Two types additionally have side-effectful recovery steps that
* cannot be expressed as attributes and keep explicit branches in the
* loop: {@link #PROMPT_TOO_LONG} (report server-stated window, return to
* node for compaction) and {@link #THINKING_BLOCK_ERROR} (strip stale
* thinking blocks from the prompt, then retry once).</p>
*/
public enum ErrorType {
/** 无错误 */
NONE,
/** 速率限制 (429) */
RATE_LIMIT,
/** 服务端错误 (5xx, timeout) */
SERVER_ERROR,
/** Prompt 过长 (context length exceeded) */
PROMPT_TOO_LONG,
/** 认证错误 */
AUTH_ERROR,
/** 客户端错误 (400 Bad Request, 不支持的格式等) — 不应重试 */
CLIENT_ERROR,
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
THINKING_BLOCK_ERROR,
// retryBudget failsOver evicts countsHealth
/** No error. */
NONE (0, false, false, false),
/**
* The caller's own key is throttled (HTTP 429). Small retry budget
* staying on a rate-limited provider wastes time then fail over.
*/
RATE_LIMIT (MAX_RETRIES_RATE_LIMIT, true, false, true),
/**
* The provider's serving capacity is saturated (HTTP 529,
* "engine_overloaded", "model is overloaded"). The caller's key is
* healthy, so this neither dents provider health (a busy provider is
* not a broken one) nor rotates away eagerly it waits on the long
* backoff table, then falls over.
*/
OVERLOADED (MAX_RETRIES_OVERLOADED, true, false, false),
/** Transient server / network failure (5xx, timeout, TLS/socket flap). */
SERVER_ERROR (MAX_RETRIES, true, false, true),
/**
* Context window exceeded. Never retried here returned to the node,
* which owns structured compaction and its own retry.
*/
PROMPT_TOO_LONG (0, false, false, false),
/** Auth / infrastructure failure (bad key, cert, DNS). Will not self-heal. */
AUTH_ERROR (0, true, true, true),
/**
* 400-class request-shape error. Fails identically on every provider,
* so neither retried nor failed over surfaced to the caller.
*/
CLIENT_ERROR (0, false, false, false),
/**
* Stale thinking blocks rejected by the provider. Retried once after
* stripping thinking from the prompt (explicit branch needs the
* prompt mutation the generic path cannot do).
*/
THINKING_BLOCK_ERROR (1, false, false, false),
/**
* RFC-009: LLM returned no content, no thinking, and no tool calls.
* Treated as a soft failure skip same-model retries and hand off to
* the fallback chain directly. Typical cause: upstream rate-limit
* rejection that comes back as HTTP 200 with empty body.
* Typical cause: upstream soft failure surfaced as HTTP 200 with an
* empty body. Retried in the outer loop (it is a result, not an
* exception), then falls over.
*/
EMPTY_RESPONSE,
EMPTY_RESPONSE (MAX_RETRIES_EMPTY_RESPONSE, true, false, true),
/**
* RFC-009 P3.2: payment / billing failure (HTTP 402, "insufficient_quota",
* "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR}
* because the right response is to <i>switch provider</i> (a different
* provider may have credits) rather than just terminate. Skips same-model
* retries and falls through to the fallback chain.
* provider may have credits) rather than just terminate.
*/
BILLING,
BILLING (0, true, true, true),
/**
* RFC-009 P3.2: requested model id not recognized by the provider
* (HTTP 404, "Model not exist", "model_not_found", DashScope's
* "url error"). Same handling as {@link #BILLING} heads straight
* to the fallback chain instead of looping retries against a model
* that does not exist.
* "url error"). Model-scoped: heads to the fallback chain but never
* evicts the provider or dents its health sibling models still work.
*/
MODEL_NOT_FOUND,
/** 其他未知错误 */
UNKNOWN
MODEL_NOT_FOUND (0, true, false, false),
/**
* Unclassifiable. Retried defensively with a conservative budget
* a transient mis-missed by the keyword patterns is cheaper to retry
* than a lost turn; the wall-clock budget bounds the fatal case.
*/
UNKNOWN (MAX_RETRIES_UNKNOWN, true, false, true);
private final int retryBudget;
private final boolean failsOver;
private final boolean evictsProvider;
private final boolean countsHealth;
ErrorType(int retryBudget, boolean failsOver, boolean evictsProvider, boolean countsHealth) {
this.retryBudget = retryBudget;
this.failsOver = failsOver;
this.evictsProvider = evictsProvider;
this.countsHealth = countsHealth;
}
/** Same-model retry attempts before this type is exhausted (0 = never retried). */
public int retryBudget() { return retryBudget; }
/** Whether an exhausted failure hands off to the fallback chain. */
public boolean failsOver() { return failsOver; }
/** Whether this failure HARD-removes the provider from the available pool. */
public boolean evictsProvider() { return evictsProvider; }
/** Whether this failure counts toward the provider health cooldown tracker. */
public boolean countsHealth() { return countsHealth; }
}
/**

View File

@ -99,9 +99,12 @@ public class ToolExecutionExecutor {
* when size &gt; perResultThresholdChars and tool is not
* in the spill exclusion list. Returns a SPILL_MARKER preview
* on success, or the original string otherwise.
* if no SPILL_MARKER on the return, truncateToolResult(...)
* caps inline to MAX_TOOL_RESULT_CHARS so a multi-MB raw
* body never enters the model prompt.
* retrieval-excluded tool (load_skill / read_file / ...)
* returned RAW, never inline-truncated: a partial SKILL.md
* invites the model to fabricate the omitted span.
* otherwise truncateToolResult(...) caps inline to
* MAX_TOOL_RESULT_CHARS so a multi-MB raw body never
* enters the model prompt.
* enforceTurnBudget(..., perTurnBudgetChars=32000) // per-turn aggregate
* </pre>
* Spill must see the RAW result so the full output is preserved on disk
@ -132,8 +135,10 @@ public class ToolExecutionExecutor {
* @param toolUseId unique within the conversation; becomes the file name
* @param conversationId spill files are scoped per conversation; blank/null falls back to "unknown"
* @param workspaceBasePath where the spill directory lives when set
* @return the SPILL_MARKER preview when spill succeeded, otherwise the
* original string (when threshold) or the inline-truncated string.
* @return the SPILL_MARKER preview when spill succeeded; the original
* string when threshold or when the tool is retrieval-excluded
* (those must reach the model whole); otherwise the
* inline-truncated string.
*/
static String spillRawOrTruncate(ToolResultStorage storage, int maxTruncateChars,
String result, String toolName, String toolUseId,
@ -147,6 +152,20 @@ public class ToolExecutionExecutor {
if (candidate != null && candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
return candidate;
}
// Retrieval-style tools (load_skill, read_file, readSkillFile,
// memory reads) are on the spill-exclusion list precisely so their
// full output reaches the model. persistIfOversized returns them
// unchanged (no spill), so control reaches here but inline
// hard-truncation would silently re-introduce exactly the
// incompleteness the exclusion prevents: a chopped SKILL.md makes
// the model act on partial instructions, and weak models fabricate
// the omitted middle instead of heeding the fidelity note. Return
// the raw body; enforceTurnBudget (Layer 3) already skips these
// tools and only compacts them as a last resort when the whole
// turn blows its aggregate budget and nothing else can be freed.
if (storage.isRetrievalExcluded(toolName)) {
return result;
}
}
return truncateToolResult(result, maxTruncateChars);
}
@ -594,7 +613,7 @@ public class ToolExecutionExecutor {
toolCall.id(), toolName, redirect.response()));
continue;
}
String msg = skillAwareNotFoundMessage(toolName);
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
log.warn("[ToolExecutor] {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
@ -684,7 +703,7 @@ public class ToolExecutionExecutor {
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, redirect.response());
}
String msg = skillAwareNotFoundMessage(toolName);
String msg = skillAwareNotFoundMessage(toolName, replayOriginForRedirect);
log.warn("[ToolExecutor] Pre-approved {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
@ -1035,7 +1054,10 @@ public class ToolExecutionExecutor {
.withWorkspaceBasePath(origin != null ? origin.workspaceBasePath() : null);
if (toolGuardService != null) {
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
// Defer the NEEDS_APPROVAL audit row: it is written below, once, after
// the auto-grant decision, so it carries the resolution outcome
// (AUTO_GRANT / SEVERITY_CEILING / NO_GRANT / ) and the pendingId.
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx, true);
if (evaluation.shouldBlock()) {
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
@ -1049,6 +1071,7 @@ public class ToolExecutionExecutor {
// HARD_BLOCK short-circuits to a blocked decision (no approval banner).
// APPROVED skips createPending() and lets the tool run as normal.
// REQUIRES_HUMAN falls through to the existing manual approval path.
String autoOutcome = null;
if (autoGrantWired) {
AutoApproveResult auto = approvalGrantResolver.tryAutoApprove(guardCtx, evaluation);
if (auto.isHardBlocked()) {
@ -1057,29 +1080,34 @@ public class ToolExecutionExecutor {
+ "Please use a safer alternative.";
log.warn("[ToolExecutor] Auto-grant HARD_BLOCK: tool={}, reason={}", toolName, auto.reason());
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, "HARD_BLOCK");
return GuardDecision.blocked(msg);
}
if (auto.isApproved()) {
log.info("[ToolExecutor] Auto-grant APPROVED: tool={}, grantId={}", toolName, auto.grantId());
toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, "AUTO_GRANT");
return GuardDecision.allowed();
}
// requiresHuman fall through to legacy human-approval path below.
// requiresHuman fall through to legacy human-approval path below,
// carrying the denial reason for the audit row.
autoOutcome = auto.reason();
}
// No human can resolve an approval in a non-interactive (scheduled-job)
// run, so a pending request would hang the turn until it times out with
// no answer. Deny immediately with an actionable message instead.
if (origin != null && origin.cronOrigin()) {
toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, autoOutcome);
return denyNonInteractiveApproval(toolCall, toolName, events);
}
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
String approvalResponse = ToolExecutionGuardHelper.handleToolApproval(
ToolExecutionGuardHelper.ApprovalRequest approval = ToolExecutionGuardHelper.handleToolApproval(
toolCall, toolName, arguments, evaluation,
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
// Extract pendingId from response (format: "[APPROVAL_PENDING] tool=xxx awaiting user decision")
return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse));
toolGuardService.recordApprovalAudit(guardCtx, evaluation, approval.pendingId(), autoOutcome);
return GuardDecision.needsApproval(approval.response(), approval.pendingId());
}
} else if (toolGuard != null) {
ToolGuardResult guardResult = toolGuard.check(toolName, arguments);
@ -1100,7 +1128,9 @@ public class ToolExecutionExecutor {
toolCall, toolName, arguments, guardResult,
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse));
// Legacy path never persisted a pendingId to carry here; the value
// is unused downstream (only the boolean awaitingApproval is read).
return GuardDecision.needsApproval(approvalResponse, null);
}
}
@ -1196,14 +1226,6 @@ public class ToolExecutionExecutor {
return "Tool execution failed: " + message;
}
/**
* approval response 中提取 pendingIdbest-effort
*/
private String extractPendingId(String approvalResponse) {
// handleToolApproval 内部已经创建了 pending这里只做标记
return approvalResponse;
}
/**
* Issue #46 when a tool callback miss happens, check whether the
* unrecognized name actually matches an active skill. If it does, return
@ -1273,10 +1295,34 @@ public class ToolExecutionExecutor {
return Map.copyOf(result);
}
private String skillAwareNotFoundMessage(String toolName) {
/**
* Best-effort conversation workspace from a {@link ChatOrigin}, with a
* {@code WorkspaceLookupCache} fallback for paths (e.g. approval replay)
* that carry a conversationId but no workspaceId. A {@code null} result
* makes the skill lookup scope to builtin/global only never another
* workspace's skill.
*/
private Long resolveWorkspaceId(ChatOrigin origin) {
if (origin == null) return null;
if (origin.workspaceId() != null) return origin.workspaceId();
return workspaceIdForConversation(origin.conversationId());
}
/**
* Resolve a conversation's owning workspace via the lookup cache, or
* {@code null} when unavailable. Exposed so sibling graph nodes (e.g.
* {@code ActionNode}) that only hold a conversationId can scope skill
* resolution to the right workspace without their own cache dependency.
*/
public Long workspaceIdForConversation(String conversationId) {
return (workspaceLookupCache != null && conversationId != null)
? workspaceLookupCache.resolveByConversation(conversationId) : null;
}
private String skillAwareNotFoundMessage(String toolName, ChatOrigin origin) {
if (skillRuntimeService != null && toolName != null && !toolName.isBlank()) {
try {
boolean isSkill = skillRuntimeService.getActiveSkills().stream()
boolean isSkill = skillRuntimeService.getActiveSkills(resolveWorkspaceId(origin)).stream()
.anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName));
if (isSkill) {
return String.format(
@ -1395,7 +1441,9 @@ public class ToolExecutionExecutor {
private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) {
if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null;
try {
boolean isSkill = skillRuntimeService.getActiveSkills().stream()
// Scope to the conversation's workspace so an agent is never redirected
// into (and handed the SKILL.md content of) another workspace's skill.
boolean isSkill = skillRuntimeService.getActiveSkills(resolveWorkspaceId(origin)).stream()
.anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName));
if (!isSkill) return null;
} catch (Exception e) {

View File

@ -94,8 +94,15 @@ public class ToolResultProperties {
* <p>Defaults to file-read tools that already cap their own output internally.
* Configurable so deployments can add more retrieval-style tools (e.g.,
* MCP-provided readers) without code changes.</p>
*
* <p>{@code readSkillFile} / {@code load_skill} are included because they
* deliberately return the full SKILL.md the skill's usage contract
* and spilling it down to a preview makes the model act on incomplete
* instructions (e.g. wrong API parameter names). Their references/scripts
* reads are already self-paginated to a bounded size.</p>
*/
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
private List<String> excludedTools = List.of(
"read_file", "read_workspace_memory_file", "readSkillFile", "load_skill");
/**
* Days to retain spill files before the scheduled cleanup deletes them.

View File

@ -105,6 +105,21 @@ public class ToolResultStorage {
return spillCount.get();
}
/**
* Public view of the exclusion list for callers outside this class (e.g.
* the executor's spill/truncate dispatcher). Retrieval-style tools on this
* list must have their full output preserved: they are never spilled here
* (Layer 2) and never inline-truncated by the executor a partial
* {@code SKILL.md} / {@code read_file} body makes the model act on
* incomplete data, and weak models fabricate the omitted span instead of
* heeding the fidelity note. The aggregate turn budget (Layer 3) remains
* the only place an excluded result may be compacted, and only as a last
* resort when nothing else can free budget.
*/
public boolean isRetrievalExcluded(String toolName) {
return isExcluded(toolName);
}
/**
* Returns true when {@code toolName} is in the configured exclusion list.
* Excluded tools (typically retrieval tools like {@code read_file}) are

View File

@ -56,12 +56,28 @@ public class ActionNode implements NodeAction {
/**
* Tools whose results should NOT be auto-recorded into the ledger.
* Meta-tools (load_skill, enable_tool, progress_update) either have
* their own ledger side-effects or are the ledger itself.
* Two groups:
* <ul>
* <li><b>Meta-tools</b> (load_skill, enable_tool, progress_update,
* skill helpers) they either have their own ledger side-effects
* or are the ledger itself.</li>
* <li><b>Read-only / status-query tools</b> querying live state is
* not a task step that must not be repeated. Recording it as DONE
* (with a frozen result excerpt in the note) pushes the model to
* answer follow-up questions from stale output instead of
* re-checking, because the snapshot instructs "已完成的步骤不要
* 重复执行".</li>
* </ul>
*/
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
"listAvailableSkills", "readSkillFile", "runSkillScript"
"listAvailableSkills", "readSkillFile", "runSkillScript",
// read-only / status-query tools
"read_file", "web_search",
"extract_document_text", "extract_pdf_text", "extract_docx_text",
"detect_file_type",
"getCurrentDateTime", "getCurrentDate", "getCurrentTime",
"listSubagents"
);
private final ToolExecutionExecutor executor;
@ -213,7 +229,8 @@ public class ActionNode implements NodeAction {
}
for (String skillName : skillNames) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName);
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(
skillName, executor.workspaceIdForConversation(conversationId));
if (skill == null || skill.getManifest() == null) {
continue;
}
@ -255,8 +272,8 @@ public class ActionNode implements NodeAction {
* avoid collisions between servers that expose tools with the same slug.
* The display label uses the simplified slug for readability.
*/
private void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> responses) {
void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> responses) {
if (progressLedgerService == null || conversationId == null
|| conversationId.isBlank() || responses == null || responses.isEmpty()) {
return;

View File

@ -32,6 +32,7 @@ import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamContextBuilder;
import java.util.*;
import java.util.concurrent.CancellationException;
@ -390,6 +391,18 @@ public class ReasoningNode implements NodeAction {
this.runningConversationRegistry = runningConversationRegistry;
}
/**
* Live team-board snapshot source for agents leading a team. When non-null,
* each turn's prompt prefix carries the board's in-flight tasks as a meta
* user message so the lead never duplicates or prematurely closes work.
* Null in tests / legacy paths injection is simply skipped.
*/
private TeamContextBuilder teamContextBuilder;
public void setTeamContextBuilder(TeamContextBuilder teamContextBuilder) {
this.teamContextBuilder = teamContextBuilder;
}
/** Floor for the window-aware output clamp — an answer needs at least this much room. */
private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512;
@ -1318,6 +1331,20 @@ public class ReasoningNode implements NodeAction {
// agentId not numeric skip wiki injection (matches prior behavior).
}
}
// Live team-board snapshot for leads: a UserMessage (never SystemMessage,
// per the runtime-context cache discipline) listing in-flight tasks, so a
// lead mid-conversation neither duplicates nor prematurely closes work.
// buildBoardSnapshot returns null for non-leads and idle boards.
if (teamContextBuilder != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
String boardSnapshot = teamContextBuilder.buildBoardSnapshot(Long.parseLong(agentIdStr));
if (boardSnapshot != null && !boardSnapshot.isBlank()) {
prefix.add(new UserMessage(boardSnapshot));
}
} catch (NumberFormatException ignored) {
// agentId not numeric skip board injection.
}
}
return prefix;
}

View File

@ -3,6 +3,7 @@ package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
/**
* Routes the graph after the triage node.
@ -22,6 +23,12 @@ public class PlanGenerationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) {
// A board-delegated plan whose tasks all settled resumes straight into
// the summary its step results were rebuilt from the team task board
// by the resume gate; the step loop has nothing left to execute.
if ("plan_delegated_settled".equals(state.value(MateClawStateKeys.CURRENT_PHASE, ""))) {
return PlanStateKeys.PLAN_SUMMARY_NODE;
}
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false);
if (!needsPlanning) {
return PlanStateKeys.DIRECT_ANSWER_NODE;

View File

@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import vip.mate.planning.service.PlanningService;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.service.TeamPlanBridge;
import java.util.ArrayList;
import java.util.HashMap;
@ -70,6 +72,17 @@ public class PlanGenerationNode implements NodeAction {
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
private final AgentService agentService;
/**
* Optional hands a team lead's plan off to the team task board and
* resumes parked plans on later messages. Null keeps the legacy serial
* pipeline (non-team deployments / tests).
*/
private TeamPlanBridge teamPlanBridge;
public void setTeamPlanBridge(TeamPlanBridge teamPlanBridge) {
this.teamPlanBridge = teamPlanBridge;
}
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
/** Cap the auto-derived goal title; the full request rides in the description. */
@ -88,7 +101,13 @@ public class PlanGenerationNode implements NodeAction {
// order). An empty string / missing entry means "run with the parent
// agent". Only populated when delegatable specialist agents are
// advertised to the planner; absent for backward compatibility.
@JsonProperty("step_agents") List<String> stepAgents
@JsonProperty("step_agents") List<String> stepAgents,
// Optional per-step prerequisites, parallel to steps: each entry is
// a comma-separated list of earlier 1-based step numbers ("" = no
// prerequisite, may start immediately). Only requested when steps
// hand off to a team board, where independent steps run in
// parallel; anything invalid falls back to a sequential chain.
@JsonProperty("step_deps") List<String> stepDeps
) {}
private static final String PLANNING_PROMPT = """
@ -331,6 +350,60 @@ public class PlanGenerationNode implements NodeAction {
}
}
/**
* Parse the planner's step_deps (1-based step numbers, comma separated)
* into 0-based prerequisite indices. Any irregularity missing field,
* length mismatch, unparseable entry, self/forward reference falls back
* to a sequential chain (step i depends on step i-1), which is exactly
* the serial semantics the plan pipeline has today: dependency info is a
* parallelism bonus, never a correctness requirement. Package-private for
* direct unit testing.
*/
static List<List<Integer>> parseStepDeps(List<String> stepDeps, int stepCount) {
if (stepDeps == null || stepDeps.size() != stepCount) {
return sequentialChain(stepCount);
}
List<List<Integer>> parsed = new ArrayList<>();
for (int i = 0; i < stepCount; i++) {
List<Integer> deps = new ArrayList<>();
String raw = stepDeps.get(i);
if (raw != null && !raw.isBlank()) {
for (String part : raw.split("[,]")) {
if (part.isBlank()) {
continue;
}
try {
int depIndex = Integer.parseInt(part.trim()) - 1;
if (depIndex < 0 || depIndex >= i) {
return sequentialChain(stepCount);
}
deps.add(depIndex);
} catch (NumberFormatException e) {
return sequentialChain(stepCount);
}
}
}
parsed.add(deps);
}
return parsed;
}
private static List<List<Integer>> sequentialChain(int stepCount) {
List<List<Integer>> chain = new ArrayList<>();
for (int i = 0; i < stepCount; i++) {
chain.add(i == 0 ? List.of() : List.of(i - 1));
}
return chain;
}
private static Long parseNumericAgentId(String agentId) {
try {
return Long.valueOf(agentId);
} catch (Exception e) {
return null;
}
}
/**
* Enabled agents in the given workspace, excluding the parent (plan) agent
* itself these are the agents a step can be delegated to. Empty when
@ -422,6 +495,45 @@ public class PlanGenerationNode implements NodeAction {
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
// Delegated-plan resume gate: a plan parked on the team board resumes
// here on ANY inbound message (settle announcement or a user asking
// for status) deterministic routing that never depends on how the
// triage LLM classifies the wake-up text. Mirrors the approval-replay
// pattern: park in the DB, resume from the DB.
if (teamPlanBridge != null) {
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId);
if (parked instanceof TeamPlanBridge.Settled settled) {
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
settled.planId(), settled.completedResults().size());
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(settled.planId())
.planSteps(settled.steps())
.planValid(true)
.currentStepIndex(settled.steps().size())
// Summarize against the original plan goal, not the
// wake-up message that happened to trigger the resume.
.goal(settled.goal())
.put(PlanStateKeys.COMPLETED_RESULTS, settled.completedResults())
.currentPhase("plan_delegated_settled")
.events(events)
.build();
}
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
log.info("[PlanGeneration] Delegated plan still in flight — answering with progress");
if (streamingHelper != null) {
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
}
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(inFlight.progressText())
.currentPhase("direct_answer")
.contentStreamed(true)
.events(events)
.build();
}
}
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
if (existingPlanId != null) {
@ -467,22 +579,42 @@ public class PlanGenerationNode implements NodeAction {
+ "\n单次工具调用应归为单步B不要拆成多步。"));
}
// Advertise delegatable specialist agents so the planner can assign a
// multi-step plan's step to a dedicated agent (e.g. a test step to a
// QA agent, a UI step to a frontend agent). Only fills the step's
// step_agents slot; unassigned steps stay with the parent agent.
// Skipped entirely when no peer agents exist in the workspace.
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
String agentLines = delegatable.stream()
// Advertise delegatable agents. A team lead advertises its member
// roster with mandatory assignment (steps hand off to the team
// board and run in parallel there); everyone else advertises the
// workspace-wide specialist list with optional assignment.
AgentTeamEntity leadTeam = null;
if (teamPlanBridge != null) {
Long numericAgentId = parseNumericAgentId(agentId);
leadTeam = numericAgentId == null ? null
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
}
if (leadTeam != null) {
String memberLines = teamPlanBridge.roster(leadTeam).stream()
.map(a -> "- " + a.getName()
+ (StringUtils.hasText(a.getDescription()) ? "" + a.getDescription() : ""))
.collect(Collectors.joining("\n"));
promptMessages.add(new UserMessage(
"可委派的专职 Agent仅当某步骤明显属于其专长时才指派否则该步骤留空、由你自己执行\n"
+ agentLines
+ "\n若要委派在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
"你是团队「" + leadTeam.getName() + "」的 lead。多步任务的每个步骤都将分派到团队任务板"
+ "由成员并行执行。团队成员:\n" + memberLines
+ "\n要求\n"
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号1 起始,逗号分隔;无前置填空字符串)。"
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
} else {
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
String agentLines = delegatable.stream()
.map(a -> "- " + a.getName()
+ (StringUtils.hasText(a.getDescription()) ? "" + a.getDescription() : ""))
.collect(Collectors.joining("\n"));
promptMessages.add(new UserMessage(
"可委派的专职 Agent仅当某步骤明显属于其专长时才指派否则该步骤留空、由你自己执行\n"
+ agentLines
+ "\n若要委派在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
}
}
// Inject working context (rolling conversation summary) so triage respects
@ -599,6 +731,39 @@ public class PlanGenerationNode implements NodeAction {
steps = List.of(persistGoal);
}
// Team hand-off: a lead whose every step resolved to a member
// parks the plan on the task board and ends the turn execution
// continues through the board's dispatch/announce machinery, and
// any later message resumes via the delegated-plan gate above.
if (leadTeam != null) {
List<Long> memberIds = teamPlanBridge.resolveMembers(leadTeam, steps,
triage != null ? triage.stepAgents() : null);
if (memberIds != null) {
List<List<Integer>> stepDeps = parseStepDeps(
triage != null ? triage.stepDeps() : null, steps.size());
var delegatedPlan = planningService.createPlan(
agentId, conversationId, persistGoal, steps, memberIds);
events.add(GraphEventPublisher.planCreated(delegatedPlan.getId(), steps));
String announcement = teamPlanBridge.delegatePlan(leadTeam,
delegatedPlan.getId(), persistGoal, steps, stepDeps,
memberIds, conversationId);
streamingHelper.broadcastContent(conversationId, announcement);
log.info("[PlanGeneration] Plan {} handed off to team {} board ({} steps)",
delegatedPlan.getId(), leadTeam.getId(), steps.size());
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(announcement)
.currentPhase("direct_answer")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
}
log.info("[PlanGeneration] Lead plan not fully assigned to members; "
+ "falling back to the serial pipeline");
}
// Resolve any per-step agent delegation the planner asked for. Null
// when nothing is delegated, keeping createPlan on the legacy path.
List<Long> stepAgentIds = resolveStepAgents(steps,

View File

@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction {
Prompt prompt = new Prompt(List.of(
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
+ "直接回答用户的原始问题,不要罗列步骤。"
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"
+ "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。"
+ "若某些步骤未完成,如实说明未完成的部分及原因。"),
new UserMessage(userContent.toString())
));

View File

@ -202,6 +202,39 @@ public class ProgressLedgerService {
}
}
/**
* Remove every auto-recorded entry ({@code auto_} key prefix) from the
* conversation's ledger. Called at the start of each new user turn.
*
* <p>Auto-recorded entries are a safety net against context trimming
* <b>within</b> one turn's tool loop. Letting them survive into the next
* user turn is harmful: the snapshot renders them as DONE alongside the
* "已完成的步骤不要重复执行" instruction, which stops the agent from
* re-running read-only / status-query tools when the user repeats a
* question that needs fresh data (e.g. "看下会议室有没有人"), and the
* frozen 120-char result note tempts it to answer from stale output.
*
* <p>LLM-authored regular entries and pinned skill constraints are
* untouched multi-turn task tracking keeps working.
*/
public void clearAutoRecorded(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return;
}
ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
LedgerWrapper wrapper = loadWrapper(conversationId);
boolean removed = wrapper.entries.keySet().removeIf(
k -> k != null && k.startsWith(ProgressLedger.AUTO_RECORDED_PREFIX));
if (removed) {
persistWrapper(conversationId, wrapper);
}
} finally {
lock.unlock();
}
}
/**
* Auto-record a completed tool call as a ledger entry (B5). Uses the
* {@link ProgressLedger#AUTO_RECORDED_PREFIX} on the key so the renderer

View File

@ -11,6 +11,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.approval.grant.entity.ApprovalGrant;
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
import vip.mate.approval.grant.service.ApprovalGrantService;
@ -55,6 +56,7 @@ public class ApprovalGrantController {
private static final long DEFAULT_WORKSPACE_ID = 1L;
private final ApprovalGrantService grantService;
private final AgentMapper agentMapper;
private final ApprovalGrantMapper grantMapper;
private final ApprovalResolutionLogMapper resolutionMapper;
private final AuthService authService;
@ -295,6 +297,7 @@ public class ApprovalGrantController {
}
}
case ApprovalGrant.ScopeType.AGENT -> {
requireExistingAgent(body.scopeId);
if (toolNull) {
requireAdminPlusPassword(isAdmin, body.password, actorId);
} else if (!isAdmin) {
@ -304,6 +307,15 @@ public class ApprovalGrantController {
}
}
case ApprovalGrant.ScopeType.WORKSPACE -> {
// WORKSPACE-scope matching requires scope_id == the invocation's
// workspaceId AND the grant row's workspace_id (tenant column) to
// equal that same workspace a scopeId pointing anywhere else can
// never fire. Reject the dead configuration outright.
if (!String.valueOf(workspaceId).equals(body.scopeId)) {
throw new MateClawException("err.approval.workspace_scope_mismatch", 400,
"WORKSPACE-scope scopeId must equal the current workspace id ("
+ workspaceId + "); a cross-workspace grant can never match");
}
workspaceService.requirePermission(workspaceId, actorId, "admin");
if (toolNull) {
requireAdminPlusPassword(true, body.password, actorId);
@ -314,6 +326,24 @@ public class ApprovalGrantController {
}
}
/**
* AGENT-scope scopeId must reference an existing agent a workspace or
* conversation id pasted here compiles into a grant that never matches.
*/
private void requireExistingAgent(String scopeId) {
Long agentId;
try {
agentId = Long.parseLong(scopeId);
} catch (NumberFormatException e) {
throw new MateClawException("err.approval.agent_not_found", 400,
"AGENT-scope scopeId must be a numeric agent id: " + scopeId);
}
if (agentMapper.selectById(agentId) == null) {
throw new MateClawException("err.approval.agent_not_found", 400,
"AGENT-scope scopeId does not reference an existing agent: " + scopeId);
}
}
private void requireAdminPlusPassword(boolean isAdmin, String rawPassword, Long actorId) {
if (!isAdmin) {
throw new MateClawException("err.approval.admin_required", 403, "admin role required");

View File

@ -49,6 +49,21 @@ public interface ApprovalGrantMapper extends BaseMapper<ApprovalGrant> {
@Param("candidateRuleIds") List<String> candidateRuleIds,
@Param("evalSeverity") String evalSeverity);
/**
* Diagnostic twin of {@link #findFirstMatching}: identical matching except the
* severity-ceiling comparison is dropped. Called only when {@code findFirstMatching}
* returned no row, to distinguish "a grant exists but its ceiling is below this
* invocation's severity" (SEVERITY_CEILING) from "no grant matches at all" (NO_GRANT).
*/
ApprovalGrant findFirstMatchingIgnoringSeverity(
@Param("workspaceId") Long workspaceId,
@Param("userId") String userId,
@Param("agentId") String agentId,
@Param("conversationId") String conversationId,
@Param("workspaceScopeId") String workspaceScopeId,
@Param("toolName") String toolName,
@Param("candidateRuleIds") List<String> candidateRuleIds);
/**
* Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the given
* conversation. Called by {@code ConversationLifecycleListener} on

View File

@ -114,6 +114,21 @@ public class ApprovalGrantResolver {
evalSeverity);
if (matched == null) {
// Miss-path diagnosis: re-run the same match without the severity
// ceiling. A hit here means a grant exists but its ceiling is below
// this invocation's severity the single most common misconfiguration
// (form default LOW vs HIGH findings). Only executed on the miss path,
// so the hot path stays one query.
ApprovalGrant ceilingBlocked = grantMapper.findFirstMatchingIgnoringSeverity(
ctx.workspaceId(),
ctx.userId(), ctx.agentId(), ctx.conversationId(),
workspaceScopeId,
ctx.toolName(),
candidateRuleIds);
if (ceilingBlocked != null) {
return AutoApproveResult.requiresHuman(
"SEVERITY_CEILING:" + ceilingBlocked.getMaxSeverity() + "<" + evalSeverity);
}
return AutoApproveResult.requiresHuman("NO_GRANT");
}

View File

@ -319,6 +319,23 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
}
}
/**
* 按渠道配置过滤外发文本不做平台分割
* <p>
* 卡片式流式渠道不经过 {@link #renderAndSend}它们自己管理消息长度和
* 卡片更新节奏如果不在流式收尾处调用本方法
* {@code filter_thinking} / {@code filter_tool_messages} 两个开关
* 在这些路径上就完全不生效
*
* @param content 原始文本
* @return 过滤后的文本入参为空时返回空串
*/
protected String filterOutboundContent(String content) {
return ChannelMessageRenderer.applyFilters(content,
getConfigBoolean("filter_thinking", true),
getConfigBoolean("filter_tool_messages", true));
}
/**
* Approval notice rendering primary implementation position.
*

View File

@ -0,0 +1,79 @@
package vip.mate.channel;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Tunables for inbound channel-message deduplication.
*
* <p>IM platforms redeliver the same message when an acknowledgement is late,
* lost, or answered with a non-200 DingTalk, WeCom and Feishu all do this.
* Every redelivery that reaches the router starts a full, independent agent
* turn, so the user sees the same answer twice (and the conversation gains a
* duplicate user/assistant pair). {@link InboundMessageDeduplicator} keeps a
* short-lived record of the message identities already claimed so a
* redelivery is dropped instead of answered again.
*
* <p>入站渠道消息去重配置平台重投同一条消息时若不去重则每次重投都会跑一轮完整
* Agent 回合用户看到重复答复
*
* <pre>
* mate:
* channel:
* dedup:
* enabled: true
* ttl: 5m
* max-size: 2000
* </pre>
*/
@ConfigurationProperties(prefix = "mate.channel.dedup")
public class ChannelDedupProperties {
/**
* Master switch. When false every message is treated as new only useful
* when debugging a suspected false-positive drop.
*/
private boolean enabled = true;
/**
* How long a claimed message identity keeps suppressing redeliveries.
*
* <p>Must comfortably exceed the platforms' redelivery windows (seconds to
* low minutes) while staying short enough that a user who genuinely resends
* the identical payload later is not silenced. Note that a resend carries a
* fresh platform message id in every channel we support, so the TTL only
* matters for the id-less fallback identity.
*/
private Duration ttl = Duration.ofMinutes(5);
/**
* Hard cap on tracked identities. Reached only under sustained traffic
* within one TTL window; the oldest claims are dropped first.
*/
private int maxSize = 2000;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Duration getTtl() {
return ttl;
}
public void setTtl(Duration ttl) {
this.ttl = ttl;
}
public int getMaxSize() {
return maxSize;
}
public void setMaxSize(int maxSize) {
this.maxSize = maxSize;
}
}

View File

@ -0,0 +1,134 @@
package vip.mate.channel;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/**
* User-typed channel control commands that should be handled by the platform
* instead of being sent to the agent as normal prompt text.
* <p>
* Matching rules:
* <ul>
* <li>Case-insensitive; the whole message is trimmed first.</li>
* <li>Bare aliases (no leading "/") match only when the entire message is
* exactly the alias "clear 一下北京天气" is normal prompt text.</li>
* <li>Slash-prefixed aliases may carry trailing arguments after the first
* whitespace; the remainder is passed through verbatim as args.</li>
* </ul>
*/
final class ChannelMagicCommand {
/** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */
enum Type { CLEAR, NEW, HELP, STATUS, STOP, MODEL }
/** A recognized command plus its raw (possibly empty) argument string. */
record Parsed(Type type, String args) {
}
/**
* Alias token command type. LinkedHashMap keeps registration ordering
* stable. Every bare alias also registers its "/"-prefixed twin.
*/
private static final Map<String, Type> ALIASES = buildAliases();
private ChannelMagicCommand() {
}
static Optional<Parsed> parse(String text) {
String trimmed = text == null ? "" : text.trim();
if (trimmed.isEmpty()) {
return Optional.empty();
}
String lower = trimmed.toLowerCase(Locale.ROOT);
Type wholeMatch = ALIASES.get(lower);
if (wholeMatch != null) {
return Optional.of(new Parsed(wholeMatch, ""));
}
// Only slash-prefixed commands may carry arguments; bare words with a
// trailing remainder are ordinary prompts, never commands.
if (!lower.startsWith("/")) {
return Optional.empty();
}
int ws = indexOfWhitespace(lower);
if (ws < 0) {
return Optional.empty();
}
Type type = ALIASES.get(lower.substring(0, ws));
if (type == null) {
return Optional.empty();
}
return Optional.of(new Parsed(type, trimmed.substring(ws).trim()));
}
static String clearConfirmation() {
return "✅ 上下文已清理,后续消息会从新的上下文开始。";
}
static String newConfirmation() {
return "✨ 已开启新会话,之前的上下文不会带入。";
}
static String stopConfirmation() {
return "⏹️ 已停止当前任务。";
}
static String stopNothingRunning() {
return "当前没有进行中的任务。";
}
static String helpText() {
return """
🪄 可用命令
/clear 清空当前会话上下文别名/reset清空上下文
/new 开启新会话别名新会话
/stop 停止当前进行中的任务别名停止
/status 查看当前会话状态别名状态
/model 查看可用模型/model <名称> 切换本会话模型/model reset 恢复默认
/help 显示本帮助别名帮助""";
}
private static Map<String, Type> buildAliases() {
Map<String, Type> aliases = new LinkedHashMap<>();
register(aliases, Type.CLEAR,
"clear", "reset",
"清空", "清空上下文", "清理上下文", "清除上下文", "重置上下文");
register(aliases, Type.NEW,
"new", "新会话", "新对话");
register(aliases, Type.HELP,
"help", "帮助");
register(aliases, Type.STATUS,
"status", "状态");
register(aliases, Type.STOP,
"stop", "停止");
// Slash-only: "model" / "模型" are common standalone words in normal
// prompts ("模型是什么?"), so the bare form must never be a command.
registerSlashOnly(aliases, Type.MODEL,
"model", "模型");
return aliases;
}
private static void register(Map<String, Type> aliases, Type type, String... names) {
for (String name : names) {
aliases.put(name, type);
aliases.put("/" + name, type);
}
}
/** Register only the "/"-prefixed form — for aliases whose bare word is ordinary prose. */
private static void registerSlashOnly(Map<String, Type> aliases, Type type, String... names) {
for (String name : names) {
aliases.put("/" + name, type);
}
}
private static int indexOfWhitespace(String text) {
for (int i = 0; i < text.length(); i++) {
if (Character.isWhitespace(text.charAt(i))) {
return i;
}
}
return -1;
}
}

View File

@ -1213,7 +1213,7 @@ public class ChannelManager {
generatedFileCache, chatUploadLocationResolver);
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper,
chatUploadLocationResolver);
chatUploadLocationResolver, generatedFileScrubber);
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);

View File

@ -72,10 +72,34 @@ public final class ChannelMessageRenderer {
boolean filterToolMessages,
String messageFormat,
int maxLength) {
if (content == null || content.isBlank()) {
String rendered = applyFilters(content, filterThinking, filterToolMessages);
if (rendered.isEmpty()) {
return List.of("");
}
// 按平台限制分割
return truncateForPlatform(rendered, maxLength);
}
/**
* 只做内容过滤不做平台分割
* <p>
* 卡片式流式渠道钉钉 AI Card飞书 CardKit自己管理长度限制
* 但同样需要遵守渠道的消息过滤配置因此把过滤部分单独暴露出来
*
* @param content 原始内容
* @param filterThinking 是否过滤 thinking 标签
* @param filterToolMessages 是否过滤工具调用信息
* @return 过滤后的内容入参为空时返回空串
*/
public static String applyFilters(String content,
boolean filterThinking,
boolean filterToolMessages) {
if (content == null || content.isBlank()) {
return "";
}
String rendered = content;
// 1. 过滤 thinking
@ -89,14 +113,7 @@ public final class ChannelMessageRenderer {
}
// 3. 清理多余空行
rendered = rendered.replaceAll("\n{3,}", "\n\n").trim();
if (rendered.isEmpty()) {
return List.of("");
}
// 4. 按平台限制分割
return truncateForPlatform(rendered, maxLength);
return rendered.replaceAll("\n{3,}", "\n\n").trim();
}
// ==================== 过滤方法 ====================

View File

@ -15,11 +15,15 @@ import vip.mate.channel.event.ChannelMessageReceivedEvent;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.web.AgentStreamAccumulator;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -33,6 +37,7 @@ import java.nio.file.Paths;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
@ -65,6 +70,7 @@ public class ChannelMessageRouter {
private final ChatStreamTracker streamTracker;
private final ChannelChatOriginFactory chatOriginFactory;
private final ChannelErrorClassifier errorClassifier;
private final InboundMessageDeduplicator inboundDedup;
/** Field-injected (rather than constructor) to avoid a signature
* change that would ripple through every test that constructs the
* router directly. Spring's stock publisher is always available. */
@ -78,6 +84,22 @@ public class ChannelMessageRouter {
@Autowired(required = false)
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/** Field-injected for the same reason as {@link #events}: backs the
* /model magic command (list + switch). Optional so tests that build
* the router directly still work; when unset the command degrades to
* a "service unavailable" reply instead of failing message intake. */
@Autowired(required = false)
private ModelConfigService modelConfigService;
/** Field-injected so the IM sync path can scrub hallucinated
* {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped
* link without ever calling a render tool). The graph's FinalAnswerNode
* already does this, but the IM sync path accumulates {@code delta.content()}
* directly and bypasses FinalAnswerNode without this scrub, the fake
* URL reaches the IM channel as a clickable link that 404s. */
@Autowired(required = false)
private vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@ -148,30 +170,6 @@ public class ChannelMessageRouter {
return currentMergedLength > LONG_TEXT_THRESHOLD ? LONG_DEBOUNCE_MS : DEBOUNCE_MS;
}
/**
* 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;
@ -186,7 +184,8 @@ public class ChannelMessageRouter {
ObjectMapper objectMapper,
ChatStreamTracker streamTracker,
ChannelChatOriginFactory chatOriginFactory,
ChannelErrorClassifier errorClassifier) {
ChannelErrorClassifier errorClassifier,
InboundMessageDeduplicator inboundDedup) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
@ -199,6 +198,7 @@ public class ChannelMessageRouter {
this.streamTracker = streamTracker;
this.chatOriginFactory = chatOriginFactory;
this.errorClassifier = errorClassifier;
this.inboundDedup = inboundDedup;
}
// ==================== 防抖辅助类 ====================
@ -271,6 +271,23 @@ public class ChannelMessageRouter {
}
channelEntity = fresh;
// Inbound idempotency, before ANY side effect (magic commands, trigger
// fan-out, agent turn). IM platforms redeliver a message whose ack was
// late, lost, or non-200; without this claim every redelivery runs its
// own agent turn and the user gets the same answer again. Claiming here
// rather than in each adapter means every channel including the four
// that never had dedup is covered by one code path.
if (!inboundDedup.claim(channelEntity.getId(), inboundIdentity(message))) {
log.info("[{}] Duplicate inbound message (id={}) on channel {}; dropping",
adapter.getChannelType(), inboundIdentity(message), channelEntity.getId());
return;
}
String conversationId = buildConversationId(message, channelEntity.getId());
if (handleMagicCommand(message, adapter, channelEntity, conversationId)) {
return;
}
// Fan out to the trigger pipeline FIRST channel_message and
// content_match triggers fire on every received message regardless
// of whether the channel has an agent attached. If we returned
@ -292,7 +309,6 @@ public class ChannelMessageRouter {
}
String channelType = adapter.getChannelType();
String conversationId = buildConversationId(message);
log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}",
channelType, message.getSenderId(), conversationId, agentId);
@ -372,15 +388,18 @@ public class ChannelMessageRouter {
try {
long ws = channelEntity.getWorkspaceId() == null ? 0L : channelEntity.getWorkspaceId();
String channelType = adapter.getChannelType();
// messageId may be null for adapters that don't surface one;
// fall back to a sender+timestamp composite so the dedup key
// is at least deterministic-ish per webhook delivery.
String messageId = message.getMessageId();
if (messageId == null || messageId.isBlank()) {
messageId = channelType + ":" + message.getSenderId() + ":"
+ (message.getTimestamp() == null ? System.currentTimeMillis()
: message.getTimestamp());
}
// Reuse the identity the inbound claim uses so both agree on what
// "the same message" is. Unlike the claim which the deduplicator
// scopes by channel id this key travels to the trigger pipeline
// unscoped, so anything we derive ourselves keeps the channelType
// prefix: two channels can otherwise produce the same
// sender+timestamp pair inside one workspace.
String platformId = message.getMessageId();
String messageId = (platformId != null && !platformId.isBlank())
? platformId
: channelType + ":" + (inboundIdentity(message) != null
? inboundIdentity(message)
: message.getSenderId() + "@" + System.currentTimeMillis());
events.publishEvent(new ChannelMessageReceivedEvent(
ws,
channelType,
@ -395,6 +414,53 @@ public class ChannelMessageRouter {
}
}
/**
* The stable identity of an inbound message, used both for the inbound
* dedup claim and as the trigger pipeline's dedup key.
*
* <p>Prefers the platform message id every adapter that has one puts it
* on {@link ChannelMessage#getMessageId()}, and a redelivery carries the
* same value. Adapters whose stable token is not the raw message id (WeCom
* uses its {@code context_token}) put that token there instead.
*
* <p>Falls back to {@code sender@timestamp} when there is no id but the
* platform stamped the message still stable across redeliveries of the
* same payload. Returns {@code null} when neither exists: there is nothing
* to tell a redelivery apart from a fresh message, so the caller must fail
* open rather than guess.
*
* <p>Package-private for unit-test access.
*/
static String inboundIdentity(ChannelMessage message) {
if (message == null) {
return null;
}
String messageId = message.getMessageId();
if (messageId != null && !messageId.isBlank()) {
return messageId;
}
if (message.getTimestamp() == null) {
return null;
}
return message.getSenderId() + "@" + message.getTimestamp();
}
/**
* Has this inbound message already been claimed? A peek, not a claim
* the authoritative claim happens once, in {@link #enqueue}.
*
* <p>For adapters to call before expensive inbound work (media download,
* payload decryption) so a known redelivery costs nothing. Adapters reach
* it through the router they already hold, which keeps the deduplicator
* out of every adapter constructor.
*
* @param identity the same value the adapter will put on
* {@link ChannelMessage#getMessageId()}
*/
public boolean isDuplicateInbound(Long channelId, String identity) {
return inboundDedup.contains(channelId, identity);
}
/**
* 防抖到期将合并后的消息真正放入渠道队列
*/
@ -415,6 +481,12 @@ public class ChannelMessageRouter {
if (!offered) {
log.error("[{}] Message queue full (capacity={}), dropping message from {}",
channelType, QUEUE_CAPACITY, pending.firstMessage.getSenderId());
// Never handed off give the claim back so the platform's own
// retry can still get an answer. A turn that ran and *failed*
// keeps its claim: the user already got the error reply, and a
// retry would only produce a second one.
inboundDedup.release(pending.channelEntity != null ? pending.channelEntity.getId() : null,
inboundIdentity(pending.firstMessage));
try {
String replyTarget = resolveReplyTarget(pending.firstMessage);
pending.adapter.sendMessage(replyTarget, "系统繁忙,请稍后再试");
@ -467,7 +539,7 @@ public class ChannelMessageRouter {
continue; // 超时重新检查 shutdown 标志
}
String conversationId = buildConversationId(entry.message());
String conversationId = buildConversationId(entry.message(), entry.channelEntity().getId());
ReentrantLock lock = sessionLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
@ -592,15 +664,21 @@ public class ChannelMessageRouter {
}
channelEntity = fresh;
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}",
adapter.getChannelType(), channelEntity.getName(), message.getSenderId());
return;
}
log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}",
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
try {
// Magic commands run before the agent-binding check so /help and
// /status still answer on a channel with no agent attached.
if (handleMagicCommand(message, adapter, channelEntity, conversationId)) {
return;
}
if (agentId == null) {
log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}",
adapter.getChannelType(), channelEntity.getName(), message.getSenderId());
return;
}
// ======= 审批拦截层 =======
String userText = message.getContent() != null ? message.getContent().trim() : "";
PendingApproval pending = approvalService.findPendingByConversation(conversationId);
@ -780,46 +858,72 @@ public class ChannelMessageRouter {
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
} else {
// Sync path for non-streaming IM adapters (feishu / wecom / weixin /
// slack / discord / qq / telegram). We can't use agentService.chat()
// Sync path for non-streaming IM adapters (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();
// would silently drop the tool/plan events the Web Console
// mirror and the persisted execution metadata both need.
// Instead we consume chatStructuredStream directly through
// the shared accumulator (reply text, metadata, live mirror).
final String channelType = adapter.getChannelType();
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
// Channel-level toggle for relaying per-stage narration as
// standalone messages mid-run. Shares the key the streaming
// progress path uses so operators have one knob per channel.
// Disabled narration is dropped from the IM channel (it is
// never part of the final reply either way; web observers
// still see it via the live broadcast).
final boolean relayNarration = channelConfigBoolean(
channelEntity, "stream_progress", true);
// Shared accumulator: builds the segments/toolCalls metadata
// the Web console renders for history, mirrors events +
// deltas to live Web observers, and captures token usage +
// model attribution (_usage_final is consumed internally).
// Reply text also comes from it same semantics as the
// legacy collector: persistOnly deltas included (DirectAnswerNode-
// routed answers arrive as persistOnly when CONTENT_STREAMED=true
// and IM channels still need the text for the outgoing reply),
// segmentOnly narration excluded (issue #120).
AgentStreamAccumulator accumulator = newAccumulator();
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
.doOnNext(delta -> {
if (delta.isEvent()) {
if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
accumulator.accept(delta, conversationId);
if (!delta.isEvent() && delta.segmentOnly()) {
// Per-stage narration ("Let me look that up…"), emitted as
// one complete delta per agent loop iteration. Relay it
// immediately as its own outgoing message so the user sees
// progress mid-run.
String narration = delta.content() != null ? delta.content().trim() : "";
if (relayNarration && !narration.isEmpty() && replyTarget != null) {
try {
adapter.renderAndSend(replyTarget, narration);
} catch (Exception sendErr) {
// A failed progress send must not abort the agent
// run the final reply still goes out below.
log.warn("[{}] Narration relay failed (non-fatal): {}",
channelType, sendErr.getMessage());
}
}
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();
String reply = accumulator.getContent();
// The IM sync path bypasses FinalAnswerNode, so hallucinated
// /api/v1/files/generated/{id} URLs (LLM wrote a fake link
// without calling a render tool) reach here verbatim. Scrub
// them to the user-visible warning so IM clients don't see
// a clickable link that 404s. Real tool-produced URLs are
// left intact for the channel adapter's scrubber to upgrade
// into native attachments.
if (generatedFileCache != null) {
String scrubbed = generatedFileCache.scrubMissingReferences(reply);
if (!scrubbed.equals(reply)) {
log.info("[{}] Scrubbed hallucinated generated-file URL(s) from IM reply ({} -> {} chars)",
adapter.getChannelType(), reply.length(), scrubbed.length());
reply = scrubbed;
}
}
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
@ -842,9 +946,19 @@ public class ChannelMessageRouter {
// error turns must not pollute memory extraction.
boolean isError = errorClassifier.isErrorReply(reply);
String status = isError ? "error" : "completed";
// Persist the full execution record (parts + metadata)
// so the Web console renders IM-routed turns exactly
// like Web direct chats. The content column keeps the
// scrubbed reply text that actually went out.
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", reply, null, status,
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
conversationId, "assistant", reply,
accumulator.toAssistantParts(), status,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(), accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
blankToNull(accumulator.getRuntimeModelName()),
blankToNull(accumulator.getRuntimeProviderId()),
accumulator.toMetadataJson());
savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
@ -910,6 +1024,272 @@ public class ChannelMessageRouter {
}
}
/**
* Handle channel-native control commands before the message is persisted
* or forwarded to the agent. A recognized command is terminal for this
* inbound message: it never reaches the debounce queue or the LLM.
*/
private boolean handleMagicCommand(ChannelMessage message, ChannelAdapter adapter,
ChannelEntity channelEntity, String conversationId) {
String userText = message != null ? message.getContent() : null;
ChannelMagicCommand.Parsed command = ChannelMagicCommand.parse(userText).orElse(null);
if (command == null) {
return false;
}
String replyTarget = resolveReplyTarget(message);
String reply = switch (command.type()) {
case CLEAR -> {
cancelPending(conversationId);
conversationService.clearMessages(conversationId);
yield ChannelMagicCommand.clearConfirmation();
}
case NEW -> {
// Channel conversation ids are deterministic (channelType:chatId),
// so "new session" cannot rotate the id it clears the context
// like CLEAR and only differs in the confirmation wording.
cancelPending(conversationId);
conversationService.clearMessages(conversationId);
yield ChannelMagicCommand.newConfirmation();
}
case STOP -> {
cancelPending(conversationId);
boolean stopped = streamTracker.requestStop(conversationId);
yield stopped ? ChannelMagicCommand.stopConfirmation()
: ChannelMagicCommand.stopNothingRunning();
}
case HELP -> ChannelMagicCommand.helpText();
case STATUS -> buildStatusReply(channelEntity, conversationId);
case MODEL -> handleModelCommand(channelEntity, conversationId, command.args());
};
if (replyTarget != null && reply != null) {
// renderAndSend (not sendMessage) so adapters that pre-post a
// "thinking..." placeholder on inbound (WeCom reply_stream)
// consume it here: the confirmation overwrites the placeholder
// bubble in place and the keepalive refresher is stopped.
// Plain sendMessage would leave the placeholder dangling forever.
adapter.renderAndSend(replyTarget, reply);
}
log.info("[{}] Magic command handled: {} conversationId={}, sender={}",
adapter.getChannelType(), command.type(), conversationId,
message != null ? message.getSenderId() : null);
return true;
}
/** Build the /status reply; every lookup degrades gracefully to keep the command side-effect free. */
private String buildStatusReply(ChannelEntity channelEntity, String conversationId) {
StringBuilder sb = new StringBuilder("📊 会话状态\n");
sb.append("- 会话: ").append(conversationId).append('\n');
Long agentId = channelEntity != null ? channelEntity.getAgentId() : null;
String[] pinned = findPinnedModel(conversationId);
if (agentId == null) {
sb.append("- 智能体: 未绑定\n");
} else {
try {
AgentEntity agent = agentService.getAgent(agentId);
if (agent != null) {
sb.append("- 智能体: ").append(agent.getName()).append('\n');
// Conversation-pinned model wins over the agent default
// mirrors the resolution order in AgentService, so /status
// never contradicts what /model just switched to.
if (pinned != null) {
sb.append("- 模型: ").append(pinned[0]).append(':').append(pinned[1])
.append("(会话指定)\n");
} else if (agent.getModelName() != null && !agent.getModelName().isBlank()) {
sb.append("- 模型: ").append(agent.getModelName()).append('\n');
}
} else {
sb.append("- 智能体: 未找到id=").append(agentId).append("\n");
}
} catch (Exception e) {
log.warn("Failed to load agent {} for /status: {}", agentId, e.getMessage());
sb.append("- 智能体: 查询失败\n");
}
}
try {
sb.append("- 历史消息数: ").append(conversationService.countMessages(conversationId)).append('\n');
} catch (Exception e) {
log.warn("Failed to count messages for /status: {}", e.getMessage());
}
boolean running = streamTracker.isRunning(conversationId);
sb.append("- 当前任务: ").append(running ? "进行中(可用 /stop 停止)" : "空闲");
return sb.toString();
}
/**
* Handle the /model command: list enabled chat models, pin one on this
* conversation, or reset to the agent default. Listing and resetting work
* without a bound agent; switching requires one because the pinned pair
* only takes effect when the agent graph is built.
*/
private String handleModelCommand(ChannelEntity channelEntity, String conversationId, String args) {
if (modelConfigService == null) {
return "⚠️ 模型管理服务不可用,请稍后再试。";
}
String arg = args == null ? "" : args.trim();
if ("reset".equalsIgnoreCase(arg) || "恢复默认".equals(arg)) {
conversationService.clearConversationModel(conversationId);
return "✅ 已恢复默认模型(跟随智能体配置),下一条消息生效。";
}
List<ModelConfigEntity> models;
try {
models = modelConfigService.listEnabledModels();
} catch (Exception e) {
log.warn("Failed to list models for /model on {}: {}", conversationId, e.getMessage());
return "⚠️ 查询模型列表失败,请稍后再试。";
}
if (arg.isEmpty() || "list".equalsIgnoreCase(arg)) {
return buildModelListReply(models, conversationId);
}
return switchConversationModel(channelEntity, conversationId, arg, models);
}
/** Max rows shown by /model list a full catalog can exceed 180 rows,
* which segments into several IM bubbles and buries the usage hint. */
private static final int MODEL_LIST_MAX_ROWS = 20;
private String buildModelListReply(List<ModelConfigEntity> models, String conversationId) {
if (models.isEmpty()) {
return "当前没有已启用的对话模型,请先在控制台配置。";
}
String[] pinned = findPinnedModel(conversationId);
StringBuilder sb = new StringBuilder("🧠 可用模型(/model <名称> 切换,/model reset 恢复默认):\n");
int shown = 0;
for (ModelConfigEntity m : models) {
if (shown >= MODEL_LIST_MAX_ROWS) {
break;
}
sb.append("- ").append(m.getProvider()).append(':').append(m.getModelName());
if (pinned != null && pinned[0].equalsIgnoreCase(String.valueOf(m.getProvider()))
&& pinned[1].equalsIgnoreCase(String.valueOf(m.getModelName()))) {
sb.append(" ✅ 当前");
}
sb.append('\n');
shown++;
}
if (models.size() > MODEL_LIST_MAX_ROWS) {
sb.append("…共 ").append(models.size())
.append(" 个已启用模型,仅展示前 ").append(MODEL_LIST_MAX_ROWS)
.append(" 个;发送 /model <关键词> 搜索其余模型。\n");
}
sb.append(pinned == null
? "当前:跟随智能体默认模型"
: "当前会话已指定:" + pinned[0] + ":" + pinned[1]);
return sb.toString();
}
private String switchConversationModel(ChannelEntity channelEntity, String conversationId,
String arg, List<ModelConfigEntity> models) {
if (channelEntity == null || channelEntity.getAgentId() == null) {
return "⚠️ 当前渠道未绑定智能体,请先在控制台绑定后再切换模型。";
}
String wantedProvider = null;
String wantedName = arg;
int colon = arg.indexOf(':');
if (colon > 0 && colon < arg.length() - 1) {
wantedProvider = arg.substring(0, colon).trim();
wantedName = arg.substring(colon + 1).trim();
}
final String fProvider = wantedProvider;
final String fName = wantedName;
List<ModelConfigEntity> matches = models.stream()
.filter(m -> fName.equalsIgnoreCase(m.getModelName()))
.filter(m -> fProvider == null || fProvider.equalsIgnoreCase(m.getProvider()))
.toList();
if (matches.isEmpty()) {
// No exact hit treat the arg as a search keyword so users can
// discover models the capped /model list didn't show.
String keyword = fName.toLowerCase(Locale.ROOT);
List<ModelConfigEntity> fuzzy = models.stream()
.filter(m -> String.valueOf(m.getModelName()).toLowerCase(Locale.ROOT).contains(keyword)
|| String.valueOf(m.getProvider()).toLowerCase(Locale.ROOT).contains(keyword))
.limit(MODEL_LIST_MAX_ROWS)
.toList();
if (fuzzy.isEmpty()) {
return "⚠️ 未找到已启用的模型「" + arg + "」,发送 /model 查看可用列表。";
}
StringBuilder sb = new StringBuilder("未找到精确匹配「").append(arg)
.append("」,相近的可用模型:\n");
for (ModelConfigEntity m : fuzzy) {
sb.append("- /model ").append(m.getProvider()).append(':')
.append(m.getModelName()).append('\n');
}
return sb.toString().stripTrailing();
}
if (matches.size() > 1) {
StringBuilder sb = new StringBuilder("⚠️ 模型「").append(fName)
.append("」在多个 provider 下存在,请带上前缀再试:\n");
for (ModelConfigEntity m : matches) {
sb.append("- /model ").append(m.getProvider()).append(':').append(m.getModelName()).append('\n');
}
return sb.toString().stripTrailing();
}
ModelConfigEntity target = matches.get(0);
try {
// The magic-command layer runs before processMessage's
// get-or-create, so a /model sent as the very first message must
// create the conversation row itself updateConversationModel
// silently no-ops on a missing row.
conversationService.getOrCreateSharedConversation(
conversationId, channelEntity.getAgentId(), channelEntity.getWorkspaceId());
conversationService.updateConversationModel(
conversationId, target.getProvider(), target.getModelName());
} catch (Exception e) {
log.warn("Failed to pin model {} on {}: {}", arg, conversationId, e.getMessage());
return "⚠️ 切换失败,请稍后再试。";
}
return "✅ 本会话模型已切换为 " + target.getProvider() + ":" + target.getModelName()
+ ",下一条消息生效。发送 /model reset 可恢复默认。";
}
/** Conversation-pinned (provider, model) pair, or null when unpinned/unavailable. */
private String[] findPinnedModel(String conversationId) {
try {
ConversationEntity conv = conversationService.findByConversationId(conversationId);
if (conv != null
&& conv.getModelProvider() != null && !conv.getModelProvider().isBlank()
&& conv.getModelName() != null && !conv.getModelName().isBlank()) {
return new String[]{conv.getModelProvider(), conv.getModelName()};
}
} catch (Exception e) {
log.debug("Failed to load pinned model for {}: {}", conversationId, e.getMessage());
}
return null;
}
private void cancelPending(String conversationId) {
PendingMessage pending;
synchronized (pendingMessages) {
pending = pendingMessages.remove(conversationId);
}
if (pending != null && pending.timer != null) {
pending.timer.cancel(false);
}
}
/**
* Build a per-turn accumulator wired to the stream tracker, so live Web
* observers of an IM conversation receive the same event fan-out as Web
* direct chats, and the persisted metadata matches byte-for-byte.
*/
private AgentStreamAccumulator newAccumulator() {
return new AgentStreamAccumulator(objectMapper, new AgentStreamAccumulator.Sink() {
@Override
public void broadcast(String conversationId, String eventName, Object payload) {
streamTracker.broadcastObject(conversationId, eventName, payload);
}
@Override
public void updatePhase(String conversationId, String phase) {
streamTracker.updatePhase(conversationId, phase);
}
});
}
/** Map the accumulator's empty-string defaults back to SQL NULL. */
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s;
}
/**
* 流式处理路径渠道无关
* <p>
@ -918,30 +1298,6 @@ 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) {
@ -949,33 +1305,22 @@ public class ChannelMessageRouter {
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
try {
// Step 1: 产生事件流RFC-063r §2.5: forward ChatOrigin so tools see channelId
// Step 1: 产生事件流forward ChatOrigin so tools see channelId
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.
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
}
mirrorPlanEventToTracker(conversationId, delta, channelType);
});
// Feed every delta through the shared accumulator before the
// adapter consumes the Flux. The accumulator builds the same
// segments/toolCalls metadata the Web SSE path persists (so the
// console renders the execution timeline for IM-routed turns),
// mirrors tool/plan/content events to any live Web observer of
// this conversation, and captures token usage + model
// attribution. Internal bookkeeping events (_usage_final,
// _routing_decision) are consumed inside the accumulator and
// never reach subscribers.
AgentStreamAccumulator accumulator = newAccumulator();
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta ->
accumulator.accept(delta, conversationId));
// Step 2: 委托渠道渲染渠道内部消费 Flux 并处理 UI 更新
String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId);
@ -994,9 +1339,20 @@ public class ChannelMessageRouter {
} else if (finalContent != null && !finalContent.isBlank()) {
boolean isError = errorClassifier.isErrorReply(finalContent);
String status = isError ? "error" : "completed";
// Persist the full execution record parts (text/thinking/
// tool_call) and metadata (segments/toolCalls/plan/) so
// the Web console renders IM-routed turns exactly like Web
// direct chats. The content column keeps the adapter's final
// text (the adapter may have post-processed it).
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", finalContent, null, status,
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
conversationId, "assistant", finalContent,
accumulator.toAssistantParts(), status,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(), accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
blankToNull(accumulator.getRuntimeModelName()),
blankToNull(accumulator.getRuntimeProviderId()),
accumulator.toMetadataJson());
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
}
@ -1202,7 +1558,7 @@ public class ChannelMessageRouter {
return Flux.error(new IllegalStateException("Channel has no associated agent"));
}
String conversationId = buildConversationId(message);
String conversationId = buildConversationId(message, channelEntity.getId());
String username = message.getSenderName() != null ? message.getSenderName() : message.getSenderId();
conversationService.getOrCreateConversation(conversationId, agentId, username, channelEntity.getWorkspaceId());
@ -1319,9 +1675,26 @@ public class ChannelMessageRouter {
* 格式{channelType}:{chatId senderId}
* 格式采用 {channelType}:{identifier} 命名规则
*/
private String buildConversationId(ChannelMessage message) {
/**
* Build the conversation id for an inbound channel message.
*
* <p>The id is scoped by {@code channelId} so the same sender reaching two
* different workspaces' same-type channels (e.g. two separate wecom channels)
* no longer collapses into one shared conversation row. {@code channelId} is
* the {@code ChannelEntity} primary key, which binds to exactly one workspace.
*
* <p>Format: {@code {channelType}:{channelId}:{chatId|senderId}}. When
* {@code channelId} is null (defensive; the routed channel row always has an
* id) the legacy {@code {channelType}:{identifier}} form is used so nothing
* NPEs those ids remain workspace-ambiguous but that path is not reachable
* for a persisted channel.
*/
private String buildConversationId(ChannelMessage message, Long channelId) {
String identifier = message.getChatId() != null ? message.getChatId() : message.getSenderId();
return message.getChannelType() + ":" + identifier;
if (channelId == null) {
return message.getChannelType() + ":" + identifier;
}
return message.getChannelType() + ":" + channelId + ":" + identifier;
}
/**
@ -1577,6 +1950,18 @@ public class ChannelMessageRouter {
return "auto".equals(voiceMode) && "voice".equals(message.getInputMode());
}
/**
* Boolean lookup on the channel's configJson. Accepts Boolean or String
* values, mirroring the adapter-side config parsing rules, so the router
* and the adapters read the same key identically.
*/
private boolean channelConfigBoolean(ChannelEntity channelEntity, String key, boolean defaultValue) {
Object value = parseChannelConfig(channelEntity.getConfigJson()).get(key);
if (value instanceof Boolean b) return b;
if (value instanceof String s && !s.isBlank()) return Boolean.parseBoolean(s.trim());
return defaultValue;
}
/**
* 解析 Channel configJson Map
*/

View File

@ -8,6 +8,7 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.channel.repository.ChannelSessionMapper;
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
import java.time.LocalDateTime;
import java.util.Comparator;
@ -56,6 +57,30 @@ public class ChannelSessionStore {
log.info("ChannelSessionStore initialized: loaded {} sessions from DB", sessions.size());
}
/**
* Drop the cached session for a conversation the user just deleted.
*
* <p>{@code deleteConversation} removes the {@code mate_channel_session}
* row inside its DB cascade, but the cache is this class's private state
* and no DB delete can reach it. Without this listener the entry survives
* as a phantom: the next inbound message takes the "update existing" branch
* and calls {@code updateById} against a primary key that no longer exists,
* which affects 0 rows and never re-inserts so the channel session stays
* missing and proactive push / cron channel resolution silently degrade
* after the next restart.
*
* <p>Runs after the DB cascade commits see {@link ConversationDeletedEvent}.
*
* <p>会话被删除后清理内存缓存避免留下指向已删除行的幽灵条目
*/
@EventListener
public void onConversationDeleted(ConversationDeletedEvent event) {
if (cache.remove(event.conversationId()) != null) {
log.info("[ChannelSession] Evicted cached session for deleted conversation {}",
event.conversationId());
}
}
/**
* 保存或更新会话标识收到用户消息时调用
*
@ -78,40 +103,51 @@ public class ChannelSessionStore {
existing.setSenderName(senderName);
existing.setChannelId(channelId);
existing.setLastActiveTime(now);
sessionMapper.updateById(existing);
log.debug("Updated channel session: conversationId={}, targetId={}", conversationId, targetId);
} else {
// 先查 DB可能是上次启动后的新记录
ChannelSessionEntity dbEntity = sessionMapper.selectOne(
new LambdaQueryWrapper<ChannelSessionEntity>()
.eq(ChannelSessionEntity::getConversationId, conversationId));
if (dbEntity != null) {
dbEntity.setTargetId(targetId);
dbEntity.setSenderId(senderId);
dbEntity.setSenderName(senderName);
dbEntity.setChannelId(channelId);
dbEntity.setLastActiveTime(now);
sessionMapper.updateById(dbEntity);
cache.put(conversationId, dbEntity);
log.debug("Updated channel session from DB: conversationId={}", conversationId);
} else {
// 新建
ChannelSessionEntity entity = new ChannelSessionEntity();
entity.setConversationId(conversationId);
entity.setChannelType(channelType);
entity.setTargetId(targetId);
entity.setSenderId(senderId);
entity.setSenderName(senderName);
entity.setChannelId(channelId);
entity.setLastActiveTime(now);
sessionMapper.insert(entity);
cache.put(conversationId, entity);
log.debug("Created channel session: conversationId={}, targetId={}", conversationId, targetId);
// 容量保护超过上限时淘汰最久未活跃的会话
evictIfNeeded();
int updated = sessionMapper.updateById(existing);
if (updated > 0) {
log.debug("Updated channel session: conversationId={}, targetId={}", conversationId, targetId);
return;
}
// The cached entity points at a row that no longer exists the
// conversation was deleted out from under us (deletes are physical;
// no logical-delete column is honoured project-wide). Without this
// self-heal the update silently affects 0 rows on every subsequent
// message and the session is never re-created, so proactive push
// and cron channel resolution break after the next restart.
log.info("Channel session row for {} vanished; re-creating from cache miss", conversationId);
cache.remove(conversationId);
}
// 先查 DB可能是上次启动后的新记录
ChannelSessionEntity dbEntity = sessionMapper.selectOne(
new LambdaQueryWrapper<ChannelSessionEntity>()
.eq(ChannelSessionEntity::getConversationId, conversationId));
if (dbEntity != null) {
dbEntity.setTargetId(targetId);
dbEntity.setSenderId(senderId);
dbEntity.setSenderName(senderName);
dbEntity.setChannelId(channelId);
dbEntity.setLastActiveTime(now);
sessionMapper.updateById(dbEntity);
cache.put(conversationId, dbEntity);
log.debug("Updated channel session from DB: conversationId={}", conversationId);
} else {
// 新建
ChannelSessionEntity entity = new ChannelSessionEntity();
entity.setConversationId(conversationId);
entity.setChannelType(channelType);
entity.setTargetId(targetId);
entity.setSenderId(senderId);
entity.setSenderName(senderName);
entity.setChannelId(channelId);
entity.setLastActiveTime(now);
sessionMapper.insert(entity);
cache.put(conversationId, entity);
log.debug("Created channel session: conversationId={}, targetId={}", conversationId, targetId);
// 容量保护超过上限时淘汰最久未活跃的会话
evictIfNeeded();
}
}
@ -189,13 +225,29 @@ public class ChannelSessionStore {
}
/**
* 删除会话
* Drop a channel session from both layers.
*
* <p>Use this rather than the mapper: this class owns the cache, so a
* caller that deletes the row directly leaves a phantom entry behind
* every later {@code saveOrUpdate} then updates a primary key that no
* longer exists and the session is never re-created.
*
* <p>The conversation-delete cascade does not come through here: it removes
* the row inside its own transaction and lets
* {@link #onConversationDeleted} drop the cache after commit, so the cache
* is never cleared for a delete that later rolls back.
*
* <p>删除会话内存 + DB 双层
*
* @return number of DB rows removed
*/
public void remove(String conversationId) {
ChannelSessionEntity removed = cache.remove(conversationId);
if (removed != null) {
sessionMapper.deleteById(removed.getId());
public int remove(String conversationId) {
cache.remove(conversationId);
int deleted = sessionMapper.delete(new LambdaQueryWrapper<ChannelSessionEntity>()
.eq(ChannelSessionEntity::getConversationId, conversationId));
if (deleted > 0) {
log.debug("Removed channel session: conversationId={}", conversationId);
}
return deleted;
}
}

View File

@ -0,0 +1,187 @@
package vip.mate.channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* TTL- and capacity-bounded claim register for inbound channel messages.
*
* <p>One shared implementation for every channel. Before this existed, four
* adapters carried four hand-rolled variants (a 500-entry LRU, an unbounded
* set halved on overflow, an access-ordered map) and four adapters carried
* none at all DingTalk among them, which is why a redelivered DingTalk
* message produced a second full answer.
*
* <p>A message is identified by {@code channelId + identity}, where identity is
* the platform message id (see
* {@link ChannelMessageRouter#inboundIdentity(ChannelMessage)}). Scoping by
* channel keeps two channels of the same type from colliding on a platform id
* that is only unique per app.
*
* <p>Three operations, matching the three things a caller needs:
* <ul>
* <li>{@link #claim} take ownership of a message. The first caller gets
* {@code true} and proceeds; a redelivery inside the TTL gets
* {@code false} and must drop the message.</li>
* <li>{@link #contains} peek without claiming, so an adapter can drop a
* known redelivery <em>before</em> expensive inbound work (media
* download, payload decryption) and still leave the authoritative claim
* to the router.</li>
* <li>{@link #release} give a claim back when the message was never handed
* off for processing (e.g. the channel queue was full), so the
* platform's own retry can still get through.</li>
* </ul>
*
* <p>Fail-open by design: a blank identity means "this platform gave us
* nothing stable to dedup on", and the message is always let through. Dropping
* a real message is worse than answering a redelivery twice.
*
* <p>入站消息去重登记表TTL + 容量双约束全渠道共用一份实现
*/
@Slf4j
@Component
@EnableConfigurationProperties(ChannelDedupProperties.class)
public class InboundMessageDeduplicator {
private final ChannelDedupProperties props;
/**
* Claimed identity -> claim timestamp (epoch millis). Insertion-ordered so
* the eldest entries sit at the head and overflow trimming is a head scan.
* Guarded by its own monitor claims are short, contended only by the
* channel intake threads.
*/
private final LinkedHashMap<String, Long> claims = new LinkedHashMap<>();
public InboundMessageDeduplicator(ChannelDedupProperties props) {
this.props = props;
}
/**
* Take ownership of an inbound message.
*
* @return {@code true} when the caller owns this message and should process
* it; {@code false} when it is a redelivery already claimed inside
* the TTL window and must be dropped
*/
public boolean claim(Long channelId, String identity) {
String key = key(channelId, identity);
if (key == null || !props.isEnabled()) {
return true;
}
long now = System.currentTimeMillis();
long ttlMs = ttlMillis();
synchronized (claims) {
Long claimedAt = claims.get(key);
if (claimedAt != null && now - claimedAt < ttlMs) {
return false;
}
// Either new, or an expired claim being retaken. Remove first so
// the re-insert moves the entry to the tail insertion order is
// what the overflow trim relies on to find the eldest claims.
claims.remove(key);
claims.put(key, now);
if (claims.size() > props.getMaxSize()) {
trim(now, ttlMs);
}
return true;
}
}
/**
* Peek at a claim without taking one. Lets an adapter short-circuit a
* redelivery before doing expensive inbound work while leaving the single
* authoritative claim to the router.
*/
public boolean contains(Long channelId, String identity) {
String key = key(channelId, identity);
if (key == null || !props.isEnabled()) {
return false;
}
long now = System.currentTimeMillis();
long ttlMs = ttlMillis();
synchronized (claims) {
Long claimedAt = claims.get(key);
if (claimedAt == null) {
return false;
}
if (now - claimedAt < ttlMs) {
return true;
}
claims.remove(key);
return false;
}
}
/**
* Hand a claim back. Call this only when the message was never handed off
* for processing a turn that ran and failed keeps its claim, because the
* user already received the error and a platform retry would just send a
* second one.
*/
public void release(Long channelId, String identity) {
String key = key(channelId, identity);
if (key == null) {
return;
}
synchronized (claims) {
claims.remove(key);
}
}
/** Drop every claim. Called when a channel restarts. */
public void clear() {
synchronized (claims) {
claims.clear();
}
}
/** Live claim count. Package-private for tests. */
int size() {
synchronized (claims) {
return claims.size();
}
}
/**
* Evict expired claims first; if the map is still over capacity (every
* entry fresh under sustained traffic), drop the eldest until it fits.
* Caller holds the monitor.
*/
private void trim(long now, long ttlMs) {
claims.entrySet().removeIf(e -> now - e.getValue() >= ttlMs);
int overflow = claims.size() - props.getMaxSize();
if (overflow <= 0) {
return;
}
Iterator<Map.Entry<String, Long>> it = claims.entrySet().iterator();
for (int i = 0; i < overflow && it.hasNext(); i++) {
it.next();
it.remove();
}
log.debug("[dedup] Trimmed {} eldest claims (cap={})", overflow, props.getMaxSize());
}
private long ttlMillis() {
Duration ttl = props.getTtl();
return ttl != null ? Math.max(1L, ttl.toMillis()) : Duration.ofMinutes(5).toMillis();
}
/**
* Compose the tracking key, or {@code null} when there is nothing stable to
* track. Scoped by channel id so two channels of the same type can't
* collide on a per-app platform id.
*/
private static String key(Long channelId, String identity) {
if (identity == null || identity.isBlank()) {
return null;
}
return (channelId == null ? "-" : channelId.toString()) + ":" + identity;
}
}

View File

@ -36,4 +36,26 @@ public interface StreamingChannelAdapter extends ChannelAdapter {
* @return 最终完整回复内容
*/
String processStream(Flux<StreamDelta> stream, ChannelMessage message, String conversationId);
/**
* 判断一个 delta 的文本是否属于"最终回复内容"
* <p>
* {@code segmentOnly} delta 携带的是每轮 ReAct 的旁白"我来查一下…"
* 共享累加器刻意不把它写进 {@code mate_message.content}适配器如果直接
* 累加 {@code delta.content()}就会把每轮旁白拼进外发文本 而旁白通常
* 是对答案的复述用户就会把同一段内容读到两三遍被污染的文本还会回写
* 持久化并在下一轮作为历史重放重复量随轮次增长而不是稳定在 2
* <p>
* 旁白要不要露出由渠道的 {@code stream_progress} 开关决定想露出就作为
* 独立的进度消息下发而不是混进最终答案
*
* @param delta 流式片段
* @return true 表示该片段的文本应计入最终回复
*/
static boolean contributesToFinalContent(StreamDelta delta) {
return delta != null
&& !delta.isEvent()
&& !delta.segmentOnly()
&& delta.content() != null;
}
}

View File

@ -27,6 +27,8 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 钉钉渠道适配器
@ -62,6 +64,12 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
/** AI Card 管理器message_type=card 时初始化) */
private DingTalkAICardManager aiCardManager;
/**
* Off-callback worker for inbound parsing, so the Stream frame is acked
* immediately. See {@link #dispatchInbound}.
*/
private volatile ExecutorService inboundExecutor;
/** 钉钉媒体上传器doStart 时初始化) */
private DingTalkMediaUploader mediaUploader;
@ -118,6 +126,11 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
// 启动 Stream 模式或 Webhook 模式
if (isStreamMode()) {
this.inboundExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "dingtalk-inbound-" + channelEntity.getId());
t.setDaemon(true);
return t;
});
startStreamMode(clientId, clientSecret);
} else {
log.info("[dingtalk] Webhook mode: waiting for callbacks at /api/v1/channels/webhook/dingtalk");
@ -234,12 +247,44 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
return;
}
handleWebhook(payload);
dispatchInbound(payload);
} catch (Exception e) {
log.error("[dingtalk-stream] Failed to parse stream message: {}", e.getMessage(), e);
}
}
/**
* Hand the parsed payload to a worker and return, so the SDK can ack the
* Stream frame immediately.
*
* <p>{@link #handleWebhook} resolves media inline each attachment costs a
* download-URL call plus a byte fetch against DingTalk. Running that on the
* callback thread delays the ack by however long the downloads take, and a
* late ack makes DingTalk redeliver the message: the user gets the same
* answer once per redelivery. Acking first removes the cause; the router's
* inbound claim is the second line of defence for redeliveries we can't
* prevent.
*
* <p>Single-threaded on purpose the agent turn itself already runs on the
* router's queue, so this thread only parses, and keeping it serial
* preserves the arrival order of a sender's messages.
*/
private void dispatchInbound(Map<String, Object> payload) {
ExecutorService executor = inboundExecutor;
if (executor == null || executor.isShutdown()) {
// Channel stopped mid-flight process inline rather than drop.
handleWebhook(payload);
return;
}
executor.execute(() -> {
try {
handleWebhook(payload);
} catch (Exception e) {
log.error("[dingtalk-stream] Inbound dispatch failed: {}", e.getMessage(), e);
}
});
}
@Override
protected void doStop() {
// 关闭 Stream 客户端
@ -252,6 +297,10 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
}
streamClient = null;
}
if (inboundExecutor != null) {
inboundExecutor.shutdownNow();
inboundExecutor = null;
}
if (aiCardManager != null) {
aiCardManager.cleanup();
aiCardManager = null;
@ -348,7 +397,10 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
StringBuilder contentAccumulator = new StringBuilder();
try {
stream.doOnNext(delta -> {
if (delta.content() != null) {
// segmentOnly narration is skipped: appending every
// ReAct iteration's "我来查一下…" into the card text is
// what makes the answer read as if it were sent twice.
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
contentAccumulator.append(delta.content());
aiCardManager.appendContent(outTrackId, delta.content(), false);
}
@ -361,15 +413,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
.blockLast(Duration.ofMinutes(5));
// Step 3: 完成
String finalContent = contentAccumulator.toString();
if (finalContent.isBlank()) {
finalContent = "(无回复内容)";
// The AI Card path never touches renderAndSend, so the channel's
// message-filter config has to be applied here otherwise
// filter_thinking / filter_tool_messages are inert whenever AI
// Card mode is on. The unfiltered text is still what we return,
// so persistence keeps the model's original answer.
String rawContent = contentAccumulator.toString();
String cardContent = filterOutboundContent(rawContent);
if (cardContent.isBlank()) {
cardContent = "(无回复内容)";
}
aiCardManager.finishCard(outTrackId, finalContent);
aiCardManager.finishCard(outTrackId, cardContent);
log.info("[dingtalk] AI Card streaming completed: outTrackId={}, contentLen={}",
outTrackId, finalContent.length());
return finalContent;
outTrackId, cardContent.length());
return rawContent.isBlank() ? cardContent : rawContent;
} catch (Exception e) {
log.error("[dingtalk] AI Card streaming failed: outTrackId={}, error={}",
@ -398,7 +456,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
StringBuilder contentAccumulator = new StringBuilder();
stream.doOnNext(delta -> {
if (delta.content() != null) {
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
contentAccumulator.append(delta.content());
}
})

View File

@ -60,14 +60,6 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter {
/** 媒体下载用 HttpClient复用 http_proxy 配置) */
private volatile HttpClient mediaHttpClient;
/** 已处理消息去重LRU最多保留 500 条) */
private final Set<String> processedMessageIds = Collections.newSetFromMap(new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Boolean> eldest) {
return size() > 500;
}
});
public DiscordChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
@ -146,7 +138,6 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter {
}
selfId = null;
mediaHttpClient = null;
processedMessageIds.clear();
log.info("[discord] Discord channel stopped");
}
@ -447,14 +438,10 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter {
return;
}
// 去重
// Inbound dedup lives in ChannelMessageRouter.enqueue now msgId is
// carried on the ChannelMessage below and claimed there, once, for
// every channel.
String msgId = message.getId();
synchronized (processedMessageIds) {
if (processedMessageIds.contains(msgId)) {
return;
}
processedMessageIds.add(msgId);
}
String channelId = message.getChannel().getId();
String guildId = message.isFromGuild() ? message.getGuild().getId() : null;

View File

@ -84,9 +84,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
/** 定时 Token 刷新任务 */
private ScheduledFuture<?> tokenRefreshFuture;
/** 消息去重:最近处理过的 message_id */
private final Set<String> processedMessageIds = ConcurrentHashMap.newKeySet();
/**
* 群内 bot 别名缓存chatId 学到的别名集合openId / unionId / userId / name
* <p>飞书 SDK 投递的 mention bot 的标识可能是群内自定义别名{@code ou_357e...} / 自定义名称
@ -460,7 +457,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
this.botName = null;
this.botOpenIdLastFailureMs = 0L;
}
this.processedMessageIds.clear();
this.chatBotAliases.clear();
this.mentionTracker.clear();
this.nicknameCache.clear();
@ -1212,7 +1208,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
if (isGroup && chatId != null) {
shortSuffix = resolveGroupSessionSuffix(chatId);
}
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup,
channelEntity != null ? channelEntity.getId() : null);
String stagedUploadPath = null;
if (isFileMessage) {
@ -1232,12 +1229,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
log.warn("[feishu] require_mention=true but bot open_id unavailable; allowing messageId={}", messageId);
}
// 消息去重
if (messageId != null && !processedMessageIds.add(messageId)) {
// Early duplicate gate. The authoritative claim happens once, in
// ChannelMessageRouter.enqueue; this peek only spares a redelivery the
// side effects below (the "received" reaction, media downloads) that
// would otherwise fire again before the router ever sees the message.
if (messageRouter.isDuplicateInbound(channelEntity.getId(), messageId)) {
log.debug("[feishu] Duplicate message_id: {}, skipping", messageId);
return;
}
cleanupProcessedIds();
// 添加消息反应非阻塞表示"已收到"
if (messageId != null && getConfigBoolean("enable_reaction", true)) {
@ -1298,21 +1297,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
onMessage(channelMessage);
}
/**
* 清理旧的去重记录超过 1000 条时保留最近添加的移除最早的一半
*/
private void cleanupProcessedIds() {
if (processedMessageIds.size() > 1000) {
int toRemove = processedMessageIds.size() / 2;
var iterator = processedMessageIds.iterator();
while (iterator.hasNext() && toRemove > 0) {
iterator.next();
iterator.remove();
toRemove--;
}
}
}
// ==================== 消息反应 ====================
/**
@ -1731,14 +1715,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
* {@code senderId} is the full open id. Mirror that exactly:
* {@code groups feishu:{shortSuffix}}, {@code DMs feishu:{senderOpenId}}.
*/
static String buildConversationId(String shortSuffix, String senderOpenId, boolean isGroup) {
static String buildConversationId(String shortSuffix, String senderOpenId, boolean isGroup,
Long channelId) {
// The routed ChannelMessage carries chatId = (isGroup ? shortSuffix : null);
// the router then falls back to senderId when that chatId is null. Mirror both
// steps so the storage id matches the runtime id in every case (including the
// degenerate group-with-no-suffix path).
String routedChatId = isGroup ? shortSuffix : null;
String identifier = routedChatId != null ? routedChatId : senderOpenId;
return identifier != null ? CHANNEL_TYPE + ":" + identifier : null;
if (identifier == null) {
return null;
}
// Mirror ChannelMessageRouter#buildConversationId: scope the id by channelId so
// the same sender on two workspaces' feishu channels never shares a conversation.
return channelId != null
? CHANNEL_TYPE + ":" + channelId + ":" + identifier
: CHANNEL_TYPE + ":" + identifier;
}
// ==================== Per-chat recent file cache ====================
@ -2613,7 +2605,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
StringBuilder accumulator = new StringBuilder();
try {
stream.doOnNext(delta -> {
if (delta.content() != null) {
// segmentOnly narration is skipped: appending every
// ReAct iteration's "我来查一下…" into the card text is
// what makes the answer read as if it were sent twice.
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
accumulator.append(delta.content());
streamingCardManager.appendContent(sessionKey, delta.content(), false);
}
@ -2626,8 +2621,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
.blockLast(Duration.ofMinutes(5));
String finalContent = accumulator.toString();
if (finalContent.isBlank()) {
finalContent = "(无回复内容)";
// Card streaming never touches renderAndSend, so the channel's
// message-filter config has to be applied here otherwise
// filter_thinking / filter_tool_messages are inert on this path.
String cardContent = filterOutboundContent(finalContent);
if (cardContent.isBlank()) {
cardContent = "(无回复内容)";
}
// Strip any /api/v1/files/generated/{id} URLs out of the card
// text (replacing each with a "📎 filename" marker) AND send
@ -2636,11 +2635,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
// the user sees a broken-looking download link instead of the
// actual file. Cache-miss URLs fall back to the user-facing
// retry hint that GeneratedFileScrubber emits.
String renderedContent = scrubAndSendAttachments(receiveId, finalContent);
String renderedContent = scrubAndSendAttachments(receiveId, cardContent);
streamingCardManager.finishCard(sessionKey, renderedContent);
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
sessionKey, renderedContent.length());
return finalContent;
return finalContent.isBlank() ? cardContent : finalContent;
} catch (Exception e) {
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
@ -2671,13 +2670,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
private String processStreamAsText(Flux<StreamDelta> stream, ChannelMessage message) {
StringBuilder accumulator = new StringBuilder();
stream.doOnNext(delta -> {
if (delta.content() != null) {
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
accumulator.append(delta.content());
}
})
.blockLast(Duration.ofMinutes(5));
String finalContent = accumulator.toString();
if (!finalContent.isBlank()) {
// sendMessage is called directly (rather than renderAndSend) because
// Feishu does its own card/text split and chunking, so the channel's
// message-filter config is applied explicitly here.
String outbound = filterOutboundContent(finalContent);
if (!outbound.isBlank()) {
String replyTarget = message.getReplyToken() != null
? message.getReplyToken()
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
@ -2685,7 +2688,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
// Same scrub-and-upload hop as the streaming card finish path
// a generated-file URL in plain text would otherwise reach the
// user as a markdown link that opens to nothing useful in IM.
String renderedContent = scrubAndSendAttachments(replyTarget, finalContent);
String renderedContent = scrubAndSendAttachments(replyTarget, outbound);
sendMessage(replyTarget, renderedContent);
}
}

View File

@ -284,8 +284,8 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
// Schema 2.0 body works fine on cardkit/v1 card.create and on
// im/v1 message.create msg_type=interactive. Two different
// server-side validators, only one of which has been upgraded
// for Schema 2.0. QwenPaw's production Feishu adapter uses the
// same type="raw" approach for callback updates.
// for Schema 2.0, so callback updates must go through
// type="raw" with a Schema 1.0 body.
cb.setType("raw");
cb.setData(cardJson);
return cb;
@ -320,15 +320,22 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
private static ChannelMessage buildSynthetic(String commandText, String clickerOpenId,
PendingApproval pending,
P2CardActionTriggerData data) {
// pending.conversationId looks like "feishu:<scope>" where
// <scope> is either ou_xxx (1:1 chat derived from senderId)
// or oc_xxx (group chat derived from chatId). Reverse the
// scope back into the right chatId field so buildConversationId
// reproduces the exact same key.
// pending.conversationId looks like "feishu:{channelId}:{scope}" (or the
// legacy two-segment "feishu:{scope}"), where {scope} is either ou_xxx
// (1:1 chat derived from senderId) or oc_xxx (group chat derived from
// chatId). Extract the trailing {scope} and reverse it back into the right
// chatId field so buildConversationId reproduces the exact same key. The
// replay routes through this same feishu channel, so the router re-embeds
// the matching channelId automatically. Scopes never contain ':', so
// splitting on the first ':' after the "feishu:" prefix is unambiguous and
// handles both the new three-segment and the legacy two-segment forms.
String convId = pending.getConversationId();
String scope = (convId != null && convId.startsWith("feishu:"))
? convId.substring("feishu:".length())
: null;
String scope = null;
if (convId != null && convId.startsWith("feishu:")) {
String rest = convId.substring("feishu:".length());
int colon = rest.indexOf(':');
scope = colon >= 0 ? rest.substring(colon + 1) : rest;
}
boolean isGroup = scope != null && scope.startsWith("oc_");
String chatId = isGroup ? scope : null;
String replyToken = isGroup ? scope : clickerOpenId;

View File

@ -74,8 +74,7 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer {
// mismatch error. Schema 2.0 is supported by im/v1/message.create
// BUT the callback response validator only accepts Schema 1.0
// inline (type="raw") once we commit to Schema 1.0 here the
// resolved-state card update lands cleanly. QwenPaw's
// production Feishu integration uses the same Schema 1.0 path.
// resolved-state card update lands cleanly.
Map<String, Object> approveBtn = new LinkedHashMap<>();
approveBtn.put("tag", "button");
approveBtn.put("text", plainText("批准"));
@ -116,8 +115,7 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer {
* {@code cardkit/v1 card.create}, both of which DO accept Schema
* 2.0. So we keep the original approval card (sent via message
* create) in Schema 2.0 for the column_set button layout, but the
* resolved-state update has to be Schema 1.0. QwenPaw's production
* Feishu integration uses the same split.
* resolved-state update has to be Schema 1.0.
*
* <p>Caller passes the resulting Map to a {@code CallBackCard}
* with {@code type="raw"} (NOT {@code card_json}).

View File

@ -0,0 +1,530 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.AgentService;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 流式累积器 收集 StreamDelta 事件持久化到 DB
* <p>
* 维护两份数据
* <ul>
* <li>{@code toolCalls} 兼容旧逻辑执行面板等 UI 使用</li>
* <li>{@code segments} 按事件到达顺序记录的有序时间线前端分段渲染用</li>
* </ul>
* 两份数据从同一事件流构建保证一致segments 保留了 thinking tools content
* 的真实交错顺序toolCalls segments tool_call 类型的平铺视图
* <p>
* Shared by the Web SSE path ({@code ChatController}) and the IM channel
* router live fan-out side effects go through the injected {@link Sink}
* so each caller keeps its own broadcast semantics. Internal bookkeeping
* events ({@code _usage_final}, {@code _routing_decision}) are consumed
* here and never reach the sink.
*/
@Slf4j
public final class AgentStreamAccumulator {
/**
* Live fan-out hooks. The accumulator itself only builds the persisted
* metadata/parts; anything a subscriber should see in real time is
* delegated here.
*/
public interface Sink {
/** Broadcast a named event to live subscribers of the conversation. */
void broadcast(String conversationId, String eventName, Object payload);
/** Update the conversation's current phase indicator. */
void updatePhase(String conversationId, String phase);
}
/** Markdown link pointing at a generated-file download URL. Used to
* surface generated artifacts in the run-overview rail. */
private static final Pattern GENERATED_FILE_LINK_PATTERN =
Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
private final ObjectMapper objectMapper;
private final Sink sink;
private final StringBuilder content = new StringBuilder();
private final StringBuilder thinking = new StringBuilder();
private final List<Map<String, Object>> toolCalls = new ArrayList<>();
/** 有序事件时间线 — 前端分段渲染的权威数据源 */
private final List<Map<String, Object>> segments = new ArrayList<>();
private final List<Map<String, Object>> browserActions = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** Tool names whose returnDirect output was folded into the assistant message */
private final List<String> directToolNames = new ArrayList<>();
/** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */
private final List<Map<String, Object>> generatedFiles = new ArrayList<>();
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = "";
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 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 = "";
/**
* Recovery affordance payload from {@link GraphEventPublisher#feedback}.
* Persisted into {@code metadata.feedbackEvent} so a page reload still
* surfaces the retry/regenerate/report card on the failed assistant
* bubble. Null when the turn ended cleanly.
*/
private Map<String, Object> feedbackEvent = null;
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
private Map<String, Object> pendingApproval = null;
/**
* Multimodal sidecar routing decision for this turn (null when no
* routing happened). Captured from the {@code _routing_decision}
* event emitted before the graph stream and folded into
* {@code metadata.routing} on persistence so the chat UI can show
* which sidecar (if any) was invoked.
*/
private Map<String, Object> routingDecision = null;
public AgentStreamAccumulator(ObjectMapper objectMapper, Sink sink) {
this.objectMapper = objectMapper;
this.sink = sink;
}
public synchronized void accept(AgentService.StreamDelta delta, String conversationId) {
if (delta == null) return;
if (delta.isEvent()) {
if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return;
}
if ("phase".equals(delta.eventType())) {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) {
currentPhase = phase;
sink.updatePhase(conversationId, phase);
// phase 切换时关闭 running content/thinking segment保留边界
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);
}
}
if (GraphEventPublisher.EVENT_FEEDBACK.equals(delta.eventType())) {
// Snapshot the affordance payload so it persists into
// message metadata. The same event is also rebroadcast
// live (via the sink fall-through below) so an
// already-mounted UI sees it instantly without
// waiting for the message-save round trip.
feedbackEvent = delta.eventData();
}
if (GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) {
// Captured at turn start; persisted under metadata.routing so the
// chat UI can render which sidecar (if any) was invoked. Internal
// event return early to skip rebroadcast on IM channels.
routingDecision = delta.eventData();
return;
}
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
sink.broadcast(conversationId, delta.eventType(), delta.eventData());
} catch (Exception e) {
log.warn("Failed to broadcast event {}: {}", delta.eventType(), e.getMessage());
}
return;
}
// content_delta
if (delta.content() != null && !delta.content().isBlank()) {
// segmentOnly deltas route per-iteration narration to the
// segments timeline only the persisted top-level content
// field stays clean so it carries the final answer span,
// not "我来…让我…" concatenations across iterations (issue
// #120 narration leg). segmentOnly implies persistenceOnly,
// so no broadcast either.
if (!delta.segmentOnly()) {
content.append(delta.content());
}
sink.updatePhase(conversationId, "drafting_answer");
if (!delta.persistenceOnly()) {
sink.broadcast(conversationId, "content_delta", Map.of("delta", delta.content()));
}
// segments: 追加到当前 running content segment或创建新的
var seg = findLastRunning("content");
if (seg != null) {
seg.put("text", seg.getOrDefault("text", "") + delta.content());
} else {
finalizeRunningSegments("thinking");
var s = newSegment("content");
s.put("text", delta.content());
segments.add(s);
}
}
// thinking_delta
if (delta.thinking() != null && !delta.thinking().isBlank()) {
if (!delta.segmentOnly()) {
thinking.append(delta.thinking());
}
if (!delta.persistenceOnly()) {
sink.broadcast(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
}
var seg = findLastRunning("thinking");
if (seg != null) {
seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking());
} else {
var s = newSegment("thinking");
s.put("thinkingText", delta.thinking());
segments.add(s);
}
}
}
public boolean isAwaitingApproval() { return awaitingApproval; }
private void accumulateToolEvent(String eventType, Map<String, Object> data, String conversationId) {
if ("tool_approval_requested".equals(eventType)) {
awaitingApproval = true;
currentPhase = "awaiting_approval";
pendingApproval = new LinkedHashMap<>();
pendingApproval.put("pendingId", data.getOrDefault("pendingId", ""));
pendingApproval.put("toolName", data.getOrDefault("toolName", ""));
pendingApproval.put("arguments", data.getOrDefault("arguments", ""));
pendingApproval.put("reason", data.getOrDefault("reason", ""));
pendingApproval.put("status", "pending_approval");
if (data.containsKey("findings")) pendingApproval.put("findings", data.get("findings"));
if (data.containsKey("maxSeverity")) pendingApproval.put("maxSeverity", data.get("maxSeverity"));
if (data.containsKey("summary")) pendingApproval.put("summary", data.get("summary"));
sink.updatePhase(conversationId, "awaiting_approval");
} else if ("tool_approval_resolved".equals(eventType)) {
if (pendingApproval != null) {
pendingApproval.put("status",
"approved".equals(String.valueOf(data.getOrDefault("decision", ""))) ? "approved" : "denied");
}
} else if ("plan_created".equals(eventType)) {
Object rawPlanId = data.get("planId");
if (rawPlanId instanceof Number n) {
planId = n.longValue();
} else if (rawPlanId != null) {
try { planId = Long.valueOf(String.valueOf(rawPlanId)); } catch (Exception ignored) {}
}
Object steps = data.get("steps");
if (steps instanceof List<?> list) {
planSteps = list.stream().map(String::valueOf).toList();
planStepResults.clear();
for (int i = 0; i < planSteps.size(); i++) {
planStepResults.add(null);
}
}
currentPlanStep = 0;
} else if ("plan_step_started".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
currentPlanStep = n.intValue();
}
} else if ("plan_step_completed".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
int index = n.intValue();
currentPlanStep = index;
ensurePlanStepCapacity(index + 1);
Map<String, Object> stepResult = new LinkedHashMap<>();
stepResult.put("result", data.getOrDefault("result", ""));
stepResult.put("status", "completed");
planStepResults.set(index, stepResult);
}
} else if ("browser_action".equals(eventType)) {
browserActions.add(new LinkedHashMap<>(data));
} else if ("warning".equals(eventType)) {
String warning = String.valueOf(data.getOrDefault("message",
data.getOrDefault("delta", "")));
if (!warning.isBlank()) {
warnings.add(warning);
}
} else if ("tool_call_started".equals(eventType)) {
// toolCalls兼容
Map<String, Object> tc = new LinkedHashMap<>();
// toolCallId is required for history replay to pair the persisted
// assistant tool_call with its tool_response providers reject any
// sequence whose ids don't match. Always record it (empty string
// when the upstream event didn't carry one, e.g. forced tool calls).
tc.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", "")));
tc.put("name", data.getOrDefault("toolName", ""));
tc.put("arguments", data.getOrDefault("arguments", ""));
tc.put("status", "running");
toolCalls.add(tc);
// segments: 关闭 running thinking/content插入 tool_call
finalizeRunningSegments("thinking", "content");
var seg = newSegment("tool_call");
seg.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", "")));
seg.put("toolName", data.getOrDefault("toolName", ""));
seg.put("toolArgs", data.getOrDefault("arguments", ""));
segments.add(seg);
} else if ("tool_direct_result".equals(eventType)) {
// returnDirect tool track the tool name so history replay can
// render a "data returned directly by tool" badge. The actual
// textual content reaches the user/persistence layer through the
// regular content_delta path (FinalAnswerNode's FINAL_ANSWER
// StateGraphReActAgent StreamDelta), so we intentionally do
// NOT add a content-bearing segment here to avoid the user
// seeing the same text twice.
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
if (!toolName.isBlank() && !directToolNames.contains(toolName)) {
directToolNames.add(toolName);
}
} else if ("tool_call_completed".equals(eventType)) {
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
String toolCallId = String.valueOf(data.getOrDefault("toolCallId", ""));
// toolCalls兼容 prefer toolCallId match so parallel calls of
// the same tool don't collide on the running+toolName fallback.
for (int i = toolCalls.size() - 1; i >= 0; i--) {
Map<String, Object> tc = toolCalls.get(i);
boolean matches = (!toolCallId.isEmpty()
&& toolCallId.equals(String.valueOf(tc.getOrDefault("toolCallId", ""))))
|| (toolCallId.isEmpty()
&& "running".equals(tc.get("status"))
&& toolName.equals(tc.get("name")));
if (matches) {
tc.put("result", data.getOrDefault("result", ""));
tc.put("success", data.getOrDefault("success", true));
tc.put("status", "completed");
break;
}
}
// segments: 标记对应 tool_call 完成
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
if (!"tool_call".equals(seg.get("type"))) continue;
boolean matches = (!toolCallId.isEmpty()
&& toolCallId.equals(String.valueOf(seg.getOrDefault("toolCallId", ""))))
|| (toolCallId.isEmpty()
&& "running".equals(seg.get("status"))
&& toolName.equals(seg.get("toolName")));
if (matches) {
seg.put("status", "completed");
seg.put("toolResult", data.getOrDefault("result", ""));
seg.put("toolSuccess", data.getOrDefault("success", true));
break;
}
}
// Extract generated-file links from the tool result so the
// run-overview rail can surface artifacts without re-scanning
// segments on the frontend.
extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName);
}
}
/** Scan a tool result for markdown links pointing at generated-file
* download URLs and collect them into {@link #generatedFiles}.
* De-duplicates by URL so a link echoed in later tool results doesn't
* produce duplicate entries in the run-overview rail. */
private void extractGeneratedFiles(String result, String toolName) {
if (result == null || result.isBlank()) return;
Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result);
while (m.find()) {
String url = m.group(2);
boolean dup = generatedFiles.stream()
.anyMatch(f -> url.equals(String.valueOf(f.get("url"))));
if (dup) continue;
Map<String, Object> file = new LinkedHashMap<>();
file.put("filename", m.group(1));
file.put("url", url);
file.put("toolName", toolName);
generatedFiles.add(file);
}
}
private void ensurePlanStepCapacity(int size) {
while (planStepResults.size() < size) {
planStepResults.add(null);
}
}
// ==================== Segment helpers ====================
private Map<String, Object> newSegment(String type) {
Map<String, Object> seg = new LinkedHashMap<>();
seg.put("id", type.substring(0, 2) + "-" + segCounter++);
seg.put("type", type);
seg.put("status", "running");
return seg;
}
private Map<String, Object> findLastRunning(String type) {
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg;
}
return null;
}
private void finalizeRunningSegments(String... types) {
var typeSet = Set.of(types);
for (var seg : segments) {
if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) {
seg.put("status", "completed");
}
}
}
// ==================== 原有访问器 ====================
public String getContent() { return content.toString().trim(); }
public String getThinking() { return thinking.toString().trim(); }
public int getPromptTokens() { return promptTokens; }
public int getCompletionTokens() { return completionTokens; }
public int getCacheReadTokens() { return cacheReadTokens; }
public int getCacheWriteTokens() { return cacheWriteTokens; }
public int getReasoningTokens() { return reasoningTokens; }
public String getRuntimeModelName() { return runtimeModelName; }
public String getRuntimeProviderId() { return runtimeProviderId; }
public String getCurrentPhase() { return currentPhase; }
public String getFinishReason() { return finishReason; }
public boolean segmentsEmpty() { return segments.isEmpty(); }
public synchronized List<MessageContentPart> toAssistantParts() {
List<MessageContentPart> parts = new ArrayList<>();
if (!getContent().isBlank()) {
MessageContentPart textPart = new MessageContentPart();
textPart.setType("text");
textPart.setText(getContent());
parts.add(textPart);
}
if (!getThinking().isBlank()) {
MessageContentPart thinkingPart = new MessageContentPart();
thinkingPart.setType("thinking");
thinkingPart.setText(getThinking());
parts.add(thinkingPart);
}
for (Map<String, Object> tc : toolCalls) {
try {
parts.add(MessageContentPart.toolCall(objectMapper.writeValueAsString(tc)));
} catch (Exception e) {
log.warn("Failed to serialize tool call: {}", e.getMessage());
}
}
return parts;
}
private void finalizeToolCalls() {
for (Map<String, Object> tc : toolCalls) {
if ("running".equals(tc.get("status"))) tc.put("status", "completed");
}
}
/**
* 生成 metadata JSON包含 toolCalls + segments
* toolCalls 保留兼容旧 UIsegments 是按事件顺序的完整时间线
*/
public synchronized String toMetadataJson() {
finalizeToolCalls();
finalizeRunningSegments("thinking", "content", "tool_call");
SegmentSupersedeDetector.markSuperseded(segments);
try {
Map<String, Object> metadata = new LinkedHashMap<>();
if (!toolCalls.isEmpty()) {
metadata.put("toolCalls", toolCalls);
}
if (!segments.isEmpty()) {
metadata.put("segments", segments);
}
if (!currentPhase.isBlank()) {
metadata.put("currentPhase", currentPhase);
}
if (planId != null || !planSteps.isEmpty() || currentPlanStep != null) {
Map<String, Object> plan = new LinkedHashMap<>();
if (planId != null) plan.put("planId", planId);
if (!planSteps.isEmpty()) plan.put("steps", planSteps);
if (currentPlanStep != null) plan.put("currentStep", currentPlanStep);
if (planStepResults.stream().anyMatch(Objects::nonNull)) {
plan.put("stepResults", planStepResults);
}
metadata.put("plan", plan);
}
if (pendingApproval != null && !pendingApproval.isEmpty()) {
metadata.put("pendingApproval", pendingApproval);
}
if (!browserActions.isEmpty()) {
metadata.put("browserActions", browserActions);
}
if (!warnings.isEmpty()) {
metadata.put("warnings", warnings);
}
if (!directToolNames.isEmpty()) {
// Only the tool names go into metadata the full content
// already lives in mate_message.content (assembled by
// FinalAnswerNode). UI uses this to badge historical
// messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!generatedFiles.isEmpty()) {
metadata.put("generatedFiles", generatedFiles);
}
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);
}
if (feedbackEvent != null && !feedbackEvent.isEmpty()) {
// Persist the recovery-affordance payload so the
// retry/regenerate/report card survives page reload.
// Stored as-is (errorType, errorMessage, actions,
// timestamp) frontend MessageBubble reads
// metadata.feedbackEvent and renders one button per
// entry in `actions`.
metadata.put("feedbackEvent", feedbackEvent);
}
if (routingDecision != null && !routingDecision.isEmpty()) {
metadata.put("routing", routingDecision);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());
return "{}";
}
}
}

View File

@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@ -38,8 +39,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
@ -65,9 +64,13 @@ public class ChatController {
private final ConversationCompletionPublisher completionPublisher;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
// 使用虚拟线程池处理 SSEJava 17+ 兼容Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
// Virtual thread per SSE task: matches the app-wide virtual-thread model
// (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread
// pool, never reuses a thread across tasks, so no ThreadLocal state can leak
// from one stream into another.
private final ExecutorService sseExecutor = Executors.newVirtualThreadPerTaskExecutor();
/**
* SSE 流式对话支持断线重连
@ -155,7 +158,7 @@ public class ChatController {
// ---- 分支 B正常请求 ----
Long agentId = request.getAgentId();
String message = request.getMessage() != null ? request.getMessage() : "";
String requestMessage = request.getMessage() != null ? request.getMessage() : "";
if (auth == null) {
try {
sendEvent(emitter, "error", Map.of("message", "未登录,请先登录"));
@ -189,7 +192,7 @@ public class ChatController {
}
// ---- 审批命令拦截/approve/deny SSE 流式 replay ----
String normalizedMsg = message.trim().toLowerCase();
String normalizedMsg = requestMessage.trim().toLowerCase();
boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg);
boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg);
@ -244,7 +247,7 @@ public class ChatController {
AtomicBoolean approvalEmitterDone = new AtomicBoolean(false);
sseExecutor.execute(() -> {
StreamAccumulator accumulator = new StreamAccumulator();
AgentStreamAccumulator accumulator = newAccumulator();
AtomicBoolean finalized = new AtomicBoolean(false);
try {
// 广播 approval_resolved 事件
@ -513,6 +516,34 @@ public class ChatController {
return emitter;
}
// ---- 重新生成regenerate=true删除会话末尾的 assistant 回答块
// 复用 DB 中的种子 user 消息作为本轮输入不重复持久化 user
// message 字段被忽略以持久化的种子为准 ----
final boolean regenerate = Boolean.TRUE.equals(request.getRegenerate());
final ConversationService.RegenerateSeed regenerateSeed;
if (regenerate) {
if (!conversationService.isConversationOwner(conversationId, username)) {
sendErrorDoneAndComplete(emitter, "无权操作该会话");
return emitter;
}
if (streamTracker.isRunning(conversationId)) {
sendErrorDoneAndComplete(emitter, "正在生成回复,请先停止再重新生成");
return emitter;
}
regenerateSeed = conversationService.prepareRegenerate(conversationId);
if (regenerateSeed == null) {
sendErrorDoneAndComplete(emitter, "当前没有可重新生成的回答");
return emitter;
}
log.info("SSE regenerate: conversationId={}, seedMessageId={}",
conversationId, regenerateSeed.seedMessageId());
} else {
regenerateSeed = null;
}
final String message = regenerateSeed != null
? (regenerateSeed.content() != null ? regenerateSeed.content() : "")
: requestMessage;
// ---- 正常请求注册流状态并附着首个订阅者 ----
streamTracker.register(conversationId);
streamTracker.bindRunMeta(conversationId, agentId, username);
@ -536,7 +567,7 @@ public class ChatController {
AtomicBoolean emitterDone = new AtomicBoolean(false);
sseExecutor.execute(() -> {
StreamAccumulator accumulator = new StreamAccumulator();
AgentStreamAccumulator accumulator = newAccumulator();
AtomicBoolean finalized = new AtomicBoolean(false);
try {
conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId);
@ -545,9 +576,15 @@ public class ChatController {
// of every other conversation.
conversationService.updateConversationModel(conversationId,
request.getModelProvider(), request.getModelName());
List<MessageContentPart> requestParts = normalizeRequestParts(request);
List<MessageContentPart> requestParts = regenerateSeed != null
? regenerateSeed.parts()
: normalizeRequestParts(request);
String promptText = buildPromptText(message, requestParts);
conversationService.saveMessage(conversationId, "user", message, requestParts);
if (regenerateSeed == null) {
// Regenerate reuses the already-persisted seed user row
// inserting again would duplicate it (issue #547).
conversationService.saveMessage(conversationId, "user", message, requestParts);
}
conversationService.updateStreamStatus(conversationId, "running");
broadcastEvent(conversationId, "session", Map.of(
@ -1142,21 +1179,7 @@ public class ChatController {
return ResponseEntity.status(403).build();
}
// Check every candidate root (workspace-scoped dir + legacy default dir)
// so attachments written before the workspace-aware relocation, and the
// current workspace-scoped ones, are both servable. Each candidate keeps
// its own startsWith traversal guard.
Path filePath = null;
// Sanitized-then-raw candidate dirs so both new writes (sanitized) and
// legacy Linux uploads (raw ':' dir) resolve.
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normDir = conversationDir.normalize();
Path candidate = normDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
filePath = candidate;
break;
}
}
Path filePath = resolveUploadedFile(conversationId, storedName);
if (filePath == null) {
return ResponseEntity.notFound().build();
}
@ -1183,6 +1206,62 @@ public class ChatController {
.body(resource);
}
@Operation(summary = "生成聊天附件的 PDF 预览office 格式soffice 转换)")
@GetMapping("/files/{conversationId}/{storedName:.+}/preview")
public ResponseEntity<byte[]> previewUploadedFile(
@PathVariable String conversationId,
@PathVariable String storedName,
Authentication auth) {
// Same ownership gate as the raw file endpoint.
String username = auth != null ? auth.getName() : "anonymous";
if (!conversationService.isConversationOwner(conversationId, username)) {
return ResponseEntity.status(403).build();
}
// 415: the client asked to preview a format this endpoint won't convert.
if (!officePreviewService.isConvertible(storedName)) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
}
// 501: no soffice on this host the UI degrades to a download link.
if (!officePreviewService.isAvailable()) {
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
}
Path filePath = resolveUploadedFile(conversationId, storedName);
if (filePath == null) {
return ResponseEntity.notFound().build();
}
try {
byte[] pdf = officePreviewService.renderPdf(filePath);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
.body(pdf);
} catch (IOException e) {
log.warn("[ChatController] office preview conversion failed for {}: {}", storedName, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Resolve an uploaded attachment to its on-disk path, probing every
* candidate conversation dir (workspace-scoped + legacy default, sanitized +
* raw id) with a per-candidate path-traversal guard. Returns {@code null}
* when no candidate holds the file.
*/
private Path resolveUploadedFile(String conversationId, String storedName) {
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normDir = conversationDir.normalize();
Path candidate = normDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
return candidate;
}
}
return null;
}
/**
* Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner
* memory isolation for a web request. When {@code endUserId} is supplied
@ -1292,6 +1371,12 @@ public class ChatController {
* end-user when one MateClaw account fronts many of them.
*/
private String endUserId;
/**
* true 表示重新生成删除会话末尾的 assistant 回答块复用其前最近一条
* 已持久化的 user 消息作为本轮输入{@link #message} 字段被忽略且不
* 重复插入 user 生成中的会话拒绝该请求
*/
private Boolean regenerate;
}
/**
@ -1353,7 +1438,7 @@ public class ChatController {
streamTracker.attach(conversationId, emitter);
// 启动新的流复用现有 sseExecutor.execute 的逻辑模式
StreamAccumulator accumulator = new StreamAccumulator();
AgentStreamAccumulator accumulator = newAccumulator();
AtomicBoolean finalized = new AtomicBoolean(false);
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
@ -1485,6 +1570,22 @@ public class ChatController {
() -> emergencySaveAccumulator(conversationId, accumulator));
}
/**
* Terminal error path for requests rejected before a stream is registered:
* emit an {@code error} + terminal {@code done} pair and complete the
* emitter, so the client's SSE reader exits cleanly instead of waiting
* for a timeout.
*/
private void sendErrorDoneAndComplete(SseEmitter emitter, String errorMessage) {
try {
sendEvent(emitter, "error", Map.of("message", errorMessage));
sendEvent(emitter, "done", Map.of("status", "completed"));
} catch (IOException e) {
log.warn("SSE pre-stream error send failed: {}", e.getMessage());
}
emitter.complete();
}
private void sendEvent(SseEmitter emitter, String name, Object data) throws IOException {
String payload;
try {
@ -1569,7 +1670,7 @@ public class ChatController {
}
private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status,
StreamAccumulator accumulator, String source) {
AgentStreamAccumulator accumulator, String source) {
log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}",
source, conversationId, status, accumulator.getFinishReason(),
accumulator.getCurrentPhase(), !accumulator.segmentsEmpty());
@ -1675,7 +1776,7 @@ public class ChatController {
* (race window is sub-second between dispose and save) and acceptable. Skipping
* save when nothing to save avoids empty rows.
*/
private void emergencySaveAccumulator(String conversationId, StreamAccumulator accumulator) {
private void emergencySaveAccumulator(String conversationId, AgentStreamAccumulator accumulator) {
try {
String text = accumulator.getContent();
List<MessageContentPart> parts = accumulator.toAssistantParts();
@ -1820,491 +1921,23 @@ public class ChatController {
|| lower.contains("client abort") || lower.contains("closed");
}
/** Markdown link pointing at a generated-file download URL. Used by the
* StreamAccumulator to surface generated artifacts in the run-overview rail. */
private static final Pattern GENERATED_FILE_LINK_PATTERN =
Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
/**
* 流式累积器 收集 StreamDelta 事件持久化到 DB
* <p>
* 维护两份数据
* <ul>
* <li>{@code toolCalls} 兼容旧逻辑执行面板等 UI 使用</li>
* <li>{@code segments} 按事件到达顺序记录的有序时间线前端分段渲染用</li>
* </ul>
* 两份数据从同一事件流构建保证一致segments 保留了 thinking tools content
* 的真实交错顺序toolCalls segments tool_call 类型的平铺视图
* Build a per-stream accumulator wired to this controller's SSE
* broadcast and phase tracking. Kept as a factory so every stream gets
* its own instance while the fan-out semantics stay in one place.
*/
private final class StreamAccumulator {
private final StringBuilder content = new StringBuilder();
private final StringBuilder thinking = new StringBuilder();
private final List<Map<String, Object>> toolCalls = new ArrayList<>();
/** 有序事件时间线 — 前端分段渲染的权威数据源 */
private final List<Map<String, Object>> segments = new ArrayList<>();
private final List<Map<String, Object>> browserActions = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** RFC-052: tool names whose returnDirect output was folded into the assistant message */
private final List<String> directToolNames = new ArrayList<>();
/** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */
private final List<Map<String, Object>> generatedFiles = new ArrayList<>();
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = "";
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 = "";
/**
* Recovery affordance payload from {@link
* vip.mate.agent.GraphEventPublisher#feedback}. Persisted into
* {@code metadata.feedbackEvent} so a page reload still surfaces
* the retry/regenerate/report card on the failed assistant
* bubble. Null when the turn ended cleanly.
*/
private Map<String, Object> feedbackEvent = null;
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
private Map<String, Object> pendingApproval = null;
/**
* Multimodal sidecar routing decision for this turn (null when no
* routing happened). Captured from the {@code _routing_decision}
* event emitted before the graph stream and folded into
* {@code metadata.routing} on persistence so the chat UI can show
* which sidecar (if any) was invoked.
*/
private Map<String, Object> routingDecision = null;
synchronized void accept(AgentService.StreamDelta delta, String conversationId) {
if (delta == null) return;
if (delta.isEvent()) {
if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return;
}
if ("phase".equals(delta.eventType())) {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) {
currentPhase = phase;
streamTracker.updatePhase(conversationId, phase);
// phase 切换时关闭 running content/thinking segment保留边界
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);
}
}
if (vip.mate.agent.GraphEventPublisher.EVENT_FEEDBACK
.equals(delta.eventType())) {
// Snapshot the affordance payload so it persists into
// message metadata. The same event is also rebroadcast
// live (via the broadcastEvent fall-through below) so
// an already-mounted UI sees it instantly without
// waiting for the message-save round trip.
feedbackEvent = delta.eventData();
}
if (vip.mate.agent.GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) {
// Captured at turn start; persisted under metadata.routing so the
// chat UI can render which sidecar (if any) was invoked. Internal
// event return early to skip rebroadcast on IM channels.
routingDecision = delta.eventData();
return;
}
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
broadcastEvent(conversationId, delta.eventType(), delta.eventData());
} catch (Exception e) {
log.warn("Failed to broadcast event {}: {}", delta.eventType(), e.getMessage());
}
return;
private AgentStreamAccumulator newAccumulator() {
return new AgentStreamAccumulator(objectMapper, new AgentStreamAccumulator.Sink() {
@Override
public void broadcast(String conversationId, String eventName, Object payload) {
broadcastEvent(conversationId, eventName, payload);
}
// content_delta
if (delta.content() != null && !delta.content().isBlank()) {
// segmentOnly deltas route per-iteration narration to the
// segments timeline only the persisted top-level content
// field stays clean so it carries the final answer span,
// not "我来…让我…" concatenations across iterations (issue
// #120 narration leg). segmentOnly implies persistenceOnly,
// so no broadcast either.
if (!delta.segmentOnly()) {
content.append(delta.content());
}
streamTracker.updatePhase(conversationId, "drafting_answer");
if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content()));
}
// segments: 追加到当前 running content segment或创建新的
var seg = findLastRunning("content");
if (seg != null) {
seg.put("text", seg.getOrDefault("text", "") + delta.content());
} else {
finalizeRunningSegments("thinking");
var s = newSegment("content");
s.put("text", delta.content());
segments.add(s);
}
@Override
public void updatePhase(String conversationId, String phase) {
streamTracker.updatePhase(conversationId, phase);
}
// thinking_delta
if (delta.thinking() != null && !delta.thinking().isBlank()) {
if (!delta.segmentOnly()) {
thinking.append(delta.thinking());
}
if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
}
var seg = findLastRunning("thinking");
if (seg != null) {
seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking());
} else {
var s = newSegment("thinking");
s.put("thinkingText", delta.thinking());
segments.add(s);
}
}
}
boolean isAwaitingApproval() { return awaitingApproval; }
private void accumulateToolEvent(String eventType, Map<String, Object> data, String conversationId) {
if ("tool_approval_requested".equals(eventType)) {
awaitingApproval = true;
currentPhase = "awaiting_approval";
pendingApproval = new LinkedHashMap<>();
pendingApproval.put("pendingId", data.getOrDefault("pendingId", ""));
pendingApproval.put("toolName", data.getOrDefault("toolName", ""));
pendingApproval.put("arguments", data.getOrDefault("arguments", ""));
pendingApproval.put("reason", data.getOrDefault("reason", ""));
pendingApproval.put("status", "pending_approval");
if (data.containsKey("findings")) pendingApproval.put("findings", data.get("findings"));
if (data.containsKey("maxSeverity")) pendingApproval.put("maxSeverity", data.get("maxSeverity"));
if (data.containsKey("summary")) pendingApproval.put("summary", data.get("summary"));
streamTracker.updatePhase(conversationId, "awaiting_approval");
} else if ("tool_approval_resolved".equals(eventType)) {
if (pendingApproval != null) {
pendingApproval.put("status",
"approved".equals(String.valueOf(data.getOrDefault("decision", ""))) ? "approved" : "denied");
}
} else if ("plan_created".equals(eventType)) {
Object rawPlanId = data.get("planId");
if (rawPlanId instanceof Number n) {
planId = n.longValue();
} else if (rawPlanId != null) {
try { planId = Long.valueOf(String.valueOf(rawPlanId)); } catch (Exception ignored) {}
}
Object steps = data.get("steps");
if (steps instanceof List<?> list) {
planSteps = list.stream().map(String::valueOf).toList();
planStepResults.clear();
for (int i = 0; i < planSteps.size(); i++) {
planStepResults.add(null);
}
}
currentPlanStep = 0;
} else if ("plan_step_started".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
currentPlanStep = n.intValue();
}
} else if ("plan_step_completed".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
int index = n.intValue();
currentPlanStep = index;
ensurePlanStepCapacity(index + 1);
Map<String, Object> stepResult = new LinkedHashMap<>();
stepResult.put("result", data.getOrDefault("result", ""));
stepResult.put("status", "completed");
planStepResults.set(index, stepResult);
}
} else if ("browser_action".equals(eventType)) {
browserActions.add(new LinkedHashMap<>(data));
} else if ("warning".equals(eventType)) {
String warning = String.valueOf(data.getOrDefault("message",
data.getOrDefault("delta", "")));
if (!warning.isBlank()) {
warnings.add(warning);
}
} else if ("tool_call_started".equals(eventType)) {
// toolCalls兼容
Map<String, Object> tc = new LinkedHashMap<>();
// toolCallId is required for history replay to pair the persisted
// assistant tool_call with its tool_response providers reject any
// sequence whose ids don't match. Always record it (empty string
// when the upstream event didn't carry one, e.g. forced tool calls).
tc.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", "")));
tc.put("name", data.getOrDefault("toolName", ""));
tc.put("arguments", data.getOrDefault("arguments", ""));
tc.put("status", "running");
toolCalls.add(tc);
// segments: 关闭 running thinking/content插入 tool_call
finalizeRunningSegments("thinking", "content");
var seg = newSegment("tool_call");
seg.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", "")));
seg.put("toolName", data.getOrDefault("toolName", ""));
seg.put("toolArgs", data.getOrDefault("arguments", ""));
segments.add(seg);
} else if ("tool_direct_result".equals(eventType)) {
// RFC-052: returnDirect tool track the tool name so history
// replay can render a "data returned directly by tool" badge.
// The actual textual content reaches the user/persistence layer
// through the regular content_delta path (FinalAnswerNode's
// FINAL_ANSWER StateGraphReActAgent StreamDelta), so we
// intentionally do NOT add a content-bearing segment here to
// avoid the user seeing the same text twice.
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
if (!toolName.isBlank() && !directToolNames.contains(toolName)) {
directToolNames.add(toolName);
}
} else if ("tool_call_completed".equals(eventType)) {
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
String toolCallId = String.valueOf(data.getOrDefault("toolCallId", ""));
// toolCalls兼容 prefer toolCallId match so parallel calls of
// the same tool don't collide on the running+toolName fallback.
for (int i = toolCalls.size() - 1; i >= 0; i--) {
Map<String, Object> tc = toolCalls.get(i);
boolean matches = (!toolCallId.isEmpty()
&& toolCallId.equals(String.valueOf(tc.getOrDefault("toolCallId", ""))))
|| (toolCallId.isEmpty()
&& "running".equals(tc.get("status"))
&& toolName.equals(tc.get("name")));
if (matches) {
tc.put("result", data.getOrDefault("result", ""));
tc.put("success", data.getOrDefault("success", true));
tc.put("status", "completed");
break;
}
}
// segments: 标记对应 tool_call 完成
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
if (!"tool_call".equals(seg.get("type"))) continue;
boolean matches = (!toolCallId.isEmpty()
&& toolCallId.equals(String.valueOf(seg.getOrDefault("toolCallId", ""))))
|| (toolCallId.isEmpty()
&& "running".equals(seg.get("status"))
&& toolName.equals(seg.get("toolName")));
if (matches) {
seg.put("status", "completed");
seg.put("toolResult", data.getOrDefault("result", ""));
seg.put("toolSuccess", data.getOrDefault("success", true));
break;
}
}
// Extract generated-file links from the tool result so the
// run-overview rail can surface artifacts without re-scanning
// segments on the frontend.
extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName);
}
}
/** Scan a tool result for markdown links pointing at generated-file
* download URLs and collect them into {@link #generatedFiles}.
* De-duplicates by URL so a link echoed in later tool results doesn't
* produce duplicate entries in the run-overview rail. */
private void extractGeneratedFiles(String result, String toolName) {
if (result == null || result.isBlank()) return;
Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result);
while (m.find()) {
String url = m.group(2);
boolean dup = generatedFiles.stream()
.anyMatch(f -> url.equals(String.valueOf(f.get("url"))));
if (dup) continue;
Map<String, Object> file = new LinkedHashMap<>();
file.put("filename", m.group(1));
file.put("url", url);
file.put("toolName", toolName);
generatedFiles.add(file);
}
}
private void ensurePlanStepCapacity(int size) {
while (planStepResults.size() < size) {
planStepResults.add(null);
}
}
// ==================== Segment helpers ====================
private Map<String, Object> newSegment(String type) {
Map<String, Object> seg = new LinkedHashMap<>();
seg.put("id", type.substring(0, 2) + "-" + segCounter++);
seg.put("type", type);
seg.put("status", "running");
return seg;
}
private Map<String, Object> findLastRunning(String type) {
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg;
}
return null;
}
private void finalizeRunningSegments(String... types) {
var typeSet = java.util.Set.of(types);
for (var seg : segments) {
if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) {
seg.put("status", "completed");
}
}
}
// ==================== 原有访问器 ====================
String getContent() { return content.toString().trim(); }
String getThinking() { return thinking.toString().trim(); }
int getPromptTokens() { return promptTokens; }
int getCompletionTokens() { return completionTokens; }
int getCacheReadTokens() { return cacheReadTokens; }
int getCacheWriteTokens() { return cacheWriteTokens; }
int getReasoningTokens() { return reasoningTokens; }
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<>();
if (!getContent().isBlank()) {
MessageContentPart textPart = new MessageContentPart();
textPart.setType("text");
textPart.setText(getContent());
parts.add(textPart);
}
if (!getThinking().isBlank()) {
MessageContentPart thinkingPart = new MessageContentPart();
thinkingPart.setType("thinking");
thinkingPart.setText(getThinking());
parts.add(thinkingPart);
}
for (Map<String, Object> tc : toolCalls) {
try {
parts.add(MessageContentPart.toolCall(objectMapper.writeValueAsString(tc)));
} catch (Exception e) {
log.warn("Failed to serialize tool call: {}", e.getMessage());
}
}
return parts;
}
void finalizeToolCalls() {
for (Map<String, Object> tc : toolCalls) {
if ("running".equals(tc.get("status"))) tc.put("status", "completed");
}
}
/**
* 生成 metadata JSON包含 toolCalls + segments
* toolCalls 保留兼容旧 UIsegments 是按事件顺序的完整时间线
*/
synchronized String toMetadataJson() {
finalizeToolCalls();
finalizeRunningSegments("thinking", "content", "tool_call");
SegmentSupersedeDetector.markSuperseded(segments);
try {
Map<String, Object> metadata = new LinkedHashMap<>();
if (!toolCalls.isEmpty()) {
metadata.put("toolCalls", toolCalls);
}
if (!segments.isEmpty()) {
metadata.put("segments", segments);
}
if (!currentPhase.isBlank()) {
metadata.put("currentPhase", currentPhase);
}
if (planId != null || !planSteps.isEmpty() || currentPlanStep != null) {
Map<String, Object> plan = new LinkedHashMap<>();
if (planId != null) plan.put("planId", planId);
if (!planSteps.isEmpty()) plan.put("steps", planSteps);
if (currentPlanStep != null) plan.put("currentStep", currentPlanStep);
if (planStepResults.stream().anyMatch(java.util.Objects::nonNull)) {
plan.put("stepResults", planStepResults);
}
metadata.put("plan", plan);
}
if (pendingApproval != null && !pendingApproval.isEmpty()) {
metadata.put("pendingApproval", pendingApproval);
}
if (!browserActions.isEmpty()) {
metadata.put("browserActions", browserActions);
}
if (!warnings.isEmpty()) {
metadata.put("warnings", warnings);
}
if (!directToolNames.isEmpty()) {
// RFC-052 §3.3: only the tool names go into metadata
// the full content already lives in mate_message.content
// (assembled by FinalAnswerNode). UI uses this to badge
// historical messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!generatedFiles.isEmpty()) {
metadata.put("generatedFiles", generatedFiles);
}
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);
}
if (feedbackEvent != null && !feedbackEvent.isEmpty()) {
// Persist the recovery-affordance payload so the
// retry/regenerate/report card survives page reload.
// Stored as-is (errorType, errorMessage, actions,
// timestamp) frontend MessageBubble reads
// metadata.feedbackEvent and renders one button per
// entry in `actions`.
metadata.put("feedbackEvent", feedbackEvent);
}
if (routingDecision != null && !routingDecision.isEmpty()) {
metadata.put("routing", routingDecision);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());
return "{}";
}
}
});
}
private static Long parseLongOrNull(String s) {

View File

@ -1523,8 +1523,7 @@ public class ChatStreamTracker {
/**
* RunState 最长无活动时间 wall-clock {@code MAX_LIFETIME_MS=30min}
* 切换到 inactivity-based 后默认 30 min hermes-agent
* {@code gateway_timeout=1800s} 同口径只要 agent 还在持续产事件
* 切换到 inactivity-based 后默认 30 min1800s 空闲超时只要 agent 还在持续产事件
* tool call / content delta / phase transition / progress_update
* 就一直活下去墙钟跑 1 小时 2 小时都可以只有真正"完全静默 ≥ N 分钟"
* 才视为卡死并强制清理

View File

@ -1,5 +1,6 @@
package vip.mate.channel.webchat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
@ -193,7 +194,11 @@ public class WebChatController {
// 保存用户消息含访客本轮引用的附件附件元数据一律服务端按 fileId 回查
// 不信客户端传入path 用于 Agent 侧工具读取对外消息视图会被剥离
List<MessageContentPart> userParts = buildUserParts(conversationId, message, request.getAttachmentIds());
conversationService.saveMessage(conversationId, "user", message, userParts);
if (!request.isInternalSkipUserPersist()) {
// Regenerate reuses the already-persisted seed user row
// inserting again would duplicate it.
conversationService.saveMessage(conversationId, "user", message, userParts);
}
// 初始化 SSE 流跟踪
streamTracker.register(conversationId);
@ -1381,14 +1386,16 @@ public class WebChatController {
}
/**
* 重新生成最后一条助手回复
* Regenerate the last assistant reply.
* <p>
* 语义:找到会话最后一条 {@code role=user} 消息 stop 当前流(如有) 删除最后一条
* {@code role=assistant} 消息 last user message 重新启动 agent turn
* 实际启动复用 {@link #chatStream},它会重新 saveMessage user(新消息 id,内容相同)
* 这样不重复 100 SSE 代码,代价是用户消息多一条(语义上等同"重发")
* Semantics: stop any in-flight stream, rewind the conversation to the last
* {@code role=user} message (removing the trailing assistant reply), then
* re-run the agent turn from that message. The restart reuses
* {@link #chatStream} with {@code internalSkipUserPersist} set, so the
* existing user row is used as the seed and no duplicate user message is
* inserted.
* <p>
* 没有任何 user 消息时返回 400(无内容可重新生成)
* Returns an error when the conversation has no user message to regenerate from.
*/
@Operation(summary = "重新生成最后一条助手回复")
@PostMapping(value = "/sessions/regenerate", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ -1425,29 +1432,27 @@ public class WebChatController {
// right disposable; multi-node is a separate epic.
streamTracker.requestStop(conversationId);
MessageEntity lastAssistant = conversationService.findLastMessageByRole(conversationId, "assistant");
if (lastAssistant != null) {
conversationService.deleteMessageById(lastAssistant.getId());
}
MessageEntity lastUser = conversationService.findLastMessageByRole(conversationId, "user");
if (lastUser == null) {
ConversationService.RegenerateSeed seed = conversationService.prepareRegenerate(conversationId);
if (seed == null) {
sendErrorAndComplete(emitter, "No user message to regenerate from");
return emitter;
}
log.info("[WebChat] Regenerate: conversationId={}, visitor={}, seedMessageId={}",
conversationId, visitorId, lastUser.getId());
conversationId, visitorId, seed.seedMessageId());
audit(channel, visitorId, "webchat.regenerate-session", conversationId,
"{\"sessionId\":\"" + sid + "\",\"seedMessageId\":" + lastUser.getId() + "}");
"{\"sessionId\":\"" + sid + "\",\"seedMessageId\":" + seed.seedMessageId() + "}");
// Reuse chatStream: it'll resolve the agent again (cheap), re-derive
// conversationId, saveMessage user (new id, same content), and start
// the agent turn. visitorId echoes through to keep the visitor-scoped
// memory owner consistent.
// conversationId and start the agent turn. The seed user row is reused
// as-is internalSkipUserPersist stops chatStream from inserting a
// duplicate user row. visitorId echoes through to keep the
// visitor-scoped memory owner consistent.
WebChatRequest req = new WebChatRequest();
req.setMessage(lastUser.getContent());
req.setMessage(seed.content());
req.setVisitorId(visitorId);
req.setSessionId(sid);
req.setInternalSkipUserPersist(true);
return chatStream(apiKey, req);
}
@ -1656,7 +1661,7 @@ public class WebChatController {
return full;
}
return "webchat:" + key8 + ":#"
+ sha256Hex(visitorId + "" + (sessionId == null ? "" : sessionId)).substring(0, 40);
+ sha256Hex(visitorId + "\0" + (sessionId == null ? "" : sessionId)).substring(0, 40);
}
/**
@ -1917,6 +1922,13 @@ public class WebChatController {
* for this conversation. Metadata is resolved server-side; unknown / foreign / expired
* ids are dropped. */
private List<String> attachmentIds;
/**
* Internal-only regenerate flag: the seed user row is already
* persisted, so {@code chatStream} must not insert a duplicate.
* Excluded from JSON binding never client-settable.
*/
@JsonIgnore
private boolean internalSkipUserPersist;
}
/** Compact view of one of a visitor's conversation threads. */

View File

@ -2,18 +2,23 @@ package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.StreamingChannelAdapter;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.media.InboundMediaDownloader;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.wecom.cards.tool_guard.ToolGuardCardRenderer;
import vip.mate.workspace.conversation.model.MessageContentPart;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
@ -21,6 +26,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
@ -30,6 +36,7 @@ import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* 企业微信智能机器人渠道适配器 WebSocket 长连接模式
@ -55,12 +62,17 @@ import java.util.concurrent.atomic.AtomicInteger;
* <li>welcome_text: 欢迎消息可选</li>
* <li>media_download_enabled: 是否下载媒体文件默认 true</li>
* <li>media_dir: 媒体文件保存目录默认 data/media</li>
* <li>stream_progress: 处理期间是否在气泡内展示实时进度默认 true
* false 退化为"累积后一次性发送"</li>
* <li>progress_interval_ms: 进度覆写最小间隔默认 500ms</li>
* <li>filter_thinking: false 时思考内容流式进入进度气泡默认 true</li>
* <li>filter_tool_messages: false 时每次工具调用发独立留痕消息默认 true</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
public class WeComChannelAdapter extends AbstractChannelAdapter {
public class WeComChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter {
public static final String CHANNEL_TYPE = "wecom";
@ -188,6 +200,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
/** WebSocket 消息碎片缓冲区 */
private final StringBuilder wsBuffer = new StringBuilder();
/**
* Raw-byte accumulator for fragmented binary WS frames. Bytes are only
* decoded (as UTF-8) once the final fragment arrives decoding each
* fragment separately would corrupt any multi-byte character split
* across a fragment boundary. Accessed only from the JDK WebSocket
* listener callbacks, which are delivered serially per socket.
*/
private final ByteArrayOutputStream wsBinaryBuffer = new ByteArrayOutputStream();
/** 请求 ID 计数器 */
private final AtomicInteger reqIdCounter = new AtomicInteger(0);
@ -490,6 +511,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
pendingFrames.clear();
replyContexts.clear();
streamLastContent.clear();
// 断线时可能残留半截帧碎片清空以免污染下一个连接的首帧
wsBuffer.setLength(0);
wsBinaryBuffer.reset();
if (keepaliveScheduler != null) {
keepaliveScheduler.shutdownAll();
}
@ -753,8 +777,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
}
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
wsBuffer.append(new String(bytes));
wsBinaryBuffer.write(bytes, 0, bytes.length);
if (last) {
wsBuffer.append(new String(wsBinaryBuffer.toByteArray(), StandardCharsets.UTF_8));
wsBinaryBuffer.reset();
String fullMessage = wsBuffer.toString();
wsBuffer.setLength(0);
handleWebSocketFrame(fullMessage);
@ -973,7 +999,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
Map<String, Object> imgBody = (Map<String, Object>) body.getOrDefault("image", Map.of());
String url = (String) imgBody.getOrDefault("url", "");
String aesKey = (String) imgBody.getOrDefault("aeskey", "");
String inboundConvId = inboundConversationId(senderId, chatId, chatType);
String inboundConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null);
if (!url.isBlank()) {
contentParts.add(buildInboundImagePart(url, aesKey, msgId, "image.jpg", inboundConvId));
}
@ -1002,7 +1028,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
String filename = (String) fileBody.getOrDefault("filename",
fileBody.getOrDefault("file_name",
fileBody.getOrDefault("name", "file.bin")));
String fileConvId = inboundConversationId(senderId, chatId, chatType);
String fileConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null);
if (!url.isBlank()) {
MessageContentPart filePart = buildInboundFilePart(
url, aesKey, msgId, filename, fileConvId);
@ -1032,7 +1058,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
Map<String, Object> img = (Map<String, Object>) item.getOrDefault("image", Map.of());
String url = (String) img.getOrDefault("url", "");
String aesKey = (String) img.getOrDefault("aeskey", "");
String mixedConvId = inboundConversationId(senderId, chatId, chatType);
String mixedConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null);
if (!url.isBlank()) {
contentParts.add(buildInboundImagePart(
url, aesKey, msgId, "mixed_image.jpg", mixedConvId));
@ -1345,6 +1371,345 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
}
}
// ==================== StreamingChannelAdapter ====================
/** Minimum interval between progress overwrites of the stream bubble. */
private static final long PROGRESS_MIN_INTERVAL_MS = 500;
/** Max chars of a tool-argument summary in a standalone tool message. */
private static final int TOOL_ARGS_SUMMARY_MAX = 120;
/**
* 流式处理 Agent 事件并渲染到企业微信
* <p>
* 渲染策略复用入站时发出的 "🤔 思考中..." reply_stream 气泡
* agent 事件思考工具调用计划步骤内容产出持续覆写为实时进度
* 流结束后原地渐变为最终答案 renderAndSend 的分段逻辑
* <ul>
* <li>覆写节流 {@link #PROGRESS_MIN_INTERVAL_MS}工具/审批等关键事件立即刷新</li>
* <li>静默期由 {@link WeComKeepaliveScheduler} 20s 用进度快照续期</li>
* <li>{@code stream_progress=false} 或无可用气泡语音入站等时退化为
* "累积后一次性发送"与改造前行为一致</li>
* <li>{@code filter_tool_messages=false} 每次工具调用另发独立消息留痕</li>
* <li>{@code filter_thinking=false} 思考内容以引用块进入进度气泡</li>
* </ul>
*/
@Override
public String processStream(Flux<StreamDelta> stream, ChannelMessage message, String conversationId) {
String replyTarget = message.getReplyToken() != null ? message.getReplyToken()
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
WeComReplyContext ctx = replyContexts.get(replyTarget);
boolean progressEnabled = getConfigBoolean("stream_progress", true);
boolean bubbleUsable = ctx != null && ctx.processingStreamId() != null
&& !ctx.processingStreamId().isBlank()
&& ctx.frameReqId() != null && !ctx.frameReqId().isBlank();
StringBuilder contentAccumulator = new StringBuilder();
StreamOutcome outcome;
if (!progressEnabled || !bubbleUsable) {
// Degraded path accumulate content only and send once at the end.
// segmentOnly narration is excluded: gluing it into the reply is
// what makes the same paragraph reach the user two or three times.
stream.doOnNext(delta -> {
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
contentAccumulator.append(delta.content());
}
})
.blockLast(Duration.ofMinutes(10));
outcome = new StreamOutcome(null, false);
} else {
outcome = consumeWithProgress(stream, message, replyTarget, ctx, contentAccumulator);
}
String finalContent = contentAccumulator.toString();
// The newest narration was held back until the answer was known: when
// a turn's closing narration and its final answer are the same text
// (routine once the answer is short the model restates it before the
// last tool call), publishing both puts the identical bubble on screen
// twice. Same text drop the narration, the final answer covers it.
String pendingNarration = outcome.pendingNarration();
if (!finalContent.isBlank()) {
if (pendingNarration != null && !sameOutboundText(pendingNarration, finalContent)) {
publishNarrationBubble(replyTarget, pendingNarration);
}
renderAndSend(replyTarget, finalContent);
} else if (pendingNarration != null) {
// No final answer at all the held-back narration is everything
// the user gets, so it closes the live bubble in place instead of
// being dropped. Not returned: narration never becomes persisted
// content.
renderAndSend(replyTarget, pendingNarration);
} else {
// Nothing to render. Without this the live bubble would sit at
// "🤔 思考中…" with the tool trail under it forever, because only
// renderAndSend ever finishes it.
closeIdleProgressBubble(replyTarget, outcome.approvalPending());
}
return finalContent;
}
/**
* What {@link #consumeWithProgress} learned while draining the stream, but
* which only {@link #processStream} can act on (it needs the final answer
* first).
*
* @param pendingNarration the last per-stage narration, still unpublished
* @param approvalPending a tool call is parked on human approval
*/
private record StreamOutcome(String pendingNarration, boolean approvalPending) {}
/** Compare two outbound texts the way the user sees them (post-filter, trimmed). */
private boolean sameOutboundText(String a, String b) {
if (a == null || b == null) {
return false;
}
String left = filterOutboundContent(a).trim();
String right = filterOutboundContent(b).trim();
return !left.isEmpty() && left.equals(right);
}
/**
* Finish the live progress bubble when the turn produced no text at all
* (approval park, user stop, empty answer). Leaving it open strands a
* permanent "思考中…" bubble carrying the whole tool trail.
*/
private void closeIdleProgressBubble(String replyTarget, boolean approvalPending) {
WeComReplyContext ctx = replyContexts.get(replyTarget);
if (ctx == null || ctx.processingStreamId() == null || ctx.processingStreamId().isBlank()) {
return;
}
renderAndSend(replyTarget, approvalPending
? "⏸️ 已暂停,等待工具审批。"
: "(本轮没有产生回复内容)");
}
/**
* Finalize the live progress bubble with a narration, or send the
* narration as a plain message when no bubble is available (the keepalive
* force-finish at the 180s ceiling evicts the reply context).
*/
private void publishNarrationBubble(String replyTarget, String narration) {
WeComReplyContext ctx = replyContexts.get(replyTarget);
if (ctx != null) {
rollProgressBubble(replyTarget, ctx, narration, null);
} else {
sendMessage(replyTarget, narration);
}
}
/** Event-driven progress rendering into the processing-stream bubble, with per-stage bubble rolling. */
private StreamOutcome consumeWithProgress(Flux<StreamDelta> stream, ChannelMessage message,
String replyTarget, WeComReplyContext initialCtx,
StringBuilder contentAccumulator) {
boolean showThinking = !getConfigBoolean("filter_thinking", true);
boolean standaloneToolMessages = !getConfigBoolean("filter_tool_messages", true);
long minIntervalMs = getConfigLong("progress_interval_ms", PROGRESS_MIN_INTERVAL_MS);
WeComProgressRenderer progress = new WeComProgressRenderer(
System.currentTimeMillis(), showThinking, standaloneToolMessages);
if (keepaliveScheduler != null) {
// Silent stretches (long LLM calls with no events) keep showing a
// fresh elapsed-time snapshot instead of the static placeholder.
keepaliveScheduler.attachTextSupplier(initialCtx.processingStreamId(), progress::snapshot);
}
// The live progress bubble rolls forward on every stage narration:
// the current stream is finished with the narration text (making it
// a permanent bubble in place) and a fresh stream id opens below it
// as the new progress bubble, so chat chronology stays intact and
// the final answer always lands in the newest bubble.
AtomicReference<WeComReplyContext> liveCtx = new AtomicReference<>(initialCtx);
AtomicReference<String> pendingNarration = new AtomicReference<>();
final long[] lastFlushAt = {0L};
stream.doOnNext(delta -> {
boolean flushNow = false;
if (delta.isEvent()) {
flushNow = progress.onEvent(delta.eventType(), delta.eventData());
if (standaloneToolMessages) {
maybeSendToolEventMessage(replyTarget, delta.eventType(), delta.eventData());
}
} else if (delta.segmentOnly()) {
// Per-stage narration ("我来查一下…"), emitted once per agent
// loop iteration. Each becomes its own permanent bubble and is
// excluded from the final answer: glued together they read as
// a wall of text, and persisted they pollute the next turn's
// LLM history with unanswered chain-of-thought.
//
// Publishing lags one narration behind: the newest one is only
// staged (visible live in the bubble, not yet finalized) so
// processStream can still drop it if the final answer turns out
// to be the same text. Without the lag the user reads the same
// paragraph in two adjacent bubbles.
String narration = delta.content() != null ? delta.content().trim() : "";
if (!narration.isEmpty()) {
String previous = pendingNarration.getAndSet(narration);
progress.onNarration(narration);
if (previous != null) {
WeComReplyContext ctx = liveCtx.get();
if (replyContexts.get(replyTarget) == ctx) {
liveCtx.set(rollProgressBubble(replyTarget, ctx, previous, progress));
} else {
// Bubble already force-finished (180s ceiling) the
// narration still goes out as a plain message.
sendMessage(replyTarget, previous);
}
}
flushNow = true;
}
} else {
if (delta.thinking() != null) {
progress.onThinkingDelta(delta.thinking());
}
if (delta.content() != null) {
contentAccumulator.append(delta.content());
progress.onContentDelta(delta.content());
}
}
long now = System.currentTimeMillis();
if (!flushNow && now - lastFlushAt[0] < minIntervalMs) {
return;
}
WeComReplyContext ctx = liveCtx.get();
// The keepalive force-finish (180s ceiling) evicts the reply
// context; once that happens the stream slot is closed and
// further overwrites would be silently rejected stop pushing.
if (replyContexts.get(replyTarget) != ctx) {
return;
}
lastFlushAt[0] = now;
try {
replyStream(ctx.frameReqId(), ctx.processingStreamId(), progress.snapshot(), false);
} catch (Exception e) {
// Reset the throttle window so the next delta retries the
// overwrite immediately instead of waiting out the min
// interval a failed push means the bubble is stale.
lastFlushAt[0] = 0L;
log.debug("[wecom] progress overwrite failed: {}", e.getMessage());
}
}).blockLast(Duration.ofMinutes(10));
return new StreamOutcome(pendingNarration.get(), progress.isApprovalPending());
}
/**
* Finalize the current progress bubble with a stage narration and open a
* fresh stream as the next progress bubble.
* <p>
* The narration goes through the channel renderer (thinking/tool-tag
* filters, table formatting, length split): the first segment overwrites
* the current bubble with {@code finish=true}, overflow segments ride
* plain messages. The replacement context is registered in
* {@link #replyContexts} so {@code renderAndSend} / approval cards keep
* working against the newest bubble, and keepalive restarts on the new
* stream with the live progress snapshot.
*
* @param progress live progress renderer, or {@code null} when the stream
* has already finished (the replacement bubble is then a
* bare slot for {@code renderAndSend}, with no keepalive)
*/
private WeComReplyContext rollProgressBubble(String replyTarget, WeComReplyContext ctx,
String narration, WeComProgressRenderer progress) {
boolean filterThinking = getConfigBoolean("filter_thinking", true);
boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true);
String format = getConfigString("message_format", "auto");
int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS
.getOrDefault(getChannelType(), 2048);
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
narration, filterThinking, filterToolMessages, format, maxLen);
if (segments.isEmpty()) {
// Narration entirely filtered away keep the current bubble.
return ctx;
}
if (keepaliveScheduler != null) {
// Stop before the finish chunk so a refresh tick can't race it
// on the same stream.
keepaliveScheduler.stop(ctx.processingStreamId());
}
boolean first = true;
for (String rawSegment : segments) {
String segment = formatMarkdownTables(rawSegment);
if (first) {
first = false;
try {
replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true);
} catch (Exception e) {
log.debug("[wecom] stage bubble finalize failed: {}", e.getMessage());
}
} else {
sendMessage(replyTarget, segment);
}
}
String nextStreamId = generateReqId("stream");
WeComReplyContext next = new WeComReplyContext(ctx.frameReqId(), nextStreamId);
replyContexts.put(replyTarget, next);
try {
replyStream(ctx.frameReqId(), nextStreamId,
progress != null ? progress.snapshot() : "✍️ 正在整理…", false);
} catch (Exception e) {
log.debug("[wecom] next progress bubble open failed: {}", e.getMessage());
}
if (progress != null && keepaliveScheduler != null) {
try {
keepaliveScheduler.start(this, ctx.frameReqId(), nextStreamId, replyTarget);
keepaliveScheduler.attachTextSupplier(nextStreamId, progress::snapshot);
} catch (Exception e) {
log.debug("[wecom] keepalive restart failed: {}", e.getMessage());
}
}
return next;
}
/**
* Standalone tool-call trace messages, sent only when the channel's
* {@code filter_tool_messages} toggle is off: the user opted into seeing
* the tool trail as persistent bubbles (the progress bubble alone is
* transient the final answer overwrites it).
*/
private void maybeSendToolEventMessage(String replyTarget, String eventType,
Map<String, Object> data) {
if (data == null || eventType == null) {
return;
}
Object toolName = data.get("toolName");
if (toolName == null) {
return;
}
try {
switch (eventType) {
case "tool_call_started" -> {
String args = summarizeToolArgs(data.get("arguments"));
sendMessage(replyTarget, "🔧 调用工具 `" + toolName + "`"
+ (args.isEmpty() ? "" : "\n> " + args));
}
case "tool_call_completed" -> {
boolean success = !Boolean.FALSE.equals(data.get("success"));
sendMessage(replyTarget, (success ? "✅ `" : "❌ `") + toolName
+ (success ? "` 完成" : "` 失败"));
}
default -> {
// Other events carry no standalone tool trace.
}
}
} catch (Exception e) {
log.debug("[wecom] tool trace message failed: {}", e.getMessage());
}
}
private static String summarizeToolArgs(Object arguments) {
if (arguments == null) {
return "";
}
String text = arguments.toString().replaceAll("\\s+", " ").trim();
if (text.isEmpty() || "{}".equals(text)) {
return "";
}
return text.length() > TOOL_ARGS_SUMMARY_MAX
? text.substring(0, TOOL_ARGS_SUMMARY_MAX) + ""
: text;
}
@Override
public void sendMessage(String targetId, String content) {
if (webSocket == null) {
@ -1352,10 +1717,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
return;
}
// 检查是否有 pending frame用于 reply_stream 覆盖"思考中..."
// sendMessage renderAndSend 调用时尝试用 reply_stream 覆盖
// 但由于 rawPayload 信息在 ChannelMessageRouter 层已丢失
// 这里走 send_message 主动推送路径
// frame 上下文的通用发送入口cron/异步通知等主动推送场景
// WeComReplyContext 的回复路径renderAndSend / sendContentParts
// 已直接绑定入站 frame 发送不再落到这里此处保留
// send_message 主动推送 + 群聊缓存 reqId 兜底
sendMessageToChat(targetId, content);
}
@ -1491,6 +1856,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
&& !ctx.processingStreamId().isBlank()) {
replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true);
first = false;
} else if (ctx != null && ctx.frameReqId() != null && !ctx.frameReqId().isBlank()) {
// 后续分段绑定同一入站 frame 回复群聊拒收主动推送
// frame 回复在群聊/单聊都可达且保持顺序
replyMarkdown(ctx.frameReqId(), segment);
} else {
sendMessage(targetId, segment);
}
@ -1609,6 +1978,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
&& !ctx.processingStreamId().isBlank()) {
replyStream(ctx.frameReqId(), ctx.processingStreamId(), rewritten, true);
firstText = false;
} else if (ctx != null && ctx.frameReqId() != null
&& !ctx.frameReqId().isBlank()) {
// 后续文本绑定同一入站 frame 回复群聊拒收主动推送
replyMarkdown(ctx.frameReqId(), rewritten);
} else {
sendMessage(targetId, rewritten);
}
@ -1911,6 +2284,42 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
*/
private final ConcurrentHashMap<String, String> streamLastContent = new ConcurrentHashMap<>();
/**
* Send a markdown bubble bound to an inbound frame via
* {@code aibot_respond_msg}.
*
* <p>Used for reply segments after the first when a live
* {@link WeComReplyContext} exists: the platform rejects
* {@code aibot_send_msg} in group chats, so segments pushed actively
* would silently vanish there. Riding the inbound frame's reply slot
* works in both group and single chats, and keeps segment ordering
* behind the stream bubble (same per-reqId serial worker queue).
*
* <p>Failures are logged, not propagated one bad segment must not
* abort the remaining segments of a long reply.
*/
private void replyMarkdown(String frameReqId, String content) {
if (frameReqId == null || frameReqId.isBlank()
|| content == null || content.isBlank()) {
return;
}
try {
Map<String, Object> body = Map.of(
"msgtype", "markdown",
"markdown", Map.of("content", content)
);
Map<String, Object> frame = Map.of(
"cmd", CMD_RESPONSE,
"headers", Map.of("req_id", frameReqId),
"body", body
);
sendFrameWithAck(frameReqId, frame);
} catch (Exception e) {
log.error("[wecom] Failed to send reply segment via frame {}: {}",
frameReqId, e.getMessage());
}
}
// ==================== 上传大小预校验WeCom 平台限制 ====================
/** WeCom hard limits — verified empirically; sources differ slightly. */
@ -2088,6 +2497,16 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
sendFrameWithAck(eventReqId, frame);
}
/**
* URL for the resolved approval card's mandatory {@code card_action}
* link (WeCom rejects {@code text_notice} cards without a type-1/2
* action). Reads channel config {@code card_action_url}; public so the
* card handler (different package) can pass it to the renderer.
*/
public String resolvedCardActionUrl() {
return getConfigString("card_action_url", ToolGuardCardRenderer.DEFAULT_CARD_ACTION_URL);
}
/**
* Keepalive refresh tick (called by {@link WeComKeepaliveScheduler}
* every 20s). Sends {@code finish=false} on the existing stream so
@ -2098,6 +2517,13 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
* not be triggering refresh ticks.
*/
public void replyStreamRefreshForKeepalive(String reqId, String streamId, String text) {
// Bypass chunk dedup: a refresh whose text equals the previous chunk
// (e.g. the static placeholder when no progress supplier is attached)
// would otherwise be swallowed by replyStream's dedup guard no
// network frame goes out, the server-side TTL is NOT reset, and the
// slot dies exactly the way this keepalive exists to prevent. Clearing
// the dedup slot first guarantees every tick produces a real frame.
streamLastContent.remove(streamId);
replyStream(reqId, streamId, text, false);
}
@ -2599,9 +3025,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
* matching row, returned 403, and the IM client rendered every
* group-quoted image as a broken icon.
*/
private static String inboundConversationId(String senderId, String chatId, String chatType) {
private static String inboundConversationId(String senderId, String chatId, String chatType,
Long channelId) {
boolean isGroup = "group".equals(chatType);
return isGroup ? "wecom:" + chatId : "wecom:" + senderId;
String identifier = isGroup ? chatId : senderId;
// Mirror ChannelMessageRouter#buildConversationId: scope the id by channelId so the
// same sender on two workspaces' wecom channels never shares a conversation. channelId
// is the ChannelEntity primary key; a null (unreachable for a persisted channel) keeps
// the legacy two-segment form so nothing NPEs.
return channelId != null ? "wecom:" + channelId + ":" + identifier : "wecom:" + identifier;
}
// ==================== 引用消息quote解析 ====================
@ -2657,7 +3089,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
items = List.of(quote);
}
String inboundConvId = inboundConversationId(senderId, chatId, chatType);
String inboundConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null);
StringBuilder summary = new StringBuilder();
List<MessageContentPart> attached = new ArrayList<>();
@ -2769,7 +3201,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
String title = ((String) appmsg.getOrDefault("title", "")).trim();
String desc = ((String) appmsg.getOrDefault("description", "")).trim();
String linkUrl = ((String) appmsg.getOrDefault("url", "")).trim();
String inboundConvId = inboundConversationId(senderId, chatId, chatType);
String inboundConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null);
Object fileObj = appmsg.get("file");
Object imageObj = appmsg.get("image");
@ -2997,63 +3429,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
}
/**
* 下载并解密企业微信媒体文件旧版本保留给 outbound / 其他场景使用
* AES-256-CBC decryption for WeCom media payloads.
* <p>
* AES-256-CBC 解密base64 decode aesKey IV = 16 字节 PKCS#7 去填充
*
* @param url 文件下载 URL
* @param aesKey Base64 编码的 AES-256 密钥
* @param msgId 消息 ID用于生成文件名
* @param fileNameHint 文件名提示
* @return 本地文件路径失败返回 null
*/
private String downloadAndDecryptMedia(String url, String aesKey, String msgId, String fileNameHint) {
try {
String mediaDir = getConfigString("media_dir", "data/media");
Path mediaDirPath = Path.of(mediaDir);
Files.createDirectories(mediaDirPath);
// 1. HTTP GET 下载文件
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
byte[] encryptedData = response.body().readAllBytes();
byte[] fileData;
// 2. AES 解密如果提供了 aesKey
if (aesKey != null && !aesKey.isBlank()) {
fileData = decryptAes256Cbc(encryptedData, aesKey);
} else {
fileData = encryptedData;
}
// 3. 保存到本地
String urlHash = md5Hex(url).substring(0, 8);
String safeName = fileNameHint.replaceAll("[^a-zA-Z0-9._-]", "_");
if (safeName.isBlank()) safeName = "media";
Path filePath = mediaDirPath.resolve("wecom_" + urlHash + "_" + safeName);
Files.write(filePath, fileData);
log.info("[wecom] Media downloaded: {} ({} bytes)", filePath, fileData.length);
return filePath.toAbsolutePath().toString();
} catch (Exception e) {
log.error("[wecom] Failed to download media: {}", e.getMessage(), e);
return null;
}
}
/**
* AES-256-CBC 解密对齐 wecom-aibot-python-sdk crypto_utils.py
* <p>
* 1. Base64 decode aesKey自动补齐 padding
* 2. IV = decoded key 16 字节
* 3. AES-256-CBC 解密
* 4. PKCS#7 去填充
* 1. Base64 decode aesKey (auto-fix missing padding)
* 2. IV = first 16 bytes of the decoded key
* 3. AES-256-CBC decrypt
* 4. Strip PKCS#7 padding
*/
private byte[] decryptAes256Cbc(byte[] encryptedData, String aesKeyBase64) throws Exception {
// 补齐 Base64 padding
@ -3131,19 +3512,6 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
return prefix + "_" + System.currentTimeMillis() + "_" + reqIdCounter.incrementAndGet();
}
/**
* MD5 哈希hex 字符串
*/
private String md5Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(input.getBytes());
return bytesToHex(hash);
} catch (Exception e) {
return Integer.toHexString(input.hashCode());
}
}
/**
* MD5 哈希字节数组输入
*/

View File

@ -9,6 +9,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/**
* Periodically refreshes a WeCom AI Bot {@code stream} reply with the
@ -47,9 +48,17 @@ public class WeComKeepaliveScheduler {
/** Hard ceiling — after this many seconds, force-finish the stream. */
static final long MAX_DURATION_SECONDS = 180;
/** Placeholder text written on every refresh tick + on force-finish. */
/** Placeholder text written on every refresh tick (when no live progress supplier is attached). */
static final String PROCESSING_TEXT = "🤔 思考中...";
/**
* Text written when the 180s ceiling force-finishes the stream. The real
* answer will arrive later as a separate pushed bubble (the reply context
* is invalidated below), so the sealed bubble must tell the user the
* reply is still coming freezing it on "思考中..." reads as a hang.
*/
static final String FORCE_FINISH_TEXT = "⏳ 任务耗时较长,仍在处理中,结果稍后送达";
/** One-shot state per active stream. Held by reference inside the scheduled task. */
private static final class StreamState {
final WeComChannelAdapter adapter;
@ -58,6 +67,10 @@ public class WeComKeepaliveScheduler {
final String replyToken;
final long startedAt;
volatile ScheduledFuture<?> future;
/** Optional live progress text source; when set, refresh ticks write
* its current snapshot instead of the static placeholder so the
* bubble keeps showing elapsed time / tool state between events. */
volatile Supplier<String> textSupplier;
StreamState(WeComChannelAdapter a, String r, String s, String t) {
this.adapter = a; this.reqId = r; this.streamId = s; this.replyToken = t;
this.startedAt = System.currentTimeMillis();
@ -105,6 +118,22 @@ public class WeComKeepaliveScheduler {
log.debug("[wecom-keepalive] started for stream={} reqId={}", streamId, reqId);
}
/**
* Attach a live progress text source to an already-tracked stream.
* Subsequent refresh ticks write the supplier's snapshot instead of the
* static placeholder. No-op when the stream is not tracked (already
* stopped or force-finished).
*/
public void attachTextSupplier(String streamId, Supplier<String> supplier) {
if (streamId == null || streamId.isBlank()) {
return;
}
StreamState st = states.get(streamId);
if (st != null) {
st.textSupplier = supplier;
}
}
/**
* Stop keepalive for a stream call this immediately before sending
* the real reply so the next refresh tick doesn't race the
@ -138,7 +167,7 @@ public class WeComKeepaliveScheduler {
// replyContext entry so the eventual real reply takes the
// fresh-stream path.
try {
st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, FORCE_FINISH_TEXT);
} catch (Exception e) {
log.debug("[wecom-keepalive] force-finish replyStream failed for {}: {}",
st.streamId, e.getMessage());
@ -155,12 +184,29 @@ public class WeComKeepaliveScheduler {
return;
}
try {
st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, refreshText(st));
} catch (Exception e) {
log.debug("[wecom-keepalive] refresh failed for {}: {}", st.streamId, e.getMessage());
}
}
/** Current refresh text: live progress snapshot when attached, static placeholder otherwise. */
private String refreshText(StreamState st) {
Supplier<String> supplier = st.textSupplier;
if (supplier != null) {
try {
String text = supplier.get();
if (text != null && !text.isBlank()) {
return text;
}
} catch (Exception e) {
log.debug("[wecom-keepalive] progress supplier failed for {}: {}",
st.streamId, e.getMessage());
}
}
return PROCESSING_TEXT;
}
// ---- Test hooks ----
int activeStreamCount() { return states.size(); }

View File

@ -0,0 +1,280 @@
package vip.mate.channel.wecom;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
/**
* Builds the live progress text shown in the WeCom stream bubble while an
* agent turn is running: a status header (thinking / tool call / replying,
* with elapsed time), the most recent tool-call lines, an optional rolling
* window of reasoning text, and the tail of the answer produced so far.
* <p>
* Mutations arrive from the stream-consuming thread; {@link #snapshot()} may
* also be called from the keepalive scheduler thread, so all state access is
* synchronized on this instance.
*/
final class WeComProgressRenderer {
/** Max completed/running tool lines rendered before collapsing to a counter. */
private static final int MAX_TOOL_LINES = 3;
/** Rolling window (chars) of reasoning text when thinking display is on. */
private static final int THINKING_WINDOW = 500;
/** Tail window (chars) of the streamed answer kept inside the bubble.
* The full answer is delivered by the final render pass; the bubble only
* needs enough to show live progress while staying under the 2048 limit. */
private static final int ANSWER_WINDOW = 1200;
private record ToolLine(String callId, String name, long startedAt,
Long finishedAt, boolean success) {
}
private final long startedAtMillis;
private final boolean showThinking;
private final boolean showToolTrace;
private final Deque<ToolLine> toolLines = new ArrayDeque<>();
private int collapsedToolCount;
private final StringBuilder thinkingTail = new StringBuilder();
private final StringBuilder answerTail = new StringBuilder();
private boolean thinkingSeen;
private boolean contentSeen;
private boolean approvalPending;
private String planStepLine;
private String narration;
/**
* @param startedAtMillis turn start, for the elapsed-time counter
* @param showThinking render the rolling reasoning window
* ({@code filter_thinking=false})
* @param showToolTrace render tool names and per-tool completion lines
* ({@code filter_tool_messages=false}). When off,
* the bubble only says that a tool is running
* the whole point of the toggle is that the tool
* trail stays out of the user's view, and the
* progress bubble is as user-visible as a
* standalone trace message.
*/
WeComProgressRenderer(long startedAtMillis, boolean showThinking, boolean showToolTrace) {
this.startedAtMillis = startedAtMillis;
this.showThinking = showThinking;
this.showToolTrace = showToolTrace;
}
synchronized void onThinkingDelta(String delta) {
thinkingSeen = true;
if (showThinking && delta != null && !delta.isEmpty()) {
thinkingTail.append(delta);
trimLeading(thinkingTail, THINKING_WINDOW);
}
}
synchronized void onContentDelta(String delta) {
contentSeen = true;
if (delta != null && !delta.isEmpty()) {
answerTail.append(delta);
trimLeading(answerTail, ANSWER_WINDOW);
}
}
/**
* Consume a graph event. Returns true when the event changes what the
* bubble shows in a way worth flushing immediately (tool transitions,
* plan steps, approval waits) rather than waiting for the throttle tick.
*/
synchronized boolean onEvent(String eventType, Map<String, Object> data) {
if (eventType == null) {
return false;
}
switch (eventType) {
case "tool_call_started" -> {
toolLines.addLast(new ToolLine(
stringField(data, "toolCallId"),
stringField(data, "toolName"),
System.currentTimeMillis(), null, false));
compactToolLines();
return true;
}
case "tool_call_completed" -> {
String callId = stringField(data, "toolCallId");
boolean success = data == null || !Boolean.FALSE.equals(data.get("success"));
markToolCompleted(callId, stringField(data, "toolName"), success);
return true;
}
case "plan_step_started" -> {
Object index = data != null ? data.get("index") : null;
String title = stringField(data, "title");
planStepLine = "📋 步骤" + (index != null ? " " + index : "")
+ (title != null && !title.isBlank() ? ": " + title : "");
return true;
}
case "tool_approval_requested" -> {
approvalPending = true;
return true;
}
default -> {
return false;
}
}
}
/**
* Stage the newest per-stage narration so it shows in the live bubble
* straight away. Only the newest one is kept the previous narration has
* already been published as its own bubble by then.
*/
synchronized void onNarration(String text) {
narration = (text == null || text.isBlank()) ? null : text.trim();
}
/** True once a tool call has asked for human approval this turn. */
synchronized boolean isApprovalPending() {
return approvalPending;
}
/** Render the current progress text for the stream bubble. */
synchronized String snapshot() {
StringBuilder sb = new StringBuilder();
sb.append(statusLine());
if (planStepLine != null) {
sb.append('\n').append(planStepLine);
}
appendToolLines(sb);
if (showThinking && !contentSeen && thinkingTail.length() > 0) {
sb.append("\n\n> 💭 ").append(thinkingTail.toString().replace("\n", "\n> "));
}
if (narration != null) {
sb.append("\n\n").append(narration);
}
if (answerTail.length() > 0) {
sb.append("\n\n").append(answerTail);
}
return sb.toString();
}
private String statusLine() {
if (approvalPending) {
return "⏸️ 等待工具审批…(" + elapsed() + "";
}
if (contentSeen) {
return "✍️ 正在回复…(" + elapsed() + "";
}
ToolLine running = lastRunningTool();
if (running != null) {
return showToolTrace
? "🔧 正在调用 " + displayName(running) + "…(" + elapsed() + ""
: "🔧 正在执行工具…(" + elapsed() + "";
}
if (thinkingSeen) {
return "💭 思考中…(" + elapsed() + "";
}
return "🤔 思考中…(" + elapsed() + "";
}
private void appendToolLines(StringBuilder sb) {
if (!showToolTrace) {
// filter_tool_messages=true the tool trail is suppressed
// everywhere the user can see it, progress bubble included.
return;
}
if (collapsedToolCount > 0) {
sb.append("\n…等 ").append(collapsedToolCount).append(" 项已完成");
}
for (ToolLine line : toolLines) {
if (line.finishedAt() == null) {
// The running tool is already the status header skip here
// unless content started (header shows "正在回复" instead).
if (contentSeen || approvalPending) {
sb.append("\n🔧 ").append(displayName(line)).append(" 运行中…");
}
} else {
long seconds = Math.max(0, (line.finishedAt() - line.startedAt()) / 1000);
sb.append('\n').append(line.success() ? "" : "")
.append(displayName(line))
.append(line.success() ? " 完成" : " 失败")
.append("").append(seconds).append(" 秒)");
}
}
}
private ToolLine lastRunningTool() {
ToolLine running = null;
for (ToolLine line : toolLines) {
if (line.finishedAt() == null) {
running = line;
}
}
return running;
}
private void markToolCompleted(String callId, String toolName, boolean success) {
ToolLine match = null;
for (ToolLine line : toolLines) {
if (line.finishedAt() != null) {
continue;
}
boolean idMatch = callId != null && callId.equals(line.callId());
boolean nameMatch = callId == null && toolName != null && toolName.equals(line.name());
if (idMatch || nameMatch) {
match = line;
}
}
if (match == null) {
toolLines.addLast(new ToolLine(callId, toolName,
System.currentTimeMillis(), System.currentTimeMillis(), success));
} else {
ToolLine done = new ToolLine(match.callId(), match.name(),
match.startedAt(), System.currentTimeMillis(), success);
replaceLine(match, done);
}
compactToolLines();
}
private void replaceLine(ToolLine oldLine, ToolLine newLine) {
Deque<ToolLine> rebuilt = new ArrayDeque<>(toolLines.size());
for (ToolLine line : toolLines) {
rebuilt.addLast(line == oldLine ? newLine : line);
}
toolLines.clear();
toolLines.addAll(rebuilt);
}
/** Keep at most {@link #MAX_TOOL_LINES}; older completed lines collapse into a counter. */
private void compactToolLines() {
while (toolLines.size() > MAX_TOOL_LINES) {
ToolLine oldest = toolLines.peekFirst();
if (oldest != null && oldest.finishedAt() == null) {
// Never collapse a still-running tool line.
break;
}
toolLines.pollFirst();
collapsedToolCount++;
}
}
private String elapsed() {
long seconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000);
if (seconds < 60) {
return "" + seconds + "";
}
return "" + (seconds / 60) + "" + (seconds % 60) + "";
}
private static String displayName(ToolLine line) {
return line.name() != null && !line.name().isBlank() ? line.name() : "工具";
}
private static String stringField(Map<String, Object> data, String key) {
Object value = data != null ? data.get(key) : null;
return value != null ? value.toString() : null;
}
private static void trimLeading(StringBuilder sb, int maxLen) {
int excess = sb.length() - maxLen;
if (excess > 0) {
sb.delete(0, excess);
}
}
}

View File

@ -199,7 +199,8 @@ public class ToolGuardCardHandler implements WeComCardHandler {
: "Tool " + toolName + " 已拒绝";
try {
adapter.updateTemplateCard(eventReqId,
ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc));
ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc,
adapter.resolvedCardActionUrl()));
} catch (Exception e) {
log.warn("[wecom-toolguard] update_template_card (resolved) failed: {}", e.getMessage());
}
@ -212,7 +213,8 @@ public class ToolGuardCardHandler implements WeComCardHandler {
adapter.updateTemplateCard(eventReqId,
ToolGuardCardRenderer.buildResolvedCard(taskId,
"❌ 仅原请求者可审批",
"请由 " + requesterLabel + " 操作"));
"请由 " + requesterLabel + " 操作",
adapter.resolvedCardActionUrl()));
} catch (Exception e) {
log.warn("[wecom-toolguard] update_template_card (unauthorised) failed: {}", e.getMessage());
}
@ -224,7 +226,8 @@ public class ToolGuardCardHandler implements WeComCardHandler {
adapter.updateTemplateCard(eventReqId,
ToolGuardCardRenderer.buildResolvedCard(taskId,
"⌛ 审批已过期",
"Tool " + toolName + " 的审批已过期或被处理"));
"Tool " + toolName + " 的审批已过期或被处理",
adapter.resolvedCardActionUrl()));
} catch (Exception e) {
log.warn("[wecom-toolguard] update_template_card (expired) failed: {}", e.getMessage());
}

View File

@ -99,13 +99,29 @@ public class ToolGuardCardRenderer implements WeComCardRenderer {
* to stay inside WeCom's main_title.desc limit
*/
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc) {
return buildResolvedCard(taskId, title, desc, DEFAULT_CARD_ACTION_URL);
}
/**
* Fallback for the mandatory {@code card_action} link when the channel
* config doesn't provide one ({@code card_action_url}).
*/
public static final String DEFAULT_CARD_ACTION_URL = "https://mateclaw.vip";
/**
* Same as {@link #buildResolvedCard(String, String, String)} but with an
* explicit {@code card_action} URL (per-channel configurable).
*/
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc,
String actionUrl) {
Map<String, Object> mainTitle = new LinkedHashMap<>();
mainTitle.put("title", title == null ? "" : title);
mainTitle.put("desc", truncate(desc == null ? "" : desc, 30));
Map<String, Object> cardAction = new LinkedHashMap<>();
cardAction.put("type", 1);
cardAction.put("url", "https://mateclaw.vip");
cardAction.put("url", (actionUrl == null || actionUrl.isBlank())
? DEFAULT_CARD_ACTION_URL : actionUrl);
Map<String, Object> card = new LinkedHashMap<>();
card.put("card_type", "text_notice");

View File

@ -44,7 +44,8 @@ import java.util.*;
public class ILinkClient {
public static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
private static final String CHANNEL_VERSION = "2.0.1";
private static final String CHANNEL_VERSION = "1.0.2";
private static final ObjectMapper WIRE_OBJECT_MAPPER = new ObjectMapper();
/** 长轮询超时(服务端最长 35s客户端设 45s */
private static final Duration GETUPDATES_TIMEOUT = Duration.ofSeconds(45);
@ -226,14 +227,30 @@ public class ILinkClient {
body.put("msg", msg);
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
String requestJson = WIRE_OBJECT_MAPPER.writeValueAsString(body);
log.info("[weixin] sendMessage request: toUser={}, contextTokenPresent={}, itemSummary={}, payload={}",
maskId(String.valueOf(msg.get("to_user_id"))),
String.valueOf(msg.getOrDefault("context_token", "")).length() > 0,
summarizeItems(msg.get("item_list")), redactSendMessagePayload(body));
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/sendmessage"))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
.POST(HttpRequest.BodyPublishers.ofString(requestJson)))
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("[weixin] sendMessage response: status={}, body={}", response.statusCode(), response.body());
ensureOk(response, "sendMessage");
return objectMapper.readValue(response.body(), new TypeReference<>() {});
Map<String, Object> result = objectMapper.readValue(response.body(), new TypeReference<>() {});
Object ret = result.get("ret");
if (ret instanceof Number n && n.intValue() != 0) {
log.error("[weixin] sendMessage business error: ret={}, errmsg={}, toUser={}, itemSummary={}, body={}",
ret, result.get("errmsg"), maskId(String.valueOf(msg.get("to_user_id"))),
summarizeItems(msg.get("item_list")), response.body());
throw new RuntimeException("sendMessage business error: ret=" + ret
+ ", errmsg=" + result.get("errmsg"));
}
return result;
}
/**
@ -376,14 +393,42 @@ public class ILinkClient {
body.put("no_need_thumb", true);
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
String requestJson = WIRE_OBJECT_MAPPER.writeValueAsString(body);
log.info("[weixin] getUploadUrl request: mediaType={}, toUser={}, rawSize={}({}), encryptedSize={}({}), "
+ "rawMd5={}, aesKeyHexLen={}, noNeedThumb={}, channelVersion={}, payload={}",
mediaType, maskId(toUserId), rawSize, typeName(body.get("rawsize")),
fileSize, typeName(body.get("filesize")), rawFileMd5,
aesKeyHex == null ? 0 : aesKeyHex.length(), true, CHANNEL_VERSION,
redactUploadUrlPayload(body));
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/getuploadurl"))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
.POST(HttpRequest.BodyPublishers.ofString(requestJson)))
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("[weixin] getUploadUrl response: status={}, body={}",
response.statusCode(), response.body());
ensureOk(response, "getUploadUrl");
return objectMapper.readValue(response.body(), new TypeReference<>() {});
Map<String, Object> result = objectMapper.readValue(response.body(), new TypeReference<>() {});
Object ret = result.get("ret");
if (ret instanceof Number n && n.intValue() != 0) {
log.error("[weixin] getUploadUrl business error: ret={}, errmsg={}, mediaType={}, toUser={}, "
+ "rawSize={}, encryptedSize={}, rawMd5={}, filekeyPrefix={}, body={}",
ret, result.get("errmsg"), mediaType, maskId(toUserId), rawSize, fileSize,
rawFileMd5, prefix(filekey, 8), response.body());
throw new RuntimeException("getUploadUrl business error: ret=" + ret
+ ", errmsg=" + result.get("errmsg"));
} else if (!result.containsKey("upload_full_url") && !result.containsKey("upload_param")) {
log.error("[weixin] getUploadUrl: missing upload_full_url and upload_param. Full response: {}",
response.body());
} else {
log.info("[weixin] getUploadUrl ok: mediaType={}, rawSize={}, hasFullUrl={}, hasUploadParam={}, "
+ "uploadParamLen={}",
mediaType, rawSize, result.containsKey("upload_full_url"), result.containsKey("upload_param"),
String.valueOf(result.getOrDefault("upload_param", "")).length());
}
return result;
}
/**
@ -406,6 +451,8 @@ public class ILinkClient {
// 1. 原始文件元数据
long rawSize = fileBytes.length;
String rawFileMd5 = md5Hex(fileBytes);
log.info("[weixin] uploadMedia begin: mediaType={}, fileName={}, rawSize={}, rawMd5={}, toUser={}",
mediaType, fileName, rawSize, rawFileMd5, maskId(toUserId));
// 2. 生成 AES key 并加密
SecureRandom random = new SecureRandom();
@ -418,11 +465,16 @@ public class ILinkClient {
byte[] encryptedData = WeixinAesUtil.aesEcbEncrypt(fileBytes, aesKeyB64ForEncrypt);
long encryptedSize = encryptedData.length;
log.info("[weixin] uploadMedia encrypted: mediaType={}, rawSize={}, encryptedSize={}, paddingBytes={}, "
+ "aesKeyHexLen={}",
mediaType, rawSize, encryptedSize, encryptedSize - rawSize, aesKeyHex.length());
// 3. 生成 filekey16 字节随机 hex
byte[] filekeyBytes = new byte[16];
random.nextBytes(filekeyBytes);
String filekey = bytesToHex(filekeyBytes);
log.info("[weixin] uploadMedia filekey generated: mediaType={}, filekeyPrefix={}, filekeyLen={}",
mediaType, prefix(filekey, 8), filekey.length());
// 4. 获取上传 URL
Map<String, Object> uploadUrlResp = getUploadUrl(filekey, mediaType, toUserId,
@ -440,6 +492,13 @@ public class ILinkClient {
String encParam = URLEncoder.encode(uploadParam, StandardCharsets.UTF_8);
uploadUrl = CDN_BASE_URL + "/upload?encrypted_query_param=" + encParam + "&filekey=" + filekey;
}
log.info("[weixin] CDN upload target resolved: mediaType={}, mode={}, uploadParamPresent={}, uploadParamLen={}, "
+ "uploadFullUrlPresent={}, uploadHost={}, uploadPath={}, filekeyPrefix={}",
mediaType, (fullUrl != null && !fullUrl.isBlank()) ? "upload_full_url" : "upload_param",
uploadUrlResp.get("upload_param") != null,
String.valueOf(uploadUrlResp.getOrDefault("upload_param", "")).length(),
fullUrl != null && !fullUrl.isBlank(),
URI.create(uploadUrl).getHost(), URI.create(uploadUrl).getPath(), prefix(filekey, 8));
// 5. POST 加密数据到 CDN注意使用 upload_param 时不需要 Authorization
HttpRequest.Builder cdnBuilder = HttpRequest.newBuilder()
@ -453,6 +512,12 @@ public class ILinkClient {
}
HttpResponse<byte[]> cdnResponse = httpClient.send(cdnBuilder.build(), HttpResponse.BodyHandlers.ofByteArray());
log.info("[weixin] CDN upload response: status={}, bodyBytes={}, hasXEncryptedParam={}, headers={}",
cdnResponse.statusCode(),
cdnResponse.body() == null ? 0 : cdnResponse.body().length,
cdnResponse.headers().firstValue("x-encrypted-param")
.or(() -> cdnResponse.headers().firstValue("X-Encrypted-Param")).isPresent(),
cdnResponse.headers().map().keySet());
ensureOk(cdnResponse, "CDN upload");
// 6. 从响应头提取 encrypt_query_param
@ -469,7 +534,7 @@ public class ILinkClient {
log.info("[weixin] Media uploaded: type={}, size={}KB, encryptedSize={}KB",
mediaType, rawSize / 1024, encryptedSize / 1024);
return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize);
return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize, rawFileMd5, rawSize);
}
/**
@ -480,6 +545,9 @@ public class ILinkClient {
* @param contextToken 上下文 token
*/
public void sendImage(String toUserId, byte[] imageBytes, String contextToken) throws Exception {
log.info("[weixin] sendImage begin: toUser={}, imageBytes={}, contextTokenPresent={}",
maskId(toUserId), imageBytes == null ? 0 : imageBytes.length,
contextToken != null && !contextToken.isBlank());
UploadResult result = uploadMedia(imageBytes, "image.jpg", 1, toUserId);
Map<String, Object> imageItem = new LinkedHashMap<>();
@ -488,6 +556,7 @@ public class ILinkClient {
"media", Map.of(
"encrypt_query_param", result.encryptQueryParam(),
"aes_key", result.aesKeyB64(),
"encrypt_type", 1,
"mid_size", result.fileSize()
)
));
@ -517,16 +586,21 @@ public class ILinkClient {
* @param contextToken 上下文 token
*/
public void sendFile(String toUserId, byte[] fileBytes, String fileName, String contextToken) throws Exception {
log.info("[weixin] sendFile begin: toUser={}, fileName={}, fileBytes={}, contextTokenPresent={}",
maskId(toUserId), fileName, fileBytes == null ? 0 : fileBytes.length,
contextToken != null && !contextToken.isBlank());
UploadResult result = uploadMedia(fileBytes, fileName, 3, toUserId);
Map<String, Object> fileItem = new LinkedHashMap<>();
fileItem.put("type", 4);
fileItem.put("file_item", Map.of(
"file_name", fileName,
"len", (long) fileBytes.length,
"md5", result.rawFileMd5(),
"len", String.valueOf(result.rawSize()),
"media", Map.of(
"encrypt_query_param", result.encryptQueryParam(),
"aes_key", result.aesKeyB64()
"aes_key", result.aesKeyB64(),
"encrypt_type", 1
)
));
@ -539,6 +613,10 @@ public class ILinkClient {
msg.put("context_token", contextToken);
msg.put("item_list", List.of(fileItem));
log.info("[weixin] sendFile message prepared: toUser={}, fileName={}, len={}, encryptParamLen={}, aesKeyLen={}",
maskId(toUserId), fileName, fileBytes.length,
result.encryptQueryParam() == null ? 0 : result.encryptQueryParam().length(),
result.aesKeyB64() == null ? 0 : result.aesKeyB64().length());
sendMessage(msg);
}
@ -557,7 +635,8 @@ public class ILinkClient {
videoItem.put("video_item", Map.of(
"media", Map.of(
"encrypt_query_param", result.encryptQueryParam(),
"aes_key", result.aesKeyB64()
"aes_key", result.aesKeyB64(),
"encrypt_type", 1
)
));
@ -610,6 +689,128 @@ public class ILinkClient {
return sb.toString();
}
private static String maskId(String value) {
if (value == null || value.isBlank()) {
return "";
}
int head = Math.min(12, value.length());
return value.substring(0, head) + "...";
}
private static String prefix(String value, int length) {
if (value == null || value.isBlank()) {
return "";
}
return value.substring(0, Math.min(length, value.length()));
}
private static String typeName(Object value) {
return value == null ? "null" : value.getClass().getSimpleName();
}
private static String redactUploadUrlPayload(Map<String, Object> body) {
try {
Map<String, Object> redacted = new LinkedHashMap<>(body);
redacted.put("to_user_id", maskId(String.valueOf(body.get("to_user_id"))));
redacted.put("filekey", prefix(String.valueOf(body.get("filekey")), 8) + "...");
redacted.put("aeskey", "len:" + String.valueOf(body.get("aeskey")).length());
return WIRE_OBJECT_MAPPER.writeValueAsString(redacted);
} catch (Exception e) {
return "<redact-failed:" + e.getMessage() + ">";
}
}
private static String redactSendMessagePayload(Map<String, Object> body) {
try {
Map<String, Object> redacted = new LinkedHashMap<>(body);
Object msgObj = redacted.get("msg");
if (msgObj instanceof Map<?, ?> msgMap) {
Map<String, Object> msg = new LinkedHashMap<>();
msgMap.forEach((k, v) -> msg.put(String.valueOf(k), v));
msg.put("to_user_id", maskId(String.valueOf(msg.get("to_user_id"))));
if (msg.containsKey("context_token")) {
msg.put("context_token", "present:" + !String.valueOf(msg.get("context_token")).isBlank());
}
msg.put("item_list", redactItems(msg.get("item_list")));
redacted.put("msg", msg);
}
return WIRE_OBJECT_MAPPER.writeValueAsString(redacted);
} catch (Exception e) {
return "<redact-failed:" + e.getMessage() + ">";
}
}
private static Object redactItems(Object itemListObj) {
if (!(itemListObj instanceof List<?> items)) {
return itemListObj;
}
List<Object> redacted = new ArrayList<>();
for (Object itemObj : items) {
if (!(itemObj instanceof Map<?, ?> itemMap)) {
redacted.add(itemObj);
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
itemMap.forEach((k, v) -> item.put(String.valueOf(k), v));
Object fileObj = item.get("file_item");
if (fileObj instanceof Map<?, ?> fileMap) {
Map<String, Object> file = new LinkedHashMap<>();
fileMap.forEach((k, v) -> file.put(String.valueOf(k), v));
file.put("media", redactMedia(file.get("media")));
item.put("file_item", file);
}
Object imageObj = item.get("image_item");
if (imageObj instanceof Map<?, ?> imageMap) {
Map<String, Object> image = new LinkedHashMap<>();
imageMap.forEach((k, v) -> image.put(String.valueOf(k), v));
image.put("media", redactMedia(image.get("media")));
item.put("image_item", image);
}
redacted.add(item);
}
return redacted;
}
private static Object redactMedia(Object mediaObj) {
if (!(mediaObj instanceof Map<?, ?> mediaMap)) {
return mediaObj;
}
Map<String, Object> media = new LinkedHashMap<>();
mediaMap.forEach((k, v) -> media.put(String.valueOf(k), v));
media.put("encrypt_query_param", "len:" + String.valueOf(media.get("encrypt_query_param")).length());
media.put("aes_key", "len:" + String.valueOf(media.get("aes_key")).length());
return media;
}
private static String summarizeItems(Object itemListObj) {
if (!(itemListObj instanceof List<?> items)) {
return "not-list";
}
List<String> summaries = new ArrayList<>();
for (Object itemObj : items) {
if (!(itemObj instanceof Map<?, ?> itemMap)) {
summaries.add("unknown");
continue;
}
Object type = itemMap.get("type");
Object fileObj = itemMap.get("file_item");
if (fileObj instanceof Map<?, ?> fileMap) {
Object len = fileMap.get("len");
summaries.add("type=" + type + ",file,len=" + len + "(" + typeName(len) + ")");
continue;
}
Object imageObj = itemMap.get("image_item");
if (imageObj instanceof Map<?, ?> imageMap) {
Object mediaObj = imageMap.get("media");
Object midSize = mediaObj instanceof Map<?, ?> mediaMap ? mediaMap.get("mid_size") : null;
summaries.add("type=" + type + ",image,midSize=" + midSize + "(" + typeName(midSize) + ")");
continue;
}
summaries.add("type=" + type);
}
return String.join(";", summaries);
}
// ==================== 内部模型 ====================
/**
@ -619,7 +820,8 @@ public class ILinkClient {
* @param aesKeyB64 AES key base64(hex) 编码用于 media.aes_key
* @param fileSize 加密后文件大小
*/
public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize) {}
public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize,
String rawFileMd5, long rawSize) {}
/**
* QR 码登录结果

View File

@ -60,9 +60,6 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "weixin";
/** 消息去重最大记录数 */
private static final int PROCESSED_IDS_MAX = 2000;
// ==================== 运行时状态 ====================
private ILinkClient client;
@ -99,14 +96,6 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
private static final long POLL_STUCK_THRESHOLD_MS = 90_000;
private static final long WATCHDOG_INTERVAL_MS = 30_000;
/** 消息去重集合LRU */
private final LinkedHashMap<String, Boolean> processedIds = new LinkedHashMap<>(256, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Boolean> eldest) {
return size() > PROCESSED_IDS_MAX;
}
};
/** 用户最新 context_token 缓存(用于主动推送) */
private final ConcurrentHashMap<String, String> userContextTokens = new ConcurrentHashMap<>();
@ -155,24 +144,37 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
*/
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/**
* Channel-shared scrubber that converts agent-emitted
* {@code /api/v1/files/generated/{id}} URLs into native WeChat attachments.
* Nullable for legacy callers / unit tests when null, the URL passes
* through unchanged (legacy text-only behaviour).
*/
private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber;
public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
this.generatedFileScrubber = null;
}
/**
* Full constructor used by the production factory (ChannelManager). The
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
* behaviour.
* behaviour. The {@code generatedFileScrubber} upgrades
* {@code /api/v1/files/generated/{id}} URLs in agent replies into native
* WeChat file/image attachments.
*/
public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver,
vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber) {
super(channelEntity, messageRouter, objectMapper);
this.chatUploadLocationResolver = chatUploadLocationResolver;
this.generatedFileScrubber = generatedFileScrubber;
}
@Override
@ -409,15 +411,18 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
return;
}
// 去重
// Stable inbound identity for this channel: context_token when the
// platform supplies one (it survives redelivery where msg_id does not),
// else sender + msg_id. Carried on the ChannelMessage as messageId so
// the router claims exactly this key see inboundIdentity().
String dedupKey = !contextToken.isBlank() ? contextToken
: fromUserId + "_" + getStr(msg, "msg_id");
synchronized (processedIds) {
if (processedIds.containsKey(dedupKey)) {
log.debug("[weixin] Duplicate message skipped: {}", dedupKey.substring(0, Math.min(40, dedupKey.length())));
return;
}
processedIds.put(dedupKey, Boolean.TRUE);
// Early duplicate gate: the authoritative claim happens once in
// ChannelMessageRouter.enqueue, but parsing below downloads media, so
// a known redelivery is dropped before paying for that.
if (messageRouter.isDuplicateInbound(channelEntity.getId(), dedupKey)) {
log.debug("[weixin] Duplicate message skipped: {}", dedupKey.substring(0, Math.min(40, dedupKey.length())));
return;
}
// 解析消息内容
@ -631,7 +636,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
String replyToken = contextToken + "|" + fromUserId;
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(getStr(msg, "msg_id"))
// dedupKey, not the raw msg_id: it is this channel's stable
// inbound identity and the router claims exactly this value.
.messageId(dedupKey)
.channelType(CHANNEL_TYPE)
.senderId(fromUserId)
.senderName(fromUserId) // iLink API 不提供昵称
@ -744,13 +751,27 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
// 停止输入提示
stopTyping(toUserId);
// 文本 part 里可能携带 /api/v1/files/generated/{id} URL需先 scrub
// 收集所有命中的附件待文本全部发完后再统一推送保持"文本在前,附件在后"的顺序
List<vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit> deferredAttachments =
new java.util.ArrayList<>();
for (MessageContentPart part : parts) {
if (part == null) continue;
try {
switch (part.getType()) {
case "text" -> {
if (part.getText() != null && !part.getText().isBlank()) {
client.sendText(toUserId, part.getText(), contextToken);
String textToSend = part.getText();
if (generatedFileScrubber != null) {
vip.mate.channel.media.GeneratedFileScrubber.ScrubResult scrubbed =
generatedFileScrubber.scrub(part.getText());
textToSend = scrubbed.rewrittenText();
deferredAttachments.addAll(scrubbed.attachments());
}
if (!textToSend.isBlank()) {
client.sendText(toUserId, textToSend, contextToken);
}
}
}
case "image" -> sendImagePart(toUserId, contextToken, part);
@ -769,17 +790,36 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
sendFallbackText(targetId, part);
}
}
// 推送文本 part scrub 出来的原生附件
sendAttachmentHits(toUserId, contextToken, deferredAttachments);
}
@Override
public void renderAndSend(String targetId, String content) {
// 停止输入提示
String[] split = targetId.split("\\|", 2);
String contextToken = split.length > 0 ? split[0] : "";
String toUserId = split.length > 1 ? split[1] : "";
if (!toUserId.isBlank()) {
stopTyping(toUserId);
}
// 扫描 /api/v1/files/generated/{id} URL 替换为 "📎 filename" 标记 + 收集附件字节
// 缺失此步会让 LLM 回复里的 URL 作为纯文本发到微信用户无法点击下载
List<vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit> attachments = List.of();
String rewrittenContent = content;
if (generatedFileScrubber != null && content != null && !content.isBlank()) {
vip.mate.channel.media.GeneratedFileScrubber.ScrubResult scrubbed =
generatedFileScrubber.scrub(content);
rewrittenContent = scrubbed.rewrittenText();
attachments = scrubbed.attachments();
if (!attachments.isEmpty()) {
log.info("[weixin] renderAndSend: scrubbed {} attachment(s) from content",
attachments.size());
}
}
// 调用父类默认渲染逻辑
boolean filterThinking = getConfigBoolean("filter_thinking", true);
boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true);
@ -787,10 +827,57 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 2048);
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
content, filterThinking, filterToolMessages, format, maxLen);
rewrittenContent, filterThinking, filterToolMessages, format, maxLen);
for (String segment : segments) {
sendMessage(targetId, segment);
}
// 文本发完后把缓存里的字节作为原生附件推送 WeCom/Feishu 行为对齐
sendAttachmentHits(toUserId, contextToken, attachments);
}
/**
* {@link GeneratedFileScrubber} 抓取到的附件字节通过 iLink 原生
* file/image 通道发送给微信用户图片走 {@link ILinkClient#sendImage}
* 其他类型走 {@link ILinkClient#sendFile}保留原始 fileName
* 单个附件失败不阻断后续附件仅记录 error 日志
*/
private void sendAttachmentHits(String toUserId, String contextToken,
List<vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit> attachments) {
if (attachments == null || attachments.isEmpty() || client == null
|| toUserId == null || toUserId.isBlank()
|| contextToken == null || contextToken.isBlank()) {
log.info("[weixin] sendAttachmentHits skipped: attachments={}, clientReady={}, toUserPresent={}, contextTokenPresent={}",
attachments == null ? 0 : attachments.size(), client != null,
toUserId != null && !toUserId.isBlank(),
contextToken != null && !contextToken.isBlank());
return;
}
log.info("[weixin] sendAttachmentHits begin: count={}, toUser={}, contextTokenPresent={}",
attachments.size(), toUserId.substring(0, Math.min(12, toUserId.length())),
!contextToken.isBlank());
for (vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit hit : attachments) {
try {
log.info("[weixin] sendAttachmentHit: mediaType={}, fileName={}, mimeType={}, bytes={}",
hit.mediaType(), hit.fileName(), hit.mimeType(),
hit.bytes() == null ? 0 : hit.bytes().length);
if ("image".equals(hit.mediaType())) {
client.sendImage(toUserId, hit.bytes(), contextToken);
log.info("[weixin] Generated image sent to {}: {} ({}bytes)",
toUserId.substring(0, Math.min(12, toUserId.length())),
hit.fileName(), hit.bytes().length);
} else {
client.sendFile(toUserId, hit.bytes(), hit.fileName(), contextToken);
log.info("[weixin] Generated file sent to {}: {} ({}bytes)",
toUserId.substring(0, Math.min(12, toUserId.length())),
hit.fileName(), hit.bytes().length);
}
} catch (Exception e) {
log.error("[weixin] Failed to send generated attachment {} to {}: {}",
hit.fileName(), toUserId.substring(0, Math.min(12, toUserId.length())),
e.getMessage(), e);
}
}
}
// ==================== 媒体上传发送 ====================

View File

@ -109,6 +109,7 @@ public class DatasourceService {
// 更新测试结果
entity.setLastTestTime(LocalDateTime.now());
entity.setLastTestOk(ok);
encryptPassword(entity);
datasourceMapper.updateById(entity);
return ok;
}

View File

@ -0,0 +1,50 @@
package vip.mate.llm.chatmodel;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Single source of truth for the OpenAI-compatible <em>models-listing</em> path.
*
* <p>Both the discovery flow ({@code ModelDiscoveryService}) and the failover
* liveness probe ({@code OpenAiCompatibleListModelsProbe}) list a provider's
* models to do their jobs. Keeping the path resolution here means an operator's
* {@code modelsPath} override is honored identically by both otherwise a
* self-hosted endpoint behind a non-standard prefix could be discoverable yet
* still marked unhealthy by a probe hitting the wrong hard-coded path.
*
* <p>Resolution order:
* <ol>
* <li>an explicit {@code modelsPath} in {@code generateKwargs} used verbatim
* (leading slash added if missing), for reverse-proxy / gateway prefixes
* such as {@code /openai/v1/models};</li>
* <li>otherwise {@code /v1/models}, collapsed to {@code /models} when the base
* URL already ends in a {@code /v{N}} segment (LM Studio {@code /v1},
* Zhipu {@code /v4}, Volcano Ark {@code /api/v3}) to avoid {@code /vN/v1/models}.</li>
* </ol>
* Mirrors the sibling {@code completionsPath} override on the chat path.
*/
public final class OpenAiModelsPath {
/** Trailing {@code /v{N}} segment on a base URL (any numeric major version). */
private static final Pattern VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
private OpenAiModelsPath() {}
public static String resolve(String baseUrl, Map<String, Object> kwargs) {
if (kwargs != null) {
Object raw = kwargs.get("modelsPath");
if (raw instanceof String value && StringUtils.hasText(value)) {
String path = value.trim();
return path.startsWith("/") ? path : "/" + path;
}
}
String path = "/v1/models";
if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).matches()) {
path = "/models";
}
return path;
}
}

View File

@ -1,6 +1,7 @@
package vip.mate.llm.failover;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Instant;
@ -17,17 +18,28 @@ import java.util.concurrent.ConcurrentHashMap;
* <p>Two state transitions:</p>
* <ul>
* <li><b>Add</b> at startup ({@code ProviderInitProbe}), on user-triggered
* reprobe, or after a {@code ModelConfigChangedEvent}.</li>
* reprobe, after a {@code ModelConfigChangedEvent}, or lazily when a
* TTL'd removal expires (see below).</li>
* <li><b>Remove</b> when a request hits a provider-wide HARD error
* (AUTH_ERROR / BILLING) these don't self-heal, so retrying on
* every subsequent call wastes the user's time. SOFT errors
* (RATE_LIMIT / SERVER_ERROR / EMPTY_RESPONSE) keep the provider in
* the pool and are handled by {@link ProviderHealthTracker}'s short
* cooldown instead. A rejected model id (MODEL_NOT_FOUND) is
* model-scoped, not provider-scoped, and never evicts the provider
* its sibling models stay usable.</li>
* (AUTH_ERROR / BILLING) retrying on every subsequent call wastes
* the user's time. SOFT errors (RATE_LIMIT / SERVER_ERROR /
* EMPTY_RESPONSE) keep the provider in the pool and are handled by
* {@link ProviderHealthTracker}'s short cooldown instead. A rejected
* model id (MODEL_NOT_FOUND) is model-scoped, not provider-scoped, and
* never evicts the provider its sibling models stay usable.</li>
* </ul>
*
* <p><b>TTL readmission</b>: AUTH_ERROR and BILLING removals are not
* permanent. Users top up balances, aggregator quotas refresh, and providers
* have transient 401 flaps none of which the process can observe. Each
* removal carries a {@code readmitAtMs} deadline (from
* {@link ProviderHealthProperties}); once it passes, the next
* {@link #contains} check lazily readmits the provider. No pre-readmission
* probe: the first real call is the probe, and a still-broken provider is
* simply re-evicted (self-correcting). INIT_PROBE and MANUAL removals never
* auto-readmit the former means the configuration itself is broken, the
* latter is explicit operator intent.</p>
*
* <p>State is process-local; a restart re-runs the init probe. That's
* intentional full distributed coordination is out of scope for v1
* (single-node and desktop deployments are the primary targets).</p>
@ -46,6 +58,18 @@ public class AvailableProviderPool {
*/
private final Map<String, RemovalReason> removalReasons = new ConcurrentHashMap<>();
private final ProviderHealthProperties props;
@Autowired
public AvailableProviderPool(ProviderHealthProperties props) {
this.props = props != null ? props : new ProviderHealthProperties();
}
/** Convenience constructor with default readmission TTLs. Used by tests. */
public AvailableProviderPool() {
this(new ProviderHealthProperties());
}
/** Add (or re-add) a provider to the pool. Clears any prior removal reason. */
public void add(String providerId) {
if (providerId == null || providerId.isEmpty()) return;
@ -60,24 +84,53 @@ public class AvailableProviderPool {
/**
* Remove a provider from the pool with a reason. Idempotent calling
* twice updates the reason (so the latest cause wins) but doesn't double-log.
* twice updates the reason (so the latest cause wins and the readmission
* TTL restarts from the latest incident) but doesn't double-log.
*/
public void remove(String providerId, RemovalSource source, String message) {
if (providerId == null || providerId.isEmpty()) return;
boolean wasMember = members.remove(providerId);
RemovalReason reason = new RemovalReason(source, message, Instant.now().toEpochMilli());
long now = Instant.now().toEpochMilli();
long ttl = readmitDelayMs(source);
RemovalReason reason = new RemovalReason(source, message, now, ttl > 0 ? now + ttl : 0);
removalReasons.put(providerId, reason);
if (wasMember) {
log.warn("[Pool] removing provider={} due to {} ({})", providerId, source, message);
log.warn("[Pool] removing provider={} due to {} ({}){}", providerId, source, message,
ttl > 0 ? " — auto-readmission in " + (ttl / 1000) + "s" : "");
} else {
log.debug("[Pool] removal reason updated for already-out provider={}: {} ({})",
providerId, source, message);
}
}
/** Membership check — the walker / primary short-circuit consults this on every entry. */
/**
* Membership check the walker / primary short-circuit consults this on
* every entry. Lazily readmits a provider whose removal TTL has expired,
* so recovery needs no scheduler thread and no user action.
*/
public boolean contains(String providerId) {
return providerId != null && members.contains(providerId);
if (providerId == null) return false;
if (members.contains(providerId)) return true;
RemovalReason reason = removalReasons.get(providerId);
if (reason != null && reason.readmitAtMs() > 0
&& Instant.now().toEpochMilli() >= reason.readmitAtMs()) {
log.info("[Pool] readmitting provider={} — {} removal TTL expired (removed {}s ago)",
providerId, reason.source(),
(Instant.now().toEpochMilli() - reason.removedAtMs()) / 1000);
add(providerId);
return true;
}
return false;
}
/** Readmission TTL for a removal source; 0 = never auto-readmit. */
private long readmitDelayMs(RemovalSource source) {
if (source == null) return 0;
return switch (source) {
case BILLING -> props.getBillingReadmitMs();
case AUTH_ERROR -> props.getAuthReadmitMs();
case INIT_PROBE, MANUAL -> 0;
};
}
/**
@ -105,8 +158,13 @@ public class AvailableProviderPool {
// Records
// ============================================================
/** Why a provider was removed from the pool. {@code removedAtMs} is epoch milliseconds. */
public record RemovalReason(RemovalSource source, String message, long removedAtMs) {}
/**
* Why a provider was removed from the pool. {@code removedAtMs} is epoch
* milliseconds; {@code readmitAtMs} is the epoch-millisecond deadline
* after which {@link #contains} lazily readmits the provider ({@code 0}
* = never auto-readmitted).
*/
public record RemovalReason(RemovalSource source, String message, long removedAtMs, long readmitAtMs) {}
/**
* Categorical source of a pool removal. Covers the provider-wide HARD

View File

@ -13,6 +13,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* enabled: true
* failure-threshold: 3 # consecutive failures before cooldown
* cooldown-ms: 300000 # 5 minutes
* billing-readmit-ms: 3600000 # auto-readmit a BILLING-evicted provider after 1h (0 = never)
* auth-readmit-ms: 1800000 # auto-readmit an AUTH-evicted provider after 30min (0 = never)
* </pre>
*/
@ConfigurationProperties(prefix = "mateclaw.llm.failover.health")
@ -27,6 +29,22 @@ public class ProviderHealthProperties {
/** Cooldown window in milliseconds. */
private long cooldownMs = 300_000L;
/**
* TTL after which a provider HARD-removed for BILLING is lazily readmitted
* to the available pool. Users top up balances and aggregator quotas
* refresh hourly without this, recovery requires a manual reprobe or a
* process restart. {@code 0} disables auto-readmission.
*/
private long billingReadmitMs = 3_600_000L;
/**
* TTL after which a provider HARD-removed for AUTH_ERROR is lazily
* readmitted. Bounds the damage of provider-side 401 flaps; a genuinely
* bad key just gets re-evicted by its first post-readmission call.
* {@code 0} disables auto-readmission.
*/
private long authReadmitMs = 1_800_000L;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@ -39,4 +57,14 @@ public class ProviderHealthProperties {
public void setCooldownMs(long cooldownMs) {
this.cooldownMs = Math.max(1000, cooldownMs);
}
public long getBillingReadmitMs() { return billingReadmitMs; }
public void setBillingReadmitMs(long billingReadmitMs) {
this.billingReadmitMs = Math.max(0, billingReadmitMs);
}
public long getAuthReadmitMs() { return authReadmitMs; }
public void setAuthReadmitMs(long authReadmitMs) {
this.authReadmitMs = Math.max(0, authReadmitMs);
}
}

View File

@ -62,14 +62,46 @@ public class ProviderHealthTracker {
return true;
}
/**
* Upper bound for a provider-supplied cooldown override. Guards against
* a provider returning an absurd {@code Retry-After} (misconfigured proxy,
* clock-skewed reset timestamp) locking a provider out for days.
*/
static final long MAX_COOLDOWN_OVERRIDE_MS = 2 * 60 * 60 * 1000L;
/**
* Record a single failure against {@code providerId}. When the counter
* reaches the configured threshold, the provider enters cooldown.
*/
public void recordFailure(String providerId) {
recordFailure(providerId, 0);
}
/**
* Record a failure with an optional provider-supplied cooldown override
* (milliseconds), typically parsed from a 429 response's
* {@code Retry-After} / rate-limit reset headers.
*
* <p>When {@code cooldownOverrideMs > 0} the provider has stated exactly
* when capacity returns, so the cooldown starts <b>immediately</b>
* waiting for {@link ProviderHealthProperties#getFailureThreshold} more
* consecutive failures would burn extra calls against a window the
* provider already announced. The override is clamped to
* {@link #MAX_COOLDOWN_OVERRIDE_MS} and never <i>shortens</i> an active
* cooldown.</p>
*/
public void recordFailure(String providerId, long cooldownOverrideMs) {
if (!props.isEnabled() || providerId == null) return;
AtomicLong counter = consecutiveFailures.computeIfAbsent(providerId, k -> new AtomicLong());
long failures = counter.incrementAndGet();
if (cooldownOverrideMs > 0) {
long clamped = Math.min(cooldownOverrideMs, MAX_COOLDOWN_OVERRIDE_MS);
long cooldownEnd = System.currentTimeMillis() + clamped;
cooldownUntilMs.merge(providerId, cooldownEnd, Math::max);
log.warn("[ProviderHealth] provider={} entering cooldown for {}s (provider-stated retry window)",
providerId, clamped / 1000);
return;
}
if (failures >= props.getFailureThreshold()) {
long cooldownEnd = System.currentTimeMillis() + props.getCooldownMs();
cooldownUntilMs.put(providerId, cooldownEnd);

View File

@ -1,5 +1,7 @@
package vip.mate.llm.failover.probe;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@ -9,6 +11,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;
import vip.mate.llm.chatmodel.OpenAiModelsPath;
import vip.mate.llm.failover.ProbeResult;
import vip.mate.llm.failover.ProviderProbeStrategy;
import vip.mate.llm.model.ModelProtocol;
@ -16,7 +19,7 @@ import vip.mate.llm.model.ModelProviderEntity;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.regex.Pattern;
import java.util.Map;
/**
* Probes an OpenAI-compatible provider by listing its models.
@ -24,14 +27,11 @@ import java.util.regex.Pattern;
* <p>Two non-trivial things this implementation handles:</p>
*
* <ol>
* <li><b>Path construction.</b> Different vendors set Base URL to different
* depths: OpenAI/DeepSeek/Kimi point at the API root
* ({@code https://api.openai.com}) while LMStudio / ZhipuAI bake the
* version segment in ({@code http://localhost:1234/v1},
* {@code https://open.bigmodel.cn/api/paas/v4}). We append {@code /models}
* when the URL already ends with a {@code /vN} segment, otherwise
* {@code /v1/models}. Without this we'd hit {@code /v1/v1/models} on
* LMStudio and {@code /v4/v1/models} on Zhipu both 404.</li>
* <li><b>Path construction.</b> Delegated to {@link OpenAiModelsPath} so the
* probe lists the exact same endpoint discovery does including an
* operator's {@code modelsPath} override for non-standard gateway prefixes.
* Without sharing, a provider could be discoverable yet still marked
* unhealthy here by a probe hitting the wrong hard-coded path.</li>
*
* <li><b>Permissive 4xx/5xx handling.</b> Not every OpenAI-compatible
* vendor implements {@code /models}. Kimi for Coding returns a
@ -49,8 +49,11 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy {
/** Conservative HTTP timeout — keeps a stalled provider from holding up the parallel batch. */
private static final Duration TIMEOUT = Duration.ofSeconds(5);
/** Matches a trailing {@code /v1}, {@code /v2}, ..., {@code /v99} segment on the base URL. */
private static final Pattern VERSION_SUFFIX = Pattern.compile("/v\\d{1,2}$");
private final ObjectMapper objectMapper;
public OpenAiCompatibleListModelsProbe(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public ModelProtocol supportedProtocol() {
@ -63,7 +66,7 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy {
return ProbeResult.fail(0, "base URL not configured");
}
String baseUrl = stripTrailingSlash(provider.getBaseUrl().trim());
String modelsPath = resolveModelsPath(baseUrl);
String modelsPath = OpenAiModelsPath.resolve(baseUrl, parseKwargs(provider.getGenerateKwargs()));
long start = System.currentTimeMillis();
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
@ -124,15 +127,21 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy {
}
/**
* Pick the right path to append. If the base URL already ends in a {@code /vN}
* version segment, append only {@code /models}. Otherwise append {@code /v1/models}.
* Package-private so the unit test can exercise it directly.
* Parse the provider's {@code generateKwargs} JSON into a map so a
* {@code modelsPath} override is visible to {@link OpenAiModelsPath}. Returns
* an empty map on null / blank / malformed JSON a bad kwargs blob must not
* knock a provider out of the pool; it simply falls back to the default path.
*/
static String resolveModelsPath(String baseUrl) {
if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).find()) {
return "/models";
private Map<String, Object> parseKwargs(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("[Probe] ignoring unparseable generateKwargs: {}", e.getMessage());
return Map.of();
}
return "/v1/models";
}
private static String stripTrailingSlash(String url) {

View File

@ -4,24 +4,30 @@ import java.util.Arrays;
public enum ModelProtocol {
OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"),
OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel"),
ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"),
OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel", true),
// OAuth-based: discovery relies on a separately established OAuth session
// (stored, auto-refreshed access token) rather than the provider row's
// baseUrl/apiKey, so a self-configured custom provider cannot drive it.
OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel", false),
ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel", true),
/**
* RFC-062: same Anthropic Messages API but authenticated with the user's
* Claude Code OAuth token (Pro/Max subscription) instead of an API key.
* Routed by {@code ClaudeCodeChatModelBuilder}.
* Routed by {@code ClaudeCodeChatModelBuilder}. Has no discovery endpoint
* (fixed, Flyway-seeded catalog), so discovery is unsupported.
*/
ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel"),
GEMINI_NATIVE("gemini-native", "GeminiChatModel"),
DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel");
ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel", false),
GEMINI_NATIVE("gemini-native", "GeminiChatModel", true),
DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel", true);
private final String id;
private final String chatModelClass;
private final boolean supportsSelfConfiguredDiscovery;
ModelProtocol(String id, String chatModelClass) {
ModelProtocol(String id, String chatModelClass, boolean supportsSelfConfiguredDiscovery) {
this.id = id;
this.chatModelClass = chatModelClass;
this.supportsSelfConfiguredDiscovery = supportsSelfConfiguredDiscovery;
}
public String getId() {
@ -32,6 +38,23 @@ public enum ModelProtocol {
return chatModelClass;
}
/**
* Whether a self-configured provider (baseUrl + apiKey) of this protocol can
* drive model discovery. Used to decide the default {@code supportModelDiscovery}
* flag for user-created custom providers. OAuth-based protocols return false:
* their discovery hangs off a separately established OAuth session, not the
* provider row's baseUrl/apiKey.
*
* <p><b>Note:</b> this is narrower than "can this protocol ever discover"
* the built-in ChatGPT-OAuth provider <em>does</em> discover (via its OAuth
* session) yet this returns false for {@code OPENAI_CHATGPT}. Do not reuse
* this to gate the discover button in general; it answers only the custom-
* provider default.
*/
public boolean supportsSelfConfiguredDiscovery() {
return supportsSelfConfiguredDiscovery;
}
public static ModelProtocol fromChatModel(String chatModel) {
if (chatModel == null || chatModel.isBlank()) {
return OPENAI_COMPATIBLE;
@ -52,13 +75,24 @@ public enum ModelProtocol {
.orElse(OPENAI_COMPATIBLE);
}
public static String resolveChatModel(String protocolId, String chatModel) {
/**
* Resolve the effective protocol from an explicit protocol id, falling back
* to inference from the chat-model class, and finally to
* {@link #OPENAI_COMPATIBLE}. Single source of truth so callers can derive
* both the chat-model class and capability flags (e.g. {@link #supportsSelfConfiguredDiscovery()})
* from one consistent resolution.
*/
public static ModelProtocol resolve(String protocolId, String chatModel) {
if (protocolId != null && !protocolId.isBlank()) {
return fromId(protocolId).getChatModelClass();
return fromId(protocolId);
}
if (chatModel != null && !chatModel.isBlank()) {
return fromChatModel(chatModel).getChatModelClass();
return fromChatModel(chatModel);
}
return OPENAI_COMPATIBLE.getChatModelClass();
return OPENAI_COMPATIBLE;
}
public static String resolveChatModel(String protocolId, String chatModel) {
return resolve(protocolId, chatModel).getChatModelClass();
}
}

View File

@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.OpenAiModelsPath;
import vip.mate.llm.model.*;
import vip.mate.llm.oauth.OpenAIOAuthService;
@ -461,12 +462,12 @@ public class ModelDiscoveryService {
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
RestClient.RequestHeadersSpec<?> spec = client.get().uri(resolveModelsPath(baseUrl));
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
RestClient.RequestHeadersSpec<?> spec = client.get().uri(OpenAiModelsPath.resolve(baseUrl, kwargs));
if (modelProviderService.hasUsableApiKey(apiKey)) {
spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim());
}
// Apply any custom headers declared in generateKwargs.
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
applyCustomHeaders(spec, kwargs);
String body = spec.retrieve().body(String.class);
@ -931,19 +932,6 @@ public class ModelDiscoveryService {
return path;
}
/**
* Resolve the OpenAI-compatible {@code /v1/models} path against a base URL,
* stripping the {@code /v1} prefix when the base already carries a {@code /v{N}}
* suffix (Volcano Engine Ark, etc.).
*/
private String resolveModelsPath(String baseUrl) {
String path = "/v1/models";
if (baseUrl != null && BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()) {
path = "/models";
}
return path;
}
private String normalizeBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
return null;

View File

@ -157,11 +157,12 @@ public class ModelProviderService {
if (modelProviderMapper.selectById(request.getId()) != null) {
throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId());
}
ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel());
ModelProviderEntity provider = new ModelProviderEntity();
provider.setProviderId(request.getId());
provider.setName(request.getName());
provider.setApiKeyPrefix(request.getApiKeyPrefix());
provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel()));
provider.setChatModel(protocol.getChatModelClass());
provider.setBaseUrl(request.getDefaultBaseUrl());
provider.setGenerateKwargs("{}");
provider.setIsCustom(true);
@ -169,7 +170,12 @@ public class ModelProviderService {
// RFC-074: custom providers are user-created, so opt them in by default
// the user just made the row, no need to make them flip a second toggle.
provider.setEnabled(true);
provider.setSupportModelDiscovery(false);
// Default model discovery on for protocols whose discovery works from a
// self-configured baseUrl+apiKey (OpenAI-compatible, DashScope, Gemini,
// Anthropic). Previously hard-coded false, which left self-hosted
// OpenAI-compatible endpoints (vLLM/Xinference/LocalAI/) unable to
// surface the "discover models" button at all. OAuth protocols stay off.
provider.setSupportModelDiscovery(protocol.supportsSelfConfiguredDiscovery());
provider.setSupportConnectionCheck(false);
provider.setFreezeUrl(false);
provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey()));

View File

@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("mate_fact_contradiction")
public class FactContradictionEntity {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long agentId;

View File

@ -17,7 +17,7 @@ import java.time.LocalDateTime;
@TableName("mate_fact")
public class FactEntity {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long agentId;

View File

@ -61,7 +61,7 @@ public class MemoryLifecycleMediator {
public void afterLlmCall(TurnContext ctx, String assistantReply) {
try {
memoryManager.syncAll(ctx.agentId(), ctx.conversationId(),
ctx.userQuery(), assistantReply);
ctx.userQuery(), assistantReply, ctx.ownerKey());
events.publishEvent(new TurnCompletedEvent(ctx, assistantReply));
log.debug("[Memory] afterLlmCall: agent={}, conv={}, replyLen={}", ctx.agentId(),
ctx.conversationId(), assistantReply != null ? assistantReply.length() : 0);

View File

@ -14,7 +14,7 @@ import java.time.LocalDateTime;
@TableName("mate_morning_card_seen")
public class MorningCardSeenEntity {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;

View File

@ -189,9 +189,19 @@ public class MemoryManager {
*/
public void syncAll(Long agentId, String conversationId,
String userMessage, String assistantReply) {
syncAll(agentId, conversationId, userMessage, assistantReply, null);
}
/**
* Owner-scoped post-turn sync. Passes the same resolved memory
* {@code ownerKey} that prefetch used, so owner-aware providers persist
* the turn under the identifier their recall path queries by.
*/
public void syncAll(Long agentId, String conversationId,
String userMessage, String assistantReply, String ownerKey) {
for (MemoryProvider provider : providers) {
try {
provider.syncTurn(agentId, conversationId, userMessage, assistantReply);
provider.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
} catch (Exception e) {
log.warn("[MemoryManager] Provider '{}' syncTurn failed: {}",
provider.id(), e.getMessage());

View File

@ -87,6 +87,24 @@ public interface MemoryProvider {
String userMessage, String assistantReply) {
}
/**
* Owner-scoped post-turn sync. Providers that isolate memory per end-user
* override this to persist the turn under the same {@code ownerKey} that
* owner-scoped prefetch recalls by. Default delegates to
* {@link #syncTurn(Long, String, String, String)} for providers that are
* not owner-aware.
*
* @param agentId the agent ID
* @param conversationId the conversation ID
* @param userMessage user's message text
* @param assistantReply assistant's reply text
* @param ownerKey resolved memory owner key (e.g. "user:42"); may be null
*/
default void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply, String ownerKey) {
syncTurn(agentId, conversationId, userMessage, assistantReply);
}
/**
* Spring AI @Tool beans this provider wants to expose to the agent.
* These are collected by MemoryManager and added to the tool set.

View File

@ -28,6 +28,9 @@ public abstract class MemoryProviderDecorator implements MemoryProvider {
@Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
}
@Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
}
@Override public List<Object> getToolBeans() { return delegate.getToolBeans(); }
@Override public void onSessionEnd(Long agentId, String conversationId) { delegate.onSessionEnd(agentId, conversationId); }
@Override public String onPreCompress(Long agentId, List<?> messages) { return delegate.onPreCompress(agentId, messages); }

View File

@ -58,9 +58,14 @@ public class MetricsMemoryProvider extends MemoryProviderDecorator {
@Override
public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
syncTurn(agentId, conversationId, userMessage, assistantReply, null);
}
@Override
public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) {
syncTimer.record(() -> {
try {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
} catch (Exception e) {
meterRegistry.counter("memory.sync.failures",
"provider", delegate.id()).increment();

View File

@ -45,10 +45,15 @@ public class RetryableMemoryProvider extends MemoryProviderDecorator {
@Override
public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
syncTurn(agentId, conversationId, userMessage, assistantReply, null);
}
@Override
public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) {
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
return;
} catch (Exception e) {
lastException = e;

View File

@ -127,6 +127,35 @@ public class PlanningService {
}
}
/**
* Park a plan whose steps were handed off to a team task board. The plan
* stays in this status while board tasks execute; any later inbound
* message resumes it through the delegated-plan gate.
*/
public void markPlanDelegated(Long planId) {
PlanEntity plan = planMapper.selectById(planId);
if (plan != null) {
plan.setStatus("delegated");
planMapper.updateById(plan);
}
}
/**
* Latest board-delegated plan parked on this conversation, or null. The
* counterpart of {@link #findAwaitingApprovalContext} for the team
* hand-off flow: park in the DB, resume from the DB.
*/
public PlanEntity findDelegatedPlan(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return null;
}
return planMapper.selectOne(new LambdaQueryWrapper<PlanEntity>()
.eq(PlanEntity::getConversationId, conversationId)
.eq(PlanEntity::getStatus, "delegated")
.orderByDesc(PlanEntity::getCreateTime)
.last("LIMIT 1"));
}
/**
* 完成计划
*/

View File

@ -45,12 +45,27 @@ public class PluginMemoryBridge implements MemoryProvider {
return delegate.prefetch(agentId, userQuery);
}
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
// Forward ownerKey to the plugin provider; plugins that don't override the
// three-arg variant fall back to the two-arg default (ownerKey dropped).
return delegate.prefetch(agentId, userQuery, ownerKey);
}
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply) {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
}
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply, String ownerKey) {
// Forward ownerKey to the plugin provider; plugins that don't override the
// five-arg variant fall back to the four-arg default (ownerKey dropped).
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
}
@Override
public List<Object> getToolBeans() {
List<Object> beans = delegate.getToolBeans();

View File

@ -17,8 +17,11 @@ import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillDependencyChecker;
import vip.mate.skill.runtime.SkillPackageResolver;
import vip.mate.skill.runtime.SkillCatalogSort;
import vip.mate.skill.runtime.SkillCatalogSorter;
import vip.mate.skill.model.SkillFileEntity;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.synthesis.SkillSynthesisService;
import vip.mate.skill.runtime.SkillRuntimeService;
@ -26,6 +29,7 @@ import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.BundledSkillSyncer;
import vip.mate.skill.workspace.SkillFileSyncer;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import vip.mate.skill.workspace.bundle.SkillBundleFiles;
import vip.mate.exception.MateClawException;
import vip.mate.skill.lifecycle.ConfirmRequiredException;
import vip.mate.skill.lifecycle.LifecycleTransition;
@ -57,6 +61,7 @@ public class SkillController {
private final SkillService skillService;
private final SkillRuntimeService skillRuntimeService;
private final SkillPackageResolver skillPackageResolver;
private final SkillWorkspaceManager workspaceManager;
private final BundledSkillSyncer bundledSkillSyncer;
private final SkillFileSyncer skillFileSyncer;
@ -71,6 +76,7 @@ public class SkillController {
private final SkillLifecycleService skillLifecycleService;
private final SkillCuratorJob skillCuratorJob;
private final SkillCuratorReportStore skillCuratorReportStore;
private final SkillFileService skillFileService;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@ -334,6 +340,157 @@ public class SkillController {
return R.ok(body);
}
// ==================== Bundle files (scripts/ + references/) ====================
/** Per-file content ceiling for the admin editor — matches the installer's per-file bound. */
private static final int MAX_BUNDLE_FILE_CHARS = 1_000_000;
@Operation(summary = "List a skill's bundle files (scripts/ + references/), without content")
@GetMapping("/{id}/files")
@RequireWorkspaceRole("member")
public R<List<Map<String, Object>>> listBundleFiles(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// Virtual MCP/ACP skills mirror live servers and own no bundle files.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)
|| vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
return R.ok(List.of());
}
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
List<SkillFileEntity> rows = skillFileService.listBySkillId(id);
if (rows.isEmpty()) {
// Self-heal: ingest pre-canonical on-disk files into the DB store
// so legacy directory-based skills list their files too.
skillFileSyncer.syncOne(skill);
rows = skillFileService.listBySkillId(id);
}
List<Map<String, Object>> out = new ArrayList<>(rows.size());
rows.stream()
.sorted(java.util.Comparator.comparing(SkillFileEntity::getFilePath,
java.util.Comparator.nullsLast(String::compareTo)))
.forEach(row -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("path", row.getFilePath());
item.put("size", row.getContentSize());
item.put("sha256", row.getSha256());
item.put("updateTime", row.getUpdateTime());
out.add(item);
});
return R.ok(out);
}
@Operation(summary = "Read one bundle file's content")
@GetMapping("/{id}/files/content")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> getBundleFileContent(@PathVariable Long id,
@RequestParam String path,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
String normalized = normalizeBundlePath(path);
if (normalized == null) {
return R.fail("Invalid file path: " + path);
}
SkillFileEntity row = skillFileService.getFile(id, normalized);
if (row == null) {
return R.fail("File not found: " + normalized);
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("path", row.getFilePath());
body.put("content", row.getContent() == null ? "" : row.getContent());
body.put("size", row.getContentSize());
body.put("sha256", row.getSha256());
body.put("updateTime", row.getUpdateTime());
return R.ok(body);
}
@Operation(summary = "Create or update one bundle file",
description = "Writes the canonical mate_skill_file row, materializes the workspace cache, " +
"and re-resolves the skill so the runtime file tree updates immediately.")
@PutMapping("/{id}/files/content")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> putBundleFileContent(@PathVariable Long id,
@RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade.");
}
String normalized = normalizeBundlePath(body.get("path"));
if (normalized == null) {
return R.fail("Invalid file path — must be under scripts/, references/ or templates/, no '..'.");
}
String content = body.get("content");
if (content == null) {
return R.fail("content is required (use the delete endpoint to remove a file).");
}
if (content.length() > MAX_BUNDLE_FILE_CHARS) {
return R.fail("Content too large (" + content.length() + " chars, max " + MAX_BUNDLE_FILE_CHARS + ").");
}
SkillFileEntity row = skillFileService.upsertFile(id, normalized, content);
try {
workspaceManager.writeWorkspaceFile(skill.getName(), normalized, content, skill.getWorkspaceId());
} catch (Exception e) {
// Canonical store is updated; the syncer heals the cache later.
}
skillRuntimeService.rescanSingle(skill);
Map<String, Object> out = new LinkedHashMap<>();
out.put("path", row.getFilePath());
out.put("size", row.getContentSize());
out.put("sha256", row.getSha256());
out.put("updateTime", row.getUpdateTime());
return R.ok(out);
}
@Operation(summary = "Delete one bundle file")
@DeleteMapping("/{id}/files")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> deleteBundleFile(@PathVariable Long id,
@RequestParam String path,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade.");
}
String normalized = normalizeBundlePath(path);
if (normalized == null) {
return R.fail("Invalid file path: " + path);
}
boolean removed = skillFileService.deleteFile(id, normalized);
try {
workspaceManager.deleteWorkspaceFile(skill.getName(), normalized, skill.getWorkspaceId());
} catch (Exception e) {
// Cache cleanup is best-effort; the canonical row is gone.
}
skillRuntimeService.rescanSingle(skill);
return R.ok(Map.of("path", normalized, "removed", removed));
}
/**
* Normalize a bundle-relative path and enforce the same envelope the
* store and workspace cache use: forward slashes, must sit under a
* DB-persisted bucket ({@code scripts/}, {@code references/} or
* {@code templates/}), no traversal, no absolute paths, and a
* non-empty file name.
*
* @return normalized path, or {@code null} when rejected
*/
static String normalizeBundlePath(String path) {
if (path == null || path.isBlank()) return null;
String p = path.strip().replace('\\', '/');
if (p.startsWith("/") || p.contains("..") || p.contains("//") || p.endsWith("/")) return null;
if (!SkillBundleFiles.isDbEligible(p)) return null;
int slash = p.indexOf('/');
if (slash == p.length() - 1) return null;
return p;
}
/**
* Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge
* synthesizes those rows on the fly from the upstream connection
@ -445,6 +602,20 @@ public class SkillController {
}
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
// Read-time store reconciliation: the workspace SKILL.md may have
// been edited out-of-band (agent shell tools in a chat session)
// with no refresh in between. One file read + hash compare in the
// steady state; when the file side actually changed, the content
// is ingested and a single-skill rescan re-projects manifest
// columns and drops stale runtime caches so the detail view
// always shows what the runtime executes.
try {
if (skillPackageResolver.reconcileEntityContent(skill)) {
skillRuntimeService.rescanSingle(skill);
}
} catch (Exception ignored) {
// A reconcile failure must not break the detail view.
}
return R.ok(skill);
}
@ -785,7 +956,7 @@ public class SkillController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent());
var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent(), skill.getWorkspaceId());
if (path == null) {
return R.ok(Map.of("success", false, "message", "Failed to export workspace"));
}
@ -799,7 +970,7 @@ public class SkillController {
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName(), skill.getWorkspaceId()));
}
// ==================== Skill lifecycle & curator ====================

View File

@ -82,7 +82,10 @@ public class SkillInstaller {
* {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}.
*/
public void uninstall(String skillName, Long workspaceId) {
List<SkillEntity> skills = skillService.listSkills();
// Scope the lookup to this workspace (+ builtin) so a same-named skill in
// another workspace is never picked up (which would wrongly 403 below even
// though the current workspace has its own skill to uninstall).
List<SkillEntity> skills = skillService.listSkills(workspaceId);
SkillEntity target = skills.stream()
.filter(s -> s.getName().equals(skillName))
.findFirst()
@ -147,8 +150,9 @@ public class SkillInstaller {
return CompletableFuture.completedFuture(null);
}
// 3. 检查是否已存在
boolean exists = skillService.listSkills().stream()
// 3. 检查是否已存在按工作区隔离只在本工作区 + builtin 范围内查重
// 不同工作区的同名技能可以各自独立安装
boolean exists = skillService.listSkills(request.getWorkspaceId()).stream()
.anyMatch(s -> s.getName().equals(skillName));
if (exists && !Boolean.TRUE.equals(request.getOverwrite())) {
task.markFailed("Skill '" + skillName + "' already exists. Set overwrite=true to replace.");
@ -161,10 +165,10 @@ public class SkillInstaller {
}
// 4. Materialize SKILL.md (overwrite on reinstall, keep on first create).
workspaceManager.initWorkspace(skillName, bundle.content(), exists);
workspaceManager.initWorkspace(skillName, bundle.content(), exists, request.getWorkspaceId());
if (task.isCancelRequested()) {
workspaceManager.archiveWorkspace(skillName);
workspaceManager.archiveWorkspace(skillName, request.getWorkspaceId());
task.markCancelled();
return CompletableFuture.completedFuture(null);
}
@ -182,12 +186,12 @@ public class SkillInstaller {
// Empty-bundle guard protects both sides from a malformed bundle
// silently wiping pre-existing scripts/references.
boolean force = Boolean.TRUE.equals(request.getForcePrune());
persistBundleFiles(skillEntity, bundle, force, "url");
persistBundleFiles(skillEntity, bundle, force, "url", request.getWorkspaceId());
// 7. Publish event for runtime refresh / sibling-node materialization.
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
workspaceManager.resolveConventionPath(skillName, request.getWorkspaceId())));
task.markCompleted(InstallResult.builder()
.name(skillName)
@ -218,7 +222,8 @@ public class SkillInstaller {
throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle");
}
boolean exists = skillService.listSkills().stream()
// Workspace-scoped dedup: same-named skills in different workspaces coexist.
boolean exists = skillService.listSkills(workspaceId).stream()
.anyMatch(s -> s.getName().equals(skillName));
if (exists && !overwrite) {
throw new vip.mate.exception.MateClawException("err.skill.name_exists",
@ -226,17 +231,17 @@ public class SkillInstaller {
}
// Materialize SKILL.md (always overwrite on reinstall path).
workspaceManager.initWorkspace(skillName, bundle.content(), exists);
workspaceManager.initWorkspace(skillName, bundle.content(), exists, workspaceId);
// Register/update skill row first so we have an id to anchor the file rows.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable, workspaceId);
// DB-canonical, FS-cache. Empty-bundle guard on both sides.
persistBundleFiles(skillEntity, bundle, false, "zip");
persistBundleFiles(skillEntity, bundle, false, "zip", workspaceId);
eventPublisher.publishEvent(new SkillWorkspaceEvent(
skillName, SkillWorkspaceEvent.Type.INSTALLED,
workspaceManager.resolveConventionPath(skillName)));
workspaceManager.resolveConventionPath(skillName, workspaceId)));
int filesCount = (bundle.references() != null ? bundle.references().size() : 0)
+ (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1;
@ -263,7 +268,9 @@ public class SkillInstaller {
boolean enable, Long workspaceId) {
SkillEntity skillEntity;
if (exists) {
skillEntity = skillService.listSkills().stream()
// Locate the row to update within THIS workspace (+ builtin), so a
// reinstall never grabs a same-named skill owned by another workspace.
skillEntity = skillService.listSkills(workspaceId).stream()
.filter(s -> s.getName().equals(skillName))
.findFirst().orElseThrow();
skillEntity.setSkillContent(bundle.content());
@ -296,7 +303,8 @@ public class SkillInstaller {
* same prefixed-key map. Logs a single combined summary so multi-instance
* deployments can see what each node persisted vs preserved.
*/
private void persistBundleFiles(SkillEntity skillEntity, SkillBundle bundle, boolean force, String origin) {
private void persistBundleFiles(SkillEntity skillEntity, SkillBundle bundle, boolean force, String origin,
Long workspaceId) {
Map<String, String> combined = new LinkedHashMap<>();
if (bundle.references() != null) {
for (var e : bundle.references().entrySet()) {
@ -313,7 +321,7 @@ public class SkillInstaller {
var dbApply = skillFileService.applyBundleFiles(skillEntity.getId(), combined, force);
var fsApply = workspaceManager.applyBundleFiles(skillEntity.getName(),
bundle.references(), bundle.scripts(), force);
bundle.references(), bundle.scripts(), force, workspaceId);
log.info("Persisted bundle for '{}' ({}): db(write={}, prune={}, preservedScripts={}, preservedRefs={}) " +
"fs(refs write={}, prune={}, preserved={} | scripts write={}, prune={}, preserved={})",

View File

@ -8,8 +8,10 @@ import vip.mate.skill.runtime.SkillFrontmatterParser;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
@ -179,11 +181,17 @@ public class ZipSkillFetcher {
private static final Charset GBK = Charset.isSupported("GBK") ? Charset.forName("GBK") : null;
/**
* Decompress raw ZIP bytes, trying UTF-8 first and falling back to GBK when
* an entry name fails to decode as UTF-8 the common failure mode for zips
* packaged on Chinese Windows, where filenames are GBK and UTF-8 decoding
* throws a {@link CharacterCodingException}. Buffering the bytes (rather than
* a one-shot stream) is what makes the retry possible.
* Decompress raw ZIP bytes into in-memory SKILL.md + references + scripts.
*
* <p>Entry names and entry content are each decoded independently UTF-8
* first, falling back to GBK rather than picking one charset for the
* whole archive. Zips packaged on Windows commonly mix encodings: the OS
* zip tool writes entry names in the local codepage (GBK) because it
* never sets the ZIP "language encoding flag", while file content
* authored in a UTF-8 text editor stays UTF-8. Deciding the charset once
* for the entire archive means a single GBK-named entry forces every
* already-correct UTF-8 file to be mis-decoded as GBK, turning valid
* Chinese text into mojibake.
*/
public static ExtractedSkill extract(byte[] zipBytes) throws IOException {
return extract(zipBytes, Limits.DEFAULT);
@ -191,41 +199,17 @@ public class ZipSkillFetcher {
/** Variant of {@link #extract(byte[])} with explicit size caps. */
public static ExtractedSkill extract(byte[] zipBytes, Limits limits) throws IOException {
try {
return extract(zipBytes, StandardCharsets.UTF_8, limits);
} catch (IOException | RuntimeException e) {
if (GBK != null && isCharsetError(e)) {
log.warn("[ZipSkillFetcher] UTF-8 entry decode failed, retrying with GBK (Windows-authored archive?)");
return extract(zipBytes, GBK, limits);
}
throw e;
}
}
/** True if {@code t} (or any cause) is a charset-decode failure, vs a genuine "no SKILL.md" error. */
private static boolean isCharsetError(Throwable t) {
for (Throwable c = t; c != null; c = c.getCause()) {
if (c instanceof CharacterCodingException) {
return true;
}
String m = c.getMessage();
if (m != null && m.toLowerCase().contains("malformed")) {
return true;
}
if (c.getCause() == c) {
break;
}
}
return false;
}
private static ExtractedSkill extract(byte[] zipBytes, Charset charset, Limits limits) throws IOException {
List<RawEntry> raws = new ArrayList<>();
String skillMdContent = null;
String skillMdPrefix = "";
long totalSize = 0;
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
// ISO-8859-1 maps every byte value 0-255 to a distinct character, so
// ZipInputStream never throws while decoding a name with it even a
// raw GBK-encoded name comes back as reversible mojibake that
// fixEntryName() below can re-decode itself, instead of aborting the
// whole archive read the way a strict UTF-8/GBK charset would.
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), StandardCharsets.ISO_8859_1)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
@ -233,7 +217,7 @@ public class ZipSkillFetcher {
continue;
}
String entryName = entry.getName();
String entryName = fixEntryName(entry.getName());
// Skip macOS archive cruft so it doesn't surface as "ignored" noise.
if (entryName.startsWith("__MACOSX/") || entryName.equals(".DS_Store")
@ -286,7 +270,7 @@ public class ZipSkillFetcher {
continue;
}
String content = new String(bytes, charset);
String content = decodeBytes(bytes);
String normalizedName = entryPath.toString().replace('\\', '/');
String fileName = entryPath.getFileName().toString();
@ -346,6 +330,64 @@ public class ZipSkillFetcher {
return new ExtractedSkill(skillMdContent, references, scripts);
}
/**
* Re-decode a ZIP entry name that {@link ZipInputStream} returned via the
* byte-preserving ISO-8859-1 read. If the archive's language-encoding
* flag was actually set for this entry, the JDK already forced a real
* UTF-8 decode and the name contains genuine non-Latin-1 characters
* left untouched. Otherwise the name is just the raw bytes reinterpreted
* as Latin-1 (fully reversible), so the original bytes are recovered and
* decoded again, trying UTF-8 then falling back to GBK.
*/
private static String fixEntryName(String name) {
if (!isLatin1(name)) {
return name;
}
return decodeBytes(name.getBytes(StandardCharsets.ISO_8859_1));
}
private static boolean isLatin1(String s) {
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) > 0xFF) {
return false;
}
}
return true;
}
/**
* Decode raw bytes as text, trying UTF-8 first and falling back to GBK
* per entry, not per archive, so one mis-encoded name or file doesn't
* force a lossy re-decode of everything else that was already correct.
* Falls back to a lossy UTF-8 decode (replacement characters) only if
* neither charset parses cleanly.
*/
private static String decodeBytes(byte[] bytes) {
String utf8 = tryDecodeStrict(bytes, StandardCharsets.UTF_8);
if (utf8 != null) {
return utf8;
}
if (GBK != null) {
String gbk = tryDecodeStrict(bytes, GBK);
if (gbk != null) {
return gbk;
}
}
return new String(bytes, StandardCharsets.UTF_8);
}
private static String tryDecodeStrict(byte[] bytes, Charset charset) {
try {
return charset.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException e) {
return null;
}
}
/**
* Heuristic binary detector: an entry is treated as binary if a NUL byte
* (0x00) appears within the inspected prefix. UTF-8 and GBK text never

View File

@ -205,7 +205,7 @@ public class SkillLessonsService {
private Path resolveWorkspace(ResolvedSkill resolved) {
if (resolved == null || resolved.getName() == null) return null;
if (resolved.getSkillDir() != null) return resolved.getSkillDir();
Path convention = workspaceManager.resolveConventionPath(resolved.getName());
Path convention = workspaceManager.resolveConventionPath(resolved.getName(), resolved.getWorkspaceId());
return Files.exists(convention) && Files.isDirectory(convention) ? convention : null;
}

View File

@ -269,7 +269,7 @@ public class SkillCuratorJob {
List<SkillEntity> archived = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getLifecycleState, "archived"));
for (SkillEntity skill : archived) {
if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName())) {
if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName(), skill.getWorkspaceId())) {
continue;
}
report.reconciliation("skill '" + skill.getName() + "' (id=" + skill.getId()

View File

@ -166,7 +166,7 @@ public class SkillLifecycleService {
"Skill is not archived: " + skill.getName());
}
SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName());
SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName(), skill.getWorkspaceId());
switch (fs) {
case MOVED -> { /* normal path */ }
case MISSING -> {
@ -266,7 +266,7 @@ public class SkillLifecycleService {
// FAILED defers the whole transition.
SkillWorkspaceManager.ArchiveResult fsResult = SkillWorkspaceManager.ArchiveResult.MISSING;
if ("archive".equals(workspaceProperties.getDeletePolicy())) {
fsResult = workspaceManager.archiveWorkspace(skill.getName());
fsResult = workspaceManager.archiveWorkspace(skill.getName(), skill.getWorkspaceId());
}
if (fsResult == SkillWorkspaceManager.ArchiveResult.FAILED) {
log.warn("Skill '{}' workspace archive failed; deferring DB transition", skill.getName());
@ -288,7 +288,7 @@ public class SkillLifecycleService {
if (rows == 0) {
log.warn("Skill '{}' DB archive update touched 0 rows; compensating workspace", skill.getName());
if (fsResult == SkillWorkspaceManager.ArchiveResult.MOVED) {
workspaceManager.restoreWorkspace(skill.getName());
workspaceManager.restoreWorkspace(skill.getName(), skill.getWorkspaceId());
}
return false;
}

View File

@ -140,9 +140,13 @@ public class SkillManifest {
public static class RequirementDef {
/** Required: stable identifier referenced by {@code features[*].requires}. */
private String key;
/** binary | env_var | api_key */
/** binary | env_var | api_key | endpoint */
private String type;
/** Probe target — for binary, the executable name; for env_var, the env name. */
/**
* Probe target for binary, the executable name; for env_var, the
* env name; for endpoint, the service address to TCP-probe
* ({@code http(s)://host[:port][/path]} or {@code host[:port]}).
*/
private String check;
/** Optional means it only blocks features that reference it explicitly. */
@Builder.Default

View File

@ -0,0 +1,258 @@
package vip.mate.skill.runtime;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/**
* Reconciles a skill's SKILL.md between its two stores: the canonical
* {@code mate_skill.skill_content} column and the convention-workspace
* file cache ({@code {workspace-root}/{name}/SKILL.md}).
*
* <p>The database column is the single source of truth; the workspace file
* is a materialized cache kept for script execution and direct-file
* tooling. Because agents (via shell tools) and operators may still edit
* the file in place, reconciliation is a three-way sync anchored on a
* sidecar marker ({@value #SYNC_MARKER}) that records the SHA-256 of the
* content at the last successful sync:
*
* <ul>
* <li>DB == file in sync; heal a missing/stale marker.</li>
* <li>File missing/blank, DB has content materialize DB file.
* A blank file is never ingested over non-blank DB content.</li>
* <li>DB blank, file has content backfill file DB (covers installs
* that predate the canonical column).</li>
* <li>File changed since last sync, DB unchanged ingest file DB.
* This is what makes shell/agent edits to the file visible to
* DB-reading consumers (admin console, API).</li>
* <li>DB changed since last sync, file unchanged materialize DB file.
* This heals nodes whose workspace export was missed or failed.</li>
* <li>No marker and both sides non-blank but different (legacy state)
* the file wins once: prior releases resolved runtime content from
* the directory, so the file reflects what was actually in effect.</li>
* <li>Both sides changed since last sync DB wins; the losing file is
* kept as {@code SKILL.md.bak} before being overwritten.</li>
* </ul>
*
* <p>All writes are idempotent and failure-tolerant: an IO or DB error
* logs a warning and leaves the marker untouched, so the next resolve
* pass retries the same reconciliation.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SkillContentReconciler {
/** Sidecar file holding the SHA-256 of SKILL.md at the last sync. */
public static final String SYNC_MARKER = ".skillmd.sha256";
/** Backup name for a file-side edit that loses a two-sided conflict. */
static final String CONFLICT_BACKUP = "SKILL.md.bak";
private final SkillMapper skillMapper;
/** What the reconciliation pass did. */
public enum Action {
/** Both stores already held the same content. */
IN_SYNC,
/** DB was blank; the workspace file was ingested into the DB. */
BACKFILLED_TO_DB,
/** The workspace file changed; its content was written to the DB. */
INGESTED_TO_DB,
/** The DB changed; its content was written to the workspace file. */
MATERIALIZED_TO_FS,
/** Both changed; DB won and the file edit was backed up. */
CONFLICT_DB_WON,
/** A store write failed; stores may still diverge. Retried next pass. */
FAILED
}
/** Reconciled content (the value both stores now agree on) + what happened. */
public record Outcome(String content, Action action) {}
/**
* Reconcile {@code entity}'s skill_content with the SKILL.md inside
* {@code workspaceDir}. On an ingest/backfill the passed entity's
* in-memory {@code skillContent} is updated too, so downstream resolve
* stages and diff-based write-backs see the merged value.
*/
public Outcome reconcile(SkillEntity entity, Path workspaceDir) {
String dbContent = entity.getSkillContent() == null ? "" : entity.getSkillContent();
Path skillMd = workspaceDir.resolve("SKILL.md");
String fsContent = readFileQuietly(skillMd);
String dbHash = sha256(dbContent);
String fsHash = sha256(fsContent);
Path marker = workspaceDir.resolve(SYNC_MARKER);
String syncedHash = readMarker(marker);
if (dbHash.equals(fsHash)) {
if (!dbHash.equals(syncedHash)) {
writeMarkerQuietly(marker, dbHash);
}
return new Outcome(dbContent, Action.IN_SYNC);
}
if (fsContent.isBlank()) {
// File missing or empty while the DB has content: always
// materialize. Blank file content is never treated as an edit
// that guard blocks the same wipe-on-empty scenario the bundle
// apply path protects against.
return materialize(entity, skillMd, marker, dbContent, dbHash, Action.MATERIALIZED_TO_FS);
}
if (dbContent.isBlank()) {
return ingest(entity, marker, fsContent, fsHash, Action.BACKFILLED_TO_DB);
}
// Both sides non-blank and different use the marker to decide
// which side moved since the last sync.
if (syncedHash == null || syncedHash.equals(dbHash)) {
// DB unchanged since last sync (or legacy pre-marker state,
// where the directory was the effective runtime source):
// the file edit is the newer fact ingest it.
return ingest(entity, marker, fsContent, fsHash, Action.INGESTED_TO_DB);
}
if (syncedHash.equals(fsHash)) {
// File unchanged since last sync; the DB moved materialize.
return materialize(entity, skillMd, marker, dbContent, dbHash, Action.MATERIALIZED_TO_FS);
}
// Both sides changed since the last sync. The DB is canonical, so
// it wins; keep the losing file edit next to the file for manual
// recovery instead of silently discarding it.
backupQuietly(skillMd, workspaceDir.resolve(CONFLICT_BACKUP));
log.warn("SKILL.md conflict for skill '{}': both DB and workspace file changed since last sync; "
+ "DB content wins, file edit saved as {}", entity.getName(), CONFLICT_BACKUP);
return materialize(entity, skillMd, marker, dbContent, dbHash, Action.CONFLICT_DB_WON);
}
/**
* Mirror a file-authoritative skill's content into the DB column so
* DB-reading consumers (admin console, API) see what the runtime
* actually executes. Used for skills with an explicitly configured
* {@code skillDir}, where the user-managed directory not the DB
* is the source of truth and no marker/backfill dance applies.
*/
public void mirrorToDb(SkillEntity entity, String fsContent) {
if (fsContent == null || fsContent.isBlank()) return;
String dbContent = entity.getSkillContent() == null ? "" : entity.getSkillContent();
if (fsContent.equals(dbContent)) return;
if (writeDb(entity, fsContent)) {
log.info("Mirrored directory SKILL.md into skill_content for skill '{}' (explicit skillDir)",
entity.getName());
}
}
private Outcome ingest(SkillEntity entity, Path marker, String fsContent, String fsHash, Action action) {
if (!writeDb(entity, fsContent)) {
return new Outcome(fsContent, Action.FAILED);
}
writeMarkerQuietly(marker, fsHash);
log.info("Ingested workspace SKILL.md into skill_content for skill '{}' ({})",
entity.getName(), action);
return new Outcome(fsContent, action);
}
private Outcome materialize(SkillEntity entity, Path skillMd, Path marker,
String dbContent, String dbHash, Action action) {
try {
Files.createDirectories(skillMd.getParent());
Files.writeString(skillMd, dbContent, StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Failed to materialize SKILL.md for skill '{}' → {}: {}",
entity.getName(), skillMd, e.getMessage());
return new Outcome(dbContent, Action.FAILED);
}
writeMarkerQuietly(marker, dbHash);
log.info("Materialized skill_content to workspace SKILL.md for skill '{}' ({})",
entity.getName(), action);
return new Outcome(dbContent, action);
}
/**
* Column-whitelisted DB write. {@code SkillEntity} declares several
* {@code FieldStrategy.ALWAYS} columns, so a partial
* {@code updateById} would null them out the update wrapper touches
* only {@code skill_content} and {@code update_time}.
*/
private boolean writeDb(SkillEntity entity, String content) {
if (entity.getId() == null) return false;
try {
skillMapper.update(null, new LambdaUpdateWrapper<SkillEntity>()
.eq(SkillEntity::getId, entity.getId())
.set(SkillEntity::getSkillContent, content)
.set(SkillEntity::getUpdateTime, LocalDateTime.now()));
entity.setSkillContent(content);
return true;
} catch (Exception e) {
log.warn("Failed to write skill_content for skill '{}': {}", entity.getName(), e.getMessage());
return false;
}
}
private String readFileQuietly(Path file) {
if (!Files.exists(file)) return "";
try {
return Files.readString(file, StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Failed to read {}: {}", file, e.getMessage());
return "";
}
}
private String readMarker(Path marker) {
if (!Files.exists(marker)) return null;
try {
String value = Files.readString(marker, StandardCharsets.UTF_8).strip();
return value.isEmpty() ? null : value;
} catch (IOException e) {
log.warn("Failed to read sync marker {}: {}", marker, e.getMessage());
return null;
}
}
private void writeMarkerQuietly(Path marker, String hash) {
try {
Files.createDirectories(marker.getParent());
Files.writeString(marker, hash, StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Failed to write sync marker {}: {}", marker, e.getMessage());
}
}
private void backupQuietly(Path source, Path backup) {
try {
if (Files.exists(source)) {
Files.copy(source, backup, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
log.warn("Failed to back up {} → {}: {}", source, backup, e.getMessage());
}
}
static String sha256(String content) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(
md.digest((content == null ? "" : content).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
}
}
}

View File

@ -16,6 +16,9 @@ import vip.mate.tool.repository.ToolMapper;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@ -26,7 +29,7 @@ import java.util.concurrent.TimeUnit;
/**
* 技能依赖检查器
* 检查 commands / env / tools / platforms 依赖是否满足
* 检查 commands / env / tools / platforms / endpoints 依赖是否满足
*/
@Slf4j
@Service
@ -59,6 +62,23 @@ public class SkillDependencyChecker {
.maximumSize(256)
.build();
/** Timeout for a single endpoint reachability probe (TCP connect). */
private static final int ENDPOINT_PROBE_TIMEOUT_MS = 1500;
/**
* 60s cache for endpoint reachability probes, keyed by {@code host:port}.
* Same rhythm as {@link #commandAvailability}: every active-skills
* refresh re-evaluates each feature requirement, and without caching,
* N skills declaring the same service address would each open a socket
* (blocking up to the probe timeout) per pass. Network state is also
* the most volatile requirement class, so a short TTL keeps the
* "service unreachable" verdict from going stale after a VPN connect.
*/
private final Cache<String, Boolean> endpointReachability = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(60))
.maximumSize(256)
.build();
/**
* 检查依赖
*/
@ -195,6 +215,71 @@ public class SkillDependencyChecker {
}
}
// ==================== Endpoint reachability ====================
/** Parsed {@code host:port} target of an endpoint requirement. */
record EndpointTarget(String host, int port) {}
/**
* Parse an endpoint requirement's check target into {@code host:port}.
* Accepted forms:
* <ul>
* <li>{@code http(s)://host[:port][/path]} port defaults to the
* scheme's standard port when omitted</li>
* <li>{@code host:port}</li>
* <li>{@code host} port defaults to 80</li>
* </ul>
* Returns {@code null} when the target is blank or unparseable, which
* the caller maps to {@code UNKNOWN} a misdeclared manifest must not
* flip a skill to setup-needed.
*/
static EndpointTarget parseEndpointTarget(String target) {
if (target == null || target.isBlank()) return null;
String t = target.strip();
try {
if (t.contains("://")) {
URI uri = URI.create(t);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
int port = uri.getPort();
if (port <= 0) {
port = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
return new EndpointTarget(host, port);
}
int colon = t.lastIndexOf(':');
if (colon > 0 && colon < t.length() - 1) {
String maybePort = t.substring(colon + 1);
if (!maybePort.isEmpty() && maybePort.chars().allMatch(Character::isDigit)) {
return new EndpointTarget(t.substring(0, colon), Integer.parseInt(maybePort));
}
}
if (t.contains("/") || t.contains(":")) return null;
return new EndpointTarget(t, 80);
} catch (Exception e) {
return null;
}
}
private boolean isEndpointReachable(EndpointTarget target) {
String key = target.host() + ":" + target.port();
Boolean cached = endpointReachability.getIfPresent(key);
if (cached != null) return cached;
boolean reachable = probeEndpoint(target);
endpointReachability.put(key, reachable);
return reachable;
}
private boolean probeEndpoint(EndpointTarget target) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(target.host(), target.port()), ENDPOINT_PROBE_TIMEOUT_MS);
return true;
} catch (Exception e) {
log.debug("Endpoint probe failed for {}:{} — {}", target.host(), target.port(), e.getMessage());
return false;
}
}
private static boolean isWindows() {
return CURRENT_OS.equals("windows");
}
@ -221,7 +306,7 @@ public class SkillDependencyChecker {
* probe regardless of how the manifest expressed it. {@code ANY} is the
* fallback when the manifest doesn't declare a type we infer.
*/
public enum RequirementType { BINARY, ENV_VAR, API_KEY, ANY }
public enum RequirementType { BINARY, ENV_VAR, API_KEY, ENDPOINT, ANY }
/**
* Status for a single requirement after probing.
@ -263,6 +348,16 @@ public class SkillDependencyChecker {
String value = System.getenv(target);
yield (value != null && !value.isBlank()) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING;
}
case ENDPOINT -> {
// Reachability, not liveness: a TCP connect proves the
// current deployment can route to the service (intranet
// address + wrong network segment is the classic failure),
// without depending on the service answering a particular
// HTTP verb on its base path.
EndpointTarget ep = parseEndpointTarget(target);
if (ep == null) yield RequirementStatus.UNKNOWN;
yield isEndpointReachable(ep) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING;
}
case ANY -> RequirementStatus.UNKNOWN;
};
} catch (Exception e) {
@ -278,6 +373,7 @@ public class SkillDependencyChecker {
case "binary" -> RequirementType.BINARY;
case "env_var", "env" -> RequirementType.ENV_VAR;
case "api_key", "key" -> RequirementType.API_KEY;
case "endpoint", "url", "service" -> RequirementType.ENDPOINT;
default -> RequirementType.ANY;
};
}
@ -289,6 +385,11 @@ public class SkillDependencyChecker {
if (k.startsWith("env:")) return RequirementType.ENV_VAR;
if (k.endsWith("_api_key") || k.endsWith("_key")) return RequirementType.API_KEY;
}
// A check target that looks like a URL is an endpoint probe even
// without a declared type.
if (req.getCheck() != null && req.getCheck().contains("://")) {
return RequirementType.ENDPOINT;
}
return RequirementType.ANY;
}

View File

@ -59,6 +59,7 @@ public class SkillPackageResolver {
private final ObjectMapper objectMapper;
private final SkillWorkspaceManager workspaceManager;
private final SkillMapper skillMapper;
private final SkillContentReconciler contentReconciler;
/**
* RFC-090 §14.4 knowledge-skill wrapper tool factory.
* {@code @Lazy} because WikiSkillWrapperToolFactory pulls in Wiki
@ -113,6 +114,7 @@ public class SkillPackageResolver {
ObjectMapper objectMapper,
SkillWorkspaceManager workspaceManager,
SkillMapper skillMapper,
SkillContentReconciler contentReconciler,
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
@Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory,
@ -125,6 +127,7 @@ public class SkillPackageResolver {
this.objectMapper = objectMapper;
this.workspaceManager = workspaceManager;
this.skillMapper = skillMapper;
this.contentReconciler = contentReconciler;
this.wikiWrapperFactory = wikiWrapperFactory;
this.acpWrapperFactory = acpWrapperFactory;
this.scriptWrapperFactory = scriptWrapperFactory;
@ -146,7 +149,7 @@ public class SkillPackageResolver {
resolved = resolveFromDirectory(entity, skillDir, configuredDir, "directory");
} else {
// 2. 约定路径 {workspace-root}/{skillName}/
Path conventionPath = workspaceManager.resolveConventionPath(entity.getName());
Path conventionPath = workspaceManager.resolveConventionPath(entity.getName(), entity.getWorkspaceId());
if (Files.exists(conventionPath) && Files.isDirectory(conventionPath)) {
resolved = resolveFromDirectory(entity, conventionPath, conventionPath.toString(), "convention");
} else {
@ -175,6 +178,48 @@ public class SkillPackageResolver {
return resolved;
}
/**
* Content-store-only reconciliation for read paths (e.g. the admin
* detail view). Runs the same SKILL.md store sync a full resolve
* performs without the scan / dependency / manifest stages so a
* detail query always returns the content the runtime would execute,
* even when the workspace file was edited out-of-band (agent shell
* tools, manual edits) and no refresh has run yet.
*
* <p>Mutates the passed entity's {@code skillContent} when the file
* side wins.
*
* @return {@code true} when the DB side changed callers should then
* trigger a single-skill rescan so caches and manifest
* projections catch up.
*/
public boolean reconcileEntityContent(SkillEntity entity) {
if (entity == null || entity.getId() == null || entity.getName() == null) return false;
String configuredDir = extractSkillDirString(entity);
if (configuredDir != null) {
Path explicit = Paths.get(configuredDir);
if (Files.exists(explicit) && Files.isDirectory(explicit)) {
Path skillMd = explicit.resolve("SKILL.md");
if (!Files.exists(skillMd)) return false;
try {
String before = entity.getSkillContent();
contentReconciler.mirrorToDb(entity, Files.readString(skillMd));
return !Objects.equals(before, entity.getSkillContent());
} catch (Exception e) {
log.warn("Read-time mirror failed for skill '{}': {}", entity.getName(), e.getMessage());
return false;
}
}
}
Path convention = workspaceManager.resolveConventionPath(entity.getName(), entity.getWorkspaceId());
if (!Files.exists(convention) || !Files.isDirectory(convention)) return false;
SkillContentReconciler.Outcome outcome = contentReconciler.reconcile(entity, convention);
return outcome.action() == SkillContentReconciler.Action.INGESTED_TO_DB
|| outcome.action() == SkillContentReconciler.Action.BACKFILLED_TO_DB;
}
/**
* Write back the latest scan status / findings JSON / timestamp when
* they differ from what's already on the row. Keeps the DB in sync
@ -308,20 +353,35 @@ public class SkillPackageResolver {
// ==================== 阶段 1内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
Path skillMd = skillDir.resolve("SKILL.md");
String content = "";
String description = entity.getDescription();
if (Files.exists(skillMd)) {
try {
content = Files.readString(skillMd);
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
String content;
if ("convention".equals(source)) {
// Convention workspace: the DB column is canonical and the
// directory is a materialized cache. Reconcile both stores so
// runtime content and DB-reading consumers (admin console, API)
// can never diverge file edits made by agents/shell are
// ingested into the DB, DB edits are materialized to the file.
content = contentReconciler.reconcile(entity, skillDir).content();
} else {
// Explicit skillDir: the user-managed directory is the source
// of truth. Never write into it; mirror its content into the
// DB column so the console shows what actually executes.
Path skillMd = skillDir.resolve("SKILL.md");
content = "";
if (Files.exists(skillMd)) {
try {
content = Files.readString(skillMd);
contentReconciler.mirrorToDb(entity, content);
} catch (Exception e) {
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
} catch (Exception e) {
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
}
String description = entity.getDescription();
if (!content.isBlank()) {
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
}

View File

@ -202,6 +202,19 @@ public class SkillRuntimeService {
return refreshActiveSkills();
}
/**
* Workspace-scoped view of the active skills: builtin + global (virtual)
* skills plus only the skills owned by {@code workspaceId}. Reads the same
* process-global cache and filters at read time (no per-workspace cache),
* so an agent in one workspace never sees another workspace's same-named
* skill. Used by the agent runtime execution path.
*/
public List<ResolvedSkill> getActiveSkills(Long workspaceId) {
return getActiveSkills().stream()
.filter(s -> matchesWorkspace(s, workspaceId))
.collect(Collectors.toList());
}
/**
* 刷新 active skills 缓存
* 进入 active set skill 必须同时满足
@ -328,6 +341,20 @@ public class SkillRuntimeService {
.orElse(null);
}
/**
* Workspace-scoped {@link #findActiveSkill(String)}: resolves {@code name}
* only among skills visible to {@code workspaceId} (builtin + global +
* that workspace's own), so a same-named skill in another workspace is
* never returned to an agent's load / read / execute path.
*/
public ResolvedSkill findActiveSkill(String name, Long workspaceId) {
return getActiveSkills().stream()
.filter(s -> s.getName().equals(name))
.filter(s -> matchesWorkspace(s, workspaceId))
.findFirst()
.orElse(null);
}
/**
* 构建技能 prompt 增强片段全局向后兼容
*/
@ -739,9 +766,20 @@ public class SkillRuntimeService {
* visible only inside its owning workspace.
*/
static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) {
return matchesWorkspace(skill, Long.valueOf(agentWorkspaceId));
}
/**
* Nullable-workspace variant used by the execution-side overloads: a
* {@code null} execution workspace (unresolved) sees only builtin and
* global ({@code null}-workspace, e.g. MCP virtual) skills never another
* workspace's owned skills, so an unresolved context can never escalate
* into another tenant's skills.
*/
public static boolean matchesWorkspace(ResolvedSkill skill, Long workspaceId) {
if (skill.isBuiltin()) return true;
Long skillWs = skill.getWorkspaceId();
if (skillWs == null) return true;
return skillWs == agentWorkspaceId;
return workspaceId != null && skillWs.equals(workspaceId);
}
}

View File

@ -80,13 +80,16 @@ public class SkillFileService {
Map<String, String> incoming = newFiles == null ? Map.of() : newFiles;
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
boolean newHasRefs = bucketHasEntries(incoming, "references/");
boolean newHasTemplates = bucketHasEntries(incoming, "templates/");
List<SkillFileEntity> existing = listBySkillId(skillId);
boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/"));
boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/"));
boolean existingHasTemplates = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("templates/"));
boolean preserveScripts = !newHasScripts && existingHasScripts && !force;
boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force;
Map<String, SkillFileEntity> existingByPath = new HashMap<>();
for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e);
@ -106,6 +109,13 @@ public class SkillFileService {
}
}
}
if (preserveTemplates) {
for (SkillFileEntity e : existing) {
if (e.getFilePath() != null && e.getFilePath().startsWith("templates/")) {
keepPaths.add(e.getFilePath());
}
}
}
keepPaths.addAll(incoming.keySet());
int written = 0;
@ -152,6 +162,9 @@ public class SkillFileService {
if (preserveRefs) {
log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
}
if (preserveTemplates) {
log.warn("Refused to prune templates/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
}
return new ApplyResult(written, pruned, preserveScripts, preserveRefs);
}
@ -163,6 +176,61 @@ public class SkillFileService {
return mapper.deleteBySkillId(skillId);
}
/** One file row by exact path, or {@code null}. */
public SkillFileEntity getFile(Long skillId, String filePath) {
if (skillId == null || filePath == null || filePath.isBlank()) return null;
QueryWrapper<SkillFileEntity> q = new QueryWrapper<>();
q.eq("skill_id", skillId).eq("file_path", filePath);
return mapper.selectOne(q);
}
/**
* Create or update a single file row. Content hash and size are
* recomputed; a same-hash write is a no-op so idempotent saves don't
* churn {@code update_time}.
*
* @return the persisted row (existing row instance on no-op)
*/
@Transactional
public SkillFileEntity upsertFile(Long skillId, String filePath, String content) {
String safeContent = content == null ? "" : content;
String hash = sha256Hex(safeContent);
LocalDateTime now = LocalDateTime.now();
SkillFileEntity existing = getFile(skillId, filePath);
if (existing != null) {
if (hash.equals(existing.getSha256())) {
return existing;
}
existing.setContent(safeContent);
existing.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
existing.setSha256(hash);
existing.setUpdateTime(now);
mapper.updateById(existing);
return existing;
}
SkillFileEntity row = new SkillFileEntity();
row.setSkillId(skillId);
row.setFilePath(filePath);
row.setContent(safeContent);
row.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
row.setSha256(hash);
row.setCreateTime(now);
row.setUpdateTime(now);
mapper.insert(row);
return row;
}
/** Delete a single file row. Returns {@code true} when a row was removed. */
@Transactional
public boolean deleteFile(Long skillId, String filePath) {
SkillFileEntity existing = getFile(skillId, filePath);
if (existing == null) return false;
mapper.deleteById(existing.getId());
return true;
}
private boolean bucketHasEntries(Map<String, String> files, String prefix) {
for (String key : files.keySet()) {
if (key != null && key.startsWith(prefix)) return true;

View File

@ -274,7 +274,8 @@ public class SkillService {
}
/**
* 按名称查找技能RFC-023SkillManageTool 重名检查用
* 按名称查找技能全局跨所有工作区仅用于内置技能同步等全局语义场景
* 工作区相关的重名检查请用 {@link #findByName(String, Long)}
*/
public SkillEntity findByName(String name) {
return skillMapper.selectOne(new LambdaQueryWrapper<SkillEntity>()
@ -282,6 +283,17 @@ public class SkillService {
.last("LIMIT 1"));
}
/**
* 按名称在指定工作区内查找技能 builtin 全局可见用于工作区隔离的
* 重名/存在性检查避免跨工作区错误命中别的工作区的同名技能
*/
public SkillEntity findByName(String name, Long workspaceId) {
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, name);
applyWorkspaceScope(wrapper, workspaceId);
return skillMapper.selectOne(wrapper.last("LIMIT 1"));
}
/**
* 按类型获取技能列表
*/
@ -323,9 +335,12 @@ public class SkillService {
throw new MateClawException("err.skill.name_required", "技能名称不能为空");
}
// 检查名称唯一性
Long count = skillMapper.selectCount(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, skill.getName()));
// 名称唯一性按工作区隔离同名技能只要不在同一工作区且都不是 builtin即可共存
// builtin 名仍视为冲突builtin 全局可见
LambdaQueryWrapper<SkillEntity> dupCheck = new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, skill.getName());
applyWorkspaceScope(dupCheck, skill.getWorkspaceId());
Long count = skillMapper.selectCount(dupCheck);
if (count > 0) {
throw new MateClawException("err.skill.name_exists", "技能名称已存在: " + skill.getName());
}
@ -361,7 +376,7 @@ public class SkillService {
// 自动初始化工作区目录
if (workspaceProperties.isAutoInit() && !hasExplicitSkillDir(skill)) {
workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent());
workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent(), skill.getWorkspaceId());
}
// 刷新 runtime cache
@ -514,7 +529,7 @@ public class SkillService {
eventPublisher.publishEvent(new SkillRemovedEvent(id, skill.getName()));
if ("archive".equals(workspaceProperties.getDeletePolicy())) {
workspaceManager.archiveWorkspace(skill.getName());
workspaceManager.archiveWorkspace(skill.getName(), skill.getWorkspaceId());
}
// RFC-090 review #3 refresh won't deregister wrappers for a
// soft-deleted row (it only resolves rows still in
@ -562,7 +577,7 @@ public class SkillService {
log.warn("Failed to purge secrets for skill {}: {}", skill.getName(), e.getMessage());
}
workspaceManager.purgeWorkspace(skill.getName());
workspaceManager.purgeWorkspace(skill.getName(), skill.getWorkspaceId());
// RFC-090 review #3 same explicit deregister as uninstall.
if (runtimeService != null) {
@ -786,8 +801,8 @@ public class SkillService {
if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) {
return;
}
if (workspaceManager.conventionWorkspaceExists(skill.getName())) {
Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName());
if (workspaceManager.conventionWorkspaceExists(skill.getName(), skill.getWorkspaceId())) {
Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName(), skill.getWorkspaceId());
Path skillMd = workspaceDir.resolve("SKILL.md");
try {
Files.writeString(skillMd, skill.getSkillContent());

View File

@ -95,8 +95,9 @@ public class SkillSynthesisService {
}
name = name.strip().toLowerCase().replaceAll("[^a-z0-9._-]", "-");
// 去重
SkillEntity existing = skillService.findByName(name);
// 去重按工作区隔离只与本工作区 + builtin 的同名技能避让
// 不同工作区的同名技能不应互相影响命名
SkillEntity existing = skillService.findByName(name, workspaceId);
if (existing != null) {
name = name + "-" + (System.currentTimeMillis() % 10000);
}
@ -127,7 +128,7 @@ public class SkillSynthesisService {
skillService.createSkill(skill);
try {
workspaceManager.exportToWorkspace(name, skillMd);
workspaceManager.exportToWorkspace(name, skillMd, workspaceId);
} catch (Exception e) {
log.warn("[SkillSynthesis] Workspace export failed for '{}': {}", name, e.getMessage());
}

View File

@ -106,7 +106,7 @@ public class SkillTemplateService {
// classpath:{bundlePath}/** scripts, references, fonts, etc.
// Top-level SKILL.md in the bundle is skipped automatically so
// the rendered manifest from step 2 stays authoritative.
overlayBundle(template, created.getName());
overlayBundle(template, created.getName(), created.getWorkspaceId());
// 5. Persist any `secret` field values into mate_skill_secret so
// the runtime can decrypt + inject them as env vars at exec
@ -138,13 +138,13 @@ public class SkillTemplateService {
}
}
private void overlayBundle(SkillTemplate template, String skillName) {
private void overlayBundle(SkillTemplate template, String skillName, Long workspaceId) {
String bundlePath = template.getBundlePath();
if (bundlePath == null || bundlePath.isBlank()) {
return;
}
SkillBundleSource source = new ClasspathBundleSource(resourceResolver, bundlePath);
Path workspaceDir = workspaceManager.resolveConventionPath(skillName);
Path workspaceDir = workspaceManager.resolveConventionPath(skillName, workspaceId);
try {
SkillBundleMaterializer.Result result = bundleMaterializer.materialize(
source, workspaceDir, MaterializeOptions.templateOverlay());

View File

@ -7,8 +7,12 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.bundle.ClasspathBundleSource;
import vip.mate.skill.workspace.bundle.MaterializeOptions;
import vip.mate.skill.workspace.bundle.SkillBundleFiles;
import vip.mate.skill.workspace.bundle.SkillBundleMaterializer;
import vip.mate.skill.workspace.bundle.SkillBundleSource;
@ -19,6 +23,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -36,9 +41,19 @@ import java.util.regex.Pattern;
* <li>Re-install: only when the classpath SKILL.md frontmatter
* {@code version} is strictly newer than the workspace copy. The
* existing workspace is archived (not deleted) before overwrite.</li>
* <li>Same / older / unparseable version: leave the workspace alone so
* user edits aren't clobbered.</li>
* <li>Self-heal: when the bundle ships {@code scripts/} or
* {@code references/} but the workspace lacks that directory entirely
* (an install performed from a build whose jar was missing those
* folders), the workspace is archived and re-copied even though the
* version is unchanged.</li>
* <li>Same / older / unparseable version with all buckets present: leave
* the workspace alone so user edits aren't clobbered.</li>
* </ul>
*
* <p>Whenever a bundle is copied to disk (install, upgrade, or self-heal),
* its {@code scripts/} and {@code references/} files are also persisted to
* the canonical {@code mate_skill_file} store so multi-instance deployments
* see the same content regardless of which node performed the sync.
*/
@Slf4j
@Component
@ -48,10 +63,15 @@ public class BundledSkillSyncer {
private static final Pattern VERSION_PATTERN =
Pattern.compile("^version:\\s*[\"']?([^\"'\\s]+)[\"']?", Pattern.MULTILINE);
/** Builtin/bundled skills are global and materialized under the default workspace. */
private static final Long BUILTIN_WORKSPACE_ID = 1L;
private final SkillWorkspaceProperties properties;
private final SkillWorkspaceManager workspaceManager;
private final SkillBundleMaterializer bundleMaterializer;
private final ApplicationEventPublisher eventPublisher;
private final SkillService skillService;
private final SkillFileService skillFileService;
/**
* Run a full sync pass. Idempotent safe to call from both startup
@ -87,42 +107,93 @@ public class BundledSkillSyncer {
/**
* Sync a single bundled skill. Returns true if the workspace was
* created or upgraded. Same/older versions are no-ops.
* created, upgraded, or self-healed. Same/older versions with all
* bundle buckets present on disk are no-ops.
*/
private boolean syncOne(ResourcePatternResolver resolver, String bundledPath,
String skillName, Resource manifest) {
Path targetDir = workspaceManager.resolveConventionPath(skillName);
// Builtin/bundled skills are global and seeded into workspace 1.
Path targetDir = workspaceManager.resolveConventionPath(skillName, BUILTIN_WORKSPACE_ID);
boolean firstInstall = !Files.exists(targetDir);
SkillBundleSource source = new ClasspathBundleSource(resolver,
bundledPath + "/" + skillName);
if (!firstInstall) {
String bundledVersion = readVersion(manifest);
String workspaceVersion = readVersion(targetDir.resolve("SKILL.md"));
if (bundledVersion == null || !isNewerVersion(bundledVersion, workspaceVersion)) {
log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping",
boolean upgrade = bundledVersion != null && isNewerVersion(bundledVersion, workspaceVersion);
if (upgrade) {
log.info("Bundled skill '{}' version {} > workspace version {}, upgrading",
skillName, bundledVersion, workspaceVersion);
return false;
} else {
List<String> missing = missingBucketsOnDisk(source, targetDir);
if (missing.isEmpty()) {
log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping",
skillName, bundledVersion, workspaceVersion);
return false;
}
log.info("Bundled skill '{}' is missing {} on disk, restoring from bundle",
skillName, missing);
}
log.info("Bundled skill '{}' version {} > workspace version {}, upgrading",
skillName, bundledVersion, workspaceVersion);
workspaceManager.archiveWorkspace(skillName);
// Archive (never overwrite in place) so local edits stay recoverable.
workspaceManager.archiveWorkspace(skillName, BUILTIN_WORKSPACE_ID);
}
copyBundle(resolver, bundledPath, skillName, targetDir);
copyBundle(source, targetDir);
syncBundleFilesToDb(skillName, source);
eventPublisher.publishEvent(
new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, targetDir));
log.info("{} bundled skill '{}' → {}", firstInstall ? "Synced" : "Upgraded", skillName, targetDir);
return true;
}
private void copyBundle(ResourcePatternResolver resolver, String bundledPath,
String skillName, Path targetDir) {
SkillBundleSource source = new ClasspathBundleSource(resolver,
bundledPath + "/" + skillName);
/**
* Buckets ({@code scripts/}, {@code references/}) that ship in the
* bundle but are absent from the workspace directory the fingerprint
* of an install performed from a build whose jar lacked those folders.
*/
private List<String> missingBucketsOnDisk(SkillBundleSource source, Path targetDir) {
List<String> missing = new ArrayList<>();
try {
List<SkillBundleSource.BundleAsset> assets = source.assets();
for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) {
boolean inBundle = assets.stream().anyMatch(a -> a.relativePath().startsWith(prefix));
String dirName = prefix.substring(0, prefix.length() - 1);
if (inBundle && !Files.isDirectory(targetDir.resolve(dirName))) {
missing.add(dirName);
}
}
} catch (IOException e) {
log.warn("Failed to enumerate bundle assets from {}: {}", source.origin(), e.getMessage());
}
return missing;
}
/**
* Mirror the bundle's DB-persisted buckets into {@code mate_skill_file}.
* Skipped silently when the skill row doesn't exist yet (first boot
* before seeding) the skill file syncer's disk backfill covers that
* case once the row appears.
*/
private void syncBundleFilesToDb(String skillName, SkillBundleSource source) {
SkillEntity skill = skillService.findByName(skillName);
if (skill == null || skill.getId() == null) return;
try {
Map<String, String> bundleFiles = SkillBundleFiles.readDbEligible(source);
if (bundleFiles.isEmpty()) return;
skillFileService.applyBundleFiles(skill.getId(), bundleFiles, false);
} catch (IOException e) {
log.warn("Failed to load bundle files from {}: {}", source.origin(), e.getMessage());
}
}
private void copyBundle(SkillBundleSource source, Path targetDir) {
try {
bundleMaterializer.materialize(source, targetDir, MaterializeOptions.verbatim());
} catch (IOException e) {
log.warn("Failed to copy bundled skill '{}' from {}: {}",
skillName, source.origin(), e.getMessage());
log.warn("Failed to copy bundled skill from {}: {}",
source.origin(), e.getMessage());
}
}

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