release: v2.0.0
19
.gitattributes
vendored
Normal 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
@ -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
|
||||
|
||||
18
README.md
@ -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
|
||||
|
||||
18
README_zh.md
@ -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 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
||||
| 数据库 | H2(开发)· MySQL 8.0+(生产)|
|
||||
@ -220,6 +223,19 @@ mateclaw/
|
||||
|
||||
## 路线图
|
||||
|
||||
**v2.0.0(2026-07-26 发布)** — 从"一个能干活的人"到"一支能协作的队伍":**Agent 团队**成为常设编制,围着一块共享任务板干活:
|
||||
|
||||
- **Agent 团队与共享任务板** — 团队 / 角色(lead · member · reviewer)、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead;Teams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
|
||||
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`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.0(2026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景:
|
||||
|
||||
- **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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.' })
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
74
mateclaw-plugin-mem0/pom.xml
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
48
mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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(); }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 全部 401,walker 末尾会把最后一次 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; }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -99,9 +99,12 @@ public class ToolExecutionExecutor {
|
||||
* │ when size > 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 中提取 pendingId(best-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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction {
|
||||
Prompt prompt = new Prompt(List.of(
|
||||
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
|
||||
+ "直接回答用户的原始问题,不要罗列步骤。"
|
||||
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
|
||||
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"
|
||||
+ "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。"
|
||||
+ "若某些步骤未完成,如实说明未完成的部分及原因。"),
|
||||
new UserMessage(userContent.toString())
|
||||
));
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
// ==================== 过滤方法 ====================
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
})
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}).
|
||||
|
||||
@ -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 保留兼容旧 UI,segments 是按事件顺序的完整时间线。
|
||||
*/
|
||||
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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
// 使用虚拟线程池处理 SSE(Java 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 保留兼容旧 UI,segments 是按事件顺序的完整时间线。
|
||||
*/
|
||||
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) {
|
||||
|
||||
@ -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 min(1800s 空闲超时):只要 agent 还在持续产事件
|
||||
* (tool call / content delta / phase transition / progress_update),
|
||||
* 就一直活下去,墙钟跑 1 小时 2 小时都可以。只有真正"完全静默 ≥ N 分钟"
|
||||
* 才视为卡死并强制清理。
|
||||
|
||||
@ -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 + " | ||||