mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
release: merge dev into main
This commit is contained in:
parent
4cd7ccaead
commit
35bfdfcffd
3
.gitignore
vendored
3
.gitignore
vendored
@ -78,6 +78,9 @@ pom.xml.versionsBackup
|
||||
# mateclaw static build output (do not commit)
|
||||
mateclaw-server/src/main/resources/static/
|
||||
|
||||
# Maven must not materialize an unresolved property as a literal directory.
|
||||
**/${project.build.directory}/
|
||||
|
||||
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
||||
mateclaw-server/data/
|
||||
/data/
|
||||
|
||||
53
README.md
53
README.md
@ -30,15 +30,19 @@
|
||||
|
||||
---
|
||||
|
||||
> **Latest stable: v2.1.0 — Team Runs, closed skill evolution, and replayable reasoning.** One team request is now one durable `runId` across Chat, Agents, and Teams; skills can mine recurring requests under explicit controls and restore from snapshots; reasoning, tool calls, and observations can be exported in execution order. Read the [v2.1.0 release notes](https://claw.mate.vip/docs/en/releases/2.1.0).
|
||||
|
||||
---
|
||||
|
||||
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
|
||||
>
|
||||
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
|
||||
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
|
||||
>
|
||||
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
|
||||
|
||||
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
||||
|
||||
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
|
||||
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model is unavailable, the next healthy provider retries the current request.
|
||||
|
||||
---
|
||||
|
||||
@ -48,7 +52,7 @@ Most AI tools die when their vendor has a bad day. Most forget you the moment th
|
||||
|
||||
Primary key expired. Vendor returns 401. Network blip. Quota drained.
|
||||
|
||||
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
||||
Other tools hand you a red error card. MateClaw tries the next healthy provider in configured order — including built-in and OpenAI-compatible options such as DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, and MLX — and attempts to recover the current request. It returns an error only when the available chain is exhausted. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
||||
|
||||
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
|
||||
|
||||
@ -56,7 +60,7 @@ You don't write a retry script. You drag providers into priority order in **Sett
|
||||
|
||||
Upload a PDF, a batch of markdown, a scraped page — raw material in.
|
||||
|
||||
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
|
||||
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and preserves traceable citations for generated content. Open the citation drawer to inspect the corresponding source chunk and verify page or answer references.
|
||||
|
||||
This is the difference between a warehouse and a library.
|
||||
|
||||
@ -79,10 +83,10 @@ Same brain. Same memory. Same tools. Different doors.
|
||||
## What's in the box
|
||||
|
||||
### Digital employees, not chatbots
|
||||
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
|
||||
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 — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). **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.
|
||||
### Team Runs (2.1.0+)
|
||||
One request, one durable **Team Run**. A stable `runId` links the user's objective, task DAG, worker executions, final synthesis, and deliverables. Chat is the outcome surface, Agents Live groups the workers for real-time observation, and Teams owns history and governance — all three consume the same server projection. Worker conversations no longer flood the normal sidebar; summaries and files lead, while tasks, evidence, approvals, and read-only worker records drill down on demand. Underneath, the 2.0 shared board still provides dependency orchestration, parallel dispatch, prerequisite hand-off, execution leases, cancel-interrupt, and human approval gates.
|
||||
|
||||
### 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
|
||||
@ -90,7 +94,7 @@ One lead, a crew of employees, one **shared task board**. Tell the lead a goal a
|
||||
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
|
||||
|
||||
### Skills · MCP · ACP — three ways to extend capability
|
||||
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
|
||||
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md**. In 2.1, reflection and cross-session recurring-request mining can produce reusable improvements; routine promotion, constrained auto-binding, curator handover/governance, origin policy, snapshots, and restore points keep evolution observable, workspace-scoped, and reversible. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** before install
|
||||
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
|
||||
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
|
||||
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
|
||||
@ -101,7 +105,7 @@ One lead, a crew of employees, one **shared task board**. Tell the lead a goal a
|
||||
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
|
||||
|
||||
### You see what every employee is doing
|
||||
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
|
||||
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), each reasoning iteration keeps its real position and wall-clock duration, and linear trajectory export lays out reasoning, calls, observations, and answers for review. Per-event SSE IDs make reconnects safe; Team Runs group member work under one live execution.
|
||||
|
||||
### Multimodal creation
|
||||
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
|
||||
@ -110,15 +114,15 @@ Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class,
|
||||
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
|
||||
|
||||
### Enterprise-ready
|
||||
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
|
||||
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema. One JAR to ship. H2 for development; the public Docker stack defaults to PostgreSQL 16, the MySQL profile remains supported, and the Kingbase driver is opt-in.
|
||||
|
||||
---
|
||||
|
||||
## AI is becoming infrastructure
|
||||
|
||||
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
|
||||
Model providers rate-limit, networks fail, keys expire, and services become temporarily unavailable. Betting every AI capability on one provider turns an upstream incident into your own outage.
|
||||
|
||||
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
|
||||
Once AI enters production, the stable layer should not be tied to one supplier. MateClaw absorbs that uncertainty into one runtime through provider priorities, health tracking, cooldown, and failover.
|
||||
|
||||
**MateClaw is that layer — built the Spring Boot way.**
|
||||
|
||||
@ -138,7 +142,7 @@ This is the same shift databases went through around 2010 and cloud went through
|
||||
|
||||
**OpenClaw and Hermes Agent are excellent personal AI platforms** — pick either if you're running one user on one laptop, building your own agent from CLI, and treating everything as config files to hand-tune. Both have bigger communities than MateClaw today.
|
||||
|
||||
**MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
|
||||
**MateClaw is the version built for teams.** Digital employees, models, and tools sit behind permissions and workspace boundaries. Approval flows can pause risky actions for review, and key operations enter the audit trail. The Admin Runtime Console centralizes active employee and provider state with force-recycle for stuck runs. Spring Boot inside — a natural fit for Java shops already running production services.
|
||||
|
||||
Same "whole widget" philosophy. Different center of gravity.
|
||||
|
||||
@ -153,7 +157,7 @@ mvn spring-boot:run # http://localhost:18088
|
||||
|
||||
# Frontend
|
||||
cd mateclaw-ui
|
||||
pnpm install && pnpm dev # http://localhost:5173
|
||||
npm install && npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Login: `admin` / `admin123`
|
||||
@ -192,9 +196,12 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
|
||||
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
||||
├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized)
|
||||
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
||||
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
||||
├── mateclaw-plugin-sample/ Reference plugin implementation
|
||||
├── mateclaw-plugin-mem0/ Optional Mem0 memory-provider plugin
|
||||
├── mateclaw-plugin-search-sample/ Search Provider SPI example
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
@ -206,10 +213,10 @@ 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 · Team task board (2.0.0+) |
|
||||
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · closed skill evolution · Team Run + shared task board (2.1.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) |
|
||||
| Database | H2 (dev) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
|
||||
| Auth | Spring Security + JWT |
|
||||
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
||||
@ -223,7 +230,17 @@ 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:
|
||||
**v2.1.0 (shipped 2026-08-15)** — from “a board full of tasks” to **one governable team run**:
|
||||
|
||||
- **Unified Team Runs** — one `runId` links request, task DAG, worker conversations, events, final synthesis, and deliverables; Chat delivers outcomes, Agents observes live work, Teams governs history
|
||||
- **Closed skill evolution** — reflection + recurring-request mining + promotion + constrained auto-binding + curator governance + snapshots/restore, conservative by default and isolated per workspace
|
||||
- **Replayable execution** — live `<think>` extraction, every reasoning iteration in emission order with real duration, superseded narration, and linear trajectory export
|
||||
- **Capabilities reach operations** — proactive IM push, targeted Cron delivery, model-specific context windows, progressive tool disclosure, and tool-backed action completion
|
||||
- **Reliability pass** — hardened browser refs/navigation/waits, WebChat/SSE cleanup and upstream idle timeout, Feishu progress, Qwen3-ASR HTTP, batch session deletion, date-partitioned files, and safe 64-bit ids
|
||||
|
||||
Full story in the [v2.1.0 release notes](https://claw.mate.vip/docs/en/releases/2.1.0).
|
||||
|
||||
**v2.0.0 (shipped 2026-07-31)** — 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
|
||||
@ -274,7 +291,7 @@ Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/
|
||||
git clone https://github.com/mateaix/mateclaw.git
|
||||
cd mateclaw
|
||||
cd mateclaw-server && mvn clean compile
|
||||
cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||
cd ../mateclaw-ui && npm install && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
55
README_zh.md
55
README_zh.md
@ -30,15 +30,19 @@
|
||||
|
||||
---
|
||||
|
||||
> **最新稳定版:v2.1.0 —— Team Run、Skill 自进化闭环与可回放执行。** 一次团队请求现在以一个持久化 `runId` 贯穿 Chat、Agents 与 Teams;技能可在显式开关和工作空间隔离下发现重复请求、晋升并从快照恢复;推理、工具调用、观察与回答可按执行顺序导出。详见 [v2.1.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.1.0)。
|
||||
|
||||
---
|
||||
|
||||
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
||||
>
|
||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
|
||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
|
||||
>
|
||||
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
|
||||
|
||||
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||
|
||||
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完。
|
||||
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型不可用时,系统会按优先级改由下一家健康供应商重新完成当前请求。
|
||||
|
||||
---
|
||||
|
||||
@ -48,7 +52,7 @@
|
||||
|
||||
Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
||||
|
||||
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX,共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
|
||||
别的工具丢你一张红色错误卡。MateClaw 会按配置顺序尝试下一家健康供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等内置或 OpenAI 兼容供应商——尽可能恢复当前请求;仅当可用链路全部失败时才返回错误。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
|
||||
|
||||
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
|
||||
|
||||
@ -56,7 +60,7 @@ Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
||||
|
||||
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
|
||||
|
||||
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题,得到的页面是从对应片段拼出来的——带可核对的出处。
|
||||
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,生成内容保留可追踪引用。点开引用抽屉,就能看到对应的原始 chunk;页面与回答中的引用可以回到来源核对。
|
||||
|
||||
这是**仓库**和**图书馆**的区别。
|
||||
|
||||
@ -79,10 +83,10 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
||||
## 盒子里有什么
|
||||
|
||||
### 数字员工,不是聊天机器人
|
||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
|
||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些“不起眼”的基础设施。
|
||||
|
||||
### 团队协作(2.0.0+)
|
||||
一个 Lead 带一群员工,围着一块**共享任务板**干活。你对 Lead 说一句目标,它拆成任务上板(`blockedBy` 声明依赖);派发引擎把任务并行分给成员,前置结果自动传给下游,完成结果自动通报回 Lead 汇总。执行租约 + 心跳杜绝双重执行,**取消即中断**正在跑的成员会话,敏感任务停在 `in_review` 等人批。交付物(docx / pptx / xlsx / pdf)登记到任务可下载,任务时间线记录全程,还能跳进任意成员的子会话看它逐字执行。Plan-Execute 型 Lead 的计划**整体移交任务板**——会规划的 Lead,规划能力就是编排能力。
|
||||
### Team Run(2.1.0+)
|
||||
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面,Agents Live 按运行聚合成员并展示实时状态,Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
|
||||
|
||||
### 知识与记忆
|
||||
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**(1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
|
||||
@ -90,7 +94,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
||||
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
|
||||
|
||||
### 技能 · MCP · ACP — 三种"接外部能力"的方式
|
||||
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
|
||||
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md**。2.1 可通过对话反思与跨会话重复请求挖掘形成可复用改进,并以候选晋升、受约束自动绑定、curator 治理、来源策略、快照和恢复点保证过程可观察、按工作空间隔离且可回滚;所有自动能力均由独立开关控制。另有 8 个起步模板、5 步创作向导和安装前 **Pre-flight 检查**
|
||||
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**(1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
|
||||
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
|
||||
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
|
||||
@ -101,24 +105,24 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
||||
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合,reverse-citation 绑定到源 chunk,JSON 输出 + 可选 JSON Schema,每个模板独立选模型
|
||||
|
||||
### 你看得见每位员工正在干什么
|
||||
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示(思考 / 工具 / 回答),SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
|
||||
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式阶段如实区分思考 / 工具 / 回答;每轮推理保留真实发生顺序,界面显示实际耗时,线性 trajectory 导出则按顺序展开推理、调用、观察与回答。SSE 每事件 ID 支持安全重连,Team Run 将成员工作聚合到同一次运行下。
|
||||
|
||||
### 多模态创作
|
||||
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
||||
|
||||
### 内容工作室(1.8.0+)
|
||||
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)** 文章以内联样式 HTML 躺进你的草稿箱,正文图自动上传进微信;**小红书** 笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化对着一个**可度量的 AI 痕迹评分**跑;每次交付都被合规扫描并记进一个按选题指纹去重的**内容日历**。
|
||||
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)**文章以内联样式 HTML 进入草稿箱,正文图自动上传到微信;**小红书**笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化围绕一个**可度量的 AI 痕迹评分**运行;每次交付都经过合规扫描,并记入按选题指纹去重的**内容日历**。
|
||||
|
||||
### 企业就绪
|
||||
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema,升级时自愈。一个 JAR 交付。生产用 MySQL,开发用 H2,代码零改动。
|
||||
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**避免多实例重复执行。完整审计事件流。Flyway 管理数据库 schema。一个 JAR 交付。开发环境可用 H2;公开 Docker 栈默认使用 PostgreSQL 16,同时保留 MySQL profile,Kingbase 驱动为按需启用。
|
||||
|
||||
---
|
||||
|
||||
## AI 正在变成基础设施
|
||||
|
||||
2026 年 3 月 2 日,Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次,**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
|
||||
模型供应商会限流,网络会抖动,Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障。
|
||||
|
||||
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
|
||||
当 AI 进入生产环境,稳定的一层不应绑定在一家供应商身上。MateClaw 通过供应商优先级、健康追踪、冷却与故障转移,把这种不确定性收进统一运行时。
|
||||
|
||||
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
||||
|
||||
@ -138,7 +142,7 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webho
|
||||
|
||||
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优,选它们没问题。两家的社区规模今天都大于 MateClaw。
|
||||
|
||||
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
|
||||
**MateClaw 是那个给团队用的版本。** 数字员工、模型与工具都纳入权限和工作空间边界。危险动作可暂停等待审批,关键操作进入审计事件流。Admin 运行时控制台集中展示正在执行的员工与供应商状态,卡住时可回收。底座是 Spring Boot,适合并入已有 Java 服务体系。
|
||||
|
||||
**同一套"完整一整套"哲学,不同的重心。**
|
||||
|
||||
@ -153,7 +157,7 @@ mvn spring-boot:run # http://localhost:18088
|
||||
|
||||
# 前端
|
||||
cd mateclaw-ui
|
||||
pnpm install && pnpm dev # http://localhost:5173
|
||||
npm install && npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
默认登录:`admin` / `admin123`
|
||||
@ -192,9 +196,12 @@ docker compose up -d # http://localhost:18080
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时)
|
||||
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
||||
├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
|
||||
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
||||
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
||||
├── mateclaw-plugin-sample/ 参考插件实现
|
||||
├── mateclaw-plugin-mem0/ 可选 Mem0 记忆 Provider 插件
|
||||
├── mateclaw-plugin-search-sample/ 搜索 Provider SPI 示例
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
@ -206,10 +213,10 @@ mateclaw/
|
||||
| 层次 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 · 团队任务板(2.0.0+)|
|
||||
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · Skill 自进化闭环 · Team Run + 共享任务板(2.1.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+(生产)|
|
||||
| 数据库 | H2(开发)· PostgreSQL 16(Docker 默认)· MySQL 8.0+(支持)· Kingbase(按需驱动)|
|
||||
| 认证 | Spring Security + JWT |
|
||||
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
||||
@ -223,7 +230,17 @@ mateclaw/
|
||||
|
||||
## 路线图
|
||||
|
||||
**v2.0.0(2026-07-26 发布)** — 从"一个能干活的人"到"一支能协作的队伍":**Agent 团队**成为常设编制,围着一块共享任务板干活:
|
||||
**v2.1.0(2026-08-15 发布)** —— 从“一块摆满任务的看板”到**一次可治理的团队运行**:
|
||||
|
||||
- **统一 Team Run** —— 一个 `runId` 串起请求、任务 DAG、成员会话、事件、最终汇总与交付物;Chat 交付成果,Agents 观察实时执行,Teams 管理历史与治理
|
||||
- **Skill 自进化闭环** —— 对话反思、重复请求挖掘、候选晋升、受约束自动绑定、curator 治理、快照与恢复;默认保守、显式控制并按工作空间隔离
|
||||
- **可回放执行** —— 实时提取内联 `<think>`,每轮推理按发生顺序展示实际耗时,保留被后续工具调用替代的阶段旁白,并可导出线性 trajectory
|
||||
- **能力进入日常运营** —— 主动 IM 推送、Cron 定向投递、模型级上下文窗口、渐进式工具披露,以及基于实际工具调用结果的行动完成检查
|
||||
- **可靠性加固** —— 浏览器 ref / 导航 / 等待、WebChat 与 SSE 清理及上游空闲超时、飞书进度、Qwen3-ASR HTTP、会话批量删除、文件按日分区和 64 位 ID 精度保护
|
||||
|
||||
完整内容见 [v2.1.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.1.0)。
|
||||
|
||||
**v2.0.0(2026-07-31 发布)** —— 从“一个能干活的人”到“一支能协作的队伍”:**Agent 团队**成为常设编制,围绕共享任务板工作:
|
||||
|
||||
- **Agent 团队与共享任务板** — 团队 / 角色(lead · member · reviewer)、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead;Teams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
|
||||
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
|
||||
@ -274,7 +291,7 @@ mateclaw/
|
||||
git clone https://github.com/mateaix/mateclaw.git
|
||||
cd mateclaw
|
||||
cd mateclaw-server && mvn clean compile
|
||||
cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||
cd ../mateclaw-ui && npm install && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mateclaw-desktop",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
|
||||
"author": "MateClaw Team",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@ -78,7 +78,7 @@ RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
||||
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
||||
# tag with the matching driver, so mismatched versions cause the java driver to
|
||||
# re-download browsers at runtime (defeating the whole point of this image).
|
||||
FROM mcr.microsoft.com/playwright:v1.59.0-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
WORKDIR /app
|
||||
|
||||
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
||||
|
||||
@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.graph.StateGraphReActAgent;
|
||||
@ -31,9 +32,13 @@ import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import vip.mate.config.GraphObservationProperties;
|
||||
import vip.mate.config.ReasoningRetentionProperties;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.chatmodel.HttpTimeouts;
|
||||
import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder;
|
||||
import vip.mate.llm.chatmodel.ProviderGenerateKwargs;
|
||||
import vip.mate.llm.chatmodel.ReasoningEffortResolver;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
@ -169,11 +174,24 @@ public class AgentGraphBuilder {
|
||||
*/
|
||||
private vip.mate.audit.service.AuditEventService auditEventService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
@Autowired(required = false)
|
||||
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
|
||||
this.auditEventService = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasoning retention policy for ReAct turns. Setter injection so the
|
||||
* {@code @RequiredArgsConstructor} signature stays stable for the unit
|
||||
* constructions across the test suite; null in those, where the agent's own
|
||||
* default (keep every iteration) applies.
|
||||
*/
|
||||
private ReasoningRetentionProperties reasoningRetentionProperties;
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setReasoningRetentionProperties(ReasoningRetentionProperties p) {
|
||||
this.reasoningRetentionProperties = p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional per-step delegation dependencies for the Plan-Execute graph.
|
||||
* Setter injection (like {@link #auditEventService}) breaks the
|
||||
@ -386,7 +404,7 @@ public class AgentGraphBuilder {
|
||||
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
|
||||
builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider);
|
||||
} else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider)
|
||||
&& Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) {
|
||||
&& Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(providerKwargs, "enableSearch"))) {
|
||||
builtinSearchEnabled = true;
|
||||
}
|
||||
if (builtinSearchEnabled) {
|
||||
@ -438,24 +456,19 @@ public class AgentGraphBuilder {
|
||||
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
||||
entity, boundTools, effectiveMaxInputTokens);
|
||||
|
||||
// Extension-tool catalog — only for ReAct. The dynamic tool split runs
|
||||
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
|
||||
// action node to record enable_tool), so baking the catalog there would
|
||||
// describe an enable_tool flow that can never take effect.
|
||||
// Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
|
||||
// would remove it with no enable_tool path to recover it.
|
||||
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
|
||||
// Progressive tool catalog is shared by both agent types. ReAct applies
|
||||
// the split in ReasoningNode; Plan-Execute receives a separate advertised
|
||||
// set for StepExecutionNode while its executor retains the full scoped
|
||||
// set, so tool_call can recover a deferred tool in the same action round.
|
||||
Set<String> autoDemotedTools = Set.of();
|
||||
if (!isPlanExecute) {
|
||||
if (prefixBudgetPlan.enabled()) {
|
||||
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
||||
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
||||
}
|
||||
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
||||
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
||||
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
||||
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
||||
}
|
||||
if (prefixBudgetPlan.enabled()) {
|
||||
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
||||
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
||||
}
|
||||
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
||||
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
||||
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
||||
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
||||
}
|
||||
|
||||
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
||||
@ -467,7 +480,8 @@ public class AgentGraphBuilder {
|
||||
BaseAgent agent;
|
||||
boolean toolCallingEnabled;
|
||||
if ("plan_execute".equals(entity.getAgentType())) {
|
||||
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
|
||||
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(),
|
||||
skillCatalogRenderer, autoDemotedTools);
|
||||
toolCallingEnabled = true;
|
||||
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
||||
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
||||
@ -571,8 +585,12 @@ public class AgentGraphBuilder {
|
||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
||||
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
|
||||
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||||
StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||||
chatModel, conversationWindowManager, toolSet);
|
||||
if (reasoningRetentionProperties != null) {
|
||||
agent.setPersistEveryIterationReasoning(reasoningRetentionProperties.persistsEveryIteration());
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
|
||||
@ -587,11 +605,19 @@ public class AgentGraphBuilder {
|
||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
||||
int maxIter, Long agentId,
|
||||
SkillCatalogRenderer skillCatalogRenderer) {
|
||||
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, agentId,
|
||||
skillCatalogRenderer, Set.of());
|
||||
}
|
||||
|
||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
||||
int maxIter, Long agentId,
|
||||
SkillCatalogRenderer skillCatalogRenderer,
|
||||
Set<String> autoDemotedTools) {
|
||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||
ChatClient chatClient = ChatClient.create(chatModel);
|
||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
||||
runtimeModel, agentId, skillCatalogRenderer);
|
||||
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
|
||||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||||
chatModel, conversationWindowManager, toolSet);
|
||||
}
|
||||
@ -615,6 +641,14 @@ public class AgentGraphBuilder {
|
||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
|
||||
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
|
||||
primaryModelConfig, agentId, skillCatalogRenderer, Set.of());
|
||||
}
|
||||
|
||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
|
||||
Set<String> autoDemotedTools) {
|
||||
try {
|
||||
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
|
||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
|
||||
@ -629,6 +663,8 @@ public class AgentGraphBuilder {
|
||||
primaryModelConfig.getProvider(),
|
||||
primaryModelConfig.getModelName(), errorMessage));
|
||||
}
|
||||
streamingHelper.setStreamIdleTimeoutSec(
|
||||
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
@ -648,7 +684,13 @@ public class AgentGraphBuilder {
|
||||
// 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);
|
||||
List<ToolCallback> advertisedCallbacks = toolDisclosureService
|
||||
.split(toolSet, Set.of(), autoDemotedTools).activeCallbacks();
|
||||
AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks(
|
||||
toolSet.toolBeans(), advertisedCallbacks);
|
||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, advertisedToolSet,
|
||||
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).
|
||||
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
||||
@ -683,6 +725,7 @@ public class AgentGraphBuilder {
|
||||
// Thinking 键
|
||||
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
||||
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
||||
.addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE)
|
||||
// 流式防重键
|
||||
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
||||
@ -729,6 +772,10 @@ public class AgentGraphBuilder {
|
||||
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
|
||||
// StateKeyRegistrationCoverageTest 专门兜这条。
|
||||
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
|
||||
// Multimodal sidecar routing decision for the current turn.
|
||||
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
|
||||
// RFC 48 — persistent goal state keys must be registered in
|
||||
@ -891,6 +938,13 @@ public class AgentGraphBuilder {
|
||||
return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
|
||||
}
|
||||
|
||||
static long resolveStreamIdleTimeoutSeconds(ModelConfigEntity modelConfig) {
|
||||
Integer override = modelConfig != null
|
||||
? modelConfig.getRequestTimeoutSeconds()
|
||||
: null;
|
||||
return HttpTimeouts.resolveStreamIdleTimeout(override).toSeconds();
|
||||
}
|
||||
|
||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
|
||||
}
|
||||
@ -932,6 +986,8 @@ public class AgentGraphBuilder {
|
||||
primaryModelConfig.getProvider(),
|
||||
primaryModelConfig.getModelName(), errorMessage));
|
||||
}
|
||||
streamingHelper.setStreamIdleTimeoutSec(
|
||||
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
@ -1069,6 +1125,10 @@ public class AgentGraphBuilder {
|
||||
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
|
||||
// StateKeyRegistrationCoverageTest 专门兜这条。
|
||||
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
|
||||
// Multimodal sidecar routing decision for the current turn.
|
||||
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
|
||||
// RFC 48 — persistent goal state keys must be registered in
|
||||
@ -1121,7 +1181,8 @@ public class AgentGraphBuilder {
|
||||
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
|
||||
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
|
||||
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
|
||||
Map.of(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
|
||||
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
|
||||
MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
|
||||
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
|
||||
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE,
|
||||
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE))
|
||||
@ -1682,7 +1743,7 @@ public class AgentGraphBuilder {
|
||||
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
|
||||
- Tools from DIFFERENT servers have DIFFERENT serverId prefixes, even if they have the same raw name (e.g. `search` on server A vs server B) — they are DIFFERENT tools and are NOT interchangeable.
|
||||
- Each MCP tool's description starts with `[MCP server: <name>]` so you can identify the source server by its human-readable name.
|
||||
- MCP tools are listed in the Extension Tools catalog by default. Use `enable_tool(toolName="<exact-name>")` to activate the one you need before calling it.
|
||||
- MCP tools are listed in the Extension Tools catalog by default. Call `tool_call(toolName="<exact-name>", arguments={...})` to execute one in the same action round; use `tool_search` first only when the exact name is unknown.
|
||||
- Always call tools by the EXACT name shown in the tool list. Do NOT reconstruct a tool name by swapping the slug into a serverId you remember from a previous successful call — that produces a non-existent tool name and the call will fail.
|
||||
- If a tool call returns "Tool not found" with candidate suggestions, pick the correct one from the candidates verbatim.
|
||||
|
||||
@ -1716,7 +1777,7 @@ public class AgentGraphBuilder {
|
||||
|
||||
## ProgressLedger Discipline (mandatory)
|
||||
The `## 当前任务进度` block injected near the top of every turn is the **authoritative record** of what you have done and what remains. Treat it as ground truth, not as a scratchpad you may ignore.
|
||||
- **On starting any multi-step task** (≥3 tool calls expected), call `progress_update` in a parallel tool_calls batch to register every pending step BEFORE doing the work. Do not wait until "later" — context compression can trim earlier turns and you will lose track.
|
||||
- **On starting any multi-step task** (≥3 tool calls expected), call `progress_update` in parallel batches of at most 16 calls to register every pending step BEFORE doing the work. Split larger ledgers across turns so the executor cap never drops entries.
|
||||
- **After each completed sub-step**, immediately call `progress_update` to flip its status to `done`. "Immediately" means in the same tool_calls batch that returns the result, not after the next reasoning turn.
|
||||
- **Never re-execute a step the ledger shows as `done`** unless you can articulate why the prior result is stale.
|
||||
- **🔒 固定约束 entries** (pinned from skill manifests) are non-negotiable. They survive context compression for a reason — re-read them every turn and make sure your planned action still satisfies them.
|
||||
|
||||
@ -236,15 +236,13 @@ public class AgentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached agent instance whenever one of its workspace files
|
||||
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured
|
||||
* memory) is baked into the cached instance at build time, so memory edits made
|
||||
* via tools, consolidation, or cleanup would otherwise stay invisible until an
|
||||
* agent config change or restart. Rebuilding on the next turn picks them up.
|
||||
* Invalidate the cached agent instance only for shared workspace files that
|
||||
* are baked into the system prompt. Owner-scoped PERSONAL memory rows are
|
||||
* injected per turn, so updating them must not force a cold agent rebuild.
|
||||
*/
|
||||
@org.springframework.context.event.EventListener
|
||||
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
|
||||
if (event.agentId() != null) {
|
||||
if (event.agentId() != null && event.affectsSystemPrompt()) {
|
||||
agentInstances.remove(event.agentId());
|
||||
}
|
||||
}
|
||||
@ -764,22 +762,42 @@ public class AgentService {
|
||||
// ==================== StreamDelta ====================
|
||||
|
||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
|
||||
boolean persistenceOnly, boolean segmentOnly) {
|
||||
boolean persistenceOnly, boolean segmentOnly, ContentKind kind) {
|
||||
|
||||
// 兼容构造器(广播+持久化)
|
||||
public StreamDelta(String content, String thinking) {
|
||||
this(content, thinking, null, null, false, false);
|
||||
this(content, thinking, null, null, false, false, null);
|
||||
}
|
||||
|
||||
// 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
|
||||
public StreamDelta(String content, String thinking, String eventType,
|
||||
Map<String, Object> eventData, boolean persistenceOnly) {
|
||||
this(content, thinking, eventType, eventData, persistenceOnly, false);
|
||||
this(content, thinking, eventType, eventData, persistenceOnly, false, null);
|
||||
}
|
||||
|
||||
// 兼容构造器:kind 出现之前的 6 参 canonical 形态
|
||||
public StreamDelta(String content, String thinking, String eventType,
|
||||
Map<String, Object> eventData, boolean persistenceOnly, boolean segmentOnly) {
|
||||
this(content, thinking, eventType, eventData, persistenceOnly, segmentOnly, null);
|
||||
}
|
||||
|
||||
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
|
||||
public static StreamDelta persistOnly(String content, String thinking) {
|
||||
return new StreamDelta(content, thinking, null, null, true, false);
|
||||
return new StreamDelta(content, thinking, null, null, true, false, null);
|
||||
}
|
||||
|
||||
/** {@link #persistOnly(String, String)} 带内容语义标注的变体。 */
|
||||
public static StreamDelta persistOnly(String content, String thinking, ContentKind kind) {
|
||||
return new StreamDelta(content, thinking, null, null, true, false, kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final-answer content of the terminal turn. {@code alreadyStreamed}
|
||||
* decides broadcast suppression exactly like the persistOnly/plain
|
||||
* split at the emission sites did before the kind tag existed.
|
||||
*/
|
||||
public static StreamDelta finalAnswer(String content, boolean alreadyStreamed) {
|
||||
return new StreamDelta(content, null, null, null, alreadyStreamed, false, ContentKind.FINAL_ANSWER);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -803,15 +821,20 @@ public class AgentService {
|
||||
* persisted content field via this flavor.
|
||||
*/
|
||||
public static StreamDelta segmentOnly(String content, String thinking) {
|
||||
return new StreamDelta(content, thinking, null, null, true, true);
|
||||
return new StreamDelta(content, thinking, null, null, true, true, null);
|
||||
}
|
||||
|
||||
/** {@link #segmentOnly(String, String)} 带内容语义标注的变体。 */
|
||||
public static StreamDelta segmentOnly(String content, String thinking, ContentKind kind) {
|
||||
return new StreamDelta(content, thinking, null, null, true, true, kind);
|
||||
}
|
||||
|
||||
public static StreamDelta empty() {
|
||||
return new StreamDelta(null, null, null, null, false, false);
|
||||
return new StreamDelta(null, null, null, null, false, false, null);
|
||||
}
|
||||
|
||||
public static StreamDelta event(String type, Map<String, Object> data) {
|
||||
return new StreamDelta(null, null, type, data, false, false);
|
||||
return new StreamDelta(null, null, type, data, false, false, null);
|
||||
}
|
||||
|
||||
public boolean isEvent() {
|
||||
|
||||
@ -179,6 +179,14 @@ public class AgentToolSet {
|
||||
return callbackByName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every runtime identifier this set can resolve: function names plus any
|
||||
* Spring bean / Java class aliases captured when the set was built.
|
||||
*/
|
||||
public Set<String> allNames() {
|
||||
return aliasIndex.keySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始的 @Tool Bean 列表
|
||||
*/
|
||||
|
||||
@ -21,6 +21,7 @@ import vip.mate.llm.routing.model.MultimodalRoutingDecision;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
@ -811,8 +812,15 @@ public abstract class BaseAgent {
|
||||
if (msg == null) return List.of();
|
||||
String metadata = msg.getMetadata();
|
||||
if (metadata == null || metadata.isEmpty()) return List.of();
|
||||
if (!metadata.contains("\"directToolNames\"")) return List.of();
|
||||
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
|
||||
// Guard on the bare key, not on `"directToolNames"`: the escaped form
|
||||
// reads \"directToolNames\", where the quotes are no longer adjacent to
|
||||
// the name, so a quoted guard exits early on every H2-backed row and the
|
||||
// badge silently disappears. Bare-key matching holds for both forms and
|
||||
// keeps the common case (no such key) allocation-free; the exact match
|
||||
// then runs against normalized JSON.
|
||||
if (!metadata.contains("directToolNames")) return List.of();
|
||||
java.util.regex.Matcher arrayMatcher =
|
||||
DIRECT_TOOL_NAMES_ARRAY.matcher(MessageMetadataJson.normalize(metadata));
|
||||
if (!arrayMatcher.find()) return List.of();
|
||||
String inner = arrayMatcher.group(1);
|
||||
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
||||
@ -887,7 +895,13 @@ public abstract class BaseAgent {
|
||||
}
|
||||
return switch (message.getRole()) {
|
||||
case "assistant" -> new AssistantMessage(renderedContent);
|
||||
case "system" -> new SystemMessage(renderedContent);
|
||||
case "system" -> isCompressionSummary(message)
|
||||
// Compression boundaries are persisted as system rows so
|
||||
// the loader can find the latest boundary cheaply. They
|
||||
// are still model-generated history context, not durable
|
||||
// instructions, so replay them at user priority.
|
||||
? new UserMessage(renderedContent)
|
||||
: new SystemMessage(renderedContent);
|
||||
// History user messages: text only. Re-injecting Media on every replay
|
||||
// accumulates attachments across turns — many providers cap at 1 video
|
||||
// per request (e.g. Zhipu GLM-5V returns code 1210). The current turn
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Semantic category of a content-bearing stream delta, assigned once at the
|
||||
* producer (the agent graph) where the classification inputs — whether the
|
||||
* completion carried tool calls and whether any tool observation preceded the
|
||||
* text this turn — are definitively known.
|
||||
*
|
||||
* <p>Downstream consumers (web segment persistence, IM channel adapters, the
|
||||
* SSE client) MUST read this tag instead of re-deriving the category from
|
||||
* stream structure. Deltas from producers that predate this tag carry
|
||||
* {@code null}; consumers fall back to their legacy structural handling in
|
||||
* that case.
|
||||
*/
|
||||
public enum ContentKind {
|
||||
|
||||
/**
|
||||
* Text emitted in a completion that also carries tool calls, before any
|
||||
* tool observation this turn. Not grounded in this turn's results — it may
|
||||
* be process narration or a fully fabricated "rehearsal" of the outcome.
|
||||
* Provisional: replaced by the next content of the same turn if one
|
||||
* arrives, kept only when the turn produces no later content at all.
|
||||
*/
|
||||
PRE_TOOL_NARRATION,
|
||||
|
||||
/**
|
||||
* Intermediate narration emitted after at least one tool observation this
|
||||
* turn (even when the same completion issues further tool calls). Grounded
|
||||
* in real results; never replaced.
|
||||
*/
|
||||
GROUNDED_NARRATION,
|
||||
|
||||
/** Final-answer text of the terminal turn. */
|
||||
FINAL_ANSWER;
|
||||
|
||||
/** Stable lower-case token used in persisted segment metadata and SSE payloads. */
|
||||
public String wireName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package vip.mate.agent.binding;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.skill.event.SkillAuthoredEvent;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Makes a self-authored skill reachable from the catalog of the agent that
|
||||
* authored it.
|
||||
*
|
||||
* <h2>Why this exists</h2>
|
||||
* An agent's visible skill catalog is filtered by
|
||||
* {@link AgentBindingService#getBoundSkillIds(Long)}. That method has a
|
||||
* three-state contract:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code null} — no binding rows: the agent inherits every globally
|
||||
* enabled skill, so a newly created skill is visible automatically.</li>
|
||||
* <li>{@code Set.of()} — the agent is explicitly scoped to zero skills
|
||||
* (opt-out flag, or every binding row disabled).</li>
|
||||
* <li>non-empty — an explicit allowlist; anything not in it is invisible.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Without this listener, an agent in the third state can author a skill,
|
||||
* persist it, and then never see it again — the catalog renderer filters the
|
||||
* new row straight out. Self-improvement writes into a hole: the skill exists
|
||||
* in the registry but the agent that learned it cannot reach it on the next
|
||||
* turn.
|
||||
*
|
||||
* <h2>Binding policy</h2>
|
||||
* Bind only when the agent is already in explicit-allowlist mode
|
||||
* (non-null, non-empty). The other two states are deliberately left alone:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code null} — writing a row here would flip the agent from "inherit
|
||||
* everything" into allowlist mode containing exactly one skill, which
|
||||
* would silently revoke every other skill it had. Strictly worse than
|
||||
* doing nothing.</li>
|
||||
* <li>{@code Set.of()} — the operator asked for an agent with no skills.
|
||||
* Binding would also clear the {@code skills_disabled} flag as a side
|
||||
* effect of {@link AgentBindingService#bindSkill}, overriding an
|
||||
* explicit human decision from a background code path.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentSkillAutoBindListener {
|
||||
|
||||
private final AgentBindingService agentBindingService;
|
||||
|
||||
@EventListener
|
||||
public void onSkillAuthored(SkillAuthoredEvent event) {
|
||||
if (event == null || event.agentId() == null || event.skillId() == null) {
|
||||
return;
|
||||
}
|
||||
Set<Long> bound;
|
||||
try {
|
||||
bound = agentBindingService.getBoundSkillIds(event.agentId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillAutoBind] Could not resolve bindings for agent={}: {}",
|
||||
event.agentId(), e.getMessage());
|
||||
return;
|
||||
}
|
||||
// null = inherits every enabled skill; empty = explicitly scoped to
|
||||
// none. Neither state should be rewritten by a background author.
|
||||
if (bound == null || bound.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (bound.contains(event.skillId())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
agentBindingService.bindSkill(event.agentId(), event.skillId());
|
||||
log.info("[SkillAutoBind] Bound self-authored skill '{}' (id={}) to agent={}",
|
||||
event.skillName(), event.skillId(), event.agentId());
|
||||
} catch (Exception e) {
|
||||
// A cross-workspace skill, a deleted agent, or a concurrent unbind
|
||||
// all land here. The skill itself is already persisted and remains
|
||||
// usable through the global catalog, so this stays a warning.
|
||||
log.warn("[SkillAutoBind] Failed to bind skill '{}' (id={}) to agent={}: {}",
|
||||
event.skillName(), event.skillId(), event.agentId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -254,7 +254,11 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
* silently undoes a user's explicit skill picks.
|
||||
*/
|
||||
public Set<Long> skillIdsBoundToEnabledAgents() {
|
||||
Set<Long> enabledAgentIds = enabledAgentIds();
|
||||
return skillIdsBoundToEnabledAgents(null);
|
||||
}
|
||||
|
||||
public Set<Long> skillIdsBoundToEnabledAgents(Long workspaceId) {
|
||||
Set<Long> enabledAgentIds = enabledAgentIds(workspaceId);
|
||||
if (enabledAgentIds.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
@ -273,7 +277,11 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
* archival candidates regardless of bindings.
|
||||
*/
|
||||
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
|
||||
Set<Long> enabledAgentIds = enabledAgentIds();
|
||||
return blockedByBindingCandidates(now, null);
|
||||
}
|
||||
|
||||
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now, Long workspaceId) {
|
||||
Set<Long> enabledAgentIds = enabledAgentIds(workspaceId);
|
||||
if (enabledAgentIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
@ -290,6 +298,9 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
List<BlockedByBindingRow> rows = new ArrayList<>();
|
||||
for (SkillEntity skill : skillMapper.selectBatchIds(bySkill.keySet())) {
|
||||
if (workspaceId != null && !workspaceId.equals(skill.getWorkspaceId())) {
|
||||
continue;
|
||||
}
|
||||
if (Boolean.TRUE.equals(skill.getBuiltin()) || Boolean.TRUE.equals(skill.getPinned())) {
|
||||
continue;
|
||||
}
|
||||
@ -334,9 +345,17 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
|
||||
/** Ids of every currently-enabled agent. */
|
||||
private Set<Long> enabledAgentIds() {
|
||||
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
||||
.eq(AgentEntity::getEnabled, true)
|
||||
.select(AgentEntity::getId))
|
||||
return enabledAgentIds(null);
|
||||
}
|
||||
|
||||
private Set<Long> enabledAgentIds(Long workspaceId) {
|
||||
LambdaQueryWrapper<AgentEntity> query = new LambdaQueryWrapper<AgentEntity>()
|
||||
.eq(AgentEntity::getEnabled, true);
|
||||
if (workspaceId != null) {
|
||||
query.eq(AgentEntity::getWorkspaceId, workspaceId);
|
||||
}
|
||||
query.select(AgentEntity::getId);
|
||||
return agentMapper.selectList(query)
|
||||
.stream()
|
||||
.map(AgentEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
@ -672,6 +691,12 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
// extension-tier tool for the rest of the conversation. Must be
|
||||
// agent-wide so the model can always surface hidden tools.
|
||||
"enable_tool",
|
||||
// Stable Hermes-style bridges. They must survive every explicit
|
||||
// allowlist because they are the only way to discover, inspect
|
||||
// and invoke schemas that were deferred by the hard budget.
|
||||
"tool_search",
|
||||
"tool_describe",
|
||||
"tool_call",
|
||||
// Skill discovery / dispatch — skills are docs, not callables;
|
||||
// these helpers let the LLM read SKILL.md / run scripts.
|
||||
"load_skill",
|
||||
|
||||
@ -75,15 +75,29 @@ public record ChatOrigin(
|
||||
* forwarding uses this to tell "MateClaw authenticated this user" apart
|
||||
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||
*/
|
||||
@Nullable Long requesterUserId
|
||||
@Nullable Long requesterUserId,
|
||||
@Nullable Long originMessageId
|
||||
) {
|
||||
|
||||
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
|
||||
@Nullable String requesterId, @Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath, @Nullable Long channelId,
|
||||
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
|
||||
@Nullable String senderName, @Nullable String channelType,
|
||||
@Nullable String chatId, @Nullable String baseUrl,
|
||||
@Nullable Long requesterUserId) {
|
||||
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
|
||||
channelId, channelTarget, cronOrigin, senderName, channelType,
|
||||
chatId, baseUrl, requesterUserId, null);
|
||||
}
|
||||
|
||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||
|
||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||
public static final ChatOrigin EMPTY =
|
||||
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
|
||||
new ChatOrigin(null, null, "", null, null, null, null, false,
|
||||
null, null, null, null, null, null);
|
||||
|
||||
// ---------------- Factories per entry point ----------------
|
||||
|
||||
@ -117,7 +131,7 @@ public record ChatOrigin(
|
||||
return new ChatOrigin(null, conversationId,
|
||||
requesterId != null ? requesterId : "",
|
||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
||||
requesterUserId);
|
||||
requesterUserId, null);
|
||||
}
|
||||
|
||||
public static ChatOrigin cron(@Nullable String conversationId,
|
||||
@ -126,7 +140,8 @@ public record ChatOrigin(
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget target) {
|
||||
return new ChatOrigin(null, conversationId, "system",
|
||||
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
|
||||
workspaceId, workspaceBasePath, channelId, target, true,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
// ---------------- Wither-style updates ----------------
|
||||
@ -134,27 +149,27 @@ public record ChatOrigin(
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, newBaseUrl, requesterUserId);
|
||||
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -168,7 +183,13 @@ public record ChatOrigin(
|
||||
@Nullable String newChatId) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
|
||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId);
|
||||
}
|
||||
|
||||
// ---------------- Spring AI ToolContext interop ----------------
|
||||
|
||||
@ -87,7 +87,8 @@ public class PrefixBudgetPlanner {
|
||||
(int) (injectionBudget * shares.getSkill() / sum),
|
||||
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
||||
(int) (injectionBudget * shares.getLedger() / sum),
|
||||
(int) (effectiveMax * properties.getToolSchemaRatio()));
|
||||
Math.min((int) (effectiveMax * properties.getToolSchemaRatio()),
|
||||
Math.max(1, properties.getToolSchemaMaxTokens())));
|
||||
|
||||
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
|
||||
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
|
||||
|
||||
@ -27,6 +27,9 @@ import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@ -47,6 +50,7 @@ import java.util.concurrent.Executors;
|
||||
public class AgentController {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final ConversationService conversationService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final AuthService authService;
|
||||
private final WorkspaceService workspaceService;
|
||||
@ -211,12 +215,13 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
ChatOrigin origin = persistOrigin(agent, id, message, conversationId, workspaceId);
|
||||
|
||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
||||
sseExecutor.execute(() -> {
|
||||
try {
|
||||
agentService.chatStream(id, message, conversationId)
|
||||
agentService.chatStream(id, message, conversationId, origin)
|
||||
.doOnNext(chunk -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("message").data(chunk));
|
||||
@ -251,7 +256,9 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
|
||||
ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
|
||||
request.getConversationId(), workspaceId);
|
||||
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId(), origin));
|
||||
}
|
||||
|
||||
@Operation(summary = "执行复杂任务(Plan-Execute)")
|
||||
@ -264,7 +271,21 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
|
||||
ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
|
||||
request.getConversationId(), workspaceId);
|
||||
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId(), origin));
|
||||
}
|
||||
|
||||
private ChatOrigin persistOrigin(AgentEntity agent, Long agentId, String message,
|
||||
String conversationId, Long requestedWorkspaceId) {
|
||||
Long resolvedWorkspaceId = agent != null && agent.getWorkspaceId() != null
|
||||
? agent.getWorkspaceId()
|
||||
: requestedWorkspaceId != null ? requestedWorkspaceId : 1L;
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
conversationId, "user", message);
|
||||
return ChatOrigin.web(conversationId, "anonymous", resolvedWorkspaceId, null)
|
||||
.withAgent(agentId)
|
||||
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取Agent运行状态")
|
||||
|
||||
@ -14,9 +14,12 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
|
||||
import vip.mate.llm.chatmodel.ReasoningContentCache;
|
||||
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@ -28,6 +31,7 @@ import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@ -91,6 +95,26 @@ public class NodeStreamingChatHelper {
|
||||
*/
|
||||
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
|
||||
|
||||
/**
|
||||
* Inter-frame idle timeout (seconds) applied to every streaming LLM call.
|
||||
* The JDK HttpClient request timeout (which {@code setReadTimeout} maps to)
|
||||
* only protects up to the response headers; once they arrive the clock
|
||||
* stops, so a provider that returns 200 + a first SSE frame then goes
|
||||
* silent hangs the body Flux forever — no exception, so health tracking /
|
||||
* failover never engage (issue #585). A reactor {@code .timeout()} on the
|
||||
* delta Flux fills that gap: total silence for this long propagates a
|
||||
* {@code TimeoutException} down the existing error path (classifyError
|
||||
* buckets it as a retryable SERVER_ERROR).
|
||||
* <p>
|
||||
* Defaults to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}
|
||||
* (180s). {@code 0} or negative disables it (for tests / opt-out).
|
||||
* Production wiring sets it from {@code ModelConfigEntity.requestTimeoutSeconds}
|
||||
* so a single per-model knob governs both the connect-level read timeout
|
||||
* and the body-level idle timeout.
|
||||
*/
|
||||
private long streamIdleTimeoutSec =
|
||||
vip.mate.llm.chatmodel.HttpTimeouts.DEFAULT_STREAM_IDLE_TIMEOUT.toSeconds();
|
||||
|
||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
||||
this(streamTracker, List.of(), null, null, null, null);
|
||||
}
|
||||
@ -384,6 +408,7 @@ public class NodeStreamingChatHelper {
|
||||
// retry (e.g., proxy timeout returns HTTP 200 with empty body). Keep
|
||||
// the cap low — if it truly takes 4+ attempts, the provider is sick.
|
||||
static final int MAX_RETRIES_EMPTY_RESPONSE = 3;
|
||||
static final long EMPTY_RESPONSE_BACKOFF_MS = 250;
|
||||
// UNKNOWN: conservative retry cap. Defensive: retry what we can't
|
||||
// classify, but with a smaller budget than SERVER_ERROR (5 vs 10) to
|
||||
// avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the
|
||||
@ -444,6 +469,17 @@ public class NodeStreamingChatHelper {
|
||||
this.maxTotalDurationMs = maxTotalDurationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the streaming inter-frame idle timeout (seconds). Wired from
|
||||
* {@code ModelConfigEntity.requestTimeoutSeconds} by AgentGraphBuilder so a
|
||||
* single per-model knob governs both the connect-level read timeout and
|
||||
* the body-level idle timeout. {@code 0} or negative disables the idle
|
||||
* timeout (used by tests / opt-out). See {@link #streamIdleTimeoutSec}.
|
||||
*/
|
||||
public void setStreamIdleTimeoutSec(long seconds) {
|
||||
this.streamIdleTimeoutSec = seconds;
|
||||
}
|
||||
|
||||
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
@ -853,6 +889,7 @@ public class NodeStreamingChatHelper {
|
||||
if (errType == ErrorType.EMPTY_RESPONSE && attempt < errType.retryBudget()) {
|
||||
log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
|
||||
phase, attempt + 1, errType.retryBudget() + 1);
|
||||
retryType.set(ErrorType.EMPTY_RESPONSE);
|
||||
continue;
|
||||
}
|
||||
// Generic routing — driven entirely by the ErrorType policy
|
||||
@ -1073,6 +1110,7 @@ public class NodeStreamingChatHelper {
|
||||
AtomicReference<Long> retryHintRef) {
|
||||
if (attempt > 0) {
|
||||
boolean overloaded = retryTypeRef.get() == ErrorType.OVERLOADED;
|
||||
boolean emptyResponse = retryTypeRef.get() == ErrorType.EMPTY_RESPONSE;
|
||||
Long hintedMs = retryHintRef.get();
|
||||
long delay;
|
||||
if (hintedMs != null && hintedMs > 0) {
|
||||
@ -1083,6 +1121,8 @@ public class NodeStreamingChatHelper {
|
||||
// in lockstep at the stated instant.
|
||||
delay = Math.min(hintedMs, HINTED_BACKOFF_CAP_MS)
|
||||
+ ThreadLocalRandom.current().nextLong(0, 1_000);
|
||||
} else if (emptyResponse) {
|
||||
delay = EMPTY_RESPONSE_BACKOFF_MS;
|
||||
} else if (overloaded) {
|
||||
// Saturated provider: recovery periods run tens of seconds, so
|
||||
// the generic 3s-based exponential would burn attempts before
|
||||
@ -1100,7 +1140,7 @@ public class NodeStreamingChatHelper {
|
||||
log.warn("[{}] Retry attempt {}/{} after {}ms (prev type={}) for conversation {}",
|
||||
phase, attempt, MAX_RETRIES, delay, retryTypeRef.get(), conversationId);
|
||||
// 广播给前端:用户可见的重试倒计时
|
||||
if (broadcast) {
|
||||
if (broadcast && !emptyResponse) {
|
||||
String cause = overloaded ? "模型服务繁忙" : "请求频率受限";
|
||||
broadcastDelta(conversationId, "warning",
|
||||
buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
|
||||
@ -1182,9 +1222,63 @@ public class NodeStreamingChatHelper {
|
||||
));
|
||||
}
|
||||
|
||||
// Inline <think> tag extraction: models without structured reasoning
|
||||
// stream their reasoning inside <think>...</think> in the content
|
||||
// channel. Split those spans off live so the stream the user watches
|
||||
// matches what persistence later stores (raw tags used to leak into
|
||||
// content_delta and only disappear after a reload).
|
||||
ThinkTagStreamExtractor thinkExtractor = new ThinkTagStreamExtractor();
|
||||
|
||||
// Shared handling for a thinking delta, regardless of origin
|
||||
// (structured reasoningContent metadata or inline-tag extraction).
|
||||
Consumer<String> onThinkingDelta = thinkingDelta -> {
|
||||
// First-token signaling fires for thinking too — UI
|
||||
// shows "thinking" activity before any content streams.
|
||||
if (broadcast && streamTracker != null
|
||||
&& firstTokenSignaled.compareAndSet(false, true)) {
|
||||
streamTracker.markFirstTokenReceived(conversationId);
|
||||
}
|
||||
// First thinking delta opens the thinking phase. We
|
||||
// emit the start lazily (on first delta) rather than
|
||||
// before subscription so models that never produce
|
||||
// thinking don't ghost-pair an empty segment.
|
||||
if (broadcast && thinkingAccum.length() == 0
|
||||
&& thinkingStartEmitted.compareAndSet(false, true)) {
|
||||
streamTracker.broadcastObject(conversationId, "thinking_start", Map.of(
|
||||
"phase", phase != null ? phase : "",
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
thinkingAccum.append(thinkingDelta);
|
||||
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
||||
boolean suppressThinking = "off".equalsIgnoreCase(ThinkingLevelHolder.get());
|
||||
if (broadcast && !suppressThinking) {
|
||||
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
||||
}
|
||||
};
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
Disposable subscription = chatModel.stream(prompt)
|
||||
// Issue #585: inter-frame idle timeout on the streaming body Flux.
|
||||
// The JDK HttpClient request timeout (which setReadTimeout maps to)
|
||||
// only protects up to the response headers; once they arrive the
|
||||
// clock stops, so a provider that returns 200 + a first frame then
|
||||
// goes silent hangs the body forever. This reactor timeout measures
|
||||
// the gap between successive stream elements, so total silence for
|
||||
// streamIdleTimeoutSec propagates an error down the existing path.
|
||||
// The fallback Flux carries a descriptive message so classifyError's
|
||||
// "timeout" pattern matches it (vanilla TimeoutException.getMessage()
|
||||
// is null) and the health tracker / failover chain engage.
|
||||
Flux<ChatResponse> streamWithIdleGuard =
|
||||
streamIdleTimeoutSec > 0
|
||||
? chatModel.stream(prompt).timeout(
|
||||
Duration.ofSeconds(streamIdleTimeoutSec),
|
||||
Flux.error(new TimeoutException(
|
||||
"LLM stream idle timeout after " + streamIdleTimeoutSec
|
||||
+ "s with no delta — provider half-open or stalled")))
|
||||
: chatModel.stream(prompt);
|
||||
|
||||
Disposable subscription = streamWithIdleGuard
|
||||
.doOnNext(chatResponse -> {
|
||||
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
|
||||
return;
|
||||
@ -1198,8 +1292,29 @@ public class NodeStreamingChatHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 提取 content delta
|
||||
String contentDelta = msg.getText();
|
||||
// 1. 拆分本 chunk 的通道:出现结构化 reasoningContent 即关闭
|
||||
// 内联标签提取(此类模型不会再用 <think> 包裹思考,正文里的
|
||||
// 字面标签是真实内容)。
|
||||
String nativeThinking = extractReasoningContent(msg);
|
||||
if (nativeThinking != null && !nativeThinking.isEmpty()) {
|
||||
thinkExtractor.disable();
|
||||
}
|
||||
String rawContent = msg.getText();
|
||||
String contentDelta = rawContent;
|
||||
String tagThinking = null;
|
||||
if (rawContent != null && !rawContent.isEmpty()) {
|
||||
var split = thinkExtractor.feed(rawContent);
|
||||
contentDelta = split.content();
|
||||
tagThinking = split.thinking();
|
||||
}
|
||||
|
||||
// 2. 标签提取的 thinking 先处理:形如 "…</think>answer" 的
|
||||
// chunk 里思考先于正文出现。
|
||||
if (tagThinking != null && !tagThinking.isEmpty()) {
|
||||
onThinkingDelta.accept(tagThinking);
|
||||
}
|
||||
|
||||
// 3. content delta(已剥离 <think> 内文本)
|
||||
if (contentDelta != null && !contentDelta.isEmpty()) {
|
||||
// First content delta closes the thinking phase if one
|
||||
// was open, and arms first-token heartbeat relaxation.
|
||||
@ -1220,43 +1335,19 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 提取 thinking delta. Do not cancel the stream for
|
||||
// 4. 结构化 thinking delta. Do not cancel the stream for
|
||||
// repeated thinking phrases: some models emit repetitive
|
||||
// internal planning while still making valid tool progress.
|
||||
String thinkingDelta = extractReasoningContent(msg);
|
||||
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
|
||||
// First-token signaling fires for thinking too — UI
|
||||
// shows "thinking" activity before any content streams.
|
||||
if (broadcast && streamTracker != null
|
||||
&& firstTokenSignaled.compareAndSet(false, true)) {
|
||||
streamTracker.markFirstTokenReceived(conversationId);
|
||||
}
|
||||
// First thinking delta opens the thinking phase. We
|
||||
// emit the start lazily (on first delta) rather than
|
||||
// before subscription so models that never produce
|
||||
// thinking don't ghost-pair an empty segment.
|
||||
if (broadcast && thinkingAccum.length() == 0
|
||||
&& thinkingStartEmitted.compareAndSet(false, true)) {
|
||||
streamTracker.broadcastObject(conversationId, "thinking_start", Map.of(
|
||||
"phase", phase != null ? phase : "",
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
thinkingAccum.append(thinkingDelta);
|
||||
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
||||
boolean suppressThinking = "off".equalsIgnoreCase(
|
||||
vip.mate.llm.chatmodel.ThinkingLevelHolder.get());
|
||||
if (broadcast && !suppressThinking) {
|
||||
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
||||
}
|
||||
if (nativeThinking != null && !nativeThinking.isEmpty()) {
|
||||
onThinkingDelta.accept(nativeThinking);
|
||||
}
|
||||
|
||||
// 3. 累积 tool calls(处理分片)
|
||||
// 5. 累积 tool calls(处理分片)
|
||||
if (msg.hasToolCalls()) {
|
||||
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
|
||||
}
|
||||
|
||||
// 4. Thinking-only no-progress guard. MUST run after both
|
||||
// 6. Thinking-only no-progress guard. MUST run after both
|
||||
// content delta and tool call accumulation, otherwise a
|
||||
// chunk that carries thinking AND a tool_call together
|
||||
// (some Anthropic / DeepSeek-thinking responses do this)
|
||||
@ -1281,7 +1372,7 @@ public class NodeStreamingChatHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Content-repetition guard. Some reasoning-mode models
|
||||
// 7. Content-repetition guard. Some reasoning-mode models
|
||||
// (qwen3.6, deepseek-r1) get stuck in a "Wait, I should X
|
||||
// → 写答案 → Wait, I should Y → 写同一份答案 → ..." loop
|
||||
// and emit the same final-answer paragraph dozens of times
|
||||
@ -1311,7 +1402,7 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
||||
// 8. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
||||
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
||||
var usage = chatResponse.getMetadata().getUsage();
|
||||
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
||||
@ -1375,6 +1466,7 @@ public class NodeStreamingChatHelper {
|
||||
"returning stopped partial result: conversationId={}",
|
||||
phase, contentAccum.length(), thinkingAccum.length(),
|
||||
toolCallAccumulators.size(), conversationId);
|
||||
drainThinkExtractor(thinkExtractor, contentAccum, thinkingAccum);
|
||||
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
@ -1396,6 +1488,11 @@ public class NodeStreamingChatHelper {
|
||||
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
||||
}
|
||||
|
||||
// Stream is over (complete, error, or disposed by a guard) — drain the
|
||||
// extractor's held-back tail so the accumulators are complete before
|
||||
// any assembly or emptiness check below.
|
||||
drainThinkExtractor(thinkExtractor, contentAccum, thinkingAccum);
|
||||
|
||||
Throwable error = errorRef.get();
|
||||
if (error != null) {
|
||||
boolean hasAccumulatedContent = !contentAccum.isEmpty() || !toolCallAccumulators.isEmpty();
|
||||
@ -1496,7 +1593,10 @@ public class NodeStreamingChatHelper {
|
||||
&& thinkingAccum.length() == 0
|
||||
&& toolCallAccumulators.isEmpty()) {
|
||||
log.warn("[{}] LLM returned empty response (no content, no thinking, no tool calls) — marking as EMPTY_RESPONSE for fallback", phase);
|
||||
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
|
||||
// The outer policy owns retry/failover. Keep transient empty
|
||||
// attempts out of the user-visible error stream, and leave text
|
||||
// blank so callers can apply a deterministic final fallback.
|
||||
return buildEmptyResponseResult();
|
||||
}
|
||||
|
||||
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
|
||||
@ -1822,6 +1922,12 @@ public class NodeStreamingChatHelper {
|
||||
List.of(), false, 0, 0, false, errorMsg, errorType);
|
||||
}
|
||||
|
||||
private StreamResult buildEmptyResponseResult() {
|
||||
return new StreamResult("", "", new AssistantMessage(""),
|
||||
List.of(), false, 0, 0, false,
|
||||
"LLM 返回空响应", ErrorType.EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
/** 构建 error 事件的 JSON payload */
|
||||
private static String buildErrorEventJson(String message, String conversationId, ErrorType errorType) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
@ -2455,6 +2561,19 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
// ==================== <think> 标签 fallback 解析 ====================
|
||||
|
||||
/** Flush the streaming extractor's held-back tail into the accumulators. */
|
||||
private static void drainThinkExtractor(ThinkTagStreamExtractor extractor,
|
||||
StringBuilder contentAccum,
|
||||
StringBuilder thinkingAccum) {
|
||||
var rest = extractor.flush();
|
||||
if (!rest.content().isEmpty()) {
|
||||
contentAccum.append(rest.content());
|
||||
}
|
||||
if (!rest.thinking().isEmpty()) {
|
||||
thinkingAccum.append(rest.thinking());
|
||||
}
|
||||
}
|
||||
|
||||
private record ThinkExtracted(String thinking, String content) {}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,6 +13,7 @@ import reactor.core.publisher.Mono;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.BaseAgent;
|
||||
import vip.mate.agent.ContentKind;
|
||||
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.StructuredStreamCapable;
|
||||
@ -62,6 +63,20 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
*/
|
||||
private final vip.mate.agent.AgentToolSet toolSet;
|
||||
|
||||
/**
|
||||
* Whether every iteration's reasoning is persisted, or only the terminal
|
||||
* one. Set from {@code mate.agent.reasoning.retention}; defaults to keeping
|
||||
* everything so a turn stays replayable without operator opt-in. A setter
|
||||
* rather than a constructor argument — the agent is built per request in a
|
||||
* builder that already threads a dozen collaborators, and this is a single
|
||||
* boolean with a safe default.
|
||||
*/
|
||||
private boolean persistEveryIterationReasoning = true;
|
||||
|
||||
public void setPersistEveryIterationReasoning(boolean persistEveryIterationReasoning) {
|
||||
this.persistEveryIterationReasoning = persistEveryIterationReasoning;
|
||||
}
|
||||
|
||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||
CompiledGraph compiledGraph,
|
||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||
@ -207,6 +222,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
|
||||
// Silent-termination guard (mirrors chatStructuredStream)
|
||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||
@ -228,6 +244,48 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||
|
||||
// Thinking is emitted BEFORE any content delta of the same
|
||||
// batch. The reasoning that produced an answer precedes the
|
||||
// answer, and the accumulator builds its segment timeline in
|
||||
// delta arrival order — emitting thinking last appended a
|
||||
// thinking segment after the content segment, which readers
|
||||
// then had to reorder. FINAL_THINKING and FINAL_ANSWER are
|
||||
// written by the same node output, so ordering them here is
|
||||
// enough to make the persisted timeline match reality.
|
||||
//
|
||||
// Every iteration's reasoning is persisted, not just the
|
||||
// terminal one. A tool-calling iteration parks its reasoning
|
||||
// in STREAMED_THINKING (REPLACE, one value per node), which
|
||||
// the live channel already broadcast — persistOnly carries it
|
||||
// into the accumulator without a second broadcast. Without
|
||||
// this, a turn that ran N tool rounds kept only the last
|
||||
// round's thinking, so the persisted turn read as a bare
|
||||
// conclusion and the reasoning that justified each tool call
|
||||
// survived nowhere.
|
||||
//
|
||||
// The cursor tracks STREAMED_THINKING and nothing else. A
|
||||
// shared cursor would let the stale value re-qualify: the key
|
||||
// keeps its last write for the rest of the run, so once an
|
||||
// unrelated emission moved a shared cursor past it, the same
|
||||
// span was emitted a second time — after the final answer,
|
||||
// since the later nodes run after the answer was streamed.
|
||||
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
|
||||
if (persistEveryIterationReasoning
|
||||
&& !iterationThinking.isEmpty()
|
||||
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
|
||||
lastEmittedIterationThinking.set(iterationThinking);
|
||||
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
|
||||
}
|
||||
|
||||
String thinking = extractFinalThinking(output);
|
||||
if (thinking != null && !thinking.isEmpty()
|
||||
&& !thinking.equals(lastEmittedIterationThinking.get())
|
||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||
deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking));
|
||||
}
|
||||
|
||||
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
||||
// SummarizingNode output) into segments only — final-answer
|
||||
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
||||
@ -251,24 +309,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
|
||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
}
|
||||
|
||||
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
String answer = extractFinalAnswer(output);
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
deltas.add(contentAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(answer, null)
|
||||
: new AgentService.StreamDelta(answer, null));
|
||||
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
|
||||
}
|
||||
}
|
||||
String thinking = extractFinalThinking(output);
|
||||
if (thinking != null && !thinking.isEmpty()
|
||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||
deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking));
|
||||
}
|
||||
|
||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||
@ -366,6 +419,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
// 同 STREAMED_CONTENT:STREAMED_THINKING 也是 REPLACE,用独立游标
|
||||
// 跟踪已持久化的每轮 thinking,避免后续节点的 NodeOutput 重复发送。
|
||||
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
|
||||
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||
@ -402,7 +458,31 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
boolean thinkingAlreadyStreamed = output.state()
|
||||
.value(THINKING_STREAMED, false);
|
||||
|
||||
// 2a. Route per-iteration narrative into the segments timeline
|
||||
// 2a. Thinking first — see the ordering note in
|
||||
// chatStructuredStream. The accumulator builds its
|
||||
// segment timeline in delta arrival order, so the
|
||||
// reasoning must be emitted ahead of the answer it
|
||||
// produced. Every iteration's reasoning is persisted —
|
||||
// see the note in chatStructuredStream for why the
|
||||
// terminal one alone is not enough.
|
||||
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
|
||||
if (persistEveryIterationReasoning
|
||||
&& !iterationThinking.isEmpty()
|
||||
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
|
||||
lastEmittedIterationThinking.set(iterationThinking);
|
||||
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
|
||||
}
|
||||
|
||||
String thinking = extractFinalThinking(output);
|
||||
if (thinking != null && !thinking.isEmpty()
|
||||
&& !thinking.equals(lastEmittedIterationThinking.get())
|
||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||
deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking));
|
||||
}
|
||||
|
||||
// 2b. Route per-iteration narrative into the segments timeline
|
||||
// so the segmented UI view still shows "我来…" preludes
|
||||
// between tool cards, but keep the top-level content
|
||||
// field (= persisted mate_message.content) reserved for
|
||||
@ -422,26 +502,20 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
|
||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
}
|
||||
|
||||
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
String answer = extractFinalAnswer(output);
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
deltas.add(contentAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(answer, null)
|
||||
: new AgentService.StreamDelta(answer, null));
|
||||
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
|
||||
}
|
||||
}
|
||||
|
||||
String thinking = extractFinalThinking(output);
|
||||
if (thinking != null && !thinking.isEmpty()
|
||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||
deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking));
|
||||
}
|
||||
|
||||
// 3. 更新最新累计 token usage
|
||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||
@ -641,15 +715,63 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
* renderers (copy / TTS / history reload) showing the full text.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Beyond flavor, this is the single assignment point for the delta's
|
||||
* {@link ContentKind}: the graph is the only layer that definitively knows
|
||||
* whether the completion carried tool calls ({@code NEEDS_TOOL_CALL}) and
|
||||
* whether any tool observation preceded the text this turn
|
||||
* ({@code TOOL_CALL_COUNT} — ObservationNode adds each round's observed
|
||||
* results to it, so 0 means "no observation yet"). Downstream consumers
|
||||
* read the tag instead of re-deriving it from stream structure.
|
||||
*
|
||||
* <p>The observation signal MUST be the observation counter, not
|
||||
* {@code CURRENT_ITERATION}: the latter is an iteration <em>budget</em>
|
||||
* counter that ObservationNode refunds for progressive-disclosure rounds
|
||||
* (load_skill / enable_tool) and GoalEvaluationNode resets to 0 on a hard
|
||||
* continuation. Either path leaves the budget at 0 after real observations
|
||||
* already landed, which tagged grounded narration as provisional and made
|
||||
* renderers collapse it.
|
||||
*
|
||||
* <ul>
|
||||
* <li>terminal turn → {@code FINAL_ANSWER};</li>
|
||||
* <li>completion carries tool calls and no tool observation happened yet
|
||||
* this turn → {@code PRE_TOOL_NARRATION} (provisional, may be
|
||||
* replaced by the turn's next content);</li>
|
||||
* <li>otherwise → {@code GROUNDED_NARRATION} (follows an observation, or
|
||||
* closed its completion without tool calls — never replaced).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Package-private so the unit test can pin the decision without standing
|
||||
* up a full StateGraph fixture. Returning {@code null} for blank input is the
|
||||
* caller's responsibility — this helper just decides flavor for non-blank
|
||||
* content.
|
||||
*/
|
||||
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
|
||||
return isFinalAnswerTurn
|
||||
? AgentService.StreamDelta.persistOnly(streamed, null)
|
||||
: AgentService.StreamDelta.segmentOnly(streamed, null);
|
||||
/**
|
||||
* Append a content-bearing delta plus, when it carries a producer-assigned
|
||||
* kind, a {@code segment_kind} broadcast event tagging the just-emitted
|
||||
* content span. The kind cannot ride on the live {@code content_delta}
|
||||
* broadcasts — text streams before the producer knows whether the
|
||||
* completion carries tool calls — so it is delivered as a follow-up event
|
||||
* once the completion resolves, letting the client tag its running content
|
||||
* segment and collapse a provisional narration the moment later content
|
||||
* arrives, without waiting for the persisted-metadata round-trip.
|
||||
*/
|
||||
static void addWithKindEvent(List<AgentService.StreamDelta> deltas, AgentService.StreamDelta delta) {
|
||||
deltas.add(delta);
|
||||
if (delta.kind() != null) {
|
||||
deltas.add(AgentService.StreamDelta.event("segment_kind",
|
||||
Map.of("kind", delta.kind().wireName())));
|
||||
}
|
||||
}
|
||||
|
||||
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, boolean carriesToolCalls,
|
||||
int observationCount, String streamed) {
|
||||
if (isFinalAnswerTurn) {
|
||||
return AgentService.StreamDelta.persistOnly(streamed, null, ContentKind.FINAL_ANSWER);
|
||||
}
|
||||
ContentKind kind = carriesToolCalls && observationCount == 0
|
||||
? ContentKind.PRE_TOOL_NARRATION
|
||||
: ContentKind.GROUNDED_NARRATION;
|
||||
return AgentService.StreamDelta.segmentOnly(streamed, null, kind);
|
||||
}
|
||||
|
||||
private boolean hasFinalAnswer(NodeOutput output) {
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
/**
|
||||
* Incremental extractor that routes inline {@code <think>...</think>} spans
|
||||
* out of a streamed content channel and into a thinking channel, chunk by
|
||||
* chunk. Models without structured reasoning support emit their reasoning
|
||||
* inline in the content stream; without live extraction the raw tags reach
|
||||
* the user during streaming and only disappear after the persisted (cleaned)
|
||||
* message is reloaded.
|
||||
* <p>
|
||||
* A tag may be split across chunk boundaries ({@code "abc<thi"} +
|
||||
* {@code "nk>xyz"}). The extractor holds back a chunk tail that is a proper
|
||||
* prefix of the next expected tag (at most {@code </think>.length() - 1}
|
||||
* characters) and re-examines it with the following chunk, so the hold-back
|
||||
* buffer is O(1). Call {@link #flush()} once the stream ends to drain that
|
||||
* tail: in text mode it is returned as content, inside an unclosed
|
||||
* {@code <think>} it is returned as thinking — matching the post-stream
|
||||
* fallback parser's semantics for unterminated tags.
|
||||
* <p>
|
||||
* Not thread-safe. One instance per streamed LLM call; Reactor serializes
|
||||
* {@code doOnNext} so no synchronization is needed.
|
||||
*/
|
||||
final class ThinkTagStreamExtractor {
|
||||
|
||||
/** Split result of one {@link #feed} / {@link #flush} call; fields are never null. */
|
||||
record Extracted(String content, String thinking) {
|
||||
static final Extracted EMPTY = new Extracted("", "");
|
||||
}
|
||||
|
||||
private static final String OPEN_TAG = "<think>";
|
||||
private static final String CLOSE_TAG = "</think>";
|
||||
|
||||
/** Carry-over between chunks: a chunk tail that may still become a tag. */
|
||||
private final StringBuilder pending = new StringBuilder();
|
||||
private boolean insideThink;
|
||||
private boolean disabled;
|
||||
|
||||
/**
|
||||
* Turn extraction off for the rest of the stream. Called when structured
|
||||
* reasoning content shows up — such a model never tag-wraps its thinking,
|
||||
* so any literal tag text in the answer is real content. Thinking already
|
||||
* extracted stays extracted; a held-back tail is returned as content on
|
||||
* the next {@link #feed} / {@link #flush}.
|
||||
*/
|
||||
void disable() {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
/** Split one content chunk into its content and thinking parts. */
|
||||
Extracted feed(String chunk) {
|
||||
if (chunk == null || chunk.isEmpty()) {
|
||||
return Extracted.EMPTY;
|
||||
}
|
||||
if (disabled) {
|
||||
if (pending.isEmpty()) {
|
||||
return new Extracted(chunk, "");
|
||||
}
|
||||
String held = pending.toString();
|
||||
pending.setLength(0);
|
||||
return new Extracted(held + chunk, "");
|
||||
}
|
||||
pending.append(chunk);
|
||||
String buf = pending.toString();
|
||||
pending.setLength(0);
|
||||
|
||||
StringBuilder content = new StringBuilder();
|
||||
StringBuilder thinking = new StringBuilder();
|
||||
int i = 0;
|
||||
while (i < buf.length()) {
|
||||
String tag = insideThink ? CLOSE_TAG : OPEN_TAG;
|
||||
StringBuilder out = insideThink ? thinking : content;
|
||||
int idx = buf.indexOf(tag, i);
|
||||
if (idx >= 0) {
|
||||
out.append(buf, i, idx);
|
||||
i = idx + tag.length();
|
||||
insideThink = !insideThink;
|
||||
} else {
|
||||
int hold = holdbackStart(buf, i, tag);
|
||||
out.append(buf, i, hold);
|
||||
pending.append(buf, hold, buf.length());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new Extracted(content.toString(), thinking.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the held-back tail once the stream is over. Inside an unclosed
|
||||
* {@code <think>} the remainder counts as thinking, otherwise as content.
|
||||
*/
|
||||
Extracted flush() {
|
||||
if (pending.isEmpty()) {
|
||||
return Extracted.EMPTY;
|
||||
}
|
||||
String rest = pending.toString();
|
||||
pending.setLength(0);
|
||||
return insideThink ? new Extracted("", rest) : new Extracted(rest, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest index {@code s >= from} such that {@code buf[s..)} is a
|
||||
* non-empty proper prefix of {@code tag}; {@code buf.length()} when the
|
||||
* tail cannot start a tag. Only the last {@code tag.length() - 1} chars
|
||||
* can qualify — a full tag would have been found by {@code indexOf}.
|
||||
*/
|
||||
private static int holdbackStart(String buf, int from, String tag) {
|
||||
int len = buf.length();
|
||||
int earliest = Math.max(from, len - tag.length() + 1);
|
||||
for (int s = earliest; s < len; s++) {
|
||||
if (isProperPrefixOfTag(buf, s, tag)) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
private static boolean isProperPrefixOfTag(String buf, int start, String tag) {
|
||||
int n = buf.length() - start;
|
||||
if (n <= 0 || n >= tag.length()) {
|
||||
return false;
|
||||
}
|
||||
for (int k = 0; k < n; k++) {
|
||||
if (buf.charAt(start + k) != tag.charAt(k)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,11 @@ public class ReasoningDispatcher implements EdgeAction {
|
||||
return LIMIT_EXCEEDED_NODE;
|
||||
}
|
||||
|
||||
if (accessor.continueReasoning()) {
|
||||
log.info("[ReasoningDispatcher] Completion gate requested another reasoning pass");
|
||||
return REASONING_NODE;
|
||||
}
|
||||
|
||||
// 2. 可直接回答 → finalAnswerNode
|
||||
// 覆盖以下场景:
|
||||
// - LLM 正常产出最终回答 (needsToolCall=false, finalAnswer 非空)
|
||||
|
||||
@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
||||
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
||||
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
||||
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
||||
@ -513,6 +514,30 @@ public class ToolExecutionExecutor {
|
||||
|
||||
for (int i = 0; i < effectiveCalls.size(); i++) {
|
||||
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
||||
// Keep the provider-facing response name paired with the function
|
||||
// name emitted by the model. The execution name may be rewritten
|
||||
// below (tool_call -> real target), but Gemini pairs a
|
||||
// functionResponse by name rather than OpenAI's call_id alone.
|
||||
String responseName = toolCall.name();
|
||||
// Hermes-style deferred tool proxy: unwrap tool_call before any
|
||||
// policy decision so guard, approval, audit, concurrency and UI
|
||||
// all operate on the real tool. The executor's callback map is
|
||||
// already scoped to this agent, making it the final authority for
|
||||
// whether the requested target may be invoked.
|
||||
if (ProgressiveToolBridgeTool.CALL.equals(resolveToolName(toolCall.name()))) {
|
||||
BridgeUnwrap unwrap = unwrapBridgeCall(toolCall);
|
||||
if (unwrap.error() != null) {
|
||||
events.add(GraphEventPublisher.toolStart(
|
||||
toolCall.id(), ProgressiveToolBridgeTool.CALL, toolCall.arguments()));
|
||||
events.add(GraphEventPublisher.toolComplete(
|
||||
toolCall.id(), ProgressiveToolBridgeTool.CALL, unwrap.error(), false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), responseName, unwrap.error()));
|
||||
continue;
|
||||
}
|
||||
toolCall = unwrap.toolCall();
|
||||
log.info("[ToolExecutor] Progressive bridge unwrapped tool_call -> {}", toolCall.name());
|
||||
}
|
||||
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
|
||||
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
|
||||
// bypass guard rules keyed on the canonical name.
|
||||
@ -545,7 +570,7 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, msg));
|
||||
toolCall.id(), responseName, msg));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -560,7 +585,7 @@ public class ToolExecutionExecutor {
|
||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, truncationError));
|
||||
toolCall.id(), responseName, truncationError));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -572,13 +597,13 @@ public class ToolExecutionExecutor {
|
||||
|
||||
if (decision.blocked) {
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, decision.response));
|
||||
toolCall.id(), responseName, decision.response));
|
||||
continue;
|
||||
}
|
||||
if (decision.needsApproval) {
|
||||
// Barrier: 当前工具创建审批,后续工具不执行
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, decision.response));
|
||||
toolCall.id(), responseName, decision.response));
|
||||
// 标记后续工具为等待审批
|
||||
for (int j = i + 1; j < effectiveCalls.size(); j++) {
|
||||
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
|
||||
@ -597,7 +622,7 @@ public class ToolExecutionExecutor {
|
||||
if (toolName.startsWith("$")) {
|
||||
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
|
||||
toolCall.id(), responseName, "Provider builtin tool executed server-side"));
|
||||
continue;
|
||||
}
|
||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||
@ -610,20 +635,20 @@ public class ToolExecutionExecutor {
|
||||
events.add(GraphEventPublisher.toolComplete(
|
||||
toolCall.id(), toolName, redirect.response(), true));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, redirect.response()));
|
||||
toolCall.id(), responseName, redirect.response()));
|
||||
continue;
|
||||
}
|
||||
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
|
||||
log.warn("[ToolExecutor] {}", msg);
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, msg));
|
||||
toolCall.id(), responseName, msg));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 分类: concurrencySafe
|
||||
boolean safe = isConcurrencySafe(toolName);
|
||||
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
|
||||
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
|
||||
// 占位,Phase 2 填充
|
||||
allResponses.add(null);
|
||||
@ -709,7 +734,12 @@ public class ToolExecutionExecutor {
|
||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
|
||||
}
|
||||
|
||||
Thread executionThread = Thread.currentThread();
|
||||
Runnable removeCancellationHook = streamTracker != null
|
||||
? streamTracker.registerCancellationHook(conversationId, executionThread::interrupt)
|
||||
: () -> { };
|
||||
try {
|
||||
throwIfStopRequested(conversationId);
|
||||
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
||||
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
||||
// still observe the originating ChatOrigin (channel/workspace).
|
||||
@ -719,7 +749,8 @@ public class ToolExecutionExecutor {
|
||||
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||
.withConversationId(conversationId)
|
||||
.withWorkspace(null, workspaceBasePath);
|
||||
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
|
||||
throwIfStopRequested(conversationId);
|
||||
int rawLen = result != null ? result.length() : 0;
|
||||
|
||||
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||
@ -754,6 +785,8 @@ public class ToolExecutionExecutor {
|
||||
// leaving the broadcast tool-result panel unchanged.
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||
} catch (CancellationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
||||
String safeError = isReturnDirect(callback)
|
||||
@ -761,6 +794,11 @@ public class ToolExecutionExecutor {
|
||||
: "Tool execution failed: " + e.getMessage();
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
||||
} finally {
|
||||
removeCancellationHook.run();
|
||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -801,6 +839,7 @@ public class ToolExecutionExecutor {
|
||||
List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls);
|
||||
|
||||
for (List<PreparedToolCall> batch : batches) {
|
||||
throwIfStopRequested(preparedCalls.isEmpty() ? null : preparedCalls.get(0).conversationId);
|
||||
if (batch.size() == 1) {
|
||||
// 单个工具(safe 或 unsafe),直接执行
|
||||
PreparedToolCall pc = batch.get(0);
|
||||
@ -868,18 +907,31 @@ public class ToolExecutionExecutor {
|
||||
// 等待所有并行工具完成,按原始顺序填入结果
|
||||
for (var entry : futures.entrySet()) {
|
||||
try {
|
||||
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
|
||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
||||
futures.values().forEach(future -> future.cancel(true));
|
||||
throw new CancellationException("Stream stopped by user during tool execution");
|
||||
}
|
||||
// 按工具名查找配置的超时时间
|
||||
PreparedToolCall matchedPc = batch.stream()
|
||||
.filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
|
||||
long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null);
|
||||
ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
allResponses.set(entry.getKey(), response);
|
||||
} catch (CancellationException e) {
|
||||
futures.values().forEach(future -> future.cancel(true));
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
|
||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
||||
futures.values().forEach(future -> future.cancel(true));
|
||||
throw new CancellationException("Stream stopped by user during tool execution");
|
||||
}
|
||||
// 超时或异常 — 填入错误响应
|
||||
PreparedToolCall pc = batch.stream()
|
||||
.filter(p -> p.resultIndex == entry.getKey())
|
||||
.findFirst().orElse(null);
|
||||
String toolName = pc != null ? pc.toolCall.name() : "unknown";
|
||||
String toolName = pc != null ? pc.responseName : "unknown";
|
||||
String toolId = pc != null ? pc.toolCall.id() : "";
|
||||
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
|
||||
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
|
||||
@ -895,7 +947,12 @@ public class ToolExecutionExecutor {
|
||||
List<GraphEventPublisher.GraphEvent> events,
|
||||
List<DirectToolOutput> directOutputs) {
|
||||
String toolName = pc.toolCall.name();
|
||||
Thread executionThread = Thread.currentThread();
|
||||
Runnable removeCancellationHook = streamTracker != null
|
||||
? streamTracker.registerCancellationHook(pc.conversationId, executionThread::interrupt)
|
||||
: () -> { };
|
||||
try {
|
||||
throwIfStopRequested(pc.conversationId);
|
||||
if (streamTracker != null) {
|
||||
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
|
||||
@ -917,7 +974,7 @@ public class ToolExecutionExecutor {
|
||||
runtimeOrigin = runtimeOrigin
|
||||
.withConversationId(pc.conversationId)
|
||||
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||
ToolContext toolContext = runtimeOrigin.toToolContext();
|
||||
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
|
||||
|
||||
// MCP progress: generate progressToken and inject into ToolContext
|
||||
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
||||
@ -931,6 +988,7 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
|
||||
result = pc.callback.call(pc.arguments, toolContext);
|
||||
throwIfStopRequested(pc.conversationId);
|
||||
} finally {
|
||||
if (progressToken != null) {
|
||||
progressContext.remove(progressToken);
|
||||
@ -968,7 +1026,7 @@ public class ToolExecutionExecutor {
|
||||
// any subsequent LLM round (the graph won't take a next round —
|
||||
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||
pc.toolCall.id(), pc.responseName, DIRECT_TOOL_PLACEHOLDER);
|
||||
}
|
||||
|
||||
// Capture SourceEvidenceLedger from the RAW result, before truncate/
|
||||
@ -1008,7 +1066,13 @@ public class ToolExecutionExecutor {
|
||||
// Append the card-rendering directive to the LLM-facing response only,
|
||||
// leaving the broadcast tool-result panel unchanged.
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||
pc.toolCall.id(), pc.responseName,
|
||||
withProductCardDirective(toolName, result != null ? result : ""));
|
||||
} catch (CancellationException e) {
|
||||
if (streamTracker != null) {
|
||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||
}
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
||||
// RFC-052: for returnDirect tools, even the error message is
|
||||
@ -1026,7 +1090,21 @@ public class ToolExecutionExecutor {
|
||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||
}
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
pc.toolCall.id(), toolName, reportedError);
|
||||
pc.toolCall.id(), pc.responseName, reportedError);
|
||||
} finally {
|
||||
removeCancellationHook.run();
|
||||
// Virtual-thread workers are not reused, but single/unsafe calls
|
||||
// can execute on a Reactor worker. Do not leak Stop's interrupt bit
|
||||
// into unrelated work scheduled on that carrier.
|
||||
if (streamTracker != null && streamTracker.isStopRequested(pc.conversationId)) {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void throwIfStopRequested(String conversationId) {
|
||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
||||
throw new CancellationException("Stream stopped by user during tool execution");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1462,7 +1540,7 @@ public class ToolExecutionExecutor {
|
||||
+ "\",\"filePath\":\"SKILL.md\"}";
|
||||
String skillMd;
|
||||
try {
|
||||
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
||||
ToolContext ctx = toolContextWithScopedCatalog(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
skillMd = readSkillFile.call(redirectArgs, ctx);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
|
||||
@ -1487,10 +1565,104 @@ public class ToolExecutionExecutor {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a progressive {@code tool_call} envelope. Validation
|
||||
* happens before guard execution and never invokes a callback. In
|
||||
* particular, required-argument probing mirrors Hermes: an incomplete
|
||||
* call returns the target schema immediately instead of spending another
|
||||
* round on a guaranteed callback failure.
|
||||
*/
|
||||
private BridgeUnwrap unwrapBridgeCall(AssistantMessage.ToolCall bridgeCall) {
|
||||
try {
|
||||
var envelope = OBJECT_MAPPER.readTree(bridgeCall.arguments());
|
||||
String requestedName = textField(envelope, "toolName", "name");
|
||||
if (requestedName == null || requestedName.isBlank()) {
|
||||
return BridgeUnwrap.error("Error: tool_call requires an exact toolName.");
|
||||
}
|
||||
String targetName = resolveToolName(requestedName);
|
||||
if (ProgressiveToolBridgeTool.BRIDGE_NAMES.contains(targetName)) {
|
||||
return BridgeUnwrap.error("Error: tool_call cannot invoke a progressive bridge recursively.");
|
||||
}
|
||||
ToolCallback target = toolCallbackMap.get(targetName);
|
||||
if (target == null) {
|
||||
return BridgeUnwrap.error("Error: Tool '" + requestedName
|
||||
+ "' is not available to this agent. Use tool_search for scoped results.");
|
||||
}
|
||||
|
||||
var argsNode = envelope != null && envelope.has("arguments")
|
||||
? envelope.get("arguments")
|
||||
: envelope != null ? envelope.get("args") : null;
|
||||
String targetArguments;
|
||||
if (argsNode == null || argsNode.isNull()) {
|
||||
targetArguments = "{}";
|
||||
} else if (argsNode.isTextual()) {
|
||||
targetArguments = argsNode.asText();
|
||||
// A textual envelope is accepted for weaker models, but it
|
||||
// must itself contain valid JSON before proceeding.
|
||||
OBJECT_MAPPER.readTree(targetArguments);
|
||||
} else {
|
||||
targetArguments = OBJECT_MAPPER.writeValueAsString(argsNode);
|
||||
}
|
||||
|
||||
String missing = missingRequiredArguments(target, targetArguments);
|
||||
if (missing != null) {
|
||||
return BridgeUnwrap.error("Error: Missing required arguments for '" + targetName
|
||||
+ "': " + missing + ". Full input schema: "
|
||||
+ target.getToolDefinition().inputSchema());
|
||||
}
|
||||
return BridgeUnwrap.success(new AssistantMessage.ToolCall(
|
||||
bridgeCall.id(), bridgeCall.type(), targetName, targetArguments));
|
||||
} catch (Exception e) {
|
||||
return BridgeUnwrap.error("Error: invalid tool_call envelope: " + normalizeToolExecutionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
private static String textField(com.fasterxml.jackson.databind.JsonNode node, String... names) {
|
||||
if (node == null) return null;
|
||||
for (String name : names) {
|
||||
var value = node.get(name);
|
||||
if (value != null && value.isTextual()) return value.asText();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String missingRequiredArguments(ToolCallback callback, String arguments) {
|
||||
try {
|
||||
var schema = OBJECT_MAPPER.readTree(callback.getToolDefinition().inputSchema());
|
||||
var required = schema.get("required");
|
||||
if (required == null || !required.isArray() || required.isEmpty()) return null;
|
||||
var actual = OBJECT_MAPPER.readTree(arguments);
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (var name : required) {
|
||||
if (actual == null || !actual.has(name.asText()) || actual.get(name.asText()).isNull()) {
|
||||
missing.add(name.asText());
|
||||
}
|
||||
}
|
||||
return missing.isEmpty() ? null : String.join(", ", missing);
|
||||
} catch (Exception ignored) {
|
||||
// Bad third-party schemas should not make an otherwise valid tool
|
||||
// unreachable; the callback remains the source of truth.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the executor's immutable, agent-scoped callback snapshot into
|
||||
* catalog bridge calls. This makes tool_search/tool_describe observe the
|
||||
* exact same authority set that tool_call validates against.
|
||||
*/
|
||||
private ToolContext toolContextWithScopedCatalog(ChatOrigin origin) {
|
||||
ToolContext base = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
||||
Map<String, Object> context = new HashMap<>(base.getContext());
|
||||
context.put(ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY, toolCallbackMap);
|
||||
return new ToolContext(context);
|
||||
}
|
||||
|
||||
// ==================== 内部数据类 ====================
|
||||
|
||||
private record PreparedToolCall(
|
||||
AssistantMessage.ToolCall toolCall,
|
||||
String responseName,
|
||||
ToolCallback callback,
|
||||
String arguments,
|
||||
boolean concurrencySafe,
|
||||
@ -1520,6 +1692,16 @@ public class ToolExecutionExecutor {
|
||||
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
|
||||
) {}
|
||||
|
||||
private record BridgeUnwrap(AssistantMessage.ToolCall toolCall, String error) {
|
||||
static BridgeUnwrap success(AssistantMessage.ToolCall call) {
|
||||
return new BridgeUnwrap(call, null);
|
||||
}
|
||||
|
||||
static BridgeUnwrap error(String message) {
|
||||
return new BridgeUnwrap(null, message);
|
||||
}
|
||||
}
|
||||
|
||||
private record ApprovalBarrier(String pendingId, String toolName) {}
|
||||
|
||||
private static final class GuardDecision {
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package vip.mate.agent.graph.guard;
|
||||
|
||||
import vip.mate.agent.graph.state.ActionExecutionLedger;
|
||||
|
||||
/** Pure completion decision for action-required ReAct turns. */
|
||||
public final class ActionCompletionPolicy {
|
||||
|
||||
public static final int MAX_RETRIES = 1;
|
||||
|
||||
public enum Decision { ALLOW, RETRY, UNVERIFIED, FAILED }
|
||||
|
||||
private ActionCompletionPolicy() {
|
||||
}
|
||||
|
||||
public static Decision evaluate(boolean actionRequired, int retryCount,
|
||||
ActionExecutionLedger ledger) {
|
||||
if (!actionRequired) return Decision.ALLOW;
|
||||
ActionExecutionLedger evidence = ledger != null ? ledger : ActionExecutionLedger.empty();
|
||||
if (evidence.hasSuccessfulSubstantiveCall()) return Decision.ALLOW;
|
||||
if (evidence.hasSubstantiveAttempt()) return Decision.FAILED;
|
||||
return retryCount < MAX_RETRIES ? Decision.RETRY : Decision.UNVERIFIED;
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.agent.graph.state.ActionExecutionLedger;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
@ -51,6 +52,10 @@ public class ActionNode implements NodeAction {
|
||||
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
||||
private static final String ENABLE_TOOL = "enable_tool";
|
||||
|
||||
/** Progressive catalog inspection is setup; tool_call itself is real work. */
|
||||
private static final String TOOL_SEARCH = "tool_search";
|
||||
private static final String TOOL_DESCRIBE = "tool_describe";
|
||||
|
||||
/** Function name of the progress-update tool — skip auto-recording it. */
|
||||
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
|
||||
|
||||
@ -70,7 +75,7 @@ public class ActionNode implements NodeAction {
|
||||
* </ul>
|
||||
*/
|
||||
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
|
||||
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
|
||||
LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
|
||||
"listAvailableSkills", "readSkillFile", "runSkillScript",
|
||||
// read-only / status-query tools
|
||||
"read_file", "web_search",
|
||||
@ -148,12 +153,14 @@ public class ActionNode implements NodeAction {
|
||||
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
|
||||
? result.rawEvidenceLedger()
|
||||
: SourceEvidenceLedger.empty();
|
||||
ActionExecutionLedger actionLedger = ActionExecutionLedger.fromEvents(result.events());
|
||||
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
|
||||
.toolResults(result.responses())
|
||||
.messages(List.of((Message) toolResponseMessage))
|
||||
.currentPhase("action")
|
||||
.events(result.events())
|
||||
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
|
||||
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger))
|
||||
.actionExecutionLedger(accessor.actionExecutionLedger().merge(actionLedger));
|
||||
|
||||
if (result.awaitingApproval()) {
|
||||
output.awaitingApproval(true);
|
||||
@ -191,6 +198,10 @@ public class ActionNode implements NodeAction {
|
||||
// and pin them into the ProgressLedger so they survive context
|
||||
// compression and stay visible on every turn.
|
||||
pinSkillConstraints(conversationId, requestedSkills);
|
||||
if (actionLedger.hasSuccessfulTool(LOAD_SKILL_TOOL)
|
||||
&& loadedSkillsRequireAction(conversationId, requestedSkills)) {
|
||||
output.actionCompletionRequired(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Same mechanism for enable_tool
|
||||
@ -206,7 +217,7 @@ public class ActionNode implements NodeAction {
|
||||
// sees what it already did even if it forgot to call progress_update.
|
||||
// Skips meta-tools (load_skill, enable_tool, progress_update) and
|
||||
// doesn't overwrite LLM-authored entries.
|
||||
autoRecordToolCalls(conversationId, result.responses());
|
||||
autoRecordToolCalls(conversationId, result.responses(), actionLedger);
|
||||
|
||||
return output.build();
|
||||
}
|
||||
@ -260,6 +271,41 @@ public class ActionNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean loadedSkillsRequireAction(String conversationId, Set<String> skillNames) {
|
||||
if (skillRuntimeService == null) return false;
|
||||
Long workspaceId = executor.workspaceIdForConversation(conversationId);
|
||||
for (String skillName : skillNames) {
|
||||
try {
|
||||
vip.mate.skill.runtime.model.ResolvedSkill skill =
|
||||
skillRuntimeService.findActiveSkill(skillName, workspaceId);
|
||||
if (resolvedSkillRequiresActionCompletion(skill)) {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[ActionNode] Could not inspect action contract for skill '{}': {}",
|
||||
skillName, e.getMessage());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean manifestRequiresActionCompletion(vip.mate.skill.manifest.SkillManifest manifest) {
|
||||
if (manifest == null) return false;
|
||||
String type = manifest.getType();
|
||||
if (type != null && Set.of("mcp", "acp", "code").contains(type.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
return (manifest.getAllowedTools() != null && !manifest.getAllowedTools().isEmpty())
|
||||
|| (manifest.getScripts() != null && !manifest.getScripts().isEmpty());
|
||||
}
|
||||
|
||||
static boolean resolvedSkillRequiresActionCompletion(
|
||||
vip.mate.skill.runtime.model.ResolvedSkill skill) {
|
||||
if (skill == null) return false;
|
||||
return manifestRequiresActionCompletion(skill.getManifest())
|
||||
|| (skill.getScripts() != null && !skill.getScripts().isEmpty());
|
||||
}
|
||||
|
||||
// ==================== B5: Auto-record tool calls ====================
|
||||
|
||||
/**
|
||||
@ -274,6 +320,12 @@ public class ActionNode implements NodeAction {
|
||||
*/
|
||||
void autoRecordToolCalls(String conversationId,
|
||||
List<ToolResponseMessage.ToolResponse> responses) {
|
||||
autoRecordToolCalls(conversationId, responses, null);
|
||||
}
|
||||
|
||||
void autoRecordToolCalls(String conversationId,
|
||||
List<ToolResponseMessage.ToolResponse> responses,
|
||||
ActionExecutionLedger executionLedger) {
|
||||
if (progressLedgerService == null || conversationId == null
|
||||
|| conversationId.isBlank() || responses == null || responses.isEmpty()) {
|
||||
return;
|
||||
@ -282,6 +334,12 @@ public class ActionNode implements NodeAction {
|
||||
// N separate lock+load+save cycles when the LLM calls tools in parallel.
|
||||
List<vip.mate.agent.progress.ProgressLedgerService.AutoRecordEntry> batch = new java.util.ArrayList<>();
|
||||
for (ToolResponseMessage.ToolResponse resp : responses) {
|
||||
if (executionLedger != null) {
|
||||
ActionExecutionLedger.Receipt receipt = executionLedger.receipts().get(resp.id());
|
||||
if (receipt == null || receipt.status() != ActionExecutionLedger.Status.SUCCEEDED) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
String toolName = resp.name();
|
||||
if (toolName == null || toolName.isBlank() || AUTO_RECORD_SKIP.contains(toolName)) {
|
||||
continue;
|
||||
|
||||
@ -139,7 +139,12 @@ public class FinalAnswerNode implements NodeAction {
|
||||
} else if (!existingAnswer.isEmpty()) {
|
||||
// 来自 reasoning 直接回答(或 stopped partial)
|
||||
finalAnswer = existingAnswer;
|
||||
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
|
||||
// FINAL_THINKING wins here: every writer of FINAL_ANSWER on this path
|
||||
// sets it in the same node output, so it is the reasoning that produced
|
||||
// this very answer. CURRENT_THINKING is REPLACE and was last written by
|
||||
// a tool-calling iteration, so preferring it swapped in an earlier
|
||||
// round's reasoning on any turn that used tools.
|
||||
finalThinking = !existingThinking.isEmpty() ? existingThinking : currentThinking;
|
||||
// 尊重上游已设的 finishReason(如 STOPPED),只有未设时才默认 NORMAL
|
||||
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
|
||||
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",
|
||||
|
||||
@ -41,7 +41,7 @@ public class ObservationNode implements NodeAction {
|
||||
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
||||
*/
|
||||
private static final java.util.Set<String> DISCLOSURE_TOOLS =
|
||||
java.util.Set.of("load_skill", "enable_tool");
|
||||
java.util.Set.of("load_skill", "enable_tool", "tool_search", "tool_describe");
|
||||
|
||||
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
|
||||
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
|
||||
|
||||
@ -30,6 +30,7 @@ import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.agent.graph.guard.ActionCompletionPolicy;
|
||||
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.team.service.TeamContextBuilder;
|
||||
@ -59,7 +60,7 @@ public class ReasoningNode implements NodeAction {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
|
||||
return MateClawStateAccessor.output();
|
||||
return MateClawStateAccessor.output().continueReasoning(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,9 +139,8 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
||||
private static final String EMPTY_COMPLETION_NUDGE =
|
||||
"Your previous turn was empty. If the task is not yet complete, continue now "
|
||||
+ "with the next concrete step — call a tool or write the next part. If every "
|
||||
+ "required step is already done, output the final answer to the user now.";
|
||||
"上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:"
|
||||
+ "调用工具或写出下一部分。如果所有必要步骤都已完成,请立即输出面向用户的最终答复。";
|
||||
|
||||
/**
|
||||
* Continuation nudge for the most common premature-stop pattern: an empty
|
||||
@ -257,8 +257,8 @@ public class ReasoningNode implements NodeAction {
|
||||
+ "\"调研 10 个模型\"、\"逐节起草报告\"、\"批量生成 N 份文档\"、\n"
|
||||
+ "\"依次调用 N 个 API\"、\"对每个文件执行同一操作\"等。\n\n"
|
||||
+ "**必须做的事**:\n"
|
||||
+ "1. **第一轮回复就用并行 tool_calls 批量注册全部子目标为 `pending`**\n"
|
||||
+ " 一条回复里 N 个 `progress_update` 同时发出(不要串行)。\n"
|
||||
+ "1. **第一轮回复就用并行 tool_calls 批量注册子目标为 `pending`**\n"
|
||||
+ " 每批最多 16 个 `progress_update`;超过 16 个时分批注册,避免超过执行器上限。\n"
|
||||
+ " 例:要调研 10 个模型,第一轮就发 10 个 `progress_update(stepKey=\"model_xxx\", status=\"pending\")`。\n"
|
||||
+ "2. **每开始一个子目标**前发 `progress_update(同 stepKey, status=\"in_progress\")`。\n"
|
||||
+ "3. **每完成一个子目标**后立即发 `progress_update(同 stepKey, status=\"done\")`。\n"
|
||||
@ -273,6 +273,30 @@ public class ReasoningNode implements NodeAction {
|
||||
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
|
||||
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
|
||||
|
||||
/**
|
||||
* Staleness guard appended to every ReasoningNode system prompt. Conversation
|
||||
* history can carry earlier rounds of the same status query verbatim (people
|
||||
* count, device state, quotes, timestamps); models pattern-match those rounds
|
||||
* and answer from the stale snapshot before this turn's tools have run —
|
||||
* sometimes while claiming they already re-queried. Static text so the
|
||||
* prompt-cache prefix stays stable; the current time it refers to is injected
|
||||
* per turn by the runtime context block.
|
||||
*/
|
||||
private static final String STALE_CONTEXT_GUARD = "\n\n"
|
||||
+ "## 历史状态数据过期规则(强制)\n\n"
|
||||
+ "- 会话历史中出现的一切状态类数据(在线人数、设备/传感器状态、电量、温度、库存、行情、"
|
||||
+ "查询时间戳等)都只是当时的快照,一律视为已过期,禁止在本轮回答中直接引用或改写后引用。\n"
|
||||
+ "- 状态类问题必须先在本轮调用工具取得最新观察结果,等结果返回后再输出结论;"
|
||||
+ "工具结果返回之前,不得输出含具体数值或结论的正文,只允许一句简短的过程说明(如\"正在查询…\")。\n"
|
||||
+ "- 最终回答中引用的状态数值与查询时间必须来自本轮工具返回,时间基准以运行时上下文注入的当前时间为准。\n"
|
||||
+ "- 注意:\"历史里查过\"不等于\"本轮已查\"。宣称已重新查询但未在本轮实际发出对应 tool_call,视为违规。\n";
|
||||
|
||||
private static final String LANGUAGE_CONSISTENCY_GUARD = "\n\n"
|
||||
+ "## 语言一致性(强制)\n\n"
|
||||
+ "- 用户使用中文时,所有可见思考、过程说明、最终答复都必须使用简体中文。\n"
|
||||
+ "- 不要用英文书写可见思考或推理过程;代码、工具名、参数名、API 字段和专有名词可以保留原文。\n"
|
||||
+ "- 如果工具结果或历史内容是英文,你可以阅读它,但面向用户展示的解释和推理必须翻译/转述为用户语言。\n";
|
||||
|
||||
private static final String GROUNDED_CONTRACT = "\n\n"
|
||||
+ "## 回答来源约束(强制规则)\n\n"
|
||||
+ "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n"
|
||||
@ -286,8 +310,8 @@ public class ReasoningNode implements NodeAction {
|
||||
+ "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n"
|
||||
+ "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n";
|
||||
|
||||
private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
|
||||
String prompt = basePrompt + TOOL_USE_ENFORCEMENT;
|
||||
static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
|
||||
String prompt = basePrompt + TOOL_USE_ENFORCEMENT + STALE_CONTEXT_GUARD + LANGUAGE_CONSISTENCY_GUARD;
|
||||
return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt;
|
||||
}
|
||||
|
||||
@ -1143,6 +1167,57 @@ public class ReasoningNode implements NodeAction {
|
||||
.build();
|
||||
} else {
|
||||
String content = result.text();
|
||||
ActionCompletionPolicy.Decision completionDecision = ActionCompletionPolicy.evaluate(
|
||||
accessor.actionCompletionRequired(), accessor.actionCompletionRetryCount(),
|
||||
accessor.actionExecutionLedger());
|
||||
if (completionDecision == ActionCompletionPolicy.Decision.RETRY) {
|
||||
log.warn("[ReasoningNode] Rejecting text-only action completion; continuing once");
|
||||
UserMessage continuation = new UserMessage("""
|
||||
[Runtime completion gate]
|
||||
This turn requires a real tool-backed action, but no substantive tool call was observed.
|
||||
Continue now by emitting the required tool call. Do not claim success or only describe the call.
|
||||
""");
|
||||
return reasonOutput()
|
||||
.continueReasoning(true)
|
||||
.actionCompletionRetryCount(accessor.actionCompletionRetryCount() + 1)
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer("")
|
||||
.clearFinishReason()
|
||||
.messages(List.of((Message) result.assistantMessage(), continuation))
|
||||
.currentPhase("reasoning")
|
||||
.streamedContent(content != null ? content : "")
|
||||
.streamedThinking(result.thinking())
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
||||
.build();
|
||||
}
|
||||
if (completionDecision == ActionCompletionPolicy.Decision.UNVERIFIED
|
||||
|| completionDecision == ActionCompletionPolicy.Decision.FAILED) {
|
||||
boolean failed = completionDecision == ActionCompletionPolicy.Decision.FAILED;
|
||||
String guardedAnswer = failed
|
||||
? "动作工具执行失败,未确认操作成功。请检查工具返回的错误后重试。"
|
||||
: "未观察到实际的动作工具调用,因此没有执行或确认该操作。请重试。";
|
||||
log.warn("[ReasoningNode] Blocking unsupported action completion: {}", completionDecision);
|
||||
return reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer(guardedAnswer)
|
||||
.finalThinking(result.thinking())
|
||||
.messages(List.of((Message) result.assistantMessage()))
|
||||
.currentPhase("reasoning")
|
||||
.streamedContent("")
|
||||
.finishReason(failed ? FinishReason.ACTION_FAILED : FinishReason.ACTION_UNVERIFIED)
|
||||
.contentStreamed(false)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
||||
.build();
|
||||
}
|
||||
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
||||
pushPhase(conversationId, "drafting_answer", Map.of(
|
||||
"iteration", accessor.iterationCount(),
|
||||
|
||||
@ -152,9 +152,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
// token usage into the turn's _usage_final and to clear the accumulator
|
||||
// on terminal so an errored turn never leaks an entry.
|
||||
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
|
||||
// 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容
|
||||
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||
// Step results are persisted by PlanningService and the plan_step_completed
|
||||
// event (metadata.plan.stepResults). They must never be appended to the
|
||||
// assistant message body: FINAL_SUMMARY is the sole canonical body. Keeping
|
||||
// the two channels separate prevents one-step plans from rendering/persisting
|
||||
// "answeranswer" and keeps live output identical to history replay.
|
||||
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
||||
// 最终汇总同样需要游标:FINAL_SUMMARY / FINAL_SUMMARY_THINKING 也是 REPLACE,
|
||||
// 一旦写入就会出现在此后每个 NodeOutput 上。
|
||||
AtomicReference<String> lastPersistedSummary = new AtomicReference<>("");
|
||||
AtomicReference<String> lastPersistedSummaryThinking = new AtomicReference<>("");
|
||||
AtomicReference<String> lastPersistedPlanThinking = new AtomicReference<>("");
|
||||
|
||||
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||
.flatMapIterable(output -> {
|
||||
@ -176,17 +184,22 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
boolean thinkingAlreadyStreamed = output.state()
|
||||
.value(MateClawStateKeys.THINKING_STREAMED, false);
|
||||
|
||||
// 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE,
|
||||
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
||||
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
||||
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
|
||||
// 2·0 规划阶段的推理。它先于计划本身发出,且是整轮唯一必然发生的
|
||||
// 一段推理 —— 步骤被派发到别处执行时,step / summary 两段
|
||||
// 根本不会产生,此前这一轮就一段思考都不落库。
|
||||
output.state().<String>value(PlanStateKeys.PLAN_THINKING)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.filter(s -> !s.equals(lastPersistedStepResult.get()))
|
||||
.ifPresent(stepContent -> {
|
||||
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
|
||||
lastPersistedStepResult.set(stepContent);
|
||||
.filter(s -> !s.equals(lastPersistedPlanThinking.get()))
|
||||
.ifPresent(planThinking -> {
|
||||
lastPersistedPlanThinking.set(planThinking);
|
||||
deltas.add(AgentService.StreamDelta.persistOnly(null, planThinking));
|
||||
});
|
||||
|
||||
// 2a. Step reasoning may remain in the diagnostic timeline, but
|
||||
// CURRENT_STEP_RESULT deliberately does not become a content
|
||||
// delta. The result is already durable in the plan record and
|
||||
// plan_step_completed metadata; only FINAL_SUMMARY belongs in
|
||||
// mate_message.content.
|
||||
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
|
||||
@ -195,18 +208,31 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
lastPersistedStepThinking.set(stepThinking);
|
||||
});
|
||||
|
||||
// 2b. 最终汇总
|
||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||
: new AgentService.StreamDelta(summary, null)));
|
||||
|
||||
// 2b. 最终汇总(同样 thinking 先于 content)
|
||||
// 两个 key 都是 REPLACE:值会滞留在后续每个 NodeOutput 里。
|
||||
// 没有游标时每批都会重发一次 —— 汇总正文被反复追加进
|
||||
// mate_message.content,而 thinking 会在正文之后再落一段,
|
||||
// 于是气泡末尾挂出一个孤立的思考框。与 2a 的 step 级
|
||||
// 去重保持同一套写法。
|
||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking)));
|
||||
.filter(s -> !s.equals(lastPersistedSummaryThinking.get()))
|
||||
.ifPresent(thinking -> {
|
||||
lastPersistedSummaryThinking.set(thinking);
|
||||
deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking));
|
||||
});
|
||||
|
||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.filter(s -> !s.equals(lastPersistedSummary.get()))
|
||||
.ifPresent(summary -> {
|
||||
lastPersistedSummary.set(summary);
|
||||
deltas.add(contentAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||
: new AgentService.StreamDelta(summary, null));
|
||||
});
|
||||
|
||||
// 3. 更新最新累计 token usage
|
||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||
|
||||
@ -501,7 +501,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
// 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);
|
||||
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId, persistGoal);
|
||||
if (parked instanceof TeamPlanBridge.Settled settled) {
|
||||
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
|
||||
settled.planId(), settled.completedResults().size());
|
||||
@ -520,7 +520,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
|
||||
log.info("[PlanGeneration] Delegated plan still in flight — answering with progress");
|
||||
log.info("[PlanGeneration] Answering from delegated team state without triage LLM");
|
||||
if (streamingHelper != null) {
|
||||
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
|
||||
}
|
||||
@ -590,6 +590,26 @@ public class PlanGenerationNode implements NodeAction {
|
||||
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
|
||||
}
|
||||
if (leadTeam != null) {
|
||||
List<String> missingNamedMembers = teamPlanBridge.namedAgentsOutsideRoster(
|
||||
leadTeam, persistGoal,
|
||||
listDelegatableAgents(chatOrigin.workspaceId(), agentId));
|
||||
if (!missingNamedMembers.isEmpty()) {
|
||||
String answer = "团队成员校验未通过:当前团队不包含「"
|
||||
+ String.join("、", missingNamedMembers)
|
||||
+ "」。请先将缺失的 Agent 加入团队后重试,或明确允许使用现有成员替代。";
|
||||
if (streamingHelper != null) {
|
||||
streamingHelper.broadcastContent(conversationId, answer);
|
||||
}
|
||||
log.info("[PlanGeneration] Team {} missing explicitly requested agents: {}",
|
||||
leadTeam.getId(), missingNamedMembers);
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer(answer)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
String memberLines = teamPlanBridge.roster(leadTeam).stream()
|
||||
.map(a -> "- " + a.getName()
|
||||
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||
@ -601,7 +621,9 @@ public class PlanGenerationNode implements NodeAction {
|
||||
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
|
||||
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。"
|
||||
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
|
||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
|
||||
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
|
||||
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。"));
|
||||
} else {
|
||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||
if (!delegatable.isEmpty()) {
|
||||
@ -664,7 +686,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
|
||||
String llmResponse = result.text();
|
||||
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
|
||||
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
|
||||
log.debug("[PlanGeneration] LLM response received ({} chars)",
|
||||
llmResponse == null ? 0 : llmResponse.length());
|
||||
|
||||
// D-6: emit triage perf summary
|
||||
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
|
||||
@ -673,7 +696,17 @@ public class PlanGenerationNode implements NodeAction {
|
||||
"completion_tokens", result.completionTokens()
|
||||
)));
|
||||
|
||||
TriageResult triage = converter.convert(llmResponse);
|
||||
TriageResult triage;
|
||||
if (!StringUtils.hasText(llmResponse)) {
|
||||
// An upstream model can occasionally finish without content.
|
||||
// Treat it as a recoverable single-step route, not a parser
|
||||
// exception (and therefore not a false backend ERROR).
|
||||
log.warn("[PlanGeneration] Triage returned empty content; using single-step fallback");
|
||||
triage = new TriageResult(true, null, "single_step",
|
||||
List.of(persistGoal), null, null);
|
||||
} else {
|
||||
triage = converter.convert(llmResponse);
|
||||
}
|
||||
boolean needsPlanning = triage != null && triage.needsPlanning();
|
||||
|
||||
if (!needsPlanning) {
|
||||
@ -699,7 +732,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.planValid(true)
|
||||
.currentStepIndex(0)
|
||||
.currentPhase("plan_generated")
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
.build();
|
||||
@ -714,6 +748,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.directAnswer(directAnswer)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
@ -755,7 +790,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.directAnswer(announcement)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
.build();
|
||||
@ -799,7 +835,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.currentStepIndex(0)
|
||||
.currentPhase("plan_generated")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events);
|
||||
if (autoGoal != null) {
|
||||
|
||||
@ -85,32 +85,68 @@ public class PlanSummaryNode implements NodeAction {
|
||||
chatModel, prompt, conversationId, "plan_summary");
|
||||
|
||||
String summary = result.text();
|
||||
String thinking = result.thinking() == null ? "" : result.thinking();
|
||||
|
||||
// An interleaved-thinking model can spend its whole turn reasoning and
|
||||
// return empty text. That empty string used to pass straight through:
|
||||
// it became the plan's summary, then the run's terminal answer, and the
|
||||
// goal evaluator skipped on "terminalAnswer empty" — so a plan whose
|
||||
// steps had all succeeded ended with a dangling reasoning block and no
|
||||
// report. The step results are already in hand, so answer from those
|
||||
// rather than hand back nothing.
|
||||
if (summary == null || summary.isBlank()) {
|
||||
log.warn("[PlanSummary] Plan {} produced an empty summary "
|
||||
+ "(thinking={} chars, {} step results); falling back to step results",
|
||||
planId, thinking.length(), completedResults.size());
|
||||
summary = buildFallbackSummary(goal, completedResults, SUMMARY_EMPTY_NOTE);
|
||||
}
|
||||
|
||||
// The steps themselves succeeded — only the summary text was missing —
|
||||
// so the plan completes rather than being marked failed.
|
||||
planningService.completePlan(planId, summary);
|
||||
log.info("[PlanSummary] Plan {} completed with summary: {}",
|
||||
planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary);
|
||||
planId, truncate(summary, 100));
|
||||
|
||||
return PlanStateAccessor.output()
|
||||
.finalSummary(summary)
|
||||
.finalSummaryThinking(result.thinking())
|
||||
.finalSummaryThinking(thinking)
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.thinkingStreamed(!thinking.isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e);
|
||||
String fallbackSummary = buildFallbackSummary(goal, completedResults);
|
||||
String fallbackSummary = buildFallbackSummary(goal, completedResults, SUMMARY_FAILED_NOTE);
|
||||
planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100));
|
||||
return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reason line for the summary call throwing. */
|
||||
private static final String SUMMARY_FAILED_NOTE = "LLM 汇总失败,以下为步骤原始结果";
|
||||
|
||||
/**
|
||||
* 在 LLM 汇总调用失败时生成本地 fallback 摘要。
|
||||
* 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。
|
||||
* Reason line for the summary call returning nothing. Distinct from the
|
||||
* failure note because nothing actually failed — the steps ran, the model
|
||||
* simply produced no text — and telling the user their run failed would be
|
||||
* wrong.
|
||||
*/
|
||||
private static String buildFallbackSummary(String goal, List<String> completedResults) {
|
||||
StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要(LLM 汇总失败,以下为步骤原始结果):\n");
|
||||
private static final String SUMMARY_EMPTY_NOTE = "模型未产出汇总正文,以下为步骤原始结果";
|
||||
|
||||
/**
|
||||
* 在 LLM 汇总不可用时生成本地 fallback 摘要。
|
||||
* 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。
|
||||
*
|
||||
* @param note 说明为何回落到步骤原始结果
|
||||
*/
|
||||
private static String buildFallbackSummary(String goal, List<String> completedResults, String note) {
|
||||
StringBuilder sb = new StringBuilder("目标:").append(goal)
|
||||
.append("\n\n执行摘要(").append(note).append("):\n");
|
||||
if (completedResults == null || completedResults.isEmpty()) {
|
||||
sb.append("(没有已完成的步骤结果可供汇总)\n");
|
||||
return sb.toString();
|
||||
}
|
||||
for (String r : completedResults) {
|
||||
sb.append(truncate(r, 300)).append("\n");
|
||||
}
|
||||
|
||||
@ -605,6 +605,10 @@ public class StepExecutionNode implements NodeAction {
|
||||
return PlanStateAccessor.output()
|
||||
.currentStepResult(shortError)
|
||||
.currentPhase("plan_aborted")
|
||||
// Terminal failures still need a canonical assistant body.
|
||||
// CURRENT_STEP_RESULT no longer enters mate_message.content;
|
||||
// FINAL_SUMMARY is the single persistence/broadcast channel.
|
||||
.finalSummary(shortError)
|
||||
.contentStreamed(false)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
|
||||
@ -94,6 +94,10 @@ public final class PlanStateAccessor {
|
||||
return state.value(FINAL_SUMMARY_THINKING, "");
|
||||
}
|
||||
|
||||
public String planThinking() {
|
||||
return state.value(PLAN_THINKING, "");
|
||||
}
|
||||
|
||||
public String currentStepThinking() {
|
||||
return state.value(CURRENT_STEP_THINKING, "");
|
||||
}
|
||||
@ -225,6 +229,10 @@ public final class PlanStateAccessor {
|
||||
return put(FINAL_SUMMARY_THINKING, thinking);
|
||||
}
|
||||
|
||||
public OutputBuilder planThinking(String thinking) {
|
||||
return put(PLAN_THINKING, thinking);
|
||||
}
|
||||
|
||||
public OutputBuilder currentStepThinking(String thinking) {
|
||||
return put(CURRENT_STEP_THINKING, thinking);
|
||||
}
|
||||
|
||||
@ -56,6 +56,15 @@ public final class PlanStateKeys {
|
||||
/** 当前步骤的完整 thinking */
|
||||
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
|
||||
|
||||
/**
|
||||
* 规划阶段的完整 thinking —— 决定整个计划长什么样的那次推理。
|
||||
* <p>
|
||||
* It is the most consequential reasoning of the turn and the only one that
|
||||
* exists when the steps are dispatched elsewhere instead of executed in
|
||||
* this run, which is when the step / summary spans never happen at all.
|
||||
*/
|
||||
public static final String PLAN_THINKING = "plan_thinking";
|
||||
|
||||
// ===== 节点名称 =====
|
||||
public static final String PLAN_GENERATION_NODE = "plan_generation";
|
||||
public static final String STEP_EXECUTION_NODE = "step_execution";
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
package vip.mate.agent.graph.state;
|
||||
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Authoritative per-run tool completion receipts used by the action completion gate. */
|
||||
public final class ActionExecutionLedger {
|
||||
|
||||
private static final int MAX_RESULT_SUMMARY_CHARS = 512;
|
||||
private static final Set<String> NON_SUBSTANTIVE_TOOLS = Set.of(
|
||||
"load_skill", "enable_tool", "tool_search", "tool_describe",
|
||||
"progress_update", "get_progress");
|
||||
|
||||
public enum Status { SUCCEEDED, FAILED }
|
||||
|
||||
public record Receipt(String toolCallId, String toolName, Status status,
|
||||
String resultSummary, long completedAt) {
|
||||
public boolean substantive() {
|
||||
return toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
private static final ActionExecutionLedger EMPTY = new ActionExecutionLedger(Map.of());
|
||||
|
||||
private final Map<String, Receipt> receipts;
|
||||
|
||||
private ActionExecutionLedger(Map<String, Receipt> receipts) {
|
||||
this.receipts = Map.copyOf(receipts);
|
||||
}
|
||||
|
||||
public static ActionExecutionLedger empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public static ActionExecutionLedger fromEvents(List<GraphEventPublisher.GraphEvent> events) {
|
||||
if (events == null || events.isEmpty()) return empty();
|
||||
Map<String, Receipt> receipts = new LinkedHashMap<>();
|
||||
int legacyIndex = 0;
|
||||
for (GraphEventPublisher.GraphEvent event : events) {
|
||||
if (event == null || !GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())) continue;
|
||||
Map<String, Object> data = event.data();
|
||||
String id = String.valueOf(data.getOrDefault("toolCallId", ""));
|
||||
String name = String.valueOf(data.getOrDefault("toolName", ""));
|
||||
if (id.isBlank()) id = "legacy-" + name + "-" + legacyIndex++;
|
||||
boolean success = Boolean.parseBoolean(String.valueOf(data.getOrDefault("success", false)));
|
||||
String result = String.valueOf(data.getOrDefault("result", ""));
|
||||
if (result.length() > MAX_RESULT_SUMMARY_CHARS) {
|
||||
result = result.substring(0, MAX_RESULT_SUMMARY_CHARS) + "...";
|
||||
}
|
||||
receipts.put(id, new Receipt(id, name,
|
||||
success ? Status.SUCCEEDED : Status.FAILED, result, event.timestamp()));
|
||||
}
|
||||
return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts);
|
||||
}
|
||||
|
||||
public Map<String, Receipt> receipts() {
|
||||
return receipts;
|
||||
}
|
||||
|
||||
public boolean hasSubstantiveAttempt() {
|
||||
return receipts.values().stream().anyMatch(Receipt::substantive);
|
||||
}
|
||||
|
||||
public boolean hasSuccessfulSubstantiveCall() {
|
||||
return receipts.values().stream()
|
||||
.anyMatch(receipt -> receipt.substantive() && receipt.status() == Status.SUCCEEDED);
|
||||
}
|
||||
|
||||
public boolean hasSuccessfulTool(String toolName) {
|
||||
return receipts.values().stream().anyMatch(receipt ->
|
||||
receipt.status() == Status.SUCCEEDED && receipt.toolName().equals(toolName));
|
||||
}
|
||||
|
||||
public ActionExecutionLedger merge(ActionExecutionLedger other) {
|
||||
if (other == null || other.receipts.isEmpty()) return this;
|
||||
if (receipts.isEmpty()) return other;
|
||||
Map<String, Receipt> merged = new LinkedHashMap<>(receipts);
|
||||
merged.putAll(other.receipts);
|
||||
return new ActionExecutionLedger(merged);
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,12 @@ public enum FinishReason {
|
||||
/** 最终回答引用了未被工具结果验证的源码事实 */
|
||||
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
|
||||
|
||||
/** An executable action was required but no substantive tool call was observed. */
|
||||
ACTION_UNVERIFIED("action_unverified"),
|
||||
|
||||
/** Substantive action tools ran, but none completed successfully. */
|
||||
ACTION_FAILED("action_failed"),
|
||||
|
||||
/** 用户主动停止 */
|
||||
STOPPED("stopped"),
|
||||
|
||||
|
||||
@ -216,6 +216,22 @@ public final class MateClawStateAccessor {
|
||||
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
|
||||
}
|
||||
|
||||
public ActionExecutionLedger actionExecutionLedger() {
|
||||
return state.<ActionExecutionLedger>value(ACTION_EXECUTION_LEDGER).orElse(ActionExecutionLedger.empty());
|
||||
}
|
||||
|
||||
public boolean actionCompletionRequired() {
|
||||
return state.value(ACTION_COMPLETION_REQUIRED, false);
|
||||
}
|
||||
|
||||
public int actionCompletionRetryCount() {
|
||||
return state.value(ACTION_COMPLETION_RETRY_COUNT, 0);
|
||||
}
|
||||
|
||||
public boolean continueReasoning() {
|
||||
return state.value(CONTINUE_REASONING, false);
|
||||
}
|
||||
|
||||
// ===== 审批重放 =====
|
||||
|
||||
public String forcedToolCall() {
|
||||
@ -523,6 +539,22 @@ public final class MateClawStateAccessor {
|
||||
return put(SOURCE_EVIDENCE_LEDGER, ledger);
|
||||
}
|
||||
|
||||
public OutputBuilder actionExecutionLedger(ActionExecutionLedger ledger) {
|
||||
return put(ACTION_EXECUTION_LEDGER, ledger);
|
||||
}
|
||||
|
||||
public OutputBuilder actionCompletionRequired(boolean required) {
|
||||
return put(ACTION_COMPLETION_REQUIRED, required);
|
||||
}
|
||||
|
||||
public OutputBuilder actionCompletionRetryCount(int count) {
|
||||
return put(ACTION_COMPLETION_RETRY_COUNT, count);
|
||||
}
|
||||
|
||||
public OutputBuilder continueReasoning(boolean shouldContinue) {
|
||||
return put(CONTINUE_REASONING, shouldContinue);
|
||||
}
|
||||
|
||||
// ---- 审批重放 ----
|
||||
public OutputBuilder forcedToolCall(String json) {
|
||||
return put(FORCED_TOOL_CALL, json);
|
||||
|
||||
@ -184,6 +184,18 @@ public final class MateClawStateKeys {
|
||||
/** Source references observed from successful tool results during this run. */
|
||||
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
|
||||
|
||||
/** Authoritative terminal tool receipts accumulated during the current graph run. */
|
||||
public static final String ACTION_EXECUTION_LEDGER = "action_execution_ledger";
|
||||
|
||||
/** True when structured runtime context says this turn must perform an executable action. */
|
||||
public static final String ACTION_COMPLETION_REQUIRED = "action_completion_required";
|
||||
|
||||
/** Number of completion-gate continuations consumed in the current run. */
|
||||
public static final String ACTION_COMPLETION_RETRY_COUNT = "action_completion_retry_count";
|
||||
|
||||
/** One-shot edge signal routing a rejected final candidate back to ReasoningNode. */
|
||||
public static final String CONTINUE_REASONING = "continue_reasoning";
|
||||
|
||||
// ===== Persistent goal — cross-turn objective lock-in =====
|
||||
|
||||
/**
|
||||
|
||||
@ -827,7 +827,8 @@ public class ChannelMessageRouter {
|
||||
// through unchanged (chatId is null).
|
||||
List<MessageContentPart> parts = message.getContentParts();
|
||||
String attributedContent = applyGroupTag(message, message.getContent());
|
||||
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
conversationId, "user", attributedContent, parts);
|
||||
|
||||
// 构建 prompt(语音输入时注入场景提示词)
|
||||
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
||||
@ -853,7 +854,8 @@ public class ChannelMessageRouter {
|
||||
// so cron jobs created during this conversation inherit the
|
||||
// channel binding (Issue #25 root path).
|
||||
ChatOrigin chatOrigin = chatOriginFactory.from(
|
||||
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||
channelEntity, message, conversationId, /* workspaceBasePath */ null)
|
||||
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
|
||||
|
||||
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
||||
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
|
||||
@ -884,30 +886,47 @@ public class ChannelMessageRouter {
|
||||
// and IM channels still need the text for the outgoing reply),
|
||||
// segmentOnly narration excluded (issue #120).
|
||||
AgentStreamAccumulator accumulator = newAccumulator();
|
||||
// Narration lifecycle: relayed messages on this path are
|
||||
// permanent (IM messages cannot be retracted), so per-stage
|
||||
// narration publishes one behind through the shared tracker —
|
||||
// a pre-tool rehearsal (possibly a fabricated result table)
|
||||
// is dropped once later content supersedes it instead of
|
||||
// reaching the user verbatim.
|
||||
final ProvisionalContentTracker narrationTracker =
|
||||
new ProvisionalContentTracker(channelType);
|
||||
agentService.chatStructuredStream(agentId, promptText, conversationId,
|
||||
message.getSenderId(), chatOrigin)
|
||||
.doOnNext(delta -> {
|
||||
accumulator.accept(delta, conversationId);
|
||||
if (!delta.isEvent() && delta.segmentOnly()) {
|
||||
if (delta.isEvent()) {
|
||||
if ("tool_call_completed".equals(delta.eventType())) {
|
||||
narrationTracker.onToolObservation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (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.
|
||||
// one complete delta per agent loop iteration, each becoming
|
||||
// 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());
|
||||
String publishable = narrationTracker.stageNarration(narration, delta.kind());
|
||||
if (publishable != null) {
|
||||
relayNarrationSafely(adapter, replyTarget, publishable);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.blockLast(Duration.ofMinutes(10));
|
||||
String reply = accumulator.getContent();
|
||||
// The last narration was held back until the answer was
|
||||
// known: superseded → dropped, otherwise it still goes out
|
||||
// (before the final reply) unless it duplicates it.
|
||||
String heldNarration = narrationTracker.settle(!reply.isBlank());
|
||||
if (heldNarration != null && replyTarget != null
|
||||
&& !heldNarration.equals(reply.trim())) {
|
||||
relayNarrationSafely(adapter, replyTarget, heldNarration);
|
||||
}
|
||||
|
||||
// The IM sync path bypasses FinalAnswerNode, so hallucinated
|
||||
// /api/v1/files/generated/{id} URLs (LLM wrote a fake link
|
||||
@ -1290,6 +1309,19 @@ public class ChannelMessageRouter {
|
||||
return s == null || s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a progress narration as its own outgoing message. A failed send
|
||||
* must not abort the agent run — the final reply still goes out.
|
||||
*/
|
||||
private void relayNarrationSafely(ChannelAdapter adapter, String replyTarget, String narration) {
|
||||
try {
|
||||
adapter.renderAndSend(replyTarget, narration);
|
||||
} catch (Exception sendErr) {
|
||||
log.warn("[{}] Narration relay failed (non-fatal): {}",
|
||||
adapter.getChannelType(), sendErr.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式处理路径(渠道无关)
|
||||
* <p>
|
||||
@ -1566,14 +1598,16 @@ public class ChannelMessageRouter {
|
||||
// Mirror processMessage's group attribution for the streaming path
|
||||
// (Web channel today; future streaming IM channels inherit it).
|
||||
String attributedContent = applyGroupTag(message, message.getContent());
|
||||
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
conversationId, "user", attributedContent, parts);
|
||||
|
||||
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
||||
promptText = applyGroupTag(message, promptText);
|
||||
// RFC-063r §2.5: forward ChatOrigin so tools created during this
|
||||
// streaming conversation inherit channel binding.
|
||||
ChatOrigin origin = chatOriginFactory.from(
|
||||
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||
channelEntity, message, conversationId, /* workspaceBasePath */ null)
|
||||
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
|
||||
return agentService.chatStream(agentId, promptText, conversationId, origin);
|
||||
}
|
||||
|
||||
@ -1913,11 +1947,11 @@ public class ChannelMessageRouter {
|
||||
*/
|
||||
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
|
||||
if (chatUploadLocationResolver != null) {
|
||||
for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
|
||||
Path candidate = dir.resolve(fileName);
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
// Probes every candidate root and both layouts (flat + date
|
||||
// sub-directories), so TTS files written under a per-day dir resolve.
|
||||
Path found = chatUploadLocationResolver.resolveExistingFile(conversationId, fileName);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
// Fallback to the legacy default dir when the resolver is absent
|
||||
|
||||
@ -0,0 +1,198 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.ContentKind;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Single authority for the "provisional narration" lifecycle shared by every
|
||||
* user-facing surface.
|
||||
*
|
||||
* <p>A {@link ContentKind#PRE_TOOL_NARRATION} span is written before any tool
|
||||
* observation of its turn, in a completion that goes on to call tools — it may
|
||||
* be process narration or a fully fabricated rehearsal of the result. The
|
||||
* policy, identical everywhere:
|
||||
*
|
||||
* <ul>
|
||||
* <li>stage it instead of publishing (it may stay visible transiently, e.g.
|
||||
* in a live progress bubble or a running SSE segment);</li>
|
||||
* <li>the turn's next content span (grounded narration or final answer)
|
||||
* supersedes it — it must not become permanent output;</li>
|
||||
* <li>if the turn ends with no later content at all, it is committed: with
|
||||
* no replacement it is everything the user gets.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Grounded narrations and final answers never stage — they publish
|
||||
* directly. Producers that predate the kind tag emit {@code null} kinds; the
|
||||
* streaming API accepts a caller-supplied structural fallback signal for that
|
||||
* case, and the segment-marking API leaves untagged timelines to the legacy
|
||||
* structural detector.
|
||||
*
|
||||
* <p>Instances are single-turn and not thread-safe — create one per stream
|
||||
* consumption, confine to the consuming thread (matches how channel adapters
|
||||
* drain a turn's {@code Flux} today).
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProvisionalContentTracker {
|
||||
|
||||
/** Marker value stored in {@code supersededReason} — same wire value the
|
||||
* legacy structural detector writes, so the UI needs no new vocabulary. */
|
||||
public static final String REASON_PRE_TOOL_CONTENT_REPLACED =
|
||||
"pre_tool_content_replaced_by_post_tool_answer";
|
||||
|
||||
private static final String METRIC_SUPERSEDED = "mateclaw.narration.superseded";
|
||||
|
||||
/** Where the supersede happened — metric tag, one value per surface. */
|
||||
private final String surface;
|
||||
|
||||
/** Tool observations completed so far this turn (caller-reported). */
|
||||
private int observations;
|
||||
/** Observation count at the time of the most recent staging. */
|
||||
private int lastStageMark;
|
||||
|
||||
private String pendingText;
|
||||
private boolean pendingProvisional;
|
||||
/** Observation count when the pending narration was staged. */
|
||||
private int pendingMark;
|
||||
|
||||
public ProvisionalContentTracker(String surface) {
|
||||
this.surface = surface;
|
||||
}
|
||||
|
||||
/** Report a completed tool observation (a {@code tool_call_completed} event). */
|
||||
public void onToolObservation() {
|
||||
observations++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a per-round narration. Returns the <em>previous</em> staged
|
||||
* narration if the new arrival makes it publishable, or {@code null} when
|
||||
* there is nothing to publish (no previous, or the previous was
|
||||
* provisional and tool observations since its staging mean this later
|
||||
* content supersedes it).
|
||||
*
|
||||
* @param kind producer-assigned kind; {@code null} for pre-tag producers,
|
||||
* in which case a narration counts as provisional when no
|
||||
* observation completed since the previous staging (the
|
||||
* pre-tag online rule)
|
||||
*/
|
||||
public String stageNarration(String text, ContentKind kind) {
|
||||
boolean observedSinceLast = observations > lastStageMark;
|
||||
boolean provisional = kind != null
|
||||
? kind == ContentKind.PRE_TOOL_NARRATION
|
||||
: !observedSinceLast;
|
||||
String previous = pendingText;
|
||||
boolean previousProvisional = pendingProvisional;
|
||||
int previousMark = pendingMark;
|
||||
pendingText = text;
|
||||
pendingProvisional = provisional;
|
||||
pendingMark = observations;
|
||||
lastStageMark = observations;
|
||||
if (previous == null) {
|
||||
return null;
|
||||
}
|
||||
if (previousProvisional && observations > previousMark) {
|
||||
recordSuperseded(previous);
|
||||
return null;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the staged narration at turn end. Returns the text to publish,
|
||||
* or {@code null} when nothing remains (no staged narration, or it was
|
||||
* provisional, tools ran after it, and the turn produced final content
|
||||
* that replaces it).
|
||||
*
|
||||
* @param hasFinalContent whether the turn produced a final answer — with
|
||||
* one, a provisional narration is superseded; with
|
||||
* none, even a provisional narration commits (no
|
||||
* replacement exists)
|
||||
*/
|
||||
public String settle(boolean hasFinalContent) {
|
||||
String text = pendingText;
|
||||
boolean provisional = pendingProvisional;
|
||||
int mark = pendingMark;
|
||||
pendingText = null;
|
||||
pendingProvisional = false;
|
||||
pendingMark = 0;
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
if (provisional && hasFinalContent && observations > mark) {
|
||||
recordSuperseded(text);
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private void recordSuperseded(String text) {
|
||||
log.info("[{}] provisional narration superseded by later content ({} chars dropped from permanent output)",
|
||||
surface, text.length());
|
||||
Metrics.counter(METRIC_SUPERSEDED, "surface", surface).increment();
|
||||
}
|
||||
|
||||
// ==================== Persisted-timeline marking ====================
|
||||
|
||||
/**
|
||||
* Whether the persisted segments timeline carries producer-assigned kind
|
||||
* tags — i.e. whether {@link #markSuperseded(List, String)} is applicable
|
||||
* or the caller should fall back to structural detection.
|
||||
*/
|
||||
public static boolean hasKindTags(List<Map<String, Object>> segments) {
|
||||
if (segments == null) {
|
||||
return false;
|
||||
}
|
||||
for (Map<String, Object> seg : segments) {
|
||||
if ("content".equals(seg.get("type")) && seg.get("kind") != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind-driven counterpart of the structural supersede scan: a
|
||||
* {@code pre_tool_narration} content segment is marked superseded by the
|
||||
* first content segment that follows it; grounded narrations and final
|
||||
* answers are never marked. Mutates segment maps in place with the same
|
||||
* three keys the structural detector writes ({@code superseded},
|
||||
* {@code supersededBySegmentId}, {@code supersededReason}).
|
||||
*/
|
||||
public static void markSuperseded(List<Map<String, Object>> segments, String surface) {
|
||||
if (segments == null || segments.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String preToolWire = ContentKind.PRE_TOOL_NARRATION.wireName();
|
||||
for (int i = 0; i < segments.size(); i++) {
|
||||
Map<String, Object> seg = segments.get(i);
|
||||
if (!"content".equals(seg.get("type"))
|
||||
|| !preToolWire.equals(seg.get("kind"))
|
||||
|| Boolean.TRUE.equals(seg.get("superseded"))) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> replacement = nextContent(segments, i + 1);
|
||||
if (replacement == null) {
|
||||
continue; // turn produced no later content — the narration stands
|
||||
}
|
||||
seg.put("superseded", true);
|
||||
seg.put("supersededBySegmentId", String.valueOf(replacement.getOrDefault("id", "")));
|
||||
seg.put("supersededReason", REASON_PRE_TOOL_CONTENT_REPLACED);
|
||||
log.info("[{}] provisional narration segment {} superseded by segment {}",
|
||||
surface, seg.get("id"), replacement.get("id"));
|
||||
Metrics.counter(METRIC_SUPERSEDED, "surface", surface).increment();
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> nextContent(List<Map<String, Object>> segments, int from) {
|
||||
for (int i = from; i < segments.size(); i++) {
|
||||
if ("content".equals(segments.get(i).get("type"))) {
|
||||
return segments.get(i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.channel.ChannelManager;
|
||||
import vip.mate.channel.ChannelSessionStore;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.model.ChannelSessionEntity;
|
||||
import vip.mate.channel.service.ChannelService;
|
||||
import vip.mate.channel.verifier.ChannelVerifierRegistry;
|
||||
import vip.mate.channel.verifier.VerificationRequest;
|
||||
@ -18,7 +20,9 @@ import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -39,6 +43,7 @@ public class ChannelController {
|
||||
|
||||
private final ChannelService channelService;
|
||||
private final ChannelManager channelManager;
|
||||
private final ChannelSessionStore channelSessionStore;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ChannelVerifierRegistry verifierRegistry;
|
||||
private final ObjectMapper objectMapper;
|
||||
@ -175,6 +180,32 @@ public class ChannelController {
|
||||
return R.ok(channel);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "获取渠道的会话列表(可作为主动推送 / 定时任务投递目标)")
|
||||
@GetMapping("/{id}/sessions")
|
||||
public R<List<ChannelSessionSummary>> sessions(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
ChannelEntity channel = channelService.getChannel(id);
|
||||
verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId);
|
||||
return R.ok(channelSessionStore.listByChannelId(id).stream()
|
||||
.sorted(Comparator.comparing(ChannelSessionEntity::getLastActiveTime,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(ChannelSessionSummary::from)
|
||||
.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim projection of {@code mate_channel_session} for target pickers —
|
||||
* exposes only what the UI needs to render and bind a delivery target.
|
||||
*/
|
||||
public record ChannelSessionSummary(String conversationId, String channelType, String targetId,
|
||||
String senderId, String senderName, LocalDateTime lastActiveTime) {
|
||||
static ChannelSessionSummary from(ChannelSessionEntity s) {
|
||||
return new ChannelSessionSummary(s.getConversationId(), s.getChannelType(), s.getTargetId(),
|
||||
s.getSenderId(), s.getSenderName(), s.getLastActiveTime());
|
||||
}
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)")
|
||||
@GetMapping("/status")
|
||||
|
||||
@ -12,6 +12,7 @@ import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
import vip.mate.channel.ProvisionalContentTracker;
|
||||
import vip.mate.channel.StreamingChannelAdapter;
|
||||
import vip.mate.channel.media.GeneratedFileScrubber;
|
||||
import vip.mate.channel.media.MediaSource;
|
||||
@ -67,6 +68,10 @@ import java.util.concurrent.TimeUnit;
|
||||
* - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never"
|
||||
* auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用)
|
||||
* - card_header: Markdown 卡片 header 文案,默认 "AI 助手";设为空串可隐藏 header
|
||||
* - card_streaming_enabled: 是否启用 CardKit 流式卡片(默认 true)
|
||||
* - stream_progress: 是否在流式卡片中展示执行轨迹(默认 true)
|
||||
* - filter_thinking: 是否隐藏原始思考文本(默认 true;状态与阶段轨迹仍展示)
|
||||
* - filter_tool_messages: 是否隐藏工具名称与逐项状态(默认 true;仍展示汇总数量)
|
||||
* - require_mention: 群聊中是否需要 @机器人 才响应(默认 false)
|
||||
* true: 仅当消息中 @了机器人才处理;通过飞书 mentions 字段精确判断,无需配置 botPrefix
|
||||
*
|
||||
@ -240,19 +245,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
*/
|
||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||
|
||||
/**
|
||||
* Resolve the upload root for a conversation, preferring the wired resolver
|
||||
* (workspace/agent-aware) and falling back to the legacy field. Read paths
|
||||
* should use {@link #candidateChatUploadRoots(String)} to probe both the
|
||||
* workspace-scoped root and the legacy root.
|
||||
*/
|
||||
private java.nio.file.Path chatUploadRootFor(String conversationId) {
|
||||
if (chatUploadLocationResolver != null) {
|
||||
return chatUploadLocationResolver.resolveUploadRoot(conversationId);
|
||||
}
|
||||
return chatUploadsRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered candidate upload roots for a conversation: workspace-scoped first
|
||||
* (when the resolver is wired), then the legacy field. Used by read/scan
|
||||
@ -1791,11 +1783,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
||||
if (dl == null) return null;
|
||||
|
||||
// Save under the workspace/agent-aware upload root ({convId}/ subdir).
|
||||
// Sanitize the id for the path segment — IM ids like "feishu:xxx"
|
||||
// Save under the workspace/agent-aware upload root ({convId}/ subdir,
|
||||
// plus the per-day sub-directory when date folders are enabled).
|
||||
// The id is sanitized for the path segment — IM ids like "feishu:xxx"
|
||||
// carry a ':' that is illegal in a Windows filename.
|
||||
Path uploadDir = chatUploadRootFor(conversationId)
|
||||
.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||
Path uploadDir = (chatUploadLocationResolver != null)
|
||||
? chatUploadLocationResolver.resolveWriteDir(conversationId)
|
||||
: chatUploadsRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||
Files.createDirectories(uploadDir);
|
||||
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
||||
? dl.fileName() : fileKey;
|
||||
@ -1889,7 +1883,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
|
||||
List<RecentFileEntry> merged = new java.util.ArrayList<>();
|
||||
for (Path dir : candidateChatUploadDirs(conversationId)) {
|
||||
merged.addAll(loadRecentFilesFromDisk(dir, cutoff));
|
||||
// Scan the flat conversation dir plus each yyyy-MM-dd sub-directory
|
||||
// so staged copies written under either layout are recovered.
|
||||
for (Path scanDir : ChatUploadLocationResolver.dateScanDirs(dir)) {
|
||||
merged.addAll(loadRecentFilesFromDisk(scanDir, cutoff));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@ -2603,29 +2601,70 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
}
|
||||
|
||||
StringBuilder accumulator = new StringBuilder();
|
||||
boolean progressEnabled = getConfigBoolean("stream_progress", true);
|
||||
FeishuProgressRenderer progress = progressEnabled
|
||||
? new FeishuProgressRenderer(
|
||||
System.currentTimeMillis(),
|
||||
!getConfigBoolean("filter_thinking", true),
|
||||
!getConfigBoolean("filter_tool_messages", true))
|
||||
: null;
|
||||
ProvisionalContentTracker narrationTracker = progressEnabled
|
||||
? new ProvisionalContentTracker("feishu") : null;
|
||||
try {
|
||||
stream.doOnNext(delta -> {
|
||||
// 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);
|
||||
if (!progressEnabled) {
|
||||
// Legacy answer-only card mode.
|
||||
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
|
||||
accumulator.append(delta.content());
|
||||
streamingCardManager.appendContent(sessionKey, delta.content(), false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
boolean forceFlush = false;
|
||||
if (delta.isEvent()) {
|
||||
if ("tool_call_completed".equals(delta.eventType())) {
|
||||
narrationTracker.onToolObservation();
|
||||
}
|
||||
forceFlush = progress.onEvent(delta.eventType(), delta.eventData());
|
||||
} else if (delta.segmentOnly()) {
|
||||
String narration = delta.content() != null ? delta.content().trim() : "";
|
||||
if (!narration.isEmpty()) {
|
||||
String publishable = narrationTracker.stageNarration(narration, delta.kind());
|
||||
if (publishable != null) progress.commitNarration(publishable);
|
||||
progress.onPendingNarration(narration);
|
||||
forceFlush = true;
|
||||
}
|
||||
} else {
|
||||
if (delta.thinking() != null) progress.onThinkingDelta(delta.thinking());
|
||||
if (delta.content() != null) {
|
||||
accumulator.append(delta.content());
|
||||
progress.onContentDelta(delta.content());
|
||||
}
|
||||
}
|
||||
streamingCardManager.updateContent(sessionKey, progress.snapshot(), forceFlush);
|
||||
})
|
||||
.doOnError(err -> {
|
||||
log.error("[feishu-stream] stream error: sessionKey={}, err={}",
|
||||
sessionKey, err.getMessage());
|
||||
streamingCardManager.failCard(sessionKey, err.getMessage());
|
||||
})
|
||||
.blockLast(Duration.ofMinutes(5));
|
||||
|
||||
String finalContent = accumulator.toString();
|
||||
// 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.
|
||||
// Card streaming never touches renderAndSend, so apply the same
|
||||
// outbound filters before the final card snapshot is assembled.
|
||||
String cardContent = filterOutboundContent(finalContent);
|
||||
if (cardContent.isBlank()) {
|
||||
cardContent = "";
|
||||
}
|
||||
if (progressEnabled) {
|
||||
String heldNarration = narrationTracker.settle(!cardContent.isBlank());
|
||||
if (heldNarration != null && !sameOutboundText(heldNarration, cardContent)) {
|
||||
progress.commitNarration(heldNarration);
|
||||
}
|
||||
progress.clearPendingNarration();
|
||||
cardContent = progress.completedSnapshot(cardContent);
|
||||
} else if (cardContent.isBlank()) {
|
||||
cardContent = "(无回复内容)";
|
||||
}
|
||||
// Strip any /api/v1/files/generated/{id} URLs out of the card
|
||||
@ -2636,15 +2675,34 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
// actual file. Cache-miss URLs fall back to the user-facing
|
||||
// retry hint that GeneratedFileScrubber emits.
|
||||
String renderedContent = scrubAndSendAttachments(receiveId, cardContent);
|
||||
streamingCardManager.finishCard(sessionKey, renderedContent);
|
||||
FeishuStreamingCardManager.FinishResult finishResult =
|
||||
streamingCardManager.finishCard(sessionKey, renderedContent);
|
||||
if (!finishResult.success()) {
|
||||
// The card was delivered but either its terminal content or
|
||||
// streaming-mode close was rejected. A regular message is the
|
||||
// only reliable fallback after both CardKit attempts fail.
|
||||
log.warn("[feishu-stream] Card finalization incomplete (contentUpdated={}, closed={}); "
|
||||
+ "falling back to regular message: sessionKey={}",
|
||||
finishResult.finalContentUpdated(), finishResult.streamingClosed(), sessionKey);
|
||||
sendMessage(receiveId, renderedContent);
|
||||
}
|
||||
if (!finishResult.streamingClosed()) {
|
||||
log.warn("[feishu-stream] Card streaming mode could not be closed after retry: sessionKey={}",
|
||||
sessionKey);
|
||||
}
|
||||
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
||||
sessionKey, renderedContent.length());
|
||||
return finalContent.isBlank() ? cardContent : finalContent;
|
||||
// Execution-trace text is channel presentation only. Never return
|
||||
// it to the router as assistant content or it will pollute the
|
||||
// next turn's LLM history. Preserve the legacy empty placeholder
|
||||
// only when progress rendering was explicitly disabled.
|
||||
return progressEnabled
|
||||
? finalContent
|
||||
: (finalContent.isBlank() ? cardContent : finalContent);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
|
||||
sessionKey, e.getMessage(), e);
|
||||
streamingCardManager.failCard(sessionKey, e.getMessage());
|
||||
|
||||
// Tag returned content with the "[错误] " prefix so
|
||||
// ChannelMessageRouter.isErrorReply flips status='error' on the
|
||||
@ -2654,6 +2712,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
// as a valid assistant turn and re-trigger the same 400.
|
||||
String partial = accumulator.toString();
|
||||
String errorPrefix = "[错误] Feishu CardKit streaming failed: " + e.getMessage();
|
||||
FeishuStreamingCardManager.FinishResult failureResult =
|
||||
streamingCardManager.failCard(sessionKey, e.getMessage());
|
||||
if (!failureResult.success()) {
|
||||
String fallbackError = partial.isBlank()
|
||||
? "⚠️ 处理失败:" + e.getMessage()
|
||||
: partial + "\n\n⚠️ 处理失败:" + e.getMessage();
|
||||
log.warn("[feishu-stream] Error card finalization incomplete; sending regular fallback: "
|
||||
+ "sessionKey={}, contentUpdated={}, closed={}",
|
||||
sessionKey, failureResult.finalContentUpdated(), failureResult.streamingClosed());
|
||||
sendMessage(receiveId, fallbackError);
|
||||
}
|
||||
if (!partial.isBlank()) {
|
||||
return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial;
|
||||
}
|
||||
@ -2661,6 +2730,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare text after the same outbound filters the receiver sees. */
|
||||
private boolean sameOutboundText(String a, String b) {
|
||||
if (a == null || b == null) return false;
|
||||
String left = filterOutboundContent(a).trim();
|
||||
String right = filterOutboundContent(b).trim();
|
||||
return !left.isEmpty() && left.equals(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming fallback — accumulate all deltas, then send through the
|
||||
* existing {@link #sendMessage} path so the message goes out as a
|
||||
|
||||
@ -0,0 +1,260 @@
|
||||
package vip.mate.channel.feishu;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Builds the execution trace rendered inside a Feishu CardKit streaming card.
|
||||
*
|
||||
* <p>The renderer deliberately separates user-visible progress from persisted
|
||||
* assistant content. The adapter returns only the final answer to the router,
|
||||
* while this class keeps a bounded live trace in the card: phase, plan step,
|
||||
* tool transitions, optional model thinking, and grounded stage narration.
|
||||
*/
|
||||
final class FeishuProgressRenderer {
|
||||
|
||||
private static final int MAX_TOOL_LINES = 3;
|
||||
private static final int MAX_NARRATION_LINES = 3;
|
||||
private static final int THINKING_WINDOW = 500;
|
||||
private static final int ANSWER_WINDOW = 1200;
|
||||
|
||||
private record ToolLine(String callId, String name, long startedAt,
|
||||
Long finishedAt, boolean success) {}
|
||||
|
||||
private final long startedAtMillis;
|
||||
private final boolean showThinking;
|
||||
private final boolean showToolTrace;
|
||||
private final Deque<ToolLine> toolLines = new ArrayDeque<>();
|
||||
private final Deque<String> committedNarrations = new ArrayDeque<>();
|
||||
private final StringBuilder thinkingTail = new StringBuilder();
|
||||
private final StringBuilder answerTail = new StringBuilder();
|
||||
|
||||
private int collapsedToolCount;
|
||||
private boolean thinkingSeen;
|
||||
private boolean contentSeen;
|
||||
private boolean approvalPending;
|
||||
private String planStepLine;
|
||||
private String pendingNarration;
|
||||
|
||||
FeishuProgressRenderer(long startedAtMillis, boolean showThinking, boolean showToolTrace) {
|
||||
this.startedAtMillis = startedAtMillis;
|
||||
this.showThinking = showThinking;
|
||||
this.showToolTrace = showToolTrace;
|
||||
}
|
||||
|
||||
void onThinkingDelta(String delta) {
|
||||
thinkingSeen = true;
|
||||
if (showThinking && delta != null && !delta.isEmpty()) {
|
||||
thinkingTail.append(delta);
|
||||
trimLeading(thinkingTail, THINKING_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
void onContentDelta(String delta) {
|
||||
contentSeen = true;
|
||||
if (delta != null && !delta.isEmpty()) {
|
||||
answerTail.append(delta);
|
||||
trimLeading(answerTail, ANSWER_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true for transitions that should bypass the normal update throttle. */
|
||||
boolean onEvent(String eventType, Map<String, Object> data) {
|
||||
if (eventType == null) return false;
|
||||
switch (eventType) {
|
||||
case "tool_call_started" -> {
|
||||
toolLines.addLast(new ToolLine(
|
||||
stringField(data, "toolCallId"),
|
||||
stringField(data, "toolName"),
|
||||
System.currentTimeMillis(), null, false));
|
||||
compactToolLines();
|
||||
return true;
|
||||
}
|
||||
case "tool_call_completed" -> {
|
||||
String callId = stringField(data, "toolCallId");
|
||||
boolean success = data == null || !Boolean.FALSE.equals(data.get("success"));
|
||||
markToolCompleted(callId, stringField(data, "toolName"), success);
|
||||
return true;
|
||||
}
|
||||
case "plan_step_started" -> {
|
||||
Object index = data != null ? data.get("index") : null;
|
||||
String title = stringField(data, "title");
|
||||
planStepLine = "📋 步骤" + (index != null ? " " + index : "")
|
||||
+ (title != null && !title.isBlank() ? ":" + title : "");
|
||||
return true;
|
||||
}
|
||||
case "tool_approval_requested" -> {
|
||||
approvalPending = true;
|
||||
return true;
|
||||
}
|
||||
default -> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onPendingNarration(String text) {
|
||||
pendingNarration = normalize(text);
|
||||
}
|
||||
|
||||
void commitNarration(String text) {
|
||||
String normalized = normalize(text);
|
||||
if (normalized == null) return;
|
||||
committedNarrations.addLast(normalized);
|
||||
while (committedNarrations.size() > MAX_NARRATION_LINES) {
|
||||
committedNarrations.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
void clearPendingNarration() {
|
||||
pendingNarration = null;
|
||||
}
|
||||
|
||||
boolean isApprovalPending() {
|
||||
return approvalPending;
|
||||
}
|
||||
|
||||
String snapshot() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
appendTrace(sb, statusLine(false), true);
|
||||
if (answerTail.length() > 0) {
|
||||
sb.append("\n\n---\n\n").append(answerTail);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String completedSnapshot(String finalAnswer) {
|
||||
String answer = finalAnswer == null ? "" : finalAnswer.trim();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
appendTrace(sb, statusLine(true), false);
|
||||
if (!answer.isEmpty()) {
|
||||
sb.append("\n\n---\n\n").append(answer);
|
||||
} else if (approvalPending) {
|
||||
sb.append("\n\n⏸️ 已暂停,等待工具审批。");
|
||||
} else {
|
||||
sb.append("\n\n(本轮没有产生回复内容)");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendTrace(StringBuilder sb, String status, boolean includePending) {
|
||||
sb.append("**执行轨迹**\n").append(status);
|
||||
if (planStepLine != null) sb.append('\n').append(planStepLine);
|
||||
appendToolLines(sb);
|
||||
for (String narration : committedNarrations) {
|
||||
sb.append("\n• ").append(narration);
|
||||
}
|
||||
if (includePending && pendingNarration != null) {
|
||||
sb.append("\n• ").append(pendingNarration);
|
||||
}
|
||||
if (showThinking && thinkingTail.length() > 0) {
|
||||
sb.append("\n\n> 💭 ")
|
||||
.append(thinkingTail.toString().replace("\n", "\n> "));
|
||||
}
|
||||
}
|
||||
|
||||
private String statusLine(boolean completed) {
|
||||
if (completed) return approvalPending ? "⏸️ 等待工具审批(" + elapsed() + ")"
|
||||
: "✅ 已完成(" + elapsed() + ")";
|
||||
if (approvalPending) return "⏸️ 等待工具审批…(" + elapsed() + ")";
|
||||
if (contentSeen) return "✍️ 正在回复…(" + elapsed() + ")";
|
||||
ToolLine running = lastRunningTool();
|
||||
if (running != null) {
|
||||
return showToolTrace
|
||||
? "🔧 正在调用 " + displayName(running) + "…(" + elapsed() + ")"
|
||||
: "🔧 正在执行工具…(" + elapsed() + ")";
|
||||
}
|
||||
return (thinkingSeen ? "💭" : "🤔") + " 思考中…(" + elapsed() + ")";
|
||||
}
|
||||
|
||||
private void appendToolLines(StringBuilder sb) {
|
||||
if (!showToolTrace) {
|
||||
int completed = collapsedToolCount;
|
||||
boolean running = false;
|
||||
for (ToolLine line : toolLines) {
|
||||
if (line.finishedAt() == null) running = true;
|
||||
else completed++;
|
||||
}
|
||||
if (completed > 0) sb.append("\n✅ 已执行 ").append(completed).append(" 项工具");
|
||||
if (running && contentSeen) sb.append("\n🔧 工具运行中…");
|
||||
return;
|
||||
}
|
||||
if (collapsedToolCount > 0) sb.append("\n…等 ").append(collapsedToolCount).append(" 项已完成");
|
||||
for (ToolLine line : toolLines) {
|
||||
if (line.finishedAt() == null) {
|
||||
if (contentSeen || approvalPending) sb.append("\n🔧 ").append(displayName(line)).append(" 运行中…");
|
||||
} else {
|
||||
long seconds = Math.max(0, (line.finishedAt() - line.startedAt()) / 1000);
|
||||
sb.append('\n').append(line.success() ? "✅ " : "❌ ")
|
||||
.append(displayName(line))
|
||||
.append(line.success() ? " 完成" : " 失败")
|
||||
.append("(").append(seconds).append(" 秒)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ToolLine lastRunningTool() {
|
||||
ToolLine running = null;
|
||||
for (ToolLine line : toolLines) if (line.finishedAt() == null) running = line;
|
||||
return running;
|
||||
}
|
||||
|
||||
private void markToolCompleted(String callId, String toolName, boolean success) {
|
||||
ToolLine match = null;
|
||||
for (ToolLine line : toolLines) {
|
||||
if (line.finishedAt() != null) continue;
|
||||
if ((callId != null && callId.equals(line.callId()))
|
||||
|| (callId == null && toolName != null && toolName.equals(line.name()))) {
|
||||
match = line;
|
||||
}
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (match == null) {
|
||||
toolLines.addLast(new ToolLine(callId, toolName, now, now, success));
|
||||
} else {
|
||||
Deque<ToolLine> rebuilt = new ArrayDeque<>(toolLines.size());
|
||||
for (ToolLine line : toolLines) {
|
||||
rebuilt.addLast(line == match
|
||||
? new ToolLine(match.callId(), match.name(), match.startedAt(), now, success)
|
||||
: line);
|
||||
}
|
||||
toolLines.clear();
|
||||
toolLines.addAll(rebuilt);
|
||||
}
|
||||
compactToolLines();
|
||||
}
|
||||
|
||||
private void compactToolLines() {
|
||||
while (toolLines.size() > MAX_TOOL_LINES) {
|
||||
ToolLine oldest = toolLines.peekFirst();
|
||||
if (oldest != null && oldest.finishedAt() == null) break;
|
||||
toolLines.pollFirst();
|
||||
collapsedToolCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private String elapsed() {
|
||||
long seconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000);
|
||||
return seconds < 60 ? "已 " + seconds + " 秒"
|
||||
: "已 " + (seconds / 60) + " 分 " + (seconds % 60) + " 秒";
|
||||
}
|
||||
|
||||
private static String displayName(ToolLine line) {
|
||||
return line.name() != null && !line.name().isBlank() ? line.name() : "工具";
|
||||
}
|
||||
|
||||
private static String stringField(Map<String, Object> data, String key) {
|
||||
Object value = data != null ? data.get(key) : null;
|
||||
return value != null ? value.toString() : null;
|
||||
}
|
||||
|
||||
private static String normalize(String text) {
|
||||
return text == null || text.isBlank() ? null : text.trim();
|
||||
}
|
||||
|
||||
private static void trimLeading(StringBuilder sb, int maxLen) {
|
||||
int excess = sb.length() - maxLen;
|
||||
if (excess > 0) sb.delete(0, excess);
|
||||
}
|
||||
}
|
||||
@ -64,6 +64,13 @@ public class FeishuStreamingCardManager {
|
||||
/** Throttle window for {@link #appendContent}, ms — matches DingTalk AICard. */
|
||||
static final long THROTTLE_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Hard per-card operation spacing. Feishu allows at most 10 CardKit
|
||||
* operations/second for one card; 120ms leaves a little clock/network
|
||||
* jitter headroom while still letting phase transitions feel immediate.
|
||||
*/
|
||||
static final long PLATFORM_MIN_INTERVAL_MS = 120;
|
||||
|
||||
/**
|
||||
* Markdown element id baked into the initial streaming card.
|
||||
* Content-update calls reference this id. Public so tests can assert.
|
||||
@ -91,6 +98,13 @@ public class FeishuStreamingCardManager {
|
||||
/** Terminal-state CAS guard — at most one of {finishCard, failCard} wins per session. */
|
||||
enum Status { STREAMING, FINISHED, FAILED }
|
||||
|
||||
/** Result of the two independently fallible terminal CardKit operations. */
|
||||
public record FinishResult(boolean finalContentUpdated, boolean streamingClosed) {
|
||||
public boolean success() {
|
||||
return finalContentUpdated && streamingClosed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-flight streaming card. State is mutated by a single Reactor
|
||||
* thread per session (the one consuming the {@code Flux}), so all
|
||||
@ -178,8 +192,8 @@ public class FeishuStreamingCardManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append delta text to the running session. May flush immediately
|
||||
* (force) or wait for the next throttle window.
|
||||
* Append delta text to the running session. A forced update bypasses the
|
||||
* normal 500ms UX throttle but still respects the platform hard limit.
|
||||
*
|
||||
* <p>No-op when {@code sessionKey} is unknown or the session has
|
||||
* already reached a terminal status — keeps the caller's
|
||||
@ -195,11 +209,22 @@ public class FeishuStreamingCardManager {
|
||||
session.accumulated.append(contentDelta);
|
||||
}
|
||||
}
|
||||
long now = currentTimeMs();
|
||||
if (!forceFlush && now - session.lastFlushMs < THROTTLE_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
flush(session, now);
|
||||
flushWithPolicy(session, forceFlush);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the streaming element with a full progress snapshot.
|
||||
*
|
||||
* <p>CardKit's content API expects the complete current text on every
|
||||
* update. Agent progress is not append-only ("thinking" becomes "calling
|
||||
* a tool", then "replying"), so treating snapshots as deltas duplicates
|
||||
* the entire trace on every refresh.
|
||||
*/
|
||||
public boolean updateContent(String sessionKey, String fullContent, boolean forceFlush) {
|
||||
CardSession session = activeSessions.get(sessionKey);
|
||||
if (session == null || !session.isStreaming()) return false;
|
||||
replaceAccumulated(session, fullContent != null ? fullContent : "");
|
||||
return flushWithPolicy(session, forceFlush);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -207,20 +232,24 @@ public class FeishuStreamingCardManager {
|
||||
* a second call is a no-op. After return, the sessionKey is no
|
||||
* longer known to the manager.
|
||||
*/
|
||||
public void finishCard(String sessionKey, String finalContent) {
|
||||
public FinishResult finishCard(String sessionKey, String finalContent) {
|
||||
CardSession session = activeSessions.get(sessionKey);
|
||||
if (session == null) return;
|
||||
if (session == null) return new FinishResult(false, false);
|
||||
if (!session.status.compareAndSet(Status.STREAMING, Status.FINISHED)) {
|
||||
return;
|
||||
return new FinishResult(false, false);
|
||||
}
|
||||
boolean contentUpdated = false;
|
||||
boolean streamingClosed = false;
|
||||
try {
|
||||
replaceAccumulated(session, finalContent != null ? finalContent : "");
|
||||
flush(session, currentTimeMs());
|
||||
closeStreaming(session);
|
||||
contentUpdated = flushWithRetry(session);
|
||||
streamingClosed = closeStreamingWithRetry(session, summaryFor(finalContent));
|
||||
return new FinishResult(contentUpdated, streamingClosed);
|
||||
} finally {
|
||||
activeSessions.remove(sessionKey);
|
||||
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}",
|
||||
sessionKey, finalContent == null ? 0 : finalContent.length());
|
||||
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}, contentUpdated={}, closed={}",
|
||||
sessionKey, finalContent == null ? 0 : finalContent.length(),
|
||||
contentUpdated, streamingClosed);
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,12 +258,14 @@ public class FeishuStreamingCardManager {
|
||||
* suffix; the card is closed so the typing animation stops.
|
||||
* Idempotent.
|
||||
*/
|
||||
public void failCard(String sessionKey, String errorMessage) {
|
||||
public FinishResult failCard(String sessionKey, String errorMessage) {
|
||||
CardSession session = activeSessions.get(sessionKey);
|
||||
if (session == null) return;
|
||||
if (session == null) return new FinishResult(false, false);
|
||||
if (!session.status.compareAndSet(Status.STREAMING, Status.FAILED)) {
|
||||
return;
|
||||
return new FinishResult(false, false);
|
||||
}
|
||||
boolean contentUpdated = false;
|
||||
boolean streamingClosed = false;
|
||||
try {
|
||||
String tail;
|
||||
synchronized (session) {
|
||||
@ -246,11 +277,13 @@ public class FeishuStreamingCardManager {
|
||||
session.accumulated.setLength(0);
|
||||
session.accumulated.append(tail);
|
||||
}
|
||||
flush(session, currentTimeMs());
|
||||
closeStreaming(session);
|
||||
contentUpdated = flushWithRetry(session);
|
||||
streamingClosed = closeStreamingWithRetry(session, "⚠️ 处理失败");
|
||||
return new FinishResult(contentUpdated, streamingClosed);
|
||||
} finally {
|
||||
activeSessions.remove(sessionKey);
|
||||
log.warn("[feishu-stream] Card failed: sessionKey={}, error={}", sessionKey, errorMessage);
|
||||
log.warn("[feishu-stream] Card failed: sessionKey={}, contentUpdated={}, closed={}, error={}",
|
||||
sessionKey, contentUpdated, streamingClosed, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,7 +305,26 @@ public class FeishuStreamingCardManager {
|
||||
// Internal — flush + SDK seams
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void flush(CardSession session, long now) {
|
||||
private boolean flushWithPolicy(CardSession session, boolean forceFlush) {
|
||||
long now = currentTimeMs();
|
||||
long elapsed = now - session.lastFlushMs;
|
||||
if (!forceFlush && elapsed < THROTTLE_INTERVAL_MS) {
|
||||
return true; // latest snapshot is queued in session.accumulated
|
||||
}
|
||||
if (forceFlush && elapsed < PLATFORM_MIN_INTERVAL_MS) {
|
||||
if (!pauseBeforeFlush(PLATFORM_MIN_INTERVAL_MS - elapsed)) return false;
|
||||
now = currentTimeMs();
|
||||
}
|
||||
return flush(session, now);
|
||||
}
|
||||
|
||||
/** One retry is enough to cover a transient rate-limit/network blip. */
|
||||
private boolean flushWithRetry(CardSession session) {
|
||||
if (flushWithPolicy(session, true)) return true;
|
||||
return flushWithPolicy(session, true);
|
||||
}
|
||||
|
||||
private boolean flush(CardSession session, long now) {
|
||||
String snapshot;
|
||||
synchronized (session) {
|
||||
snapshot = session.accumulated.toString();
|
||||
@ -281,27 +333,45 @@ public class FeishuStreamingCardManager {
|
||||
try {
|
||||
Client client = clientFactory.client(session.channelId);
|
||||
sdkPushElementContent(client, session.cardId, STREAM_ELEMENT_ID, snapshot, seq);
|
||||
session.lastFlushMs = now;
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("[feishu-stream] flush failed: sessionKey={}, seq={}, err={}",
|
||||
session.sessionKey, seq, e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
// Failed requests count against platform rate limits too.
|
||||
session.lastFlushMs = now;
|
||||
}
|
||||
}
|
||||
|
||||
private void closeStreaming(CardSession session) {
|
||||
private boolean closeStreamingWithRetry(CardSession session, String summary) {
|
||||
if (closeStreaming(session, summary)) return true;
|
||||
return closeStreaming(session, summary);
|
||||
}
|
||||
|
||||
private boolean closeStreaming(CardSession session, String summary) {
|
||||
long elapsed = currentTimeMs() - session.lastFlushMs;
|
||||
if (elapsed < PLATFORM_MIN_INTERVAL_MS
|
||||
&& !pauseBeforeFlush(PLATFORM_MIN_INTERVAL_MS - elapsed)) {
|
||||
return false;
|
||||
}
|
||||
int seq = session.sequence.incrementAndGet();
|
||||
try {
|
||||
Client client = clientFactory.client(session.channelId);
|
||||
sdkCloseStreamingMode(client, session.cardId, seq);
|
||||
sdkCloseStreamingMode(client, session.cardId, seq, summary);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("[feishu-stream] closeStreaming failed: sessionKey={}, err={}",
|
||||
session.sessionKey, e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
session.lastFlushMs = currentTimeMs();
|
||||
}
|
||||
}
|
||||
|
||||
private void tryCloseStreamingSilently(Client client, String cardId) {
|
||||
try {
|
||||
sdkCloseStreamingMode(client, cardId, 1);
|
||||
sdkCloseStreamingMode(client, cardId, 1, "⚠️ 卡片发送失败");
|
||||
} catch (Exception ignore) {
|
||||
// best-effort — already in an error path
|
||||
}
|
||||
@ -314,6 +384,31 @@ public class FeishuStreamingCardManager {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean pauseBeforeFlush(long millis) {
|
||||
if (millis <= 0) return true;
|
||||
try {
|
||||
sleepMillis(millis);
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam for advancing a fake clock without real sleeping. */
|
||||
protected void sleepMillis(long millis) throws InterruptedException {
|
||||
Thread.sleep(millis);
|
||||
}
|
||||
|
||||
static String summaryFor(String content) {
|
||||
String preview = content == null ? "" : content
|
||||
.replaceAll("[`*_>#~-]+", " ")
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
if (preview.isEmpty()) return "✅ 已完成";
|
||||
return preview.length() <= 80 ? preview : preview.substring(0, 77) + "...";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SDK seams (overridable in tests)
|
||||
// ------------------------------------------------------------------
|
||||
@ -373,15 +468,18 @@ public class FeishuStreamingCardManager {
|
||||
.build();
|
||||
ContentCardElementResp resp = client.cardkit().v1().cardElement().content(req);
|
||||
if (!resp.success()) {
|
||||
log.warn("[feishu-stream] cardElement.content failed: cardId={}, seq={}, code={}, msg={}",
|
||||
abbrev(cardId), sequence, resp.getCode(), resp.getMsg());
|
||||
throw new IllegalStateException("cardElement.content failed: cardId=" + abbrev(cardId)
|
||||
+ ", seq=" + sequence + ", code=" + resp.getCode() + ", msg=" + resp.getMsg());
|
||||
}
|
||||
}
|
||||
|
||||
/** Flip streaming_mode=false so the receiving UI stops the typing animation. */
|
||||
protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) throws Exception {
|
||||
protected void sdkCloseStreamingMode(Client client, String cardId, int sequence,
|
||||
String summary) throws Exception {
|
||||
Map<String, Object> settings = Map.of(
|
||||
"config", Map.of("streaming_mode", false)
|
||||
"config", Map.of(
|
||||
"streaming_mode", false,
|
||||
"summary", Map.of("content", summaryFor(summary)))
|
||||
);
|
||||
SettingsCardReq req = SettingsCardReq.newBuilder()
|
||||
.cardId(cardId)
|
||||
@ -393,8 +491,8 @@ public class FeishuStreamingCardManager {
|
||||
.build();
|
||||
SettingsCardResp resp = client.cardkit().v1().card().settings(req);
|
||||
if (!resp.success()) {
|
||||
log.warn("[feishu-stream] card.settings (close) failed: cardId={}, code={}, msg={}",
|
||||
abbrev(cardId), resp.getCode(), resp.getMsg());
|
||||
throw new IllegalStateException("card.settings failed: cardId=" + abbrev(cardId)
|
||||
+ ", code=" + resp.getCode() + ", msg=" + resp.getMsg());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.channel.ProvisionalContentTracker;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -192,25 +193,41 @@ public final class AgentStreamAccumulator {
|
||||
}
|
||||
// segments: 追加到当前 running content segment,或创建新的
|
||||
var seg = findLastRunning("content");
|
||||
if (seg != null) {
|
||||
seg.put("text", seg.getOrDefault("text", "") + delta.content());
|
||||
} else {
|
||||
if (seg == null) {
|
||||
finalizeRunningSegments("thinking");
|
||||
var s = newSegment("content");
|
||||
s.put("text", delta.content());
|
||||
segments.add(s);
|
||||
seg = newSegment("content");
|
||||
seg.put("text", delta.content());
|
||||
segments.add(seg);
|
||||
} else {
|
||||
seg.put("text", seg.getOrDefault("text", "") + delta.content());
|
||||
}
|
||||
// Producer-assigned content semantics (first writer wins — a
|
||||
// segment never legitimately changes kind mid-flight). Absent on
|
||||
// deltas from producers that predate the tag; consumers fall back
|
||||
// to structural detection for such segments.
|
||||
if (delta.kind() != null && !seg.containsKey("kind")) {
|
||||
seg.put("kind", delta.kind().wireName());
|
||||
}
|
||||
}
|
||||
|
||||
// thinking_delta
|
||||
if (delta.thinking() != null && !delta.thinking().isBlank()) {
|
||||
var seg = findLastRunning("thinking");
|
||||
// No running thinking segment means this delta opens a new reasoning
|
||||
// span (a fresh iteration, after a tool call closed the previous one).
|
||||
// The flat `thinking` field concatenates every span of the turn, so
|
||||
// without a break the spans glue into one run-on paragraph — spans
|
||||
// are separate thoughts and read as such only when kept apart.
|
||||
boolean opensNewSpan = seg == null;
|
||||
if (!delta.segmentOnly()) {
|
||||
if (opensNewSpan && thinking.length() > 0) {
|
||||
thinking.append("\n\n");
|
||||
}
|
||||
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 {
|
||||
@ -343,6 +360,7 @@ public final class AgentStreamAccumulator {
|
||||
&& toolName.equals(seg.get("toolName")));
|
||||
if (matches) {
|
||||
seg.put("status", "completed");
|
||||
seg.put("endTimestamp", System.currentTimeMillis());
|
||||
seg.put("toolResult", data.getOrDefault("result", ""));
|
||||
seg.put("toolSuccess", data.getOrDefault("success", true));
|
||||
break;
|
||||
@ -385,9 +403,19 @@ public final class AgentStreamAccumulator {
|
||||
|
||||
private Map<String, Object> newSegment(String type) {
|
||||
Map<String, Object> seg = new LinkedHashMap<>();
|
||||
seg.put("id", type.substring(0, 2) + "-" + segCounter++);
|
||||
int seq = segCounter++;
|
||||
seg.put("id", type.substring(0, 2) + "-" + seq);
|
||||
seg.put("type", type);
|
||||
// Monotonic emission index. Renderers order the timeline by this
|
||||
// rather than inferring a position from the segment's type: array
|
||||
// order can be perturbed on the way to the UI (dedup, fallback
|
||||
// injection, live/persisted merges), and type-based relocation
|
||||
// moves a span away from the point it was actually produced at.
|
||||
seg.put("seq", seq);
|
||||
seg.put("status", "running");
|
||||
// Wall-clock bounds let history replays show the real per-segment
|
||||
// duration (e.g. "thought for 12s") instead of estimating from length.
|
||||
seg.put("timestamp", System.currentTimeMillis());
|
||||
return seg;
|
||||
}
|
||||
|
||||
@ -404,6 +432,7 @@ public final class AgentStreamAccumulator {
|
||||
for (var seg : segments) {
|
||||
if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) {
|
||||
seg.put("status", "completed");
|
||||
seg.put("endTimestamp", System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -447,9 +476,16 @@ public final class AgentStreamAccumulator {
|
||||
return parts;
|
||||
}
|
||||
|
||||
private void finalizeToolCalls() {
|
||||
private void interruptUnfinishedToolCalls() {
|
||||
for (Map<String, Object> tc : toolCalls) {
|
||||
if ("running".equals(tc.get("status"))) tc.put("status", "completed");
|
||||
if ("running".equals(tc.get("status"))) tc.put("status", "interrupted");
|
||||
}
|
||||
for (Map<String, Object> segment : segments) {
|
||||
if ("tool_call".equals(segment.get("type"))
|
||||
&& "running".equals(segment.get("status"))) {
|
||||
segment.put("status", "interrupted");
|
||||
segment.put("endTimestamp", System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -458,9 +494,16 @@ public final class AgentStreamAccumulator {
|
||||
* toolCalls 保留兼容旧 UI,segments 是按事件顺序的完整时间线。
|
||||
*/
|
||||
public synchronized String toMetadataJson() {
|
||||
finalizeToolCalls();
|
||||
finalizeRunningSegments("thinking", "content", "tool_call");
|
||||
SegmentSupersedeDetector.markSuperseded(segments);
|
||||
interruptUnfinishedToolCalls();
|
||||
finalizeRunningSegments("thinking", "content");
|
||||
// Producer-tagged timelines use the kind-driven authority; untagged
|
||||
// ones (pre-tag producers, replayed legacy turns) keep the structural
|
||||
// scan as fallback.
|
||||
if (ProvisionalContentTracker.hasKindTags(segments)) {
|
||||
ProvisionalContentTracker.markSuperseded(segments, "web");
|
||||
} else {
|
||||
SegmentSupersedeDetector.markSuperseded(segments);
|
||||
}
|
||||
try {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
if (!toolCalls.isEmpty()) {
|
||||
|
||||
@ -25,6 +25,7 @@ import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
@ -191,6 +192,14 @@ public class ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
// Worker conversations are immutable evidence from the web UI. Keep this
|
||||
// guard at the user entry point so internal dispatch can still persist its
|
||||
// user/assistant execution transcript through ConversationService.
|
||||
if (!conversationService.isUserMessageAllowed(conversationId)) {
|
||||
sendErrorDoneAndComplete(emitter, "执行任务会话为只读,不能发送新消息");
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// ---- 审批命令拦截:/approve、/deny 走 SSE 流式 replay ----
|
||||
String normalizedMsg = requestMessage.trim().toLowerCase();
|
||||
boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg);
|
||||
@ -580,10 +589,15 @@ public class ChatController {
|
||||
? regenerateSeed.parts()
|
||||
: normalizeRequestParts(request);
|
||||
String promptText = buildPromptText(message, requestParts);
|
||||
Long originMessageId;
|
||||
if (regenerateSeed == null) {
|
||||
// Regenerate reuses the already-persisted seed user row —
|
||||
// inserting again would duplicate it (issue #547).
|
||||
conversationService.saveMessage(conversationId, "user", message, requestParts);
|
||||
MessageEntity savedUser = conversationService
|
||||
.saveMessage(conversationId, "user", message, requestParts);
|
||||
originMessageId = savedUser == null ? null : savedUser.getId();
|
||||
} else {
|
||||
originMessageId = regenerateSeed.seedMessageId();
|
||||
}
|
||||
conversationService.updateStreamStatus(conversationId, "running");
|
||||
|
||||
@ -601,7 +615,8 @@ public class ChatController {
|
||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
|
||||
.withBaseUrl(requestBaseUrl);
|
||||
.withBaseUrl(requestBaseUrl)
|
||||
.withOriginMessageId(originMessageId);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
@ -1012,6 +1027,10 @@ public class ChatController {
|
||||
return R.fail(403, "无权操作该会话");
|
||||
}
|
||||
boolean stopped = streamTracker.requestStop(conversationId);
|
||||
// Acknowledge only after the cancellation path had a chance to drain
|
||||
// and persist its partial assistant message. Bound the wait so a
|
||||
// genuinely non-cooperative third-party tool cannot pin the HTTP call.
|
||||
boolean terminationConfirmed = !stopped || streamTracker.awaitTermination(conversationId, 2000L);
|
||||
|
||||
// Sweep ghost approvals — workflow.denyAllByConversation owns DB + metadata + memory
|
||||
// atomically; we only need to broadcast SSE events on the resulting outcomes.
|
||||
@ -1030,6 +1049,7 @@ public class ChatController {
|
||||
conversationId, username, stopped, denied.size(), messagesRewritten);
|
||||
return R.ok(Map.of(
|
||||
"stopped", stopped,
|
||||
"terminationConfirmed", terminationConfirmed,
|
||||
"ghostPendingsCleared", denied.size(),
|
||||
"messagesRewritten", messagesRewritten
|
||||
));
|
||||
@ -1104,13 +1124,16 @@ public class ChatController {
|
||||
return R.fail(401, "未登录,请先登录");
|
||||
}
|
||||
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId);
|
||||
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts());
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
request.getConversationId(), "user", request.getMessage(), request.getContentParts());
|
||||
|
||||
String promptText = buildPromptText(request.getMessage(), request.getContentParts());
|
||||
// Carry the web origin so per-owner memory recall (read) and the
|
||||
// post-conversation memory write below agree on the same owner key.
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId());
|
||||
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId,
|
||||
request.getEndUserId()).withOriginMessageId(
|
||||
savedUser == null ? null : savedUser.getId());
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
|
||||
String response = result.content();
|
||||
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
||||
@ -1144,12 +1167,12 @@ public class ChatController {
|
||||
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
String storedName = System.currentTimeMillis() + "_" + safeFilename;
|
||||
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
|
||||
// Sanitize the id before using it as a path segment — IM-channel ids like
|
||||
// "wecom:XXXX" carry a ':' that is illegal in a Windows filename and would
|
||||
// throw InvalidPathException here. Reads use the same sanitization.
|
||||
Path conversationDir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||
Files.createDirectories(conversationDir);
|
||||
Path target = conversationDir.resolve(storedName);
|
||||
// resolveWriteDir sanitizes the id (IM-channel ids like "wecom:XXXX"
|
||||
// carry a ':' illegal on Windows) and appends the per-day sub-directory
|
||||
// when date folders are enabled. Reads probe both layouts.
|
||||
Path writeDir = uploadLocationResolver.resolveWriteDir(conversationId);
|
||||
Files.createDirectories(writeDir);
|
||||
Path target = writeDir.resolve(storedName);
|
||||
file.transferTo(target);
|
||||
|
||||
log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target);
|
||||
@ -1160,7 +1183,7 @@ public class ChatController {
|
||||
response.setStoredName(storedName);
|
||||
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
|
||||
// 用 root 相对路径,避免暴露服务端绝对路径(uploadRoot 现在恒为绝对路径)。
|
||||
response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName));
|
||||
response.setPath(toRelativeUploadPath(uploadRoot, target));
|
||||
response.setSize(file.getSize());
|
||||
response.setContentType(file.getContentType());
|
||||
return R.ok(response);
|
||||
@ -1248,18 +1271,12 @@ public class ChatController {
|
||||
/**
|
||||
* 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.
|
||||
* raw id) and both layouts (flat + date sub-directories) with a
|
||||
* 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;
|
||||
return uploadLocationResolver.resolveExistingFile(conversationId, storedName);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1422,9 +1439,11 @@ public class ChatController {
|
||||
// 持久化排队的用户消息(含 contentParts;幂等:如果 /interrupt 已提前持久化则跳过)。
|
||||
// 这里持久化是为了确保 user 消息在 assistant 消息(doOnError/doOnCancel 已写入)之后落库,
|
||||
// 让 listMessages ORDER BY create_time ASC 后顺序正确:Q1 → Asst1 → Q2 → Asst2。
|
||||
Long queuedOriginMessageId = null;
|
||||
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
|
||||
conversationService.saveMessage(conversationId, "user", queuedMessage,
|
||||
MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage,
|
||||
preConsumedInput.contentParts(), "queued");
|
||||
queuedOriginMessageId = savedUser == null ? null : savedUser.getId();
|
||||
}
|
||||
|
||||
// 广播 queued_input_started 事件
|
||||
@ -1449,7 +1468,8 @@ public class ChatController {
|
||||
// turn keeps a consistent (null-channel) binding.
|
||||
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null)
|
||||
.withBaseUrl(baseUrl);
|
||||
.withBaseUrl(baseUrl)
|
||||
.withOriginMessageId(queuedOriginMessageId);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
@ -1662,8 +1682,7 @@ public class ChatController {
|
||||
* upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and
|
||||
* separators are normalized to {@code /} so the value is stable across OSes.
|
||||
*/
|
||||
static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) {
|
||||
Path target = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).resolve(storedName);
|
||||
static String toRelativeUploadPath(Path uploadRoot, Path target) {
|
||||
Path base = uploadRoot.getParent();
|
||||
Path relative = base != null ? base.relativize(target) : target;
|
||||
return relative.toString().replace('\\', '/');
|
||||
@ -1714,7 +1733,12 @@ public class ChatController {
|
||||
String rawMetadata = savedAssistant.getMetadata();
|
||||
if (rawMetadata != null && !rawMetadata.isBlank()) {
|
||||
try {
|
||||
Map<String, Object> parsed = objectMapper.readValue(rawMetadata,
|
||||
// Without the unwrap this readValue throws on the H2 profile
|
||||
// and the catch below swallows it, so the superseded markers
|
||||
// never ride the done payload and every client waits for a
|
||||
// reload instead — a degradation with no symptom in the log.
|
||||
Map<String, Object> parsed = objectMapper.readValue(
|
||||
MessageMetadataJson.normalize(rawMetadata),
|
||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
|
||||
Object segs = parsed.get("segments");
|
||||
if (segs instanceof java.util.List<?> list && !list.isEmpty()) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,24 +1,28 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Marks model-predicted tool results that are replaced by the actual post-tool
|
||||
* answer segment.
|
||||
* Marks assistant content emitted <em>before</em> its tool calls ran as superseded
|
||||
* by the post-tool content that follows.
|
||||
*
|
||||
* <p>The rule is purely structural — no text inspection. A content segment that
|
||||
* (a) does not directly follow a tool result and (b) is followed by a tool call
|
||||
* before any other content segment was produced in the same model completion as
|
||||
* those tool calls. Whatever it says — process narration, a predicted result, or
|
||||
* an answer copied from stale conversation history — it is not grounded in this
|
||||
* turn's observations. When any content segment exists after that tool call
|
||||
* (the answer written with the actual results in hand), the pre-tool segment is
|
||||
* marked superseded so renderers collapse it in favor of the grounded answer.
|
||||
*
|
||||
* <p>Content that directly follows a tool result is never marked: it was written
|
||||
* after observing real output and may carry standalone value (e.g. a download
|
||||
* link for an intermediate artifact in a multi-file run).
|
||||
*/
|
||||
final class SegmentSupersedeDetector {
|
||||
|
||||
static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim";
|
||||
|
||||
private static final Pattern GENERATED_FILE_URL =
|
||||
Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+");
|
||||
private static final Pattern BYTE_COUNT =
|
||||
Pattern.compile("\\d+\\s*字节");
|
||||
private static final Pattern REPLACEMENT_COUNT =
|
||||
Pattern.compile("\\d+\\s*处");
|
||||
static final String REASON_PRE_TOOL_CONTENT_REPLACED = "pre_tool_content_replaced_by_post_tool_answer";
|
||||
|
||||
private SegmentSupersedeDetector() {
|
||||
}
|
||||
@ -35,22 +39,18 @@ final class SegmentSupersedeDetector {
|
||||
continue;
|
||||
}
|
||||
|
||||
Claim predictedClaim = parseClaim(String.valueOf(candidate.getOrDefault("text", "")));
|
||||
if (predictedClaim == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int toolIndex = nextToolIndexBeforeContent(segments, i + 1);
|
||||
if (toolIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> tool = segments.get(toolIndex);
|
||||
if (Boolean.FALSE.equals(tool.get("toolSuccess"))
|
||||
|| !toolMatchesClaim(String.valueOf(tool.getOrDefault("toolName", "")), predictedClaim)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int replacementIndex = nextMatchingContentIndex(segments, toolIndex + 1, predictedClaim);
|
||||
// The replacement is the first content segment written after the tool
|
||||
// ran — grounded in its observation. Later tool calls may sit in
|
||||
// between (parallel or chained calls from the same completion), so the
|
||||
// scan crosses tool boundaries. Tool success is irrelevant: on failure
|
||||
// the post-tool content carries the authoritative failure explanation,
|
||||
// which supersedes an optimistic pre-tool claim all the same.
|
||||
int replacementIndex = nextContentIndex(segments, toolIndex + 1);
|
||||
if (replacementIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
@ -58,10 +58,16 @@ final class SegmentSupersedeDetector {
|
||||
Map<String, Object> replacement = segments.get(replacementIndex);
|
||||
candidate.put("superseded", true);
|
||||
candidate.put("supersededBySegmentId", String.valueOf(replacement.getOrDefault("id", "")));
|
||||
candidate.put("supersededReason", REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM);
|
||||
candidate.put("supersededReason", REASON_PRE_TOOL_CONTENT_REPLACED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the next tool_call segment after {@code start}, or -1 when a
|
||||
* content segment appears first — a following content segment means the
|
||||
* candidate closed its completion without issuing tool calls, so it is not
|
||||
* pre-tool narration.
|
||||
*/
|
||||
private static int nextToolIndexBeforeContent(List<Map<String, Object>> segments, int start) {
|
||||
for (int i = start; i < segments.size(); i++) {
|
||||
Map<String, Object> segment = segments.get(i);
|
||||
@ -75,6 +81,7 @@ final class SegmentSupersedeDetector {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Whether the nearest preceding non-thinking segment is a tool call. */
|
||||
private static boolean followsToolResult(List<Map<String, Object>> segments, int index) {
|
||||
for (int i = index - 1; i >= 0; i--) {
|
||||
Map<String, Object> segment = segments.get(i);
|
||||
@ -88,17 +95,10 @@ final class SegmentSupersedeDetector {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int nextMatchingContentIndex(List<Map<String, Object>> segments, int start, Claim predictedClaim) {
|
||||
/** First content segment at or after {@code start}, crossing tool boundaries; -1 when none. */
|
||||
private static int nextContentIndex(List<Map<String, Object>> segments, int start) {
|
||||
for (int i = start; i < segments.size(); i++) {
|
||||
Map<String, Object> segment = segments.get(i);
|
||||
if (isToolCall(segment)) {
|
||||
return -1;
|
||||
}
|
||||
if (!isContent(segment)) {
|
||||
continue;
|
||||
}
|
||||
Claim actualClaim = parseClaim(String.valueOf(segment.getOrDefault("text", "")));
|
||||
if (predictedClaim.sameKind(actualClaim)) {
|
||||
if (isContent(segments.get(i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@ -112,41 +112,4 @@ final class SegmentSupersedeDetector {
|
||||
private static boolean isToolCall(Map<String, Object> segment) {
|
||||
return segment != null && "tool_call".equals(segment.get("type"));
|
||||
}
|
||||
|
||||
private static Claim parseClaim(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String upper = text.toUpperCase(Locale.ROOT);
|
||||
if ((upper.contains("成功生成") || text.contains("已生成")) && GENERATED_FILE_URL.matcher(text).find()) {
|
||||
for (String format : List.of("PDF", "DOCX", "PPTX", "XLSX")) {
|
||||
if (upper.contains(format)) {
|
||||
return new Claim("render", format);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.contains("成功写入") && BYTE_COUNT.matcher(text).find()) {
|
||||
return new Claim("write", "");
|
||||
}
|
||||
if (text.contains("成功替换") && REPLACEMENT_COUNT.matcher(text).find()) {
|
||||
return new Claim("edit", "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean toolMatchesClaim(String toolName, Claim claim) {
|
||||
String normalized = toolName == null ? "" : toolName.toLowerCase(Locale.ROOT);
|
||||
return switch (claim.type) {
|
||||
case "render" -> normalized.contains("render" + claim.detail.toLowerCase(Locale.ROOT));
|
||||
case "write" -> "write_file".equals(normalized);
|
||||
case "edit" -> "edit_file".equals(normalized);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private record Claim(String type, String detail) {
|
||||
boolean sameKind(Claim other) {
|
||||
return other != null && type.equals(other.type) && detail.equals(other.detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Generates positive event ids from a wall-clock floor and atomic sequence. */
|
||||
final class SseEventIdGenerator {
|
||||
|
||||
static final long MAX_SAFE_INTEGER = 9_007_199_254_740_991L;
|
||||
|
||||
private static final int COUNTER_BITS = 10;
|
||||
private static final long IDS_PER_MILLISECOND = 1L << COUNTER_BITS;
|
||||
private static final long MAX_EPOCH_MILLIS = MAX_SAFE_INTEGER / IDS_PER_MILLISECOND;
|
||||
|
||||
private final LongSupplier clock;
|
||||
private final AtomicLong lastId;
|
||||
|
||||
SseEventIdGenerator(LongSupplier clock) {
|
||||
this.clock = clock;
|
||||
this.lastId = new AtomicLong(epochFloor(clock.getAsLong()) - 1);
|
||||
}
|
||||
|
||||
long nextId() {
|
||||
long floor = epochFloor(clock.getAsLong());
|
||||
for (;;) {
|
||||
long current = lastId.get();
|
||||
if (current >= MAX_SAFE_INTEGER) {
|
||||
throw new IllegalStateException("SSE event id space exhausted");
|
||||
}
|
||||
long next = Math.max(current + 1, floor);
|
||||
if (lastId.compareAndSet(current, next)) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long epochFloor(long epochMillis) {
|
||||
if (epochMillis <= 0 || epochMillis > MAX_EPOCH_MILLIS) {
|
||||
throw new IllegalStateException("clock is outside the SSE event id range");
|
||||
}
|
||||
return epochMillis * IDS_PER_MILLISECOND;
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import vip.mate.tts.TtsService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@ -105,7 +106,10 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] audioData = message.getPayload().array();
|
||||
// Respect the ByteBuffer's position/limit. Calling array() can include
|
||||
// unrelated capacity bytes when a WebSocket container hands us a
|
||||
// sliced or pooled buffer, corrupting the WAV data URL sent to STT.
|
||||
byte[] audioData = copyPayload(message.getPayload());
|
||||
log.info("[TalkMode] Received audio: {} bytes", audioData.length);
|
||||
|
||||
// 异步处理:STT -> Agent -> TTS
|
||||
@ -119,8 +123,8 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
|
||||
|
||||
// 2. STT: 音频转文字
|
||||
// 前端用 WavRecorder(Web Audio API + 手写 PCM WAV 编码)— 见
|
||||
// mateclaw-ui/src/utils/wavEncoder.ts. WebM/Opus 被 DashScope
|
||||
// Paraformer 拒收,WAV 是所有 STT provider 都接受的最大公约数。
|
||||
// mateclaw-ui/src/utils/wavEncoder.ts. WAV 是所有 STT provider
|
||||
// 都接受的最大公约数,也让后端能对 PCM 做静音预检。
|
||||
Map<String, Object> sttResult = sttService.transcribe(audioData, "audio.wav", "audio/wav", null);
|
||||
if (!Boolean.TRUE.equals(sttResult.get("success"))) {
|
||||
sendJson(session, Map.of("type", "error", "message", "Speech recognition failed: " + sttResult.get("error")));
|
||||
@ -142,13 +146,15 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
|
||||
Long talkWsId = talkAgent != null ? talkAgent.getWorkspaceId() : 1L;
|
||||
conversationService.getOrCreateConversation(
|
||||
talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId);
|
||||
conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of());
|
||||
var savedUser = conversationService.saveMessage(
|
||||
talkSession.conversationId, "user", transcript, List.of());
|
||||
|
||||
// 5. Agent 对话(同步)。Carry the voice user's identity so per-owner
|
||||
// memory recall (read) and the post-turn memory write (below) agree
|
||||
// on the same owner key.
|
||||
vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web(
|
||||
talkSession.conversationId, talkSession.username, talkWsId, null);
|
||||
talkSession.conversationId, talkSession.username, talkWsId, null)
|
||||
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
|
||||
AgentService.ChatResult chatResult = agentService.chatWithUsage(
|
||||
talkSession.agentId, transcript, talkSession.conversationId, talkOrigin);
|
||||
String reply = chatResult.content();
|
||||
@ -225,4 +231,12 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
|
||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy exactly the readable WebSocket payload, independent of backing-array capacity/offset. */
|
||||
static byte[] copyPayload(ByteBuffer source) {
|
||||
ByteBuffer payload = source.slice();
|
||||
byte[] audioData = new byte[payload.remaining()];
|
||||
payload.get(audioData);
|
||||
return audioData;
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,8 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import reactor.core.Disposable;
|
||||
@ -103,6 +105,20 @@ public class WebChatController {
|
||||
@Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}")
|
||||
private String visitorTokenSecret;
|
||||
|
||||
/**
|
||||
* SseEmitter timeout (minutes) for WebChat SSE streams. Previously
|
||||
* hardcoded to 10 minutes across three {@code new Utf8SseEmitter(...)} call
|
||||
* sites; downstream integrators were forced to reason about a constant
|
||||
* living in someone else's repo (issue #586). Configurable so operators
|
||||
* have a documented knob, defaulting to the historical 10 minutes.
|
||||
*/
|
||||
@Value("${mateclaw.webchat.sse-timeout-minutes:10}")
|
||||
private int webchatSseTimeoutMinutes;
|
||||
|
||||
private long sseTimeoutMillis() {
|
||||
return (long) webchatSseTimeoutMinutes * 60_000L;
|
||||
}
|
||||
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
/**
|
||||
@ -115,7 +131,7 @@ public class WebChatController {
|
||||
@RequestBody WebChatRequest request) {
|
||||
|
||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
||||
SseEmitter emitter = new Utf8SseEmitter(sseTimeoutMillis());
|
||||
|
||||
// 验证 API Key 并获取关联的 Channel 配置
|
||||
ChannelEntity channel = resolveChannel(apiKey);
|
||||
@ -172,15 +188,40 @@ public class WebChatController {
|
||||
|
||||
log.info("[WebChat] Stream: agentId={}, conversationId={}, visitor={}", agentId, conversationId, visitorId);
|
||||
|
||||
// 注册 emitter 回调
|
||||
emitter.onCompletion(() -> log.debug("[WebChat] SSE completed: {}", conversationId));
|
||||
AtomicReference<ChatStreamTracker.RunHandle> runHandleRef = new AtomicReference<>();
|
||||
AtomicBoolean disconnected = new AtomicBoolean();
|
||||
|
||||
// Register emitter callbacks. An SSE disconnect means this subscriber
|
||||
// left, not that the agent run finished. Use detach() instead of
|
||||
// complete(); complete() would prematurely mark the RunState done,
|
||||
// drop later content deltas from the replay buffer, and double-count
|
||||
// completion when the agent Flux actually finishes.
|
||||
emitter.onCompletion(() -> {
|
||||
log.debug("[WebChat] SSE completed: {}", conversationId);
|
||||
disconnected.set(true);
|
||||
streamTracker.detach(runHandleRef.get(), emitter);
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
log.debug("[WebChat] SSE timeout: {}", conversationId);
|
||||
streamTracker.complete(conversationId);
|
||||
// INFO: a timeout means the stream went idle past the SseEmitter
|
||||
// budget, which is a key signal when diagnosing stream stalls.
|
||||
log.info("[WebChat] SSE timeout (stream went idle past the emitter budget): {}", conversationId);
|
||||
disconnected.set(true);
|
||||
streamTracker.detach(runHandleRef.get(), emitter);
|
||||
// Explicitly complete after timeout so the servlet container does
|
||||
// not rethrow AsyncRequestTimeoutException.
|
||||
emitter.complete();
|
||||
});
|
||||
emitter.onError(e -> {
|
||||
log.debug("[WebChat] SSE error: {} - {}", conversationId, e.getMessage());
|
||||
streamTracker.complete(conversationId);
|
||||
// INFO only for non-benign causes; a client simply closing the tab
|
||||
// (broken pipe / connection reset) is routine and stays DEBUG so it
|
||||
// doesn't flood production logs.
|
||||
if (isClientDisconnect(e)) {
|
||||
log.debug("[WebChat] SSE client disconnected: {} - {}", conversationId, e.getMessage());
|
||||
} else {
|
||||
log.info("[WebChat] SSE error: {} - {}", conversationId, e.getMessage());
|
||||
}
|
||||
disconnected.set(true);
|
||||
streamTracker.detach(runHandleRef.get(), emitter);
|
||||
});
|
||||
|
||||
sseExecutor.execute(() -> {
|
||||
@ -194,20 +235,27 @@ public class WebChatController {
|
||||
// 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查,
|
||||
// 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。
|
||||
List<MessageContentPart> userParts = buildUserParts(conversationId, message, request.getAttachmentIds());
|
||||
Long originMessageId = request.getInternalOriginMessageId();
|
||||
if (!request.isInternalSkipUserPersist()) {
|
||||
// Regenerate reuses the already-persisted seed user row —
|
||||
// inserting again would duplicate it.
|
||||
conversationService.saveMessage(conversationId, "user", message, userParts);
|
||||
var savedUser = conversationService
|
||||
.saveMessage(conversationId, "user", message, userParts);
|
||||
originMessageId = savedUser == null ? null : savedUser.getId();
|
||||
}
|
||||
|
||||
// 初始化 SSE 流跟踪
|
||||
streamTracker.register(conversationId);
|
||||
streamTracker.attach(conversationId, emitter);
|
||||
ChatStreamTracker.RunHandle runHandle = streamTracker.register(conversationId);
|
||||
runHandleRef.set(runHandle);
|
||||
streamTracker.attach(runHandle, emitter);
|
||||
if (disconnected.get()) {
|
||||
streamTracker.detach(runHandle, emitter);
|
||||
}
|
||||
|
||||
// Echo the effective session so the caller can persist it (especially when
|
||||
// sessionId was omitted) and address the same thread on subsequent calls. The
|
||||
// visitorToken must be stored by the caller and sent back on list/messages/delete.
|
||||
streamTracker.broadcast(conversationId, "meta",
|
||||
streamTracker.broadcast(runHandle, "meta",
|
||||
"{\"sessionId\":" + escapeJson(effectiveSessionId)
|
||||
+ ",\"conversationId\":" + escapeJson(conversationId)
|
||||
+ ",\"visitorToken\":" + escapeJson(visitorToken) + "}");
|
||||
@ -226,7 +274,8 @@ public class WebChatController {
|
||||
// (publish) paths below.
|
||||
vip.mate.agent.context.ChatOrigin webchatOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null)
|
||||
.withSender(null, "api", null);
|
||||
.withSender(null, "api", null)
|
||||
.withOriginMessageId(originMessageId);
|
||||
String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin);
|
||||
|
||||
reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin)
|
||||
@ -252,18 +301,19 @@ public class WebChatController {
|
||||
// indicator (phase), tool execution badges (tool_start/end),
|
||||
// plan-execute checklist (plan). See docs/zh/webchat.md.
|
||||
if (delta.isEvent()) {
|
||||
forwardVisitorEvent(conversationId, delta.eventType(), delta.eventData());
|
||||
forwardVisitorEvent(runHandle, conversationId,
|
||||
delta.eventType(), delta.eventData());
|
||||
}
|
||||
if (delta.content() != null && !delta.content().isEmpty()) {
|
||||
assistantReply.append(delta.content());
|
||||
if (!delta.persistenceOnly()) {
|
||||
streamTracker.broadcast(conversationId, "content_delta",
|
||||
streamTracker.broadcast(runHandle, "content_delta",
|
||||
"{\"text\":" + escapeJson(delta.content()) + "}");
|
||||
}
|
||||
}
|
||||
if (delta.thinking() != null && !delta.thinking().isEmpty()
|
||||
&& !delta.persistenceOnly()) {
|
||||
streamTracker.broadcast(conversationId, "thinking_delta",
|
||||
streamTracker.broadcast(runHandle, "thinking_delta",
|
||||
"{\"text\":" + escapeJson(delta.thinking()) + "}");
|
||||
}
|
||||
})
|
||||
@ -281,14 +331,23 @@ public class WebChatController {
|
||||
log.warn("[WebChat] Failed to persist assistant reply / publish event: {}",
|
||||
persistErr.getMessage());
|
||||
}
|
||||
streamTracker.broadcast(conversationId, "done", "{\"status\":\"completed\"}");
|
||||
streamTracker.complete(conversationId);
|
||||
streamTracker.broadcast(runHandle, "done", "{\"status\":\"completed\"}");
|
||||
// WebChat is a pure-backend SSE channel with no re-attach
|
||||
// endpoint: for third-party integrators reading until the
|
||||
// server closes, `done` IS the end of the stream. Close the
|
||||
// subscriber connections so a 5-second answer doesn't hold
|
||||
// a downstream connection-pool slot for the full SseEmitter
|
||||
// timeout (issue #586). The in-house web channel does NOT
|
||||
// do this — it keeps emitters open for reconnect + replay.
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
streamTracker.complete(runHandle);
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[WebChat] Stream error: {}", e.getMessage());
|
||||
streamTracker.broadcast(conversationId, "error",
|
||||
streamTracker.broadcast(runHandle, "error",
|
||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||
streamTracker.complete(conversationId);
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
streamTracker.complete(runHandle);
|
||||
})
|
||||
.subscribe();
|
||||
// Bind the subscription's Disposable so requestStop() (invoked by
|
||||
@ -296,7 +355,12 @@ public class WebChatController {
|
||||
// the LLM stream. Without this, stopRequested is set but the underlying
|
||||
// HTTP call keeps running — token burn + side-effect tools still fire.
|
||||
// Mirrors ChatController#chatStream line 495.
|
||||
streamTracker.setDisposable(conversationId, disposable);
|
||||
streamTracker.setDisposable(runHandle, disposable);
|
||||
// Wire the emergency save so an orphaned run (only subscriber
|
||||
// gone) is flushed as an "interrupted" assistant message when
|
||||
// the grace-period eviction reclaims it — otherwise the visitor
|
||||
// would see only their own user message (issue #587).
|
||||
registerEmergencySave(runHandle, conversationId, assistantReply, usage, modelInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[WebChat] Error: {}", e.getMessage(), e);
|
||||
@ -1187,7 +1251,7 @@ public class WebChatController {
|
||||
@RequestParam String visitorId,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestParam String pendingId) {
|
||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
||||
SseEmitter emitter = new Utf8SseEmitter(sseTimeoutMillis());
|
||||
ChannelEntity channel = resolveChannel(apiKey);
|
||||
if (channel == null) {
|
||||
sendErrorAndComplete(emitter, "Invalid API Key");
|
||||
@ -1219,14 +1283,28 @@ public class WebChatController {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
emitter.onCompletion(() -> log.debug("[WebChat] approve SSE completed: {}", conversationId));
|
||||
AtomicReference<ChatStreamTracker.RunHandle> runHandleRef = new AtomicReference<>();
|
||||
AtomicBoolean disconnected = new AtomicBoolean();
|
||||
|
||||
emitter.onCompletion(() -> {
|
||||
log.debug("[WebChat] approve SSE completed: {}", conversationId);
|
||||
disconnected.set(true);
|
||||
streamTracker.detach(runHandleRef.get(), emitter);
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
log.debug("[WebChat] approve SSE timeout: {}", conversationId);
|
||||
streamTracker.complete(conversationId);
|
||||
log.info("[WebChat] approve SSE timeout (stream went idle past the emitter budget): {}", conversationId);
|
||||
disconnected.set(true);
|
||||
streamTracker.detach(runHandleRef.get(), emitter);
|
||||
emitter.complete();
|
||||
});
|
||||
emitter.onError(e -> {
|
||||
log.debug("[WebChat] approve SSE error: {} - {}", conversationId, e.getMessage());
|
||||
streamTracker.complete(conversationId);
|
||||
if (isClientDisconnect(e)) {
|
||||
log.debug("[WebChat] approve SSE client disconnected: {} - {}", conversationId, e.getMessage());
|
||||
} else {
|
||||
log.info("[WebChat] approve SSE error: {} - {}", conversationId, e.getMessage());
|
||||
}
|
||||
disconnected.set(true);
|
||||
streamTracker.detach(runHandleRef.get(), emitter);
|
||||
});
|
||||
|
||||
String actor = webchatUsername(visitorId);
|
||||
@ -1237,23 +1315,28 @@ public class WebChatController {
|
||||
// resolveAndConsume left the already-resolved / error paths
|
||||
// broadcasting into a subscriber-less tracker, so the SSE hung
|
||||
// to the 10-min timeout (review #415).
|
||||
streamTracker.register(conversationId);
|
||||
streamTracker.attach(conversationId, emitter);
|
||||
ChatStreamTracker.RunHandle runHandle = streamTracker.register(conversationId);
|
||||
runHandleRef.set(runHandle);
|
||||
streamTracker.attach(runHandle, emitter);
|
||||
if (disconnected.get()) {
|
||||
streamTracker.detach(runHandle, emitter);
|
||||
}
|
||||
try {
|
||||
// Atomically consume the approval (DB + metadata + memory, single tx).
|
||||
ResolveOutcome consumed = approvalService.resolveAndConsume(pendingId, actor);
|
||||
if (consumed.consumedSnapshot() == null) {
|
||||
// already resolved / not found — emit a terminal done so the
|
||||
// SDK's stream listener closes cleanly instead of hanging.
|
||||
broadcastApprovalResolved(conversationId, consumed);
|
||||
streamTracker.broadcast(conversationId, "done",
|
||||
broadcastApprovalResolved(runHandle, conversationId, consumed);
|
||||
streamTracker.broadcast(runHandle, "done",
|
||||
"{\"status\":\"already_resolved\"}");
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify the SDK the approval flipped (clears the banner) before
|
||||
// replay output starts streaming.
|
||||
broadcastApprovalResolved(conversationId, consumed);
|
||||
broadcastApprovalResolved(runHandle, conversationId, consumed);
|
||||
|
||||
PendingApproval snapshot = consumed.consumedSnapshot();
|
||||
Long replayAgentId = snapshot.getAgentId() != null
|
||||
@ -1261,8 +1344,9 @@ public class WebChatController {
|
||||
if (replayAgentId == null) {
|
||||
log.warn("[WebChat] approve: no agentId on consumed approval {}, cannot replay",
|
||||
pendingId);
|
||||
streamTracker.broadcast(conversationId, "done",
|
||||
streamTracker.broadcast(runHandle, "done",
|
||||
"{\"status\":\"error\",\"message\":\"No agent bound to approval\"}");
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1282,10 +1366,10 @@ public class WebChatController {
|
||||
// tool here can mislead the LLM on fallthrough).
|
||||
String replayPrompt = "继续执行已批准的工具调用。";
|
||||
StringBuilder assistantReply = new StringBuilder();
|
||||
final int[] usage = {0, 0};
|
||||
final int[] usage = {0, 0, 0, 0, 0};
|
||||
final String[] modelInfo = {null, null};
|
||||
|
||||
streamTracker.broadcast(conversationId, "message_start",
|
||||
streamTracker.broadcast(runHandle, "message_start",
|
||||
"{\"role\":\"assistant\"}");
|
||||
|
||||
Disposable disposable = agentService.chatWithReplayStream(
|
||||
@ -1305,18 +1389,19 @@ public class WebChatController {
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
}
|
||||
if (delta.isEvent()) {
|
||||
forwardVisitorEvent(conversationId, delta.eventType(), delta.eventData());
|
||||
forwardVisitorEvent(runHandle, conversationId,
|
||||
delta.eventType(), delta.eventData());
|
||||
}
|
||||
if (delta.content() != null && !delta.content().isEmpty()) {
|
||||
assistantReply.append(delta.content());
|
||||
if (!delta.persistenceOnly()) {
|
||||
streamTracker.broadcast(conversationId, "content_delta",
|
||||
streamTracker.broadcast(runHandle, "content_delta",
|
||||
"{\"text\":" + escapeJson(delta.content()) + "}");
|
||||
}
|
||||
}
|
||||
if (delta.thinking() != null && !delta.thinking().isEmpty()
|
||||
&& !delta.persistenceOnly()) {
|
||||
streamTracker.broadcast(conversationId, "thinking_delta",
|
||||
streamTracker.broadcast(runHandle, "thinking_delta",
|
||||
"{\"text\":" + escapeJson(delta.thinking()) + "}");
|
||||
}
|
||||
})
|
||||
@ -1331,25 +1416,31 @@ public class WebChatController {
|
||||
} catch (Exception persistErr) {
|
||||
log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage());
|
||||
}
|
||||
streamTracker.broadcast(conversationId, "done",
|
||||
streamTracker.broadcast(runHandle, "done",
|
||||
"{\"status\":\"completed\"}");
|
||||
streamTracker.complete(conversationId);
|
||||
// Close the WebChat SSE connection on the logical end of
|
||||
// the replay stream — same rationale as /stream (issue #586).
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
streamTracker.complete(runHandle);
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[WebChat] approve replay stream error: {}", e.getMessage());
|
||||
streamTracker.broadcast(conversationId, "error",
|
||||
streamTracker.broadcast(runHandle, "error",
|
||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||
streamTracker.complete(conversationId);
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
streamTracker.complete(runHandle);
|
||||
})
|
||||
.subscribe();
|
||||
streamTracker.setDisposable(conversationId, disposable);
|
||||
streamTracker.setDisposable(runHandle, disposable);
|
||||
registerEmergencySave(runHandle, conversationId, assistantReply, usage, modelInfo);
|
||||
} catch (Exception e) {
|
||||
log.error("[WebChat] approve failed for {}: {}", conversationId, e.getMessage());
|
||||
try {
|
||||
streamTracker.broadcast(conversationId, "error",
|
||||
streamTracker.broadcast(runHandle, "error",
|
||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||
} catch (Exception ignored) {}
|
||||
streamTracker.complete(conversationId);
|
||||
streamTracker.closeSubscribers(runHandle);
|
||||
streamTracker.complete(runHandle);
|
||||
}
|
||||
});
|
||||
audit(channel, visitorId, "webchat.approve-approval", conversationId,
|
||||
@ -1367,6 +1458,22 @@ public class WebChatController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the SSE error is a routine client-side disconnect (closed tab,
|
||||
* network drop) rather than a server-side failure. Used to keep the
|
||||
* lifecycle log noise down: a visitor closing the tab is expected and
|
||||
* stays DEBUG; anything else is worth an INFO line for production triage.
|
||||
* Mirrors ChatController#isClientDisconnect.
|
||||
*/
|
||||
private static boolean isClientDisconnect(Throwable e) {
|
||||
if (e instanceof IOException) return true;
|
||||
String msg = e.getMessage();
|
||||
if (msg == null) return false;
|
||||
String lower = msg.toLowerCase();
|
||||
return lower.contains("broken pipe") || lower.contains("connection reset")
|
||||
|| lower.contains("client abort") || lower.contains("closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a {@code tool_approval_resolved} event so the SDK clears its
|
||||
* approval banner in real time. Shared by approve / deny / stop-sweep.
|
||||
@ -1375,16 +1482,32 @@ public class WebChatController {
|
||||
private void broadcastApprovalResolved(String conversationId, ResolveOutcome outcome) {
|
||||
try {
|
||||
streamTracker.broadcast(conversationId, "tool_approval_resolved",
|
||||
objectMapper.writeValueAsString(Map.of(
|
||||
"pendingId", outcome.pendingId(),
|
||||
"decision", outcome.decision() != null ? outcome.decision() : "",
|
||||
"toolName", outcome.toolName() != null ? outcome.toolName() : "")));
|
||||
approvalResolvedJson(outcome));
|
||||
} catch (Exception e) {
|
||||
log.debug("[WebChat] approval_resolved broadcast failed for {}: {}",
|
||||
outcome.pendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastApprovalResolved(ChatStreamTracker.RunHandle runHandle,
|
||||
String conversationId,
|
||||
ResolveOutcome outcome) {
|
||||
try {
|
||||
streamTracker.broadcast(runHandle, "tool_approval_resolved",
|
||||
approvalResolvedJson(outcome));
|
||||
} catch (Exception e) {
|
||||
log.debug("[WebChat] approval_resolved broadcast failed for {} in {}: {}",
|
||||
outcome.pendingId(), conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String approvalResolvedJson(ResolveOutcome outcome) throws IOException {
|
||||
return objectMapper.writeValueAsString(Map.of(
|
||||
"pendingId", outcome.pendingId(),
|
||||
"decision", outcome.decision() != null ? outcome.decision() : "",
|
||||
"toolName", outcome.toolName() != null ? outcome.toolName() : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the last assistant reply.
|
||||
* <p>
|
||||
@ -1404,7 +1527,7 @@ public class WebChatController {
|
||||
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
||||
@RequestParam String visitorId,
|
||||
@RequestParam(required = false) String sessionId) {
|
||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
||||
SseEmitter emitter = new Utf8SseEmitter(sseTimeoutMillis());
|
||||
ChannelEntity channel = resolveChannel(apiKey);
|
||||
if (channel == null) {
|
||||
sendErrorAndComplete(emitter, "Invalid API Key");
|
||||
@ -1453,6 +1576,7 @@ public class WebChatController {
|
||||
req.setVisitorId(visitorId);
|
||||
req.setSessionId(sid);
|
||||
req.setInternalSkipUserPersist(true);
|
||||
req.setInternalOriginMessageId(seed.seedMessageId());
|
||||
return chatStream(apiKey, req);
|
||||
}
|
||||
|
||||
@ -1608,6 +1732,54 @@ public class WebChatController {
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an emergency-save callback that flushes the partial assistant
|
||||
* reply accumulated so far as an {@code interrupted} message. Wired on
|
||||
* both {@code /stream} and {@code /sessions/approve} so that when a run is
|
||||
* reclaimed while its only subscriber is gone (orphan-grace eviction,
|
||||
* shutdown, admin force-recycle), the visitor can still retrieve the
|
||||
* partial answer via {@code /sessions/messages} instead of seeing only the
|
||||
* user message (issue #587). Mirrors ChatController#emergencySaveAccumulator.
|
||||
*
|
||||
* @param conversationId target conversation
|
||||
* @param assistantReply live accumulator appended to in doOnNext
|
||||
* @param usage [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
* @param modelInfo [runtimeModel, runtimeProvider]
|
||||
*/
|
||||
private void registerEmergencySave(ChatStreamTracker.RunHandle runHandle,
|
||||
String conversationId, StringBuilder assistantReply,
|
||||
int[] usage, String[] modelInfo) {
|
||||
streamTracker.setEmergencySaveCallback(runHandle, () -> {
|
||||
try {
|
||||
String reply = assistantReply.toString();
|
||||
if (reply.isBlank()) {
|
||||
log.debug("[WebChat] Emergency save skipped (empty reply): {}", conversationId);
|
||||
return;
|
||||
}
|
||||
// usage length varies by call site (chatStream = 5 tokens,
|
||||
// approve replay = 2); read defensively so the save never
|
||||
// throws ArrayIndexOutOfBoundsException.
|
||||
int prompt = usage.length > 0 ? usage[0] : 0;
|
||||
int completion = usage.length > 1 ? usage[1] : 0;
|
||||
int cacheRead = usage.length > 2 ? usage[2] : 0;
|
||||
int cacheWrite = usage.length > 3 ? usage[3] : 0;
|
||||
int reasoning = usage.length > 4 ? usage[4] : 0;
|
||||
String runtimeModel = modelInfo.length > 0 ? modelInfo[0] : null;
|
||||
String runtimeProvider = modelInfo.length > 1 ? modelInfo[1] : null;
|
||||
conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, List.of(),
|
||||
"interrupted",
|
||||
prompt, completion, cacheRead, cacheWrite, reasoning,
|
||||
runtimeModel, runtimeProvider, null);
|
||||
log.info("[WebChat] Emergency-saved partial assistant reply: " +
|
||||
"conversationId={}, textLen={}",
|
||||
conversationId, reply.length());
|
||||
} catch (Exception e) {
|
||||
log.warn("[WebChat] Emergency save failed for {}: {}", conversationId, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
private static final Pattern SESSION_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,64}");
|
||||
@ -1842,7 +2014,10 @@ public class WebChatController {
|
||||
* <p>Backward compat: visitors / SDKs that don't know these event types
|
||||
* silently ignore them per the SSE spec.
|
||||
*/
|
||||
private void forwardVisitorEvent(String conversationId, String eventType, Map<String, Object> data) {
|
||||
private void forwardVisitorEvent(ChatStreamTracker.RunHandle runHandle,
|
||||
String conversationId,
|
||||
String eventType,
|
||||
Map<String, Object> data) {
|
||||
if (eventType == null || data == null) return;
|
||||
Map<String, Object> payload;
|
||||
String sseName;
|
||||
@ -1885,7 +2060,7 @@ public class WebChatController {
|
||||
}
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(payload);
|
||||
streamTracker.broadcast(conversationId, sseName, json);
|
||||
streamTracker.broadcast(runHandle, sseName, json);
|
||||
} catch (Exception e) {
|
||||
log.debug("[WebChat] Failed to serialize visitor event {} for {}: {}",
|
||||
eventType, conversationId, e.getMessage());
|
||||
@ -1929,6 +2104,7 @@ public class WebChatController {
|
||||
*/
|
||||
@JsonIgnore
|
||||
private boolean internalSkipUserPersist;
|
||||
private Long internalOriginMessageId;
|
||||
}
|
||||
|
||||
/** Compact view of one of a visitor's conversation threads. */
|
||||
|
||||
@ -120,15 +120,19 @@ public class WebChatFileService {
|
||||
|
||||
String storedName = UUID.randomUUID() + "_" + safeName;
|
||||
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
|
||||
// Sanitize the id for the path segment (IM ids like "wecom:XXXX" carry a
|
||||
// ':' illegal on Windows); reads use the same sanitization.
|
||||
Path dir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).normalize();
|
||||
// resolveWriteDir sanitizes the id for the path segment (IM ids like
|
||||
// "wecom:XXXX" carry a ':' illegal on Windows) and appends the per-day
|
||||
// sub-directory when date folders are enabled.
|
||||
Path dir = uploadLocationResolver.resolveWriteDir(conversationId).normalize();
|
||||
if (!dir.startsWith(uploadRoot)) {
|
||||
// conversationId is server-derived, so this should never happen; fail closed if it does.
|
||||
throw new UploadRejectedException("Invalid conversation");
|
||||
}
|
||||
Files.createDirectories(dir);
|
||||
enforceConversationQuota(dir, file.getSize());
|
||||
// Quota counts the whole conversation tree (flat files + date subdirs),
|
||||
// not just today's write dir.
|
||||
enforceConversationQuota(
|
||||
uploadLocationResolver.resolveConversationDir(conversationId).normalize(), file.getSize());
|
||||
Path target = dir.resolve(storedName);
|
||||
file.transferTo(target.toAbsolutePath());
|
||||
|
||||
@ -169,19 +173,10 @@ public class WebChatFileService {
|
||||
* {@code startsWith} guard. Returns empty if missing or escaping the dir.
|
||||
*/
|
||||
public Optional<Path> resolve(String conversationId, String storedName) {
|
||||
if (storedName == null || storedName.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
||||
// so files written before the workspace-aware relocation still resolve.
|
||||
for (Path base : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
|
||||
Path normBase = base.normalize();
|
||||
Path file = normBase.resolve(storedName).normalize();
|
||||
if (file.startsWith(normBase) && Files.exists(file) && Files.isRegularFile(file)) {
|
||||
return Optional.of(file);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
// resolveExistingFile checks every candidate root (workspace-scoped dir
|
||||
// + legacy default dir) and both layouts (flat + date sub-directories),
|
||||
// guarding each candidate against traversal.
|
||||
return Optional.ofNullable(uploadLocationResolver.resolveExistingFile(conversationId, storedName));
|
||||
}
|
||||
|
||||
/** Map a content type to the MessageContentPart type the agent/UI understands. */
|
||||
@ -216,19 +211,23 @@ public class WebChatFileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound a conversation's disk footprint: reject when the dir already holds
|
||||
* the max file count, or when adding {@code incomingSize} would push the
|
||||
* total over the cap. Cheap dir scan (these dirs hold at most a few dozen
|
||||
* files); pairs with the staging TTL sweep that reclaims unreferenced files.
|
||||
* Bound a conversation's disk footprint: reject when the conversation tree
|
||||
* already holds the max file count, or when adding {@code incomingSize}
|
||||
* would push the total over the cap. Walks the tree so files under date
|
||||
* sub-directories are counted; cheap scan (these dirs hold at most a few
|
||||
* dozen files), pairs with the staging TTL sweep that reclaims
|
||||
* unreferenced files.
|
||||
*/
|
||||
private void enforceConversationQuota(Path dir, long incomingSize) throws IOException {
|
||||
private void enforceConversationQuota(Path conversationDir, long incomingSize) throws IOException {
|
||||
int count = 0;
|
||||
long total = 0;
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
for (Path p : (Iterable<Path>) files::iterator) {
|
||||
if (Files.isRegularFile(p)) {
|
||||
count++;
|
||||
total += Files.size(p);
|
||||
if (Files.isDirectory(conversationDir)) {
|
||||
try (Stream<Path> files = Files.walk(conversationDir)) {
|
||||
for (Path p : (Iterable<Path>) files::iterator) {
|
||||
if (Files.isRegularFile(p)) {
|
||||
count++;
|
||||
total += Files.size(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import vip.mate.agent.AgentService.StreamDelta;
|
||||
import vip.mate.channel.AbstractChannelAdapter;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.ProvisionalContentTracker;
|
||||
import vip.mate.channel.StreamingChannelAdapter;
|
||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
@ -1428,7 +1429,13 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// (routine once the answer is short — the model restates it before the
|
||||
// last tool call), publishing both puts the identical bubble on screen
|
||||
// twice. Same text → drop the narration, the final answer covers it.
|
||||
String pendingNarration = outcome.pendingNarration();
|
||||
// A pre-tool narration (no observation behind it, tools ran after it)
|
||||
// is likewise dropped once a grounded answer exists: whatever it says
|
||||
// — process narration or a predicted result — it was written before
|
||||
// this turn's observations, and IM bubbles cannot be retracted later.
|
||||
String pendingNarration = outcome.tracker() != null
|
||||
? outcome.tracker().settle(!finalContent.isBlank())
|
||||
: null;
|
||||
if (!finalContent.isBlank()) {
|
||||
if (pendingNarration != null && !sameOutboundText(pendingNarration, finalContent)) {
|
||||
publishNarrationBubble(replyTarget, pendingNarration);
|
||||
@ -1454,10 +1461,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
* which only {@link #processStream} can act on (it needs the final answer
|
||||
* first).
|
||||
*
|
||||
* @param pendingNarration the last per-stage narration, still unpublished
|
||||
* @param approvalPending a tool call is parked on human approval
|
||||
* @param tracker the narration lifecycle tracker holding the last
|
||||
* per-stage narration, still unresolved — settled by
|
||||
* {@code processStream} once the final answer is
|
||||
* known; {@code null} on the degraded path where
|
||||
* narration is never staged
|
||||
* @param approvalPending a tool call is parked on human approval
|
||||
*/
|
||||
private record StreamOutcome(String pendingNarration, boolean approvalPending) {}
|
||||
private record StreamOutcome(ProvisionalContentTracker tracker, boolean approvalPending) {}
|
||||
|
||||
/** Compare two outbound texts the way the user sees them (post-filter, trimmed). */
|
||||
private boolean sameOutboundText(String a, String b) {
|
||||
@ -1520,11 +1531,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// as the new progress bubble, so chat chronology stays intact and
|
||||
// the final answer always lands in the newest bubble.
|
||||
AtomicReference<WeComReplyContext> liveCtx = new AtomicReference<>(initialCtx);
|
||||
AtomicReference<String> pendingNarration = new AtomicReference<>();
|
||||
ProvisionalContentTracker tracker = new ProvisionalContentTracker("wecom");
|
||||
final long[] lastFlushAt = {0L};
|
||||
stream.doOnNext(delta -> {
|
||||
boolean flushNow = false;
|
||||
if (delta.isEvent()) {
|
||||
if ("tool_call_completed".equals(delta.eventType())) {
|
||||
tracker.onToolObservation();
|
||||
}
|
||||
flushNow = progress.onEvent(delta.eventType(), delta.eventData());
|
||||
if (standaloneToolMessages) {
|
||||
maybeSendToolEventMessage(replyTarget, delta.eventType(), delta.eventData());
|
||||
@ -1536,23 +1550,27 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// a wall of text, and persisted they pollute the next turn's
|
||||
// LLM history with unanswered chain-of-thought.
|
||||
//
|
||||
// Publishing lags one narration behind: the newest one is only
|
||||
// staged (visible live in the bubble, not yet finalized) so
|
||||
// processStream can still drop it if the final answer turns out
|
||||
// to be the same text. Without the lag the user reads the same
|
||||
// paragraph in two adjacent bubbles.
|
||||
// Publishing lags one narration behind via the shared
|
||||
// lifecycle tracker: the newest narration is only staged
|
||||
// (visible live in the bubble, not yet finalized) so a later
|
||||
// decision can still drop it — a pre-tool rehearsal must not
|
||||
// become an unretractable permanent bubble once grounded
|
||||
// content follows. The producer-assigned kind decides; for
|
||||
// untagged deltas the tracker falls back to the observation
|
||||
// counter (a tool result completed since the last narration
|
||||
// means this one was written with real output in hand).
|
||||
String narration = delta.content() != null ? delta.content().trim() : "";
|
||||
if (!narration.isEmpty()) {
|
||||
String previous = pendingNarration.getAndSet(narration);
|
||||
progress.onNarration(narration);
|
||||
if (previous != null) {
|
||||
String publishable = tracker.stageNarration(narration, delta.kind());
|
||||
if (publishable != null) {
|
||||
WeComReplyContext ctx = liveCtx.get();
|
||||
if (replyContexts.get(replyTarget) == ctx) {
|
||||
liveCtx.set(rollProgressBubble(replyTarget, ctx, previous, progress));
|
||||
liveCtx.set(rollProgressBubble(replyTarget, ctx, publishable, progress));
|
||||
} else {
|
||||
// Bubble already force-finished (180s ceiling) — the
|
||||
// narration still goes out as a plain message.
|
||||
sendMessage(replyTarget, previous);
|
||||
sendMessage(replyTarget, publishable);
|
||||
}
|
||||
}
|
||||
flushNow = true;
|
||||
@ -1589,7 +1607,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
}
|
||||
}).blockLast(Duration.ofMinutes(10));
|
||||
|
||||
return new StreamOutcome(pendingNarration.get(), progress.isApprovalPending());
|
||||
return new StreamOutcome(tracker, progress.isApprovalPending());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3403,7 +3421,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
|
||||
// inside the byte source so a fetch + decrypt is retried as one unit.
|
||||
Path uploadDir = (chatUploadLocationResolver != null)
|
||||
? chatUploadLocationResolver.resolveConversationDir(conversationId)
|
||||
? chatUploadLocationResolver.resolveWriteDir(conversationId)
|
||||
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
|
||||
return InboundMediaDownloader.download(
|
||||
|
||||
@ -695,7 +695,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
}
|
||||
|
||||
Path uploadDir = (chatUploadLocationResolver != null)
|
||||
? chatUploadLocationResolver.resolveConversationDir(conversationId)
|
||||
? chatUploadLocationResolver.resolveWriteDir(conversationId)
|
||||
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
|
||||
return InboundMediaDownloader.download(
|
||||
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.common.text;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Best-effort masking of credential-shaped substrings in free text.
|
||||
*
|
||||
* <p>Intended for text that is about to be <em>copied</em> out of its original
|
||||
* store — persisted into a second table, rendered in an admin screen, or sent
|
||||
* to a model. A secret that already sits in a conversation row is exposed
|
||||
* exactly once; duplicating it into a new location multiplies the places it
|
||||
* can leak from and outlives any later cleanup of the original.
|
||||
*
|
||||
* <p>Deliberately conservative: it matches shapes that are almost always
|
||||
* credentials (provider key prefixes, explicit {@code key=value} assignments,
|
||||
* bearer headers) rather than anything high-entropy. Over-matching would
|
||||
* quietly destroy the words that make a request recognisable, and this text is
|
||||
* used to tell one routine from another. This reduces exposure; it is not a
|
||||
* guarantee, and it is not a substitute for keeping secrets out of chat.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class SecretRedactor {
|
||||
|
||||
/** Replacement for any matched credential. */
|
||||
public static final String MASK = "[redacted]";
|
||||
|
||||
private static final List<Pattern> PATTERNS = List.of(
|
||||
// Provider key prefixes: OpenAI (incl. sk-proj-), Anthropic, GitHub,
|
||||
// Slack, Google, AWS access key ids.
|
||||
Pattern.compile("\\bsk-[A-Za-z0-9_-]{12,}"),
|
||||
Pattern.compile("\\bgh[pousr]_[A-Za-z0-9]{16,}"),
|
||||
Pattern.compile("\\bxox[baprs]-[A-Za-z0-9-]{10,}"),
|
||||
Pattern.compile("\\bAIza[A-Za-z0-9_-]{20,}"),
|
||||
Pattern.compile("\\bAKIA[0-9A-Z]{16}\\b"),
|
||||
// Authorization headers.
|
||||
Pattern.compile("(?i)\\bbearer\\s+[A-Za-z0-9._~+/=-]{16,}"),
|
||||
// Explicit assignments — keep the field name, mask only the value,
|
||||
// so "api_key = [redacted]" still reads as what it was.
|
||||
Pattern.compile("(?i)\\b(api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key"
|
||||
+ "|client[_-]?secret|password|passwd|token|secret)\\b\\s*[:=]\\s*"
|
||||
+ "[\"']?[^\\s\"',;]{6,}[\"']?")
|
||||
);
|
||||
|
||||
/** Index of the field-name group in the assignment pattern above. */
|
||||
private static final int ASSIGNMENT_PATTERN_INDEX = PATTERNS.size() - 1;
|
||||
|
||||
private SecretRedactor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask credential-shaped substrings.
|
||||
*
|
||||
* @param text input; {@code null} is returned unchanged
|
||||
* @return the text with credentials replaced by {@link #MASK}
|
||||
*/
|
||||
public static String redact(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
String out = text;
|
||||
for (int i = 0; i < PATTERNS.size(); i++) {
|
||||
out = i == ASSIGNMENT_PATTERN_INDEX
|
||||
? PATTERNS.get(i).matcher(out).replaceAll("$1=" + MASK)
|
||||
: PATTERNS.get(i).matcher(out).replaceAll(MASK);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package vip.mate.common.text;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Language-agnostic near-duplicate text comparison without a word segmenter.
|
||||
*
|
||||
* <p>A shingle set mixes Latin word tokens with CJK character bigrams, so the
|
||||
* same routine works on space-delimited English and on space-free Chinese.
|
||||
* Bigrams are the reason a segmenter is unnecessary: two Chinese sentences
|
||||
* that share most of their characters in the same order share most of their
|
||||
* bigrams, while unrelated sentences of similar length do not.
|
||||
*
|
||||
* <p>Extracted so relevance scoring (memory recall) and recurrence detection
|
||||
* (routine mining) agree on what "these two texts say the same thing" means.
|
||||
* Callers should lowercase the input first when case should be ignored — the
|
||||
* Latin token pattern only matches lowercase.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class Shingles {
|
||||
|
||||
/** Latin word tokens; two chars minimum so single letters do not dominate. */
|
||||
private static final Pattern WORD_RE = Pattern.compile("[a-z0-9]{2,}");
|
||||
|
||||
private Shingles() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the shingle set: Latin word tokens (length >= 2) plus CJK
|
||||
* character bigrams (a single CJK character when isolated).
|
||||
*
|
||||
* @param text input; {@code null} yields an empty set
|
||||
*/
|
||||
public static Set<String> of(String text) {
|
||||
Set<String> out = new HashSet<>();
|
||||
if (text == null || text.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Matcher m = WORD_RE.matcher(text);
|
||||
while (m.find()) {
|
||||
out.add(m.group());
|
||||
}
|
||||
|
||||
for (String run : text.replaceAll("[^\\p{IsHan}]", " ").split("\\s+")) {
|
||||
if (run.isEmpty()) continue;
|
||||
if (run.length() == 1) {
|
||||
out.add(run);
|
||||
} else {
|
||||
for (int i = 0; i + 2 <= run.length(); i++) {
|
||||
out.add(run.substring(i, i + 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jaccard similarity of two shingle sets: {@code |A ∩ B| / |A ∪ B|}.
|
||||
*
|
||||
* @return {@code 0.0} when either set is empty, otherwise a value in
|
||||
* {@code [0.0, 1.0]} where 1.0 means identical shingle sets
|
||||
*/
|
||||
public static double jaccard(Set<String> a, Set<String> b) {
|
||||
if (a == null || b == null || a.isEmpty() || b.isEmpty()) {
|
||||
return 0.0;
|
||||
}
|
||||
// Intersect against the smaller set so the scan is bounded by it.
|
||||
Set<String> smaller = a.size() <= b.size() ? a : b;
|
||||
Set<String> larger = smaller == a ? b : a;
|
||||
int intersection = 0;
|
||||
for (String s : smaller) {
|
||||
if (larger.contains(s)) {
|
||||
intersection++;
|
||||
}
|
||||
}
|
||||
int union = a.size() + b.size() - intersection;
|
||||
return union == 0 ? 0.0 : (double) intersection / union;
|
||||
}
|
||||
}
|
||||
@ -51,10 +51,18 @@ public class PrefixBudgetProperties {
|
||||
* Fraction of the effective window the advertised tool schemas may
|
||||
* occupy. When the core tool set estimates above this, the least
|
||||
* recently used demotable tools are auto-moved to the extension catalog
|
||||
* (recoverable via {@code enable_tool}) until the set fits.
|
||||
* (recoverable via {@code tool_call}) until the set fits.
|
||||
*/
|
||||
private double toolSchemaRatio = 0.25;
|
||||
|
||||
/**
|
||||
* Absolute ceiling for schemas advertised on every model request. The
|
||||
* ratio alone is ineffective for very large declared context windows
|
||||
* (for example 25% of 1M tokens), which allowed tens of thousands of
|
||||
* fixed schema tokens to survive every round.
|
||||
*/
|
||||
private int toolSchemaMaxTokens = 12000;
|
||||
|
||||
/** Relative shares of the injection budget. Normalized at plan time. */
|
||||
private Shares shares = new Shares();
|
||||
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package vip.mate.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* How much of a turn's reasoning is written to the message record.
|
||||
* <p>
|
||||
* <b>Tune in {@code application.yml} under {@code mate.agent.reasoning}, not in
|
||||
* the Java field defaults.</b> The yml is the source of truth; the field default
|
||||
* below is a conservative fallback for tests / unit constructors.
|
||||
* <p>
|
||||
* A ReAct turn reasons once per iteration. Only the terminal iteration's
|
||||
* reasoning is needed to explain the answer, but the earlier ones are what
|
||||
* explain each tool call — which is exactly what a replay of a misbehaving turn
|
||||
* needs. Persisting all of them costs message-row size, so operators running
|
||||
* long tool loops on a small database can trade the detail away.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "mate.agent.reasoning")
|
||||
public class ReasoningRetentionProperties {
|
||||
|
||||
/** {@link Retention#ALL} keeps every iteration; {@link Retention#TERMINAL} keeps only the last. */
|
||||
private Retention retention = Retention.ALL;
|
||||
|
||||
public boolean persistsEveryIteration() {
|
||||
return retention != Retention.TERMINAL;
|
||||
}
|
||||
|
||||
public enum Retention {
|
||||
/** Persist the reasoning of every iteration, positioned where it happened. */
|
||||
ALL,
|
||||
/** Persist only the reasoning of the iteration that produced the final answer. */
|
||||
TERMINAL
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import vip.mate.kbopen.auth.KbOpenApiAuthFilter;
|
||||
|
||||
/**
|
||||
@ -79,6 +80,10 @@ public class SecurityConfig {
|
||||
)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// REQUEST dispatches are authenticated below. Async SSE error/completion
|
||||
// redispatches can run after the response is committed and no longer carry
|
||||
// the JWT; challenging them produces a second AccessDeniedException.
|
||||
auth.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll();
|
||||
// GET /settings/language stays anonymous (first-paint i18n). PUT
|
||||
// requires login + admin (see @RequireGlobalAdmin on the controller).
|
||||
auth.requestMatchers(HttpMethod.GET, "/api/v1/settings/language").permitAll()
|
||||
|
||||
@ -17,7 +17,8 @@ import vip.mate.kbopen.auth.KbScopeInterceptor;
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class})
|
||||
@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class,
|
||||
ReasoningRetentionProperties.class})
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final WorkspaceAccessInterceptor workspaceAccessInterceptor;
|
||||
|
||||
@ -29,6 +29,10 @@ public class CronChatOriginFactory {
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
public ChatOrigin from(CronJobEntity job, String conversationId) {
|
||||
return from(job, conversationId, null);
|
||||
}
|
||||
|
||||
public ChatOrigin from(CronJobEntity job, String conversationId, Long originMessageId) {
|
||||
AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null;
|
||||
Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L;
|
||||
|
||||
@ -36,6 +40,6 @@ public class CronChatOriginFactory {
|
||||
ChannelTarget target = dc != null ? dc.toChannelTarget() : null;
|
||||
|
||||
return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null,
|
||||
job.getChannelId(), target);
|
||||
job.getChannelId(), target).withOriginMessageId(originMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +64,12 @@ public class CronJobEntity {
|
||||
* RFC-063r §2.9: originating channel binding. Null when this job was
|
||||
* created from the web (no proactive delivery target). The single
|
||||
* indexed column lets ops query "all jobs delivering to channel X".
|
||||
*
|
||||
* <p>{@code FieldStrategy.ALWAYS} so clearing the binding from the edit
|
||||
* form actually writes NULL — the default NOT_NULL strategy drops the
|
||||
* column from the UPDATE and the old channel silently survives.
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Long channelId;
|
||||
|
||||
/**
|
||||
@ -72,7 +77,7 @@ public class CronJobEntity {
|
||||
* persisted as JSON via MyBatis Plus JacksonTypeHandler so future fields
|
||||
* don't require schema migrations.
|
||||
*/
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
@TableField(typeHandler = JacksonTypeHandler.class, updateStrategy = FieldStrategy.ALWAYS)
|
||||
private DeliveryConfig deliveryConfig;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
package vip.mate.cron.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Result;
|
||||
import org.apache.ibatis.annotations.ResultMap;
|
||||
import org.apache.ibatis.annotations.Results;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
|
||||
@ -31,6 +35,18 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
* <p>Filters out logically-deleted rows and orders by create_time DESC
|
||||
* to mirror the existing {@code list()} ordering.
|
||||
*/
|
||||
// Shared result map for every hand-written query in this mapper. The
|
||||
// typeHandler declared on CronJobEntity.deliveryConfig only reaches the
|
||||
// result map MyBatis Plus generates for the injected BaseMapper methods;
|
||||
// annotation-driven statements build their own, and auto-mapping finds no
|
||||
// handler for the DeliveryConfig record, so MyBatis silently skips the
|
||||
// column (default AutoMappingUnknownColumnBehavior.NONE) and every job
|
||||
// read through these queries came back with a null deliveryConfig.
|
||||
// Restating the handler here fixes it — the other columns still auto-map.
|
||||
@Results(id = "cronJobResultMap", value = {
|
||||
@Result(column = "delivery_config", property = "deliveryConfig",
|
||||
typeHandler = JacksonTypeHandler.class)
|
||||
})
|
||||
@Select("""
|
||||
SELECT j.*,
|
||||
(SELECT r.delivery_status FROM mate_cron_job_run r
|
||||
@ -51,6 +67,7 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
* (cross-workspace access returns null → caller throws not_found, matching
|
||||
* the "deleted" shape so workspace existence isn't enumerable).
|
||||
*/
|
||||
@ResultMap("cronJobResultMap")
|
||||
@Select("""
|
||||
SELECT j.*,
|
||||
(SELECT r.delivery_status FROM mate_cron_job_run r
|
||||
@ -70,6 +87,7 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
* toggle / runNow). Skips the delivery-status subquery — those paths
|
||||
* don't need it and pay for the correlated lookup otherwise.
|
||||
*/
|
||||
@ResultMap("cronJobResultMap")
|
||||
@Select("SELECT * FROM mate_cron_job WHERE id = #{id} AND deleted = 0 AND workspace_id = #{workspaceId}")
|
||||
CronJobEntity selectByIdAndWorkspace(@Param("id") Long id,
|
||||
@Param("workspaceId") Long workspaceId);
|
||||
|
||||
@ -17,6 +17,7 @@ import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
import vip.mate.i18n.I18nService;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@ -45,6 +46,9 @@ import java.time.LocalDateTime;
|
||||
@RequiredArgsConstructor
|
||||
public class CronJobLifecycleService {
|
||||
|
||||
public record StartResult(CronJobRunEntity run, Long originMessageId) {
|
||||
}
|
||||
|
||||
private final CronJobRunMapper runMapper;
|
||||
private final ConversationService conversationService;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
@ -60,8 +64,8 @@ public class CronJobLifecycleService {
|
||||
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType,
|
||||
String conversationId) {
|
||||
public StartResult startRun(CronJobEntity job, String userMessage, String triggerType,
|
||||
String conversationId) {
|
||||
CronJobRunEntity run = new CronJobRunEntity();
|
||||
run.setCronJobId(job.getId());
|
||||
run.setConversationId(conversationId);
|
||||
@ -90,10 +94,13 @@ public class CronJobLifecycleService {
|
||||
// Persist the user message before the LLM call so history reads
|
||||
// see a coherent (user → assistant) ordering even if the agent
|
||||
// throws mid-run.
|
||||
Long originMessageId = null;
|
||||
if (userMessage != null && !userMessage.isBlank()) {
|
||||
conversationService.saveMessage(conversationId, "user", userMessage);
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
conversationId, "user", userMessage);
|
||||
originMessageId = savedUser == null ? null : savedUser.getId();
|
||||
}
|
||||
return run;
|
||||
return new StartResult(run, originMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -102,13 +102,14 @@ public class CronJobRunner {
|
||||
String conversationId = conversationResolver.resolve(job);
|
||||
|
||||
// T1 — short tx
|
||||
CronJobRunEntity run;
|
||||
CronJobLifecycleService.StartResult started;
|
||||
try {
|
||||
run = lifecycle.startRun(job, userMessage, triggerType, conversationId);
|
||||
started = lifecycle.startRun(job, userMessage, triggerType, conversationId);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
return;
|
||||
}
|
||||
CronJobRunEntity run = started.run();
|
||||
|
||||
// task_type='reminder' — pure notification, no LLM call. The user
|
||||
// (or the create_reminder tool on their behalf) supplied the exact
|
||||
@ -141,7 +142,8 @@ public class CronJobRunner {
|
||||
AgentService.ChatResult chatResult;
|
||||
AssistantMessage result;
|
||||
try {
|
||||
ChatOrigin origin = originFactory.from(job, conversationId);
|
||||
ChatOrigin origin = originFactory.from(
|
||||
job, conversationId, started.originMessageId());
|
||||
chatResult = runAgent(job, userMessage, origin, conversationId);
|
||||
result = new AssistantMessage(chatResult.content());
|
||||
} catch (Exception e) {
|
||||
@ -282,8 +284,14 @@ public class CronJobRunner {
|
||||
* conversation history is in scope, so the model must not assume
|
||||
* earlier context;</li>
|
||||
* <li>(channel-bound runs only) delivery back to the originating
|
||||
* channel is framework-handled, so the model must not invent
|
||||
* CLI / shell / "send to WeChat" tool calls to deliver the result;</li>
|
||||
* channel is framework-handled, so the model must not re-send the
|
||||
* final result to that same channel itself; pushing to a
|
||||
* <em>different</em> conversation, when the task explicitly asks
|
||||
* for it, goes through the {@code send_channel_message} tool;</li>
|
||||
* <li>(non-channel-bound runs) nothing is auto-delivered to any IM
|
||||
* channel — if the task instructions require notifying a channel
|
||||
* conversation, the model should use {@code list_channel_sessions}
|
||||
* + {@code send_channel_message};</li>
|
||||
* <li>when there is genuinely nothing to do or report, the model
|
||||
* should reply with exactly {@link #CRON_SILENT_MARKER} and nothing
|
||||
* else, which suppresses delivery for this run.</li>
|
||||
@ -298,8 +306,15 @@ public class CronJobRunner {
|
||||
sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;")
|
||||
.append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n");
|
||||
if (channelBound) {
|
||||
sb.append("- 执行结果会由系统自动投递回原渠道,你只需直接给出最终结果内容,")
|
||||
.append("不要尝试调用 CLI / shell / \"发送到微信\"等工具自行投递。\n");
|
||||
sb.append("- 执行结果会由系统自动投递回本任务绑定的渠道会话,你只需直接给出最终结果内容,")
|
||||
.append("不要再用工具把同样的结果重复发送到该会话;")
|
||||
.append("仅当任务指令明确要求把消息发送到其它渠道会话时,")
|
||||
.append("才先用 list_channel_sessions 查询目标会话,再用 send_channel_message 发送。\n");
|
||||
} else {
|
||||
sb.append("- 本任务未绑定渠道,执行结果只会写入任务会话,不会自动推送到任何 IM 渠道;")
|
||||
.append("如任务指令要求把消息发送到某个渠道会话(如企业微信 / 飞书 / 钉钉),")
|
||||
.append("请先用 list_channel_sessions 工具查询可用会话,")
|
||||
.append("再用 send_channel_message 工具发送,不要尝试调用 CLI / shell 自行投递。\n");
|
||||
}
|
||||
sb.append("- 如果确认本次确实无需执行、也没有新内容可汇报,")
|
||||
.append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n");
|
||||
|
||||
@ -353,6 +353,13 @@ public class CronJobService implements ApplicationRunner {
|
||||
existing.setTaskType(dto.getTaskType());
|
||||
existing.setTriggerMessage(dto.getTriggerMessage());
|
||||
existing.setRequestBody(dto.getRequestBody());
|
||||
// The edit form always submits the full delivery binding (channel +
|
||||
// target + suppress flag), so the request is authoritative for both
|
||||
// fields — including a null pair, which means "unbind this job from
|
||||
// its channel". FieldStrategy.ALWAYS on the entity lets the null
|
||||
// through to the UPDATE.
|
||||
existing.setChannelId(dto.getChannelId());
|
||||
existing.setDeliveryConfig(dto.getDeliveryConfig());
|
||||
if (dto.getEnabled() != null) {
|
||||
existing.setEnabled(dto.getEnabled());
|
||||
}
|
||||
|
||||
@ -142,8 +142,16 @@ public class GlobalExceptionHandler {
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (response.isCommitted() || isSseRequest(request)) {
|
||||
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
if (isExpectedClientDisconnect(e)) {
|
||||
// Browser reloads and tab closes routinely tear down the SSE
|
||||
// socket. This is transport lifecycle noise, not an
|
||||
// application warning, and should not page operators.
|
||||
log.debug("SSE client disconnected: {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
} else {
|
||||
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e);
|
||||
@ -167,6 +175,21 @@ public class GlobalExceptionHandler {
|
||||
return uri != null && uri.contains("/chat/stream");
|
||||
}
|
||||
|
||||
static boolean isExpectedClientDisconnect(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
String type = current.getClass().getName();
|
||||
String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase();
|
||||
if (type.endsWith("ClientAbortException")
|
||||
|| message.contains("broken pipe")
|
||||
|| message.contains("connection reset")
|
||||
|| message.contains("disconnected client")
|
||||
|| message.contains("connection aborted")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private HttpStatus httpStatusForCode(int code) {
|
||||
HttpStatus status = HttpStatus.resolve(code);
|
||||
return status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
@ -256,10 +256,17 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
/**
|
||||
* Streaming counterpart of {@link #applyHttpTimeouts(RestClient.Builder)}.
|
||||
* Without this, Spring AI's AnthropicApi would back its streaming chat
|
||||
* call by a default WebClient with neither connect nor read timeout — a
|
||||
* stalled provider could hang the agent thread indefinitely while the
|
||||
* failover chain idles (no exception = no signal).
|
||||
*
|
||||
* <p><b>Scope caveat (issue #585):</b> {@code setReadTimeout} maps to the
|
||||
* JDK HttpClient's request timeout, which only protects up to the
|
||||
* <em>response headers</em>. Once the headers arrive the clock stops, so a
|
||||
* provider that returns 200 + a first SSE frame and then goes silent is
|
||||
* <b>not</b> caught here — the body Flux would hang indefinitely. The
|
||||
* body-level gap is closed by a reactor inter-frame idle timeout applied
|
||||
* at the streaming chokepoint ({@code NodeStreamingChatHelper}, driven by
|
||||
* {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}).
|
||||
* This WebClient timeout still catches the "provider never sends headers"
|
||||
* case (connection accepted, no response), so both layers are kept.
|
||||
* <p>
|
||||
* Uses the same JDK HttpClient + JdkClientHttpConnector path, so the
|
||||
* dependency surface doesn't pull in reactor-netty (excluded by this
|
||||
|
||||
@ -92,7 +92,7 @@ public class DashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
return Boolean.TRUE.equals(runtimeModel.getEnableSearch());
|
||||
}
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
Object kwargsSearch = kwargs.get("enableSearch");
|
||||
Object kwargsSearch = ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch");
|
||||
if (kwargsSearch != null) {
|
||||
return Boolean.TRUE.equals(kwargsSearch);
|
||||
}
|
||||
@ -145,7 +145,7 @@ public class DashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
builder.withEnableSearch(true);
|
||||
String strategy = runtimeModel.getSearchStrategy();
|
||||
if (!StringUtils.hasText(strategy)) {
|
||||
strategy = (String) kwargs.get("searchStrategy");
|
||||
strategy = (String) ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy");
|
||||
}
|
||||
if (StringUtils.hasText(strategy)) {
|
||||
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
|
||||
|
||||
@ -26,6 +26,25 @@ public final class HttpTimeouts {
|
||||
*/
|
||||
public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(180);
|
||||
|
||||
/**
|
||||
* Default inter-frame idle timeout for streaming LLM responses
|
||||
* (the reactor {@code .timeout(Duration)} applied on the chat model's
|
||||
* delta Flux). Distinct from {@link #DEFAULT_READ_TIMEOUT}: the JDK
|
||||
* HttpClient request timeout (which is what {@code setReadTimeout}
|
||||
* ultimately maps to) only protects up to the response headers — once
|
||||
* the headers arrive it stops the clock, so a provider that accepts the
|
||||
* connection, returns 200 + a first SSE frame, then goes silent hangs
|
||||
* the body Flux forever with no exception and no failover signal
|
||||
* (issue #585). The reactor idle timeout fills that gap: it measures the
|
||||
* gap between successive stream elements, so total silence for this long
|
||||
* propagates a {@code TimeoutException} down the existing error path.
|
||||
* <p>
|
||||
* Defaults to the same 180s as the read timeout — long-thinking models
|
||||
* can legitimately sit between frames for a while, but complete silence
|
||||
* for three minutes is a dead provider, not a slow one.
|
||||
*/
|
||||
public static final Duration DEFAULT_STREAM_IDLE_TIMEOUT = Duration.ofSeconds(180);
|
||||
|
||||
private HttpTimeouts() {}
|
||||
|
||||
/**
|
||||
@ -40,4 +59,19 @@ public final class HttpTimeouts {
|
||||
}
|
||||
return Duration.ofSeconds(override);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective streaming inter-frame idle timeout. Same fallback
|
||||
* semantics as {@link #resolveReadTimeout(Integer)}: a positive override
|
||||
* wins, otherwise the canonical 180s default applies. Callers can pass
|
||||
* {@code modelConfig.getRequestTimeoutSeconds()} directly so the per-model
|
||||
* knob governs both the connect-level read timeout and the body-level
|
||||
* idle timeout from a single config field.
|
||||
*/
|
||||
public static Duration resolveStreamIdleTimeout(Integer override) {
|
||||
if (override == null || override <= 0) {
|
||||
return DEFAULT_STREAM_IDLE_TIMEOUT;
|
||||
}
|
||||
return Duration.ofSeconds(override);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
@ -32,6 +31,7 @@ import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@ -54,19 +54,16 @@ import java.util.regex.Pattern;
|
||||
public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
|
||||
public OpenAiCompatibleChatModelBuilder(
|
||||
ModelProviderService modelProviderService,
|
||||
ObjectMapper objectMapper,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
@ -159,11 +156,11 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
// built-in search: model-level field wins, provider generateKwargs as fallback
|
||||
boolean searchEnabled = Boolean.TRUE.equals(runtimeModel.getEnableSearch())
|
||||
|| Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
||||
|| Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch"));
|
||||
if (searchEnabled) {
|
||||
String strategy = runtimeModel.getSearchStrategy();
|
||||
if (!StringUtils.hasText(strategy)) {
|
||||
strategy = (String) kwargs.get("searchStrategy");
|
||||
strategy = (String) ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy");
|
||||
}
|
||||
OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize contextSize;
|
||||
try {
|
||||
@ -183,6 +180,19 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
// Leaving it null keeps Spring AI from serializing the field; each node controls it
|
||||
// when tools are present.
|
||||
options.setStreamUsage(true);
|
||||
|
||||
// Forward unrecognized top-level generateKwargs keys as-is via extraBody (e.g. vLLM's
|
||||
// chat_template_kwargs). Get-then-merge rather than overwrite, in case a future addition
|
||||
// to buildOpenAiOptions ever sets extraBody above this point.
|
||||
Map<String, Object> passthroughExtraBody = ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs);
|
||||
if (!passthroughExtraBody.isEmpty()) {
|
||||
Map<String, Object> existingExtraBody = options.getExtraBody();
|
||||
Map<String, Object> mergedExtraBody = (existingExtraBody == null)
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(existingExtraBody);
|
||||
mergedExtraBody.putAll(passthroughExtraBody);
|
||||
options.setExtraBody(mergedExtraBody);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@ -263,7 +273,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
}
|
||||
|
||||
boolean kimiSearchEnabled = isKimiProvider(provider)
|
||||
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
||||
&& Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch"));
|
||||
|
||||
ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey))
|
||||
? new SimpleApiKey(apiKey.trim())
|
||||
@ -281,6 +291,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
|
||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||
MultiValueMap<String, String> additionalHttpHeader) {
|
||||
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
|
||||
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
||||
@ -302,6 +313,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
|
||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||
MultiValueMap<String, String> additionalHttpHeader) {
|
||||
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
|
||||
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
||||
@ -394,7 +406,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
private static final Pattern OPENAI_BASE_URL_VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
|
||||
|
||||
private String resolveOpenAiCompletionsPath(String baseUrl, Map<String, Object> kwargs) {
|
||||
Object raw = kwargs.get("completionsPath");
|
||||
Object raw = ProviderGenerateKwargs.findOptionValue(kwargs, "completionsPath");
|
||||
boolean explicit = raw instanceof String value && StringUtils.hasText(value);
|
||||
String path = explicit ? ((String) raw).trim() : "/v1/chat/completions";
|
||||
if (!path.startsWith("/")) {
|
||||
@ -438,9 +450,19 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
/**
|
||||
* Apply equivalent timeouts to the WebClient backing OpenAI-compatible
|
||||
* STREAMING calls. Without this the streaming path uses a default WebClient
|
||||
* with neither connect nor read timeout, so a stalled provider can hang the
|
||||
* call indefinitely while the failover chain idles (no exception thrown).
|
||||
* STREAMING calls.
|
||||
*
|
||||
* <p><b>Scope caveat (issue #585):</b> {@code setReadTimeout} maps to the
|
||||
* JDK HttpClient's request timeout, which only protects up to the
|
||||
* <em>response headers</em>. Once the headers arrive the clock stops, so
|
||||
* this timeout does <b>not</b> prevent a provider that returns 200 + a
|
||||
* first SSE frame and then goes silent from hanging the body Flux. The
|
||||
* body-level gap is closed by a reactor inter-frame idle timeout applied
|
||||
* at the streaming chokepoint ({@code NodeStreamingChatHelper}, driven by
|
||||
* {@link HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}) — that is what actually
|
||||
* surfaces a stalled provider to the error path / failover chain. Both
|
||||
* layers are needed: this one catches a provider that never sends headers
|
||||
* at all, the reactor one catches a provider that sends headers then stalls.
|
||||
*
|
||||
* <p>Uses {@link org.springframework.http.client.reactive.JdkClientHttpConnector}
|
||||
* with the same {@link HttpClient} so the dependency surface stays clean
|
||||
@ -464,17 +486,39 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
// ==================== logging ====================
|
||||
|
||||
private void logOpenAiRequest(ModelProviderEntity provider, OpenAiApi.ChatCompletionRequest chatRequest) {
|
||||
try {
|
||||
log.info("OpenAI-compatible request: provider={}, body={}",
|
||||
provider.getProviderId(), objectMapper.writeValueAsString(chatRequest));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize OpenAI-compatible request for {}: {}",
|
||||
provider.getProviderId(), e.getMessage());
|
||||
}
|
||||
// Never log the request body: it contains system prompts, workspace
|
||||
// memory, user content and tool schemas. Besides leaking private
|
||||
// context, serializing it at INFO made long-running team jobs produce
|
||||
// multi-megabyte log lines. Cardinality-only diagnostics are enough to
|
||||
// correlate provider traffic without retaining payloads.
|
||||
log.debug("OpenAI-compatible request: provider={}, model={}, messages={}, tools={}, stream={}",
|
||||
provider.getProviderId(), chatRequest.model(),
|
||||
sizeOf(chatRequest.messages()), sizeOf(chatRequest.tools()), chatRequest.stream());
|
||||
}
|
||||
|
||||
private void logOpenAiError(ModelProviderEntity provider, WebClientResponseException e) {
|
||||
log.error("OpenAI-compatible error: provider={}, status={}, body={}",
|
||||
provider.getProviderId(), e.getStatusCode(), e.getResponseBodyAsString());
|
||||
String body = e.getResponseBodyAsString();
|
||||
log.error("OpenAI-compatible error: provider={}, status={}, responseBytes={}",
|
||||
provider.getProviderId(), e.getStatusCode(), body == null ? 0 : body.length());
|
||||
if (log.isDebugEnabled() && body != null && !body.isBlank()) {
|
||||
log.debug("OpenAI-compatible error detail: provider={}, body={}",
|
||||
provider.getProviderId(), redactAndTruncate(body));
|
||||
}
|
||||
}
|
||||
|
||||
private static int sizeOf(java.util.Collection<?> values) {
|
||||
return values == null ? 0 : values.size();
|
||||
}
|
||||
|
||||
/** Defensive scrub for provider error bodies, which may echo request data. */
|
||||
static String redactAndTruncate(String body) {
|
||||
String redacted = body
|
||||
.replaceAll("(?i)(\\\"(?:api[_-]?key|authorization|token)\\\"\\s*:\\s*\\\")[^\\\"]*(\\\")",
|
||||
"$1[REDACTED]$2")
|
||||
.replaceAll("(?i)((?:api[_-]?key|authorization|token)\\s*[=:]\\s*)[^,}\\s]+",
|
||||
"$1[REDACTED]")
|
||||
.replaceAll("(?i)(bearer\\s+)[A-Za-z0-9._~+\\-/=]+", "$1[REDACTED]");
|
||||
int max = 1024;
|
||||
return redacted.length() <= max ? redacted : redacted.substring(0, max) + "…";
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@ -22,13 +23,97 @@ import java.util.Map;
|
||||
* <p>The rewrites exist because OpenAI-compatible providers diverge in ways
|
||||
* Spring AI's {@code OpenAiChatOptions} cannot express — reasoning-content
|
||||
* replay contracts, reasoning-effort acceptance, strict tool-choice validation,
|
||||
* video media encoding, and Kimi's built-in web search tool.
|
||||
* JSON Schema number preservation, video media encoding, and Kimi's built-in
|
||||
* web search tool.
|
||||
*/
|
||||
@Slf4j
|
||||
final class OpenAiRequestRewriter {
|
||||
|
||||
private OpenAiRequestRewriter() {}
|
||||
|
||||
// ==================== tool schema number preservation ====================
|
||||
|
||||
/**
|
||||
* Keep integral JSON Schema values numeric on the OpenAI wire.
|
||||
*
|
||||
* <p>Spring AI parses every tool's schema string into a nested {@link Map}.
|
||||
* Values beyond {@link Integer#MAX_VALUE} consequently become {@link Long}s.
|
||||
* MateClaw's application-wide Jackson configuration intentionally serializes
|
||||
* {@code Long} as strings to protect Snowflake IDs from JavaScript precision
|
||||
* loss, but that policy must not leak into protocol metadata: providers reject
|
||||
* schemas such as {@code "maximum":"9007199254740991"} because JSON Schema
|
||||
* requires {@code maximum} to be a number.
|
||||
*
|
||||
* <p>Replace Long values inside tool parameter schemas with numerically
|
||||
* equivalent {@link BigInteger}s. Jackson still emits those as JSON numbers,
|
||||
* while the global Long-to-string policy remains intact for application DTOs.
|
||||
*/
|
||||
static OpenAiApi.ChatCompletionRequest preserveToolSchemaNumbers(
|
||||
OpenAiApi.ChatCompletionRequest request) {
|
||||
if (request.tools() == null || request.tools().isEmpty()) {
|
||||
return request;
|
||||
}
|
||||
|
||||
boolean changed = false;
|
||||
List<OpenAiApi.FunctionTool> tools = new ArrayList<>(request.tools().size());
|
||||
for (OpenAiApi.FunctionTool tool : request.tools()) {
|
||||
if (tool == null || tool.getFunction() == null
|
||||
|| tool.getFunction().getParameters() == null) {
|
||||
tools.add(tool);
|
||||
continue;
|
||||
}
|
||||
|
||||
Object normalized = preserveSchemaNumber(tool.getFunction().getParameters());
|
||||
if (normalized == tool.getFunction().getParameters()) {
|
||||
tools.add(tool);
|
||||
continue;
|
||||
}
|
||||
|
||||
OpenAiApi.FunctionTool.Function original = tool.getFunction();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parameters = (Map<String, Object>) normalized;
|
||||
OpenAiApi.FunctionTool.Function function = new OpenAiApi.FunctionTool.Function(
|
||||
original.getDescription(), original.getName(), parameters, original.getStrict());
|
||||
tools.add(new OpenAiApi.FunctionTool(tool.getType(), function));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? rebuildWithTools(request, tools) : request;
|
||||
}
|
||||
|
||||
private static Object preserveSchemaNumber(Object value) {
|
||||
if (value instanceof Long number) {
|
||||
return BigInteger.valueOf(number);
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<Object, Object> copy = null;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
Object normalized = preserveSchemaNumber(entry.getValue());
|
||||
if (normalized != entry.getValue()) {
|
||||
if (copy == null) {
|
||||
copy = new LinkedHashMap<>(map);
|
||||
}
|
||||
copy.put(entry.getKey(), normalized);
|
||||
}
|
||||
}
|
||||
return copy != null ? copy : value;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> copy = null;
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
Object normalized = preserveSchemaNumber(list.get(i));
|
||||
if (normalized != list.get(i)) {
|
||||
if (copy == null) {
|
||||
copy = new ArrayList<>(list);
|
||||
}
|
||||
copy.set(i, normalized);
|
||||
}
|
||||
}
|
||||
return copy != null ? copy : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ==================== reasoning_content patching ====================
|
||||
|
||||
/**
|
||||
@ -308,6 +393,44 @@ final class OpenAiRequestRewriter {
|
||||
);
|
||||
}
|
||||
|
||||
private static OpenAiApi.ChatCompletionRequest rebuildWithTools(
|
||||
OpenAiApi.ChatCompletionRequest request, List<OpenAiApi.FunctionTool> tools) {
|
||||
return new OpenAiApi.ChatCompletionRequest(
|
||||
request.messages(),
|
||||
request.model(),
|
||||
request.store(),
|
||||
request.metadata(),
|
||||
request.frequencyPenalty(),
|
||||
request.logitBias(),
|
||||
request.logprobs(),
|
||||
request.topLogprobs(),
|
||||
request.maxTokens(),
|
||||
request.maxCompletionTokens(),
|
||||
request.n(),
|
||||
request.outputModalities(),
|
||||
request.audioParameters(),
|
||||
request.presencePenalty(),
|
||||
request.responseFormat(),
|
||||
request.seed(),
|
||||
request.serviceTier(),
|
||||
request.stop(),
|
||||
request.stream(),
|
||||
request.streamOptions(),
|
||||
request.temperature(),
|
||||
request.topP(),
|
||||
tools,
|
||||
request.toolChoice(),
|
||||
request.parallelToolCalls(),
|
||||
request.user(),
|
||||
request.reasoningEffort(),
|
||||
request.webSearchOptions(),
|
||||
request.verbosity(),
|
||||
request.promptCacheKey(),
|
||||
request.safetyIdentifier(),
|
||||
request.extraBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean requiresReasoningContentPatch(String modelName) {
|
||||
ModelFamily family = ModelFamily.detect(modelName);
|
||||
return family.isThinking();
|
||||
|
||||
@ -3,21 +3,73 @@ package vip.mate.llm.chatmodel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reads typed values out of a provider's {@code generateKwargs} map.
|
||||
*
|
||||
* <p>A lookup tries the camelCase key first, then a snake_case fallback, and also
|
||||
* descends into a nested {@code chatOptions} map — so an admin may specify an
|
||||
* option under any of those shapes. Shared by the OpenAI-compatible chat model
|
||||
* builder and the reasoning-effort resolver.
|
||||
* descends into a nested {@code chatOptions} / {@code chat_options} map — so an
|
||||
* admin may specify an option under any of those shapes. Shared by the OpenAI-compatible chat model
|
||||
* builder, the reasoning-effort resolver, and the provider test-prompt path
|
||||
* ({@code ModelDiscoveryService}) so every outbound request built from
|
||||
* {@code generateKwargs} treats unrecognized keys the same way.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProviderGenerateKwargs {
|
||||
|
||||
private ProviderGenerateKwargs() {}
|
||||
|
||||
/**
|
||||
* Top-level {@code generateKwargs} keys with dedicated typed handling elsewhere
|
||||
* (both camelCase and snake_case spellings), plus the {@code chatOptions} nesting
|
||||
* wrappers themselves (their contents are already consumed via {@link #findOptionValue}).
|
||||
* Centralized here so passthrough logic and known-key extraction across callers
|
||||
* can't drift out of sync. Anything else at the top level of generateKwargs is
|
||||
* forwarded verbatim — see {@link #collectPassthroughExtraBody}.
|
||||
*
|
||||
* <p>{@code headers} / {@code customHeaders} are both reserved even though they're
|
||||
* consumed by different callers ({@code OpenAiCompatibleChatModelBuilder} and
|
||||
* {@code ModelDiscoveryService} respectively) — both are injected as real HTTP
|
||||
* headers, never as JSON body fields, so neither belongs in a passthrough body.
|
||||
*/
|
||||
public static final Set<String> RESERVED_GENERATE_KWARGS_KEYS = Set.of(
|
||||
"temperature",
|
||||
"maxTokens", "max_tokens",
|
||||
"maxCompletionTokens", "max_completion_tokens",
|
||||
"topP", "top_p",
|
||||
"reasoningEffort", "reasoning_effort",
|
||||
"enableSearch", "enable_search",
|
||||
"searchStrategy", "search_strategy",
|
||||
"headers",
|
||||
"customHeaders", "custom_headers",
|
||||
"completionsPath", "completions_path",
|
||||
"modelsPath", "models_path",
|
||||
"chatOptions", "chat_options"
|
||||
);
|
||||
|
||||
/**
|
||||
* Collect top-level {@code generateKwargs} entries not covered by
|
||||
* {@link #RESERVED_GENERATE_KWARGS_KEYS} so they still reach the outbound
|
||||
* request body (e.g. vLLM's {@code chat_template_kwargs} to disable Qwen
|
||||
* thinking mode). Scoped to top-level keys only — unrecognized keys nested
|
||||
* inside {@code chatOptions} are an explicit non-goal and are not forwarded.
|
||||
*/
|
||||
public static Map<String, Object> collectPassthroughExtraBody(Map<String, Object> kwargs) {
|
||||
if (kwargs == null || kwargs.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> passthrough = new LinkedHashMap<>();
|
||||
kwargs.forEach((key, value) -> {
|
||||
if (key != null && !RESERVED_GENERATE_KWARGS_KEYS.contains(key)) {
|
||||
passthrough.put(key, value);
|
||||
}
|
||||
});
|
||||
return passthrough;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a raw option value by key, trying the camelCase form then a
|
||||
* snake_case fallback. Returns {@code null} when neither is present.
|
||||
@ -43,6 +95,9 @@ public final class ProviderGenerateKwargs {
|
||||
return kwargs.get(key);
|
||||
}
|
||||
Object chatOptions = kwargs.get("chatOptions");
|
||||
if (!(chatOptions instanceof Map<?, ?>)) {
|
||||
chatOptions = kwargs.get("chat_options");
|
||||
}
|
||||
if (chatOptions instanceof Map<?, ?> optionsMap) {
|
||||
return ((Map<String, Object>) optionsMap).get(key);
|
||||
}
|
||||
|
||||
@ -46,6 +46,18 @@ public class ModelConfigController {
|
||||
return R.ok(modelProviderService.listProviders());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider id + display name only. {@link #list()} stays admin-only because
|
||||
* it carries connection settings; binding an agent to a preferred provider
|
||||
* is a member action, so members need to read the choices from here.
|
||||
*/
|
||||
@Operation(summary = "获取 Provider 选项(仅 id/名称,不含连接配置)")
|
||||
@GetMapping("/options")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<ProviderOptionDTO>> options() {
|
||||
return R.ok(modelProviderService.listProviderOptions());
|
||||
}
|
||||
|
||||
@Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用")
|
||||
@GetMapping("/catalog")
|
||||
@RequireGlobalAdmin
|
||||
@ -164,6 +176,15 @@ public class ModelConfigController {
|
||||
return R.ok(modelProviderService.removeModel(providerId, modelId));
|
||||
}
|
||||
|
||||
@Operation(summary = "设置模型上下文窗口")
|
||||
@PutMapping("/{providerId}/models/context-window")
|
||||
@RequireGlobalAdmin
|
||||
public R<ProviderInfoDTO> updateModelContextWindow(@PathVariable String providerId,
|
||||
@RequestBody UpdateModelContextWindowRequest request) {
|
||||
return R.ok(modelProviderService.updateModelContextWindow(
|
||||
providerId, request.getModelId(), request.getMaxInputTokens()));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取模型详情")
|
||||
@GetMapping("/{id}")
|
||||
@RequireGlobalAdmin
|
||||
|
||||
@ -41,6 +41,27 @@ public class ModelInfoDTO {
|
||||
*/
|
||||
private boolean supportsThinking;
|
||||
|
||||
/**
|
||||
* Explicit per-model input window from {@code mate_model_config}; null when
|
||||
* the operator has not set one (stored as 0). This is what the management
|
||||
* UI's input binds to — an empty field means "let the server decide".
|
||||
*/
|
||||
private Integer maxInputTokens;
|
||||
|
||||
/**
|
||||
* The window context budgeting would use if a turn ran right now, computed
|
||||
* without any probe traffic: explicit config, else the built-in window
|
||||
* table, else the global default. Display only.
|
||||
*/
|
||||
private Integer effectiveMaxInputTokens;
|
||||
|
||||
/**
|
||||
* Where {@link #effectiveMaxInputTokens} came from — {@code configured},
|
||||
* {@code catalog} or {@code default} — so the UI can say why a number is
|
||||
* what it is instead of presenting a guess as configuration.
|
||||
*/
|
||||
private String maxInputTokensSource;
|
||||
|
||||
public ModelInfoDTO(String id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
package vip.mate.llm.model;
|
||||
|
||||
/**
|
||||
* Credential-free projection of a configured provider: just enough to render a
|
||||
* picker and store the chosen id.
|
||||
* <p>
|
||||
* The full {@link ProviderInfoDTO} carries connection settings (base URL, the
|
||||
* masked key, request kwargs, liveness diagnostics) and is therefore only
|
||||
* served to global admins. Binding an agent to a preferred provider is a
|
||||
* workspace-member action, so the member has to be able to read the list of
|
||||
* choices — this DTO is what that read returns.
|
||||
*
|
||||
* @param id provider id, the value persisted on the agent binding
|
||||
* @param name display name shown in the picker
|
||||
*/
|
||||
public record ProviderOptionDTO(String id, String name) {
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package vip.mate.llm.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Body of {@code PUT /api/v1/models/{providerId}/models/context-window}.
|
||||
*/
|
||||
@Data
|
||||
public class UpdateModelContextWindowRequest {
|
||||
|
||||
/** Model identifier within the provider, i.e. {@code mate_model_config.model_name}. */
|
||||
private String modelId;
|
||||
|
||||
/**
|
||||
* Input window in tokens. Null or non-positive clears the override and
|
||||
* hands budgeting back to the built-in window table / global default.
|
||||
*/
|
||||
private Integer maxInputTokens;
|
||||
}
|
||||
@ -10,7 +10,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "mateclaw.context.probe")
|
||||
public class ContextProbeProperties {
|
||||
|
||||
/** Master switch. When false, {@code resolveMaxInputTokens} only honors explicit config. */
|
||||
/**
|
||||
* Master switch for probe traffic and error-text reconciliation. When
|
||||
* false, {@code resolveMaxInputTokens} honors explicit config and the
|
||||
* built-in window table only — no request ever leaves the process.
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Per-request read timeout. Probing must never hold up chat startup. */
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
package vip.mate.llm.probe;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Built-in context-window table for hosted models, keyed by lowercase
|
||||
* model-name prefix; longest match wins.
|
||||
*
|
||||
* <p>Why this exists: {@code mate_model_config.max_input_tokens} ships as 0 for
|
||||
* every catalog row and cloud endpoints are deliberately never probed, so
|
||||
* without this table every hosted model — a 1M-window one included — budgets
|
||||
* and reports against the 128k global default. That both compacts history far
|
||||
* too early on large-window models and hides the real window in the chat
|
||||
* context-usage chip.
|
||||
*
|
||||
* <p>Values are <b>input</b> windows in tokens (not max output). Entries are
|
||||
* limited to models whose window is documented by the vendor or by this
|
||||
* repository's own model catalog; a family that is not listed simply falls
|
||||
* through to the caller's global default, which is the pre-existing behavior.
|
||||
* Per-model {@code maxInputTokens} in the database always overrides this table,
|
||||
* so an operator can correct any entry without a code change.
|
||||
*
|
||||
* <p>Names carrying a vendor segment ({@code google/gemini-2.5-pro},
|
||||
* {@code Pro/deepseek-ai/DeepSeek-V3}) are retried against the segment after
|
||||
* the last slash, so aggregator providers reuse the same entries.
|
||||
*/
|
||||
final class ModelContextWindowCatalog {
|
||||
|
||||
private static final Map<String, Integer> WINDOWS;
|
||||
|
||||
static {
|
||||
Map<String, Integer> m = new LinkedHashMap<>();
|
||||
|
||||
// ===== DeepSeek =====
|
||||
// V4 ships a 1M window; the V3 line and the chat/reasoner aliases are 128k.
|
||||
m.put("deepseek-v4", 1_000_000);
|
||||
m.put("deepseek-v3", 128_000);
|
||||
m.put("deepseek-r1", 128_000);
|
||||
m.put("deepseek-chat", 128_000);
|
||||
m.put("deepseek-reasoner", 128_000);
|
||||
|
||||
// ===== Anthropic Claude =====
|
||||
// 200k across the line; the 1M variants are opt-in per request, so the
|
||||
// conservative default is the one that always holds.
|
||||
m.put("claude-", 200_000);
|
||||
|
||||
// ===== Google Gemini =====
|
||||
m.put("gemini-2.0", 1_048_576);
|
||||
m.put("gemini-2.5", 1_048_576);
|
||||
m.put("gemini-3", 1_048_576);
|
||||
|
||||
// ===== OpenAI =====
|
||||
// gpt-5's 400k total budget splits into 272k input + 128k output.
|
||||
m.put("gpt-5", 272_000);
|
||||
m.put("gpt-4.1", 1_047_576);
|
||||
m.put("gpt-4o", 128_000);
|
||||
m.put("o3", 200_000);
|
||||
m.put("o4-mini", 200_000);
|
||||
|
||||
// ===== Alibaba Qwen =====
|
||||
// The Max line stays at 256k while Plus / Turbo / Flash and the coder
|
||||
// flagship run the 1M window; qwen-long is the dedicated 10M model.
|
||||
m.put("qwen-max", 262_144);
|
||||
m.put("qwen3-max", 262_144);
|
||||
m.put("qwen-plus", 1_000_000);
|
||||
m.put("qwen-turbo", 1_000_000);
|
||||
m.put("qwen-long", 10_000_000);
|
||||
m.put("qwen-coder-plus", 1_000_000);
|
||||
m.put("qwen3-coder-plus", 1_000_000);
|
||||
m.put("qwen3-coder-next", 262_144);
|
||||
m.put("qwen3.5-plus", 1_000_000);
|
||||
m.put("qwen3.5-flash", 1_000_000);
|
||||
m.put("qwen3.6-plus", 1_000_000);
|
||||
m.put("qwen3.6-flash", 1_000_000);
|
||||
m.put("qwen3.6-max", 260_000);
|
||||
// Open-weight releases: 256k native, larger only with rope scaling the
|
||||
// hosting provider may or may not have enabled.
|
||||
m.put("qwen3-vl", 262_144);
|
||||
m.put("qwen3-235b", 262_144);
|
||||
m.put("qwen3-30b", 262_144);
|
||||
m.put("qwen3.5-122b", 262_144);
|
||||
|
||||
// ===== Moonshot Kimi =====
|
||||
m.put("kimi-k2", 262_144);
|
||||
// Coding-plan alias (plus its -highspeed variant) for the K2.7 code
|
||||
// model, which serves the same 256k window.
|
||||
m.put("kimi-for-coding", 262_144);
|
||||
|
||||
// ===== Zhipu GLM =====
|
||||
m.put("glm-4.7", 204_800);
|
||||
m.put("glm-4-7", 204_800);
|
||||
// 200k across the GLM-5 line (5 / 5.1 / turbo variants); 5.2 lifted it to 1M.
|
||||
m.put("glm-5", 204_800);
|
||||
m.put("glm-5.2", 1_000_000);
|
||||
// Multimodal sibling, stated separately rather than inherited from the
|
||||
// glm-5 prefix: its 200k window is documented in its own model page.
|
||||
m.put("glm-5v", 204_800);
|
||||
// The 9B open weights ship at 128k (the separate -1m build is its own id).
|
||||
m.put("glm-4-9b", 131_072);
|
||||
|
||||
// ===== Volcengine Doubao / Ark =====
|
||||
m.put("doubao-seed-1-8", 262_144);
|
||||
m.put("doubao-seed-code", 262_144);
|
||||
// Seed 2.0 pro / lite / mini / code all ship 256k; the console uses
|
||||
// dotted ids and the dated snapshots use dashes.
|
||||
m.put("doubao-seed-2.0", 262_144);
|
||||
m.put("doubao-seed-2-0", 262_144);
|
||||
m.put("ark-code-latest", 262_144);
|
||||
|
||||
// ===== MiniMax =====
|
||||
// M2.x documents 204,800 as the combined input+output budget.
|
||||
m.put("minimax-m2", 204_800);
|
||||
m.put("minimax-m3", 1_000_000);
|
||||
|
||||
// ===== Xiaomi MiMo =====
|
||||
m.put("mimo-v2-flash", 262_144);
|
||||
m.put("mimo-v2-pro", 1_048_576);
|
||||
m.put("mimo-v2.5", 1_000_000);
|
||||
|
||||
// ===== xAI Grok =====
|
||||
m.put("grok-3", 131_072);
|
||||
m.put("grok-4", 256_000);
|
||||
|
||||
// ===== Meta Llama =====
|
||||
m.put("llama-4-maverick", 1_048_576);
|
||||
|
||||
WINDOWS = Map.copyOf(m);
|
||||
}
|
||||
|
||||
private ModelContextWindowCatalog() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the known input window for {@code modelName}, or {@code null}
|
||||
* when the model is not in the table
|
||||
*/
|
||||
static Integer lookup(String modelName) {
|
||||
if (modelName == null || modelName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String lowered = modelName.trim().toLowerCase();
|
||||
Integer direct = matchPrefix(lowered);
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
int slash = lowered.lastIndexOf('/');
|
||||
if (slash >= 0 && slash + 1 < lowered.length()) {
|
||||
return matchPrefix(lowered.substring(slash + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer matchPrefix(String loweredName) {
|
||||
return WINDOWS.entrySet().stream()
|
||||
.filter(e -> loweredName.startsWith(e.getKey()))
|
||||
.max(Comparator.comparingInt(e -> e.getKey().length()))
|
||||
.map(Map.Entry::getValue)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,10 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* <li>explicit {@code ModelConfigEntity.maxInputTokens} — user configuration
|
||||
* always wins;</li>
|
||||
* <li>a probed value from a {@link LocalContextProbe} (runtime-cached with a
|
||||
* short TTL, never persisted — local servers hot-swap models);</li>
|
||||
* short TTL, never persisted — local servers hot-swap models), or a limit
|
||||
* previously parsed out of a provider error;</li>
|
||||
* <li>{@link ModelContextWindowCatalog} — the built-in window table for
|
||||
* hosted models, which are never probed;</li>
|
||||
* <li>{@code null} — caller falls back to the global default, exactly the
|
||||
* pre-probe behavior.</li>
|
||||
* </ol>
|
||||
@ -48,9 +51,9 @@ public class ModelContextWindowResolver {
|
||||
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* @return the effective max input tokens, or {@code null} when neither
|
||||
* explicit config nor probing yields a value (caller keeps its
|
||||
* existing global-default fallback).
|
||||
* @return the effective max input tokens, or {@code null} when explicit
|
||||
* config, probing and the built-in catalog all come up empty
|
||||
* (caller keeps its existing global-default fallback).
|
||||
*/
|
||||
public Integer resolveMaxInputTokens(ModelProviderEntity provider, ModelConfigEntity model) {
|
||||
if (model == null) {
|
||||
@ -59,36 +62,77 @@ public class ModelContextWindowResolver {
|
||||
if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) {
|
||||
return model.getMaxInputTokens();
|
||||
}
|
||||
if (!properties.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
String key = cacheKey(provider != null ? provider.getProviderId() : null, model.getModelName());
|
||||
CacheEntry cached = cache.get(key);
|
||||
long now = System.currentTimeMillis();
|
||||
if (cached != null && cached.expiresAtMs() > now) {
|
||||
return cached.value();
|
||||
}
|
||||
Integer probed = null;
|
||||
for (LocalContextProbe probe : probes) {
|
||||
try {
|
||||
if (!probe.supports(provider, model)) {
|
||||
continue;
|
||||
if (properties.isEnabled()) {
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry cached = cache.get(key);
|
||||
if (cached != null && cached.expiresAtMs() > now) {
|
||||
// A cached value outranks the catalog: it came from the live
|
||||
// endpoint or from the provider's own over-limit rejection.
|
||||
if (cached.value() != null) {
|
||||
return cached.value();
|
||||
}
|
||||
probed = probe.probeContextLength(provider, model).orElse(null);
|
||||
} else {
|
||||
Integer probed = null;
|
||||
for (LocalContextProbe probe : probes) {
|
||||
try {
|
||||
if (!probe.supports(provider, model)) {
|
||||
continue;
|
||||
}
|
||||
probed = probe.probeContextLength(provider, model).orElse(null);
|
||||
if (probed != null) {
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[ContextProbe] probe {} threw for {}: {}",
|
||||
probe.getClass().getSimpleName(), key, e.getMessage());
|
||||
}
|
||||
}
|
||||
cache.put(key, new CacheEntry(probed, now + ttlMs()));
|
||||
if (probed != null) {
|
||||
break;
|
||||
log.info("[ContextProbe] 探测到模型 {} 的上下文窗口为 {} tokens(未配置 maxInputTokens,窗口预算将使用探测值)",
|
||||
key, probed);
|
||||
return probed;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[ContextProbe] probe {} threw for {}: {}",
|
||||
probe.getClass().getSimpleName(), key, e.getMessage());
|
||||
}
|
||||
}
|
||||
cache.put(key, new CacheEntry(probed, now + ttlMs()));
|
||||
if (probed != null) {
|
||||
log.info("[ContextProbe] 探测到模型 {} 的上下文窗口为 {} tokens(未配置 maxInputTokens,窗口预算将使用探测值)",
|
||||
key, probed);
|
||||
Integer known = catalogWindow(provider, model);
|
||||
if (known != null) {
|
||||
log.debug("[ContextProbe] 模型 {} 未配置 maxInputTokens,按内置窗口表使用 {} tokens", key, known);
|
||||
}
|
||||
return probed;
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same priority as {@link #resolveMaxInputTokens} minus probing, so it
|
||||
* performs no I/O and is safe to call while rendering a model list.
|
||||
*
|
||||
* @return the window this model would budget against, or {@code null} when
|
||||
* only the caller's global default applies
|
||||
*/
|
||||
public Integer resolveWithoutProbing(ModelProviderEntity provider, ModelConfigEntity model) {
|
||||
if (model == null) {
|
||||
return null;
|
||||
}
|
||||
if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) {
|
||||
return model.getMaxInputTokens();
|
||||
}
|
||||
return catalogWindow(provider, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in table lookup, excluding self-hosted endpoints: their real window
|
||||
* is whatever the server was started with (num_ctx / max_model_len), which
|
||||
* a vendor table cannot know — a wrong guess there is worse than the
|
||||
* default.
|
||||
*/
|
||||
private Integer catalogWindow(ModelProviderEntity provider, ModelConfigEntity model) {
|
||||
if (provider != null
|
||||
&& ("ollama".equalsIgnoreCase(provider.getProviderId())
|
||||
|| LocalEndpoints.isLocal(provider.getBaseUrl()))) {
|
||||
return null;
|
||||
}
|
||||
return ModelContextWindowCatalog.lookup(model.getModelName());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,11 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
@RequiredArgsConstructor
|
||||
public class ModelConfigService {
|
||||
|
||||
/** Below this a "window" is a typo, not a model — even 4k-era models exceed it. */
|
||||
private static final int MIN_CONTEXT_WINDOW = 1024;
|
||||
/** Above this the value is a typo too; the largest published windows are ~10M. */
|
||||
private static final int MAX_CONTEXT_WINDOW = 20_000_000;
|
||||
|
||||
private final ModelConfigMapper modelConfigMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final ModelCapabilityService modelCapabilityService;
|
||||
@ -297,6 +302,33 @@ public class ModelConfigService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an explicit input-context window for one model. {@code null} or a
|
||||
* non-positive value clears the override (stored as 0), handing budgeting
|
||||
* back to the built-in window table / global default.
|
||||
*
|
||||
* @throws MateClawException when the model is unknown or the value is
|
||||
* outside the range a real model could have
|
||||
*/
|
||||
public ModelConfigEntity updateModelContextWindow(String providerId, String modelId, Integer maxInputTokens) {
|
||||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getProvider, providerId)
|
||||
.eq(ModelConfigEntity::getModelName, modelId)
|
||||
.last("LIMIT 1"));
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.llm.model_not_found", "模型不存在: " + modelId);
|
||||
}
|
||||
int value = (maxInputTokens == null || maxInputTokens <= 0) ? 0 : maxInputTokens;
|
||||
if (value > 0 && (value < MIN_CONTEXT_WINDOW || value > MAX_CONTEXT_WINDOW)) {
|
||||
throw new MateClawException("err.llm.context_window_out_of_range",
|
||||
"上下文窗口需要在 " + MIN_CONTEXT_WINDOW + " ~ " + MAX_CONTEXT_WINDOW + " tokens 之间");
|
||||
}
|
||||
entity.setMaxInputTokens(value);
|
||||
modelConfigMapper.updateById(entity);
|
||||
publishConfigChanged("model-context-window-updated");
|
||||
return entity;
|
||||
}
|
||||
|
||||
public void removeModelFromProvider(String providerId, String modelId) {
|
||||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getProvider, providerId)
|
||||
|
||||
@ -12,6 +12,7 @@ import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.chatmodel.OpenAiModelsPath;
|
||||
import vip.mate.llm.chatmodel.ProviderGenerateKwargs;
|
||||
import vip.mate.llm.model.*;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService;
|
||||
|
||||
@ -656,16 +657,10 @@ public class ModelDiscoveryService {
|
||||
throw new MateClawException("err.llm.base_url_missing", "Base URL 未配置");
|
||||
}
|
||||
|
||||
Map<String, Object> requestBody = Map.of(
|
||||
"model", modelId,
|
||||
"messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")),
|
||||
"max_tokens", 10,
|
||||
"temperature", 0
|
||||
);
|
||||
|
||||
// 从 generateKwargs 读取 completionsPath(智谱等用 /chat/completions 而非 /v1/chat/completions)
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
String completionsPath = resolveCompletionsPath(baseUrl, kwargs);
|
||||
Map<String, Object> requestBody = buildTestPromptRequestBody(modelId, kwargs);
|
||||
|
||||
RestClient.RequestHeadersSpec<?> spec = openAiCompatibleClientBuilder()
|
||||
.baseUrl(baseUrl)
|
||||
@ -684,6 +679,26 @@ public class ModelDiscoveryService {
|
||||
return extractOpenAiChatContent(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the smoke-test request body for the OpenAI-compatible test-prompt path.
|
||||
* The core fields (model/messages/max_tokens/temperature) are fixed by design —
|
||||
* this is a minimal-token connectivity probe, not a real chat turn — but any
|
||||
* unrecognized top-level {@code generateKwargs} key (e.g. vLLM's
|
||||
* {@code chat_template_kwargs} used to disable Qwen thinking mode) is forwarded
|
||||
* verbatim, same as the runtime chat path in
|
||||
* {@code OpenAiCompatibleChatModelBuilder#buildOpenAiOptions}. Passthrough is
|
||||
* merged first so the fixed probe fields always win if a key ever collides.
|
||||
* Package-private for unit tests.
|
||||
*/
|
||||
static Map<String, Object> buildTestPromptRequestBody(String modelId, Map<String, Object> kwargs) {
|
||||
Map<String, Object> requestBody = new LinkedHashMap<>(ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs));
|
||||
requestBody.put("model", modelId);
|
||||
requestBody.put("messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")));
|
||||
requestBody.put("max_tokens", 10);
|
||||
requestBody.put("temperature", 0);
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a DashScope model using the **native** endpoint
|
||||
* ({@code /api/v1/services/aigc/text-generation/generation}).
|
||||
@ -914,7 +929,7 @@ public class ModelDiscoveryService {
|
||||
*/
|
||||
private String resolveCompletionsPath(String baseUrl, Map<String, Object> kwargs) {
|
||||
if (kwargs != null) {
|
||||
Object raw = kwargs.get("completionsPath");
|
||||
Object raw = ProviderGenerateKwargs.findOptionValue(kwargs, "completionsPath");
|
||||
if (raw instanceof String value && StringUtils.hasText(value)) {
|
||||
String path = value.trim();
|
||||
if (!path.startsWith("/")) {
|
||||
@ -965,7 +980,7 @@ public class ModelDiscoveryService {
|
||||
if (kwargs == null) {
|
||||
return;
|
||||
}
|
||||
Object customHeaders = kwargs.get("customHeaders");
|
||||
Object customHeaders = ProviderGenerateKwargs.findOptionValue(kwargs, "customHeaders");
|
||||
if (customHeaders instanceof Map) {
|
||||
((Map<String, Object>) customHeaders).forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
|
||||
@ -16,7 +16,9 @@ import vip.mate.llm.failover.ProviderHealthTracker;
|
||||
import vip.mate.llm.failover.ProviderInitProbe;
|
||||
import vip.mate.llm.failover.ProviderRequirements;
|
||||
import vip.mate.llm.model.*;
|
||||
import vip.mate.llm.probe.ModelContextWindowResolver;
|
||||
import vip.mate.llm.repository.ModelProviderMapper;
|
||||
import vip.mate.config.ConversationWindowProperties;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
|
||||
@ -60,6 +62,10 @@ public class ModelProviderService {
|
||||
* defers Spring's wiring decision past construction.
|
||||
*/
|
||||
private final ObjectProvider<ProviderInitProbe> providerInitProbeProvider;
|
||||
/** Supplies the window a model would budget against when none is configured. */
|
||||
private final ModelContextWindowResolver contextWindowResolver;
|
||||
/** Global fallback window, shown in the UI when nothing more specific applies. */
|
||||
private final ConversationWindowProperties conversationWindowProperties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
|
||||
@ -101,6 +107,21 @@ public class ModelProviderService {
|
||||
return listProvidersInternal(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled providers that are actually usable, reduced to id + display name.
|
||||
* <p>
|
||||
* Feeds the agent's preferred-provider picker, which workspace members may
|
||||
* edit. They cannot read the full provider list (it carries connection
|
||||
* settings), so this projection is what makes the choices visible without
|
||||
* widening that exposure.
|
||||
*/
|
||||
public List<ProviderOptionDTO> listProviderOptions() {
|
||||
return listProviders().stream()
|
||||
.filter(p -> Boolean.TRUE.equals(p.getConfigured()))
|
||||
.map(p -> new ProviderOptionDTO(p.getId(), p.getName()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<ProviderInfoDTO> listProvidersInternal(boolean enabledOnly) {
|
||||
LambdaQueryWrapper<ModelProviderEntity> qw = new LambdaQueryWrapper<>();
|
||||
if (enabledOnly) {
|
||||
@ -220,6 +241,41 @@ public class ModelProviderService {
|
||||
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, with a null / non-positive value) the per-model input
|
||||
* window. Applies to built-in models too — the shipped catalog cannot know
|
||||
* every vendor's window, so operators need to correct it without editing
|
||||
* the database by hand.
|
||||
*/
|
||||
public ProviderInfoDTO updateModelContextWindow(String providerId, String modelId, Integer maxInputTokens) {
|
||||
getProvider(providerId);
|
||||
modelConfigService.updateModelContextWindow(providerId, modelId, maxInputTokens);
|
||||
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the three window fields the model-management UI reads. Uses the
|
||||
* probe-free resolution path so listing providers never issues a request.
|
||||
*/
|
||||
private void applyContextWindow(ModelInfoDTO info, ModelProviderEntity provider, ModelConfigEntity model) {
|
||||
Integer configured = (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0)
|
||||
? model.getMaxInputTokens() : null;
|
||||
info.setMaxInputTokens(configured);
|
||||
if (configured != null) {
|
||||
info.setEffectiveMaxInputTokens(configured);
|
||||
info.setMaxInputTokensSource("configured");
|
||||
return;
|
||||
}
|
||||
Integer resolved = contextWindowResolver.resolveWithoutProbing(provider, model);
|
||||
if (resolved != null) {
|
||||
info.setEffectiveMaxInputTokens(resolved);
|
||||
info.setMaxInputTokensSource("catalog");
|
||||
return;
|
||||
}
|
||||
info.setEffectiveMaxInputTokens(conversationWindowProperties.getDefaultMaxInputTokens());
|
||||
info.setMaxInputTokensSource("default");
|
||||
}
|
||||
|
||||
public ModelProviderEntity getProviderConfig(String providerId) {
|
||||
return getProvider(providerId);
|
||||
}
|
||||
@ -440,6 +496,7 @@ public class ModelProviderService {
|
||||
// RFC-049 PR-1-UI: ModelInfoDTO(id, name) derives supportsReasoningEffort
|
||||
// from id via ModelFamily — no extra wiring needed here.
|
||||
ModelInfoDTO info = new ModelInfoDTO(model.getModelName(), model.getName());
|
||||
applyContextWindow(info, provider, model);
|
||||
if (Boolean.TRUE.equals(model.getBuiltin())) {
|
||||
builtinModels.add(info);
|
||||
} else {
|
||||
|
||||
@ -27,12 +27,13 @@ public class FactQueryTool {
|
||||
|
||||
@Tool(description = "Probe facts about an entity. Returns relevant facts where the entity appears as subject or object.")
|
||||
public String fact_probe(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Agent ID. Must be passed as a string to preserve large integer precision") String agentId,
|
||||
@ToolParam(description = "Entity name to search for") String entity) {
|
||||
if (!properties.getFact().isProjectionEnabled()) {
|
||||
return "Fact projection is disabled.";
|
||||
}
|
||||
List<FactEntity> facts = queryService.probe(agentId, entity);
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
List<FactEntity> facts = queryService.probe(parsedAgentId, entity);
|
||||
if (facts.isEmpty()) return "No facts found for entity: " + entity;
|
||||
|
||||
// Bump use count
|
||||
@ -45,11 +46,12 @@ public class FactQueryTool {
|
||||
|
||||
@Tool(description = "List unresolved fact contradictions detected during Dream consolidation.")
|
||||
public String fact_list_contradictions(
|
||||
@ToolParam(description = "Agent ID") Long agentId) {
|
||||
@ToolParam(description = "Agent ID. Must be passed as a string to preserve large integer precision") String agentId) {
|
||||
if (!properties.getFact().isProjectionEnabled()) {
|
||||
return "Fact projection is disabled.";
|
||||
}
|
||||
List<FactContradictionEntity> contradictions = queryService.listContradictions(agentId);
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
List<FactContradictionEntity> contradictions = queryService.listContradictions(parsedAgentId);
|
||||
if (contradictions.isEmpty()) return "No unresolved contradictions.";
|
||||
|
||||
return contradictions.stream()
|
||||
@ -58,4 +60,16 @@ public class FactQueryTool {
|
||||
c.getDescription() != null ? c.getDescription() : ""))
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
private static Long parseAgentId(String agentId) {
|
||||
String trimmed = agentId != null ? agentId.trim() : "";
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("agentId is required");
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(trimmed);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("agentId must be a numeric string");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ public class SessionSearchTool {
|
||||
注意:只会搜索已完成的会话,不会返回当前正在运行中的其他会话内容。
|
||||
""")
|
||||
public String session_search(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId,
|
||||
@ToolParam(description = "搜索模式:recent 或 search") String mode,
|
||||
@ToolParam(description = "搜索关键词(mode=search 时必填)", required = false) String query,
|
||||
@ToolParam(description = "返回结果数量上限,默认 10", required = false) Integer limit,
|
||||
@ -64,13 +64,14 @@ public class SessionSearchTool {
|
||||
int effectiveLimit = limit != null && limit > 0 ? limit : 10;
|
||||
|
||||
try {
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
if ("recent".equalsIgnoreCase(mode.trim())) {
|
||||
return handleRecent(agentId, currentConversationId, effectiveLimit);
|
||||
return handleRecent(parsedAgentId, currentConversationId, effectiveLimit);
|
||||
} else if ("search".equalsIgnoreCase(mode.trim())) {
|
||||
if (query == null || query.isBlank()) {
|
||||
return error("mode=search 时 query 不能为空");
|
||||
}
|
||||
return handleSearch(agentId, currentConversationId, query, effectiveLimit);
|
||||
return handleSearch(parsedAgentId, currentConversationId, query, effectiveLimit);
|
||||
} else {
|
||||
return error("无效的 mode: " + mode + ",请使用 recent 或 search");
|
||||
}
|
||||
@ -117,4 +118,16 @@ public class SessionSearchTool {
|
||||
result.set("message", message);
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
private Long parseAgentId(String agentId) {
|
||||
String trimmed = agentId != null ? agentId.trim() : "";
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("agentId 不能为空");
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(trimmed);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("agentId 必须是数字字符串");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.memory.service;
|
||||
|
||||
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.util.List;
|
||||
@ -105,7 +106,12 @@ final class MemorySummarizationGate {
|
||||
if (metadata == null || metadata.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
Matcher matcher = FINISH_REASON.matcher(metadata);
|
||||
// The pattern matches `"finishReason":"x"`, which the escaped form
|
||||
// (`\"finishReason\":\"x\"`) does not contain — the gate would then see
|
||||
// no reason at all and promote incomplete / stopped / errored turns
|
||||
// into long-term memory, the exact guess-from-text behaviour the
|
||||
// structured field exists to avoid.
|
||||
Matcher matcher = FINISH_REASON.matcher(MessageMetadataJson.normalize(metadata));
|
||||
if (!matcher.find()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.common.text.Shingles;
|
||||
import vip.mate.memory.event.MemoryWriteEvent;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
@ -63,9 +64,6 @@ public class StructuredMemoryService {
|
||||
*/
|
||||
public static final String PROJECT_RECALLED_MARKER = "includes the user's current project";
|
||||
|
||||
/** Latin word tokens of length >= 2 used for relevance shingling. */
|
||||
private static final Pattern WORD_RE = Pattern.compile("[a-z0-9]{2,}");
|
||||
|
||||
/** Captures the ISO update date from an entry's metadata line ("> ... | Updated: YYYY-MM-DD"). */
|
||||
private static final Pattern UPDATED_RE = Pattern.compile("Updated:\\s*(\\d{4}-\\d{2}-\\d{2})");
|
||||
|
||||
@ -469,30 +467,13 @@ public class StructuredMemoryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a language-agnostic shingle set: Latin word tokens (length >= 2)
|
||||
* plus CJK character bigrams (single CJK characters when isolated). This lets
|
||||
* relevance scoring work without a word segmenter on space-free CJK text.
|
||||
* Language-agnostic shingle set (Latin word tokens + CJK character
|
||||
* bigrams). Delegates to {@link Shingles} so relevance scoring here and
|
||||
* recurrence detection in routine mining share one definition of
|
||||
* "these two texts say the same thing".
|
||||
*/
|
||||
private static Set<String> shingles(String text) {
|
||||
Set<String> out = new HashSet<>();
|
||||
|
||||
Matcher m = WORD_RE.matcher(text);
|
||||
while (m.find()) {
|
||||
out.add(m.group());
|
||||
}
|
||||
|
||||
for (String run : text.replaceAll("[^\\p{IsHan}]", " ").split("\\s+")) {
|
||||
if (run.isEmpty()) continue;
|
||||
if (run.length() == 1) {
|
||||
out.add(run);
|
||||
} else {
|
||||
for (int i = 0; i + 2 <= run.length(); i++) {
|
||||
out.add(run.substring(i, i + 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
return Shingles.of(text);
|
||||
}
|
||||
|
||||
private String toFilename(String type) {
|
||||
|
||||
@ -60,7 +60,7 @@ public class StructuredMemoryTool {
|
||||
key 用 snake_case 标识符,例如 preferred_language, no_mock_db
|
||||
""")
|
||||
public String remember_structured(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId,
|
||||
@ToolParam(description = "记忆类型:user / feedback / project / reference") String type,
|
||||
@ToolParam(description = "条目标识符(snake_case),例如 preferred_language") String key,
|
||||
@ToolParam(description = "条目内容") String content,
|
||||
@ -71,7 +71,8 @@ public class StructuredMemoryTool {
|
||||
}
|
||||
|
||||
try {
|
||||
structuredMemoryService.remember(agentId, type.trim().toLowerCase(),
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
structuredMemoryService.remember(parsedAgentId, type.trim().toLowerCase(),
|
||||
key.trim(), content.trim(), "agent", writeOwner(toolContext));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
@ -94,7 +95,7 @@ public class StructuredMemoryTool {
|
||||
type 为空时搜索所有类型。
|
||||
""")
|
||||
public String recall_structured(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId,
|
||||
@ToolParam(description = "记忆类型过滤(可选):user / feedback / project / reference", required = false) String type,
|
||||
@ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword,
|
||||
ToolContext toolContext) {
|
||||
@ -104,14 +105,15 @@ public class StructuredMemoryTool {
|
||||
}
|
||||
|
||||
try {
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
List<Map<String, String>> results = structuredMemoryService.recall(
|
||||
agentId,
|
||||
parsedAgentId,
|
||||
type != null && !type.isBlank() ? type.trim().toLowerCase() : null,
|
||||
keyword,
|
||||
readOwner(toolContext));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("agentId", agentId);
|
||||
result.set("agentId", String.valueOf(agentId));
|
||||
result.set("count", results.size());
|
||||
result.set("entries", results);
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
@ -128,7 +130,7 @@ public class StructuredMemoryTool {
|
||||
需要指定类型和 key。
|
||||
""")
|
||||
public String forget_structured(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId,
|
||||
@ToolParam(description = "记忆类型:user / feedback / project / reference") String type,
|
||||
@ToolParam(description = "要删除的条目标识符") String key,
|
||||
ToolContext toolContext) {
|
||||
@ -138,7 +140,8 @@ public class StructuredMemoryTool {
|
||||
}
|
||||
|
||||
try {
|
||||
boolean removed = structuredMemoryService.forget(agentId,
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
boolean removed = structuredMemoryService.forget(parsedAgentId,
|
||||
type.trim().toLowerCase(), key.trim(), writeOwner(toolContext));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
@ -159,4 +162,16 @@ public class StructuredMemoryTool {
|
||||
result.set("message", message);
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
private Long parseAgentId(String agentId) {
|
||||
String trimmed = agentId != null ? agentId.trim() : "";
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("agentId 不能为空");
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(trimmed);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("agentId 必须是数字字符串");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,32 +55,32 @@ public class UniversalMemoryTool {
|
||||
如果你需要记录的是结构化条目,优先用 remember_structured。
|
||||
""")
|
||||
public String remember(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "当前 Agent 的 ID。必须作为字符串传入,避免大整数精度丢失") String agentId,
|
||||
@ToolParam(description = "要记住的内容(自由形式)") String content,
|
||||
@ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null) return error("agentId 不能为空");
|
||||
if (content == null || content.isBlank()) return error("content 不能为空");
|
||||
|
||||
try {
|
||||
Long parsedAgentId = parseAgentId(agentId);
|
||||
// Write to the requester's PERSONAL MEMORY.md when per-owner isolation
|
||||
// is active; otherwise the shared file (so the note is not stranded
|
||||
// in an un-read PERSONAL row).
|
||||
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
|
||||
? memoryOwnerResolver.resolve(ChatOrigin.from(toolContext))
|
||||
: null;
|
||||
WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, MEMORY_FILENAME, ownerKey);
|
||||
WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(parsedAgentId, MEMORY_FILENAME, ownerKey);
|
||||
String existingContent = existing != null && existing.getContent() != null
|
||||
? existing.getContent() : "";
|
||||
String updated = appendLesson(existingContent, content, source);
|
||||
workspaceFileService.saveVisibleFile(agentId, MEMORY_FILENAME, updated, ownerKey);
|
||||
workspaceFileService.saveVisibleFile(parsedAgentId, MEMORY_FILENAME, updated, ownerKey);
|
||||
|
||||
// RFC-090 §14.3 — universal remember() targets MEMORY.md (the
|
||||
// canonical file), so this IS a MemoryWriteEvent. Skill-local
|
||||
// lessons go through SkillLessonWrittenEvent instead and do
|
||||
// NOT touch this path.
|
||||
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, MEMORY_FILENAME,
|
||||
eventPublisher.publishEvent(new MemoryWriteEvent(parsedAgentId, MEMORY_FILENAME,
|
||||
"remember", content));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
@ -142,6 +142,18 @@ public class UniversalMemoryTool {
|
||||
return idx < 0 ? -1 : idx + 1; // position of '#' itself
|
||||
}
|
||||
|
||||
private static Long parseAgentId(String agentId) {
|
||||
String trimmed = agentId != null ? agentId.trim() : "";
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("agentId 不能为空");
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(trimmed);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("agentId 必须是数字字符串");
|
||||
}
|
||||
}
|
||||
|
||||
private static String error(String msg) {
|
||||
JSONObject e = new JSONObject();
|
||||
e.set("success", false);
|
||||
|
||||
@ -34,9 +34,13 @@ import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.lifecycle.ConfirmRequiredException;
|
||||
import vip.mate.skill.lifecycle.LifecycleTransition;
|
||||
import vip.mate.skill.lifecycle.SkillCuratorJob;
|
||||
import vip.mate.skill.lifecycle.SkillSnapshotService;
|
||||
import vip.mate.skill.routine.SkillRoutineMiner;
|
||||
import vip.mate.skill.routine.SkillRoutineService;
|
||||
import vip.mate.skill.lifecycle.SkillCuratorReport;
|
||||
import vip.mate.skill.lifecycle.SkillCuratorReportStore;
|
||||
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||
import vip.mate.skill.lifecycle.model.SkillSnapshotEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@ -76,6 +80,9 @@ public class SkillController {
|
||||
private final SkillLifecycleService skillLifecycleService;
|
||||
private final SkillCuratorJob skillCuratorJob;
|
||||
private final SkillCuratorReportStore skillCuratorReportStore;
|
||||
private final SkillSnapshotService skillSnapshotService;
|
||||
private final SkillRoutineService skillRoutineService;
|
||||
private final SkillRoutineMiner skillRoutineMiner;
|
||||
private final SkillFileService skillFileService;
|
||||
|
||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||
@ -1037,63 +1044,245 @@ public class SkillController {
|
||||
@Operation(summary = "立即运行一次 curator 预览(dry-run)")
|
||||
@PostMapping("/curator/dry-run")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<SkillCuratorReport> curatorDryRun() {
|
||||
return R.ok(skillCuratorJob.dryRunNow());
|
||||
public R<SkillCuratorReport> curatorDryRun(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillCuratorJob.dryRunNow(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "激活/取消激活 curator(真正归档 vs 仅预览)")
|
||||
@PostMapping("/curator/activate")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorActivate(
|
||||
@RequestParam(defaultValue = "true") boolean activate) {
|
||||
skillCuratorJob.activate(activate);
|
||||
return R.ok(skillCuratorJob.status());
|
||||
@RequestParam(defaultValue = "true") boolean activate,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
skillCuratorJob.activate(workspaceId, activate);
|
||||
return R.ok(skillCuratorJob.status(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "暂停 curator 定时扫描")
|
||||
@PostMapping("/curator/pause")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorPause() {
|
||||
skillCuratorJob.setPaused(true);
|
||||
return R.ok(skillCuratorJob.status());
|
||||
public R<Map<String, Object>> curatorPause(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
skillCuratorJob.setPaused(workspaceId, true);
|
||||
return R.ok(skillCuratorJob.status(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "恢复 curator 定时扫描")
|
||||
@PostMapping("/curator/resume")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorResume() {
|
||||
skillCuratorJob.setPaused(false);
|
||||
return R.ok(skillCuratorJob.status());
|
||||
public R<Map<String, Object>> curatorResume(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
skillCuratorJob.setPaused(workspaceId, false);
|
||||
return R.ok(skillCuratorJob.status(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "开启/关闭 curator 合并去重 pass")
|
||||
@PostMapping("/curator/consolidate")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorConsolidate(
|
||||
@RequestParam(defaultValue = "true") boolean enabled) {
|
||||
skillCuratorJob.setConsolidate(enabled);
|
||||
return R.ok(skillCuratorJob.status());
|
||||
@RequestParam(defaultValue = "true") boolean enabled,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
skillCuratorJob.setConsolidate(workspaceId, enabled);
|
||||
return R.ok(skillCuratorJob.status(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "curator 控制面状态")
|
||||
@GetMapping("/curator/status")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Map<String, Object>> curatorStatus() {
|
||||
return R.ok(skillCuratorJob.status());
|
||||
public R<Map<String, Object>> curatorStatus(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillCuratorJob.status(workspaceId));
|
||||
}
|
||||
|
||||
// ==================== Routine mining ====================
|
||||
|
||||
@Operation(summary = "列出挖掘到的高频请求(例行事项)候选")
|
||||
@GetMapping("/routines")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Map<String, Object>> routineList(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("items", skillRoutineService.list(status, limit, workspaceId));
|
||||
out.put("gates", skillRoutineService.gates());
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
@Operation(summary = "立即运行一次例行事项挖掘")
|
||||
@PostMapping("/routines/mine")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> routineMine(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("refreshed", skillRoutineMiner.mine(workspaceId));
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
@Operation(summary = "忽略某个例行事项候选(后续挖掘不再重开)")
|
||||
@PostMapping("/routines/{id}/dismiss")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> routineDismiss(@PathVariable String id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillRoutineService.dismiss(parseRoutineId(id), workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "重新观察一个已忽略的例行事项候选")
|
||||
@PostMapping("/routines/{id}/reopen")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> routineReopen(@PathVariable String id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillRoutineService.reopen(parseRoutineId(id), workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "立即把例行事项候选合成为技能(跳过频次门槛)")
|
||||
@PostMapping("/routines/{id}/promote")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> routinePromote(@PathVariable String id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
try {
|
||||
return R.ok(skillRoutineService.promoteNow(parseRoutineId(id), workspaceId));
|
||||
} catch (IllegalStateException e) {
|
||||
throw new MateClawException("err.skill.routine_already_promoted", 409, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path variables stay strings end-to-end (19-digit snowflake ids lose
|
||||
* precision as JS numbers); parse once here so a bad id is a clean 400.
|
||||
*/
|
||||
private long parseRoutineId(String id) {
|
||||
try {
|
||||
return Long.parseLong(id == null ? "" : id.strip());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new MateClawException("err.skill.routine_not_found", 400, "Invalid routine id: " + id);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "列出未纳入自治治理的技能")
|
||||
@GetMapping("/curator/unmanaged")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<Map<String, Object>>> curatorUnmanaged(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillLifecycleService.listUnmanaged(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "列出已纳入自治治理的技能")
|
||||
@GetMapping("/curator/managed")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<Map<String, Object>>> curatorManaged(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillLifecycleService.listManaged(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "将技能移交给自治治理(不重置闲置时钟)")
|
||||
@PostMapping("/curator/adopt")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorAdopt(@RequestBody List<String> skillIds,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(setAdoptedBulk(skillIds, true, workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "撤销移交,技能归还用户所有")
|
||||
@PostMapping("/curator/release")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorRelease(@RequestBody List<String> skillIds,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(setAdoptedBulk(skillIds, false, workspaceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply adopt/release across a batch, reporting per-skill outcomes rather
|
||||
* than failing the whole call on one bad id — a partial batch that silently
|
||||
* rolled back would leave the operator unsure which skills moved.
|
||||
*/
|
||||
private Map<String, Object> setAdoptedBulk(List<String> skillIds, boolean adopt, Long workspaceId) {
|
||||
List<String> changed = new ArrayList<>();
|
||||
List<Map<String, Object>> rejected = new ArrayList<>();
|
||||
for (String raw : skillIds == null ? List.<String>of() : skillIds) {
|
||||
// Ids stay strings end-to-end; parse once here so a malformed one
|
||||
// is a reported rejection rather than a framework-level failure.
|
||||
try {
|
||||
skillLifecycleService.setAdopted(
|
||||
Long.parseLong(String.valueOf(raw).strip()), adopt, workspaceId);
|
||||
changed.add(String.valueOf(raw));
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", String.valueOf(raw));
|
||||
row.put("message", e.getMessage());
|
||||
rejected.add(row);
|
||||
}
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("changed", changed);
|
||||
out.put("rejected", rejected);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Operation(summary = "列出技能库还原点")
|
||||
@GetMapping("/curator/snapshots")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<Map<String, Object>>> curatorSnapshots(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillSnapshotService.list(workspaceId, 20));
|
||||
}
|
||||
|
||||
@Operation(summary = "手动捕获一个技能库还原点")
|
||||
@PostMapping("/curator/snapshots")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorSnapshotCapture(
|
||||
@RequestParam(required = false) String reason,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
SkillSnapshotEntity snapshot = skillSnapshotService.capture(
|
||||
reason == null || reason.isBlank() ? "manual" : reason, workspaceId);
|
||||
if (snapshot == null) {
|
||||
throw new MateClawException("err.skill.snapshot_unavailable", 400,
|
||||
"Snapshot not captured — backups are disabled or there are no skills to capture");
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
// Snowflake id as a string: 19 digits exceed JS Number precision.
|
||||
out.put("id", String.valueOf(snapshot.getId()));
|
||||
out.put("reason", snapshot.getReason());
|
||||
out.put("skillCount", snapshot.getSkillCount());
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
@Operation(summary = "将技能库回滚到指定还原点")
|
||||
@PostMapping("/curator/snapshots/{snapshotId}/restore")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorSnapshotRestore(@PathVariable String snapshotId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
// Path variable stays a String end-to-end; parsing happens once here so
|
||||
// a malformed id is a 400 rather than a framework-level failure.
|
||||
long id;
|
||||
try {
|
||||
id = Long.parseLong(snapshotId.strip());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new MateClawException("err.skill.snapshot_not_found", 400,
|
||||
"Invalid snapshot id: " + snapshotId);
|
||||
}
|
||||
try {
|
||||
return R.ok(skillSnapshotService.restore(id, workspaceId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new MateClawException("err.skill.snapshot_not_found", 404, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "列出最近的 curator 运行报告")
|
||||
@GetMapping("/curator/reports")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<String>> curatorReports() {
|
||||
return R.ok(skillCuratorReportStore.listRunIds(20));
|
||||
public R<List<String>> curatorReports(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(skillCuratorReportStore.listRunIds(workspaceId, 20));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取某次 curator 运行报告")
|
||||
@GetMapping("/curator/reports/{runId}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Object> curatorReport(@PathVariable String runId) {
|
||||
Object report = skillCuratorReportStore.readRun(runId);
|
||||
public R<Object> curatorReport(@PathVariable String runId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
Object report = skillCuratorReportStore.readRun(workspaceId, runId);
|
||||
if (report == null) {
|
||||
throw new MateClawException("err.skill.curator_report_not_found", 404,
|
||||
"Curator report not found: " + runId);
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package vip.mate.skill.event;
|
||||
|
||||
/**
|
||||
* Fires after an agent authored a brand-new skill for itself during a
|
||||
* conversation (the {@code skill_manage create} path), carrying the authoring
|
||||
* agent so downstream listeners can react to "this agent just learned
|
||||
* something".
|
||||
*
|
||||
* <p>Distinct from {@link SkillUpdatedEvent}, which covers every write to an
|
||||
* existing row regardless of origin. This event fires only on creation and
|
||||
* only when the write came from an agent turn, so it carries the one piece of
|
||||
* context {@code SkillService} cannot see: which agent was talking.
|
||||
*
|
||||
* <p>The primary consumer is the auto-bind listener in the {@code agent}
|
||||
* layer: a self-authored skill is useless if the authoring agent's own
|
||||
* catalog cannot see it, which is exactly what happens when the agent runs
|
||||
* with an explicit skill allowlist. Publishing an event (rather than calling
|
||||
* the binding service directly from the tool) keeps the dependency direction
|
||||
* {@code agent → skill} intact and avoids a circular bean graph, matching the
|
||||
* reasoning already documented on {@link SkillUpdatedEvent}.
|
||||
*
|
||||
* @param skillId DB id of the newly created skill row
|
||||
* @param skillName slug identifier the row carries, useful for log lines
|
||||
* @param agentId agent that authored the skill; {@code null} when the
|
||||
* write had no agent origin (e.g. a REST call)
|
||||
* @param conversationId conversation the skill was distilled from, or
|
||||
* {@code null} when unknown
|
||||
* @param workspaceId workspace the new skill row was stamped with
|
||||
*/
|
||||
public record SkillAuthoredEvent(Long skillId,
|
||||
String skillName,
|
||||
Long agentId,
|
||||
String conversationId,
|
||||
Long workspaceId) {
|
||||
}
|
||||
@ -42,11 +42,17 @@ public class SkillLessonsTool {
|
||||
public String record_lesson(
|
||||
@ToolParam(description = "skill 的 slug(即 SKILL.md frontmatter 里的 name)") String skillName,
|
||||
@ToolParam(description = "要记录的经验内容") String lesson,
|
||||
@ToolParam(description = "可选:当前 Agent 的 ID", required = false) Long agentId,
|
||||
@ToolParam(description = "可选:当前 Agent 的 ID。传入时必须使用字符串,避免大整数精度丢失", required = false) String agentId,
|
||||
@ToolParam(description = "可选:当前对话 ID", required = false) String conversationId) {
|
||||
|
||||
if (skillName == null || skillName.isBlank()) return error("skillName 不能为空");
|
||||
if (lesson == null || lesson.isBlank()) return error("lesson 不能为空");
|
||||
Long parsedAgentId;
|
||||
try {
|
||||
parsedAgentId = parseOptionalAgentId(agentId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return error(e.getMessage());
|
||||
}
|
||||
|
||||
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||
.filter(s -> s != null && skillName.equals(s.getName()))
|
||||
@ -69,7 +75,7 @@ public class SkillLessonsTool {
|
||||
int max = manifest != null && manifest.getSelfEvolution() != null
|
||||
? manifest.getSelfEvolution().getLessonsMaxEntries() : 0;
|
||||
|
||||
String lessonId = lessonsService.recordLesson(resolved, agentId, conversationId,
|
||||
String lessonId = lessonsService.recordLesson(resolved, parsedAgentId, conversationId,
|
||||
lesson, max);
|
||||
if (lessonId == null) {
|
||||
return error("Lesson 记录失败:skill 可能仅存在于数据库(无 workspace 目录)。");
|
||||
@ -83,6 +89,18 @@ public class SkillLessonsTool {
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
private static Long parseOptionalAgentId(String agentId) {
|
||||
String trimmed = agentId != null ? agentId.trim() : "";
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(trimmed);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("agentId 必须是数字字符串");
|
||||
}
|
||||
}
|
||||
|
||||
private static String error(String msg) {
|
||||
JSONObject e = new JSONObject();
|
||||
e.set("success", false);
|
||||
|
||||
@ -27,6 +27,10 @@ public class CuratorRunNotifier {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public void onRunComplete(SkillCuratorReport report) {
|
||||
onRunComplete(report, null);
|
||||
}
|
||||
|
||||
public void onRunComplete(SkillCuratorReport report, Long workspaceId) {
|
||||
// (1) Durable audit trail — always recorded.
|
||||
try {
|
||||
String detail = objectMapper.writeValueAsString(Map.of(
|
||||
@ -35,7 +39,11 @@ public class CuratorRunNotifier {
|
||||
"reactivated", report.reactivated(),
|
||||
"dryRun", report.isDryRun(),
|
||||
"reportPath", String.valueOf(report.getPath())));
|
||||
auditEventService.record("CURATOR_RUN", "SKILL", report.getRunId(), null, detail);
|
||||
if (workspaceId == null) {
|
||||
auditEventService.record("CURATOR_RUN", "SKILL", report.getRunId(), null, detail);
|
||||
} else {
|
||||
auditEventService.record("CURATOR_RUN", "SKILL", report.getRunId(), null, detail, workspaceId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record curator run audit event: {}", e.getMessage());
|
||||
}
|
||||
@ -43,7 +51,7 @@ public class CuratorRunNotifier {
|
||||
// (2) Application event — no listener is required; if none exists
|
||||
// the event is simply discarded.
|
||||
eventPublisher.publishEvent(new SkillCuratorRunCompletedEvent(
|
||||
report.getRunId(), report.markedStale(), report.archived(),
|
||||
report.getRunId(), workspaceId, report.markedStale(), report.archived(),
|
||||
report.reactivated(), report.isDryRun(), report.getPath(), report.getRunAt()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,15 +12,22 @@ import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
import vip.mate.common.text.SecretRedactor;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.model.SkillOrigin;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import vip.mate.tool.builtin.SkillManageTool;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@ -52,8 +59,10 @@ public class SkillConsolidationService {
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final SkillLifecycleProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final int CATALOG_BODY_TRUNCATE_CHARS = 1500;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillConsolidationTransactionRunner transactionRunner;
|
||||
private final SkillRuntimeService runtimeService;
|
||||
private final AgentBindingService agentBindingService;
|
||||
|
||||
/**
|
||||
* Run a consolidation pass over the given candidate skills, recording
|
||||
@ -62,10 +71,21 @@ public class SkillConsolidationService {
|
||||
*/
|
||||
public void consolidate(List<SkillEntity> candidates, LocalDateTime now,
|
||||
boolean dryRun, SkillCuratorReport.Builder report) {
|
||||
if (!properties.isConsolidate()) {
|
||||
Long workspaceId = candidates == null ? 1L : candidates.stream()
|
||||
.map(SkillEntity::getWorkspaceId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElse(1L);
|
||||
consolidate(candidates, now, dryRun, report, workspaceId);
|
||||
}
|
||||
|
||||
public void consolidate(List<SkillEntity> candidates, LocalDateTime now,
|
||||
boolean dryRun, SkillCuratorReport.Builder report,
|
||||
Long workspaceId) {
|
||||
if (workspaceId == null || workspaceId <= 0 || candidates == null) {
|
||||
return;
|
||||
}
|
||||
List<SkillEntity> withContent = candidates.stream()
|
||||
.filter(s -> workspaceId.equals(s.getWorkspaceId()))
|
||||
.filter(s -> s.getSkillContent() != null && !s.getSkillContent().isBlank())
|
||||
.toList();
|
||||
if (withContent.size() < properties.getConsolidateMinSkills()) {
|
||||
@ -88,14 +108,24 @@ public class SkillConsolidationService {
|
||||
if (applied >= properties.getConsolidateMaxGroupsPerRun()) {
|
||||
break;
|
||||
}
|
||||
if (applyGroup(group, byName, now, dryRun, report)) {
|
||||
applied++;
|
||||
try {
|
||||
if (transactionRunner.execute(
|
||||
() -> applyGroup(group, byName, now, dryRun, report, workspaceId))) {
|
||||
applied++;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// applyGroup has already compensated its filesystem work. The
|
||||
// transaction runner returns only after the DB rollback, so now
|
||||
// rebuild caches/wrappers from the committed state.
|
||||
runtimeService.refreshActiveSkills();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean applyGroup(JsonNode group, Map<String, SkillEntity> byName,
|
||||
LocalDateTime now, boolean dryRun, SkillCuratorReport.Builder report) {
|
||||
LocalDateTime now, boolean dryRun, SkillCuratorReport.Builder report,
|
||||
Long workspaceId) {
|
||||
String umbrellaName = group.path("umbrella_name").asText("").strip().toLowerCase();
|
||||
String umbrellaContent = group.path("umbrella_content").asText(null);
|
||||
String reason = group.path("reason").asText("");
|
||||
@ -112,8 +142,10 @@ public class SkillConsolidationService {
|
||||
absorb.add(nm);
|
||||
}
|
||||
}
|
||||
SkillEntity existingUmbrella = skillService.findByName(umbrellaName);
|
||||
SkillEntity existingUmbrella = skillService.findByName(umbrellaName, workspaceId);
|
||||
boolean willCreate = existingUmbrella == null;
|
||||
Path previousWorkspace = workspaceManager.resolveEffectivePath(umbrellaName, null, workspaceId);
|
||||
String previousWorkspaceContent = readWorkspaceContent(previousWorkspace);
|
||||
// A real merge must touch at least two distinct skills: a brand-new
|
||||
// umbrella needs >=2 absorbed; reusing an existing skill as the
|
||||
// umbrella needs >=1 absorbed (the umbrella itself is the second).
|
||||
@ -129,6 +161,13 @@ public class SkillConsolidationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The reviewer call may take seconds. Re-read every victim inside the
|
||||
// group transaction before any write so a concurrent pin, release,
|
||||
// workspace move, archive, or agent binding cancels the whole plan.
|
||||
for (String nm : absorb) {
|
||||
requireStillEligible(byName.get(nm), workspaceId);
|
||||
}
|
||||
|
||||
// Stamp the umbrella with a source conversation from one absorbed skill
|
||||
// so it stays curator-eligible under the AGENT_CREATED scope.
|
||||
String lineageConv = absorb.stream()
|
||||
@ -136,10 +175,11 @@ public class SkillConsolidationService {
|
||||
.map(SkillEntity::getSourceConversationId)
|
||||
.filter(c -> c != null && !c.isBlank())
|
||||
.findFirst().orElse(null);
|
||||
ToolContext ctx = toolContext(lineageConv);
|
||||
ToolContext ctx = toolContext(lineageConv, workspaceId);
|
||||
|
||||
String act = willCreate ? "create" : "edit";
|
||||
String result = skillManageTool.skill_manage(act, umbrellaName, umbrellaContent, null, null, null, ctx);
|
||||
String result = skillManageTool.skillManageAs(SkillOrigin.AGENT, act, umbrellaName,
|
||||
umbrellaContent, null, null, null, ctx);
|
||||
boolean umbrellaOk = result != null
|
||||
&& !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED");
|
||||
if (!umbrellaOk) {
|
||||
@ -148,17 +188,46 @@ public class SkillConsolidationService {
|
||||
}
|
||||
|
||||
// Archive the absorbed narrow skills (recoverable, never deleted).
|
||||
for (String nm : absorb) {
|
||||
SkillEntity victim = byName.get(nm);
|
||||
if (victim == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
lifecycleService.applyManual(victim, LifecycleTransition.TO_ARCHIVED, now,
|
||||
// Compensate filesystem moves before propagating a failure so the
|
||||
// surrounding transaction can roll back the database half as well.
|
||||
List<SkillEntity> archived = new ArrayList<>();
|
||||
try {
|
||||
for (String nm : absorb) {
|
||||
SkillEntity victim = byName.get(nm);
|
||||
if (victim == null) {
|
||||
continue;
|
||||
}
|
||||
SkillEntity freshVictim = requireStillEligible(victim, workspaceId);
|
||||
boolean ok = lifecycleService.applyManual(freshVictim, LifecycleTransition.TO_ARCHIVED, now,
|
||||
"consolidated into " + umbrellaName);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillConsolidate] Failed to archive absorbed skill '{}': {}", nm, e.getMessage());
|
||||
if (!ok) {
|
||||
throw new IllegalStateException("Failed to archive absorbed skill '" + nm + "'");
|
||||
}
|
||||
archived.add(freshVictim);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
for (int i = archived.size() - 1; i >= 0; i--) {
|
||||
SkillEntity victim = archived.get(i);
|
||||
if (workspaceManager.restoreWorkspace(victim.getName(), workspaceId)
|
||||
== SkillWorkspaceManager.RestoreResult.FAILED) {
|
||||
log.error("[SkillConsolidate] Filesystem compensation failed for '{}'", victim.getName());
|
||||
}
|
||||
}
|
||||
if (willCreate) {
|
||||
if (previousWorkspace == null) {
|
||||
workspaceManager.purgeWorkspace(umbrellaName, workspaceId);
|
||||
} else if (previousWorkspaceContent != null) {
|
||||
workspaceManager.exportToWorkspace(umbrellaName, previousWorkspaceContent, workspaceId);
|
||||
} else {
|
||||
log.error("[SkillConsolidate] Refusing to purge pre-existing workspace for '{}' during compensation",
|
||||
umbrellaName);
|
||||
}
|
||||
} else if (existingUmbrella.getSkillContent() != null) {
|
||||
workspaceManager.exportToWorkspace(umbrellaName,
|
||||
existingUmbrella.getSkillContent(), workspaceId);
|
||||
}
|
||||
throw e instanceof RuntimeException runtime ? runtime
|
||||
: new IllegalStateException("Consolidation compensation failed", e);
|
||||
}
|
||||
|
||||
log.info("[SkillConsolidate] {} umbrella '{}' absorbing {} — {}", act, umbrellaName, absorb, reason);
|
||||
@ -167,11 +236,34 @@ public class SkillConsolidationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private SkillEntity requireStillEligible(SkillEntity planned, Long workspaceId) {
|
||||
if (planned == null || planned.getId() == null) {
|
||||
throw new IllegalStateException("Consolidation victim is no longer available");
|
||||
}
|
||||
SkillEntity fresh = skillService.getSkill(planned.getId());
|
||||
boolean wrongWorkspace = fresh == null || !workspaceId.equals(fresh.getWorkspaceId());
|
||||
boolean noLongerManaged = "AGENT_CREATED".equals(properties.getScope())
|
||||
&& (fresh == null || !SkillOrigin.curatorManagedCodes().contains(fresh.getOrigin()));
|
||||
if (wrongWorkspace || noLongerManaged || lifecycleService.isExempt(fresh)
|
||||
|| "archived".equals(fresh.getLifecycleState())
|
||||
|| !agentBindingService.enabledAgentsBoundToSkill(fresh.getId()).isEmpty()) {
|
||||
throw new IllegalStateException("Skill '" + planned.getName()
|
||||
+ "' changed while consolidation was being reviewed");
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private JsonNode askReviewer(List<SkillEntity> skills) {
|
||||
try {
|
||||
String catalog = buildCatalog(skills, properties.getConsolidateCatalogCharBudget());
|
||||
if (catalog == null) {
|
||||
log.info("[SkillConsolidate] Skipping reviewer: complete catalog exceeds {} chars",
|
||||
properties.getConsolidateCatalogCharBudget());
|
||||
return null;
|
||||
}
|
||||
String systemPrompt = PromptLoader.loadPrompt("skill/consolidate-system");
|
||||
String userPrompt = PromptLoader.loadPrompt("skill/consolidate-user")
|
||||
.replace("{skills}", buildCatalog(skills, properties.getConsolidateCatalogCharBudget()));
|
||||
.replace("{skills}", catalog);
|
||||
ChatModel chatModel = buildChatModel();
|
||||
Prompt prompt = new Prompt(List.of(
|
||||
new SystemMessage(systemPrompt),
|
||||
@ -188,23 +280,34 @@ public class SkillConsolidationService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String readWorkspaceContent(Path workspace) {
|
||||
if (workspace == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Path skillMd = workspace.resolve("SKILL.md");
|
||||
return Files.isRegularFile(skillMd) ? Files.readString(skillMd) : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildCatalog(List<SkillEntity> skills, int charBudget) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (SkillEntity skill : skills) {
|
||||
String entry = "### " + skill.getName() + "\n"
|
||||
+ (skill.getDescription() == null ? "" : skill.getDescription().strip() + "\n")
|
||||
+ truncate(skill.getSkillContent(), CATALOG_BODY_TRUNCATE_CHARS) + "\n\n";
|
||||
+ SecretRedactor.redact(skill.getSkillContent()) + "\n\n";
|
||||
if (sb.length() + entry.length() > charBudget) {
|
||||
sb.append("... (catalog truncated)\n");
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
sb.append(entry);
|
||||
}
|
||||
return sb.toString().strip();
|
||||
}
|
||||
|
||||
private ToolContext toolContext(String sourceConversationId) {
|
||||
ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", null, null,
|
||||
private ToolContext toolContext(String sourceConversationId, Long workspaceId) {
|
||||
ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", workspaceId, null,
|
||||
null, null, false, null, null, null, null, null);
|
||||
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
|
||||
}
|
||||
@ -246,10 +349,4 @@ public class SkillConsolidationService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncate(String s, int maxLen) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]";
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* Gives each consolidation group its own transaction. Keeping this boundary in
|
||||
* a separate Spring bean ensures proxy interception; a self-invoked
|
||||
* {@code @Transactional} method would silently share the outer sweep.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SkillConsolidationTransactionRunner {
|
||||
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
public boolean execute(BooleanSupplier action) {
|
||||
TransactionTemplate transaction = new TransactionTemplate(transactionManager);
|
||||
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
Boolean result = transaction.execute(status -> action.getAsBoolean());
|
||||
return Boolean.TRUE.equals(result);
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import org.springframework.scheduling.support.CronExpression;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.model.SkillOrigin;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
@ -60,6 +61,7 @@ public class SkillCuratorJob {
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final CuratorRunNotifier notifier;
|
||||
private final SkillConsolidationService consolidationService;
|
||||
private final SkillSnapshotService snapshotService;
|
||||
|
||||
@Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}")
|
||||
@SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S")
|
||||
@ -68,23 +70,33 @@ public class SkillCuratorJob {
|
||||
if (!properties.isEnabled() || "OFF".equals(properties.getScope())) {
|
||||
return;
|
||||
}
|
||||
// Gate 2: operational pause.
|
||||
if (systemSettingService.getBool(PAUSED_KEY, false)) {
|
||||
log.debug("Curator paused via {} — skipping this tick", PAUSED_KEY);
|
||||
for (Long workspaceId : curatorWorkspaceIds()) {
|
||||
try {
|
||||
runWorkspace(workspaceId);
|
||||
} catch (Exception e) {
|
||||
log.error("Curator failed for workspace {}: {}", workspaceId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void runWorkspace(Long workspaceId) {
|
||||
// Gate 2: operational pause, isolated per workspace.
|
||||
if (systemSettingService.getBool(key(PAUSED_KEY, workspaceId), false)) {
|
||||
log.debug("Curator paused for workspace {} — skipping this tick", workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean activated = systemSettingService.getBool(FIRST_RUN_KEY, false);
|
||||
boolean activated = systemSettingService.getBool(key(FIRST_RUN_KEY, workspaceId), false);
|
||||
|
||||
// Gate 3: first-run throttle. Before activation the sweep is
|
||||
// informational; bound it to once per ~day so the report directory
|
||||
// doesn't fill with identical previews.
|
||||
if (!activated) {
|
||||
LocalDateTime lastObserved = parseTs(systemSettingService.getString(LAST_OBSERVED_KEY, null));
|
||||
LocalDateTime lastDry = parseTs(systemSettingService.getString(LAST_DRY_RUN_KEY, null));
|
||||
LocalDateTime lastObserved = parseTs(systemSettingService.getString(key(LAST_OBSERVED_KEY, workspaceId), null));
|
||||
LocalDateTime lastDry = parseTs(systemSettingService.getString(key(LAST_DRY_RUN_KEY, workspaceId), null));
|
||||
if (lastObserved == null) {
|
||||
systemSettingService.saveString(LAST_OBSERVED_KEY, now.toString(),
|
||||
systemSettingService.saveString(key(LAST_OBSERVED_KEY, workspaceId), now.toString(),
|
||||
"Skill curator first observed timestamp");
|
||||
log.info("Curator first observation — deferring; preview on demand via /curator/dry-run");
|
||||
return;
|
||||
@ -99,15 +111,15 @@ public class SkillCuratorJob {
|
||||
}
|
||||
|
||||
boolean dryRun = !activated;
|
||||
SkillCuratorReport report = sweep(now, dryRun);
|
||||
SkillCuratorReport report = sweep(now, dryRun, workspaceId);
|
||||
|
||||
if (dryRun) {
|
||||
systemSettingService.saveString(LAST_DRY_RUN_KEY, now.toString(),
|
||||
systemSettingService.saveString(key(LAST_DRY_RUN_KEY, workspaceId), now.toString(),
|
||||
"Skill curator last dry-run timestamp");
|
||||
}
|
||||
systemSettingService.saveString(LAST_RUN_KEY, now.toString(),
|
||||
systemSettingService.saveString(key(LAST_RUN_KEY, workspaceId), now.toString(),
|
||||
"Skill curator last run timestamp");
|
||||
notifier.onRunComplete(report);
|
||||
notifier.onRunComplete(report, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,33 +127,54 @@ public class SkillCuratorJob {
|
||||
* the scheduler lock — for the admin "preview now" action.
|
||||
*/
|
||||
public SkillCuratorReport dryRunNow() {
|
||||
SkillCuratorReport report = sweep(LocalDateTime.now(), true);
|
||||
notifier.onRunComplete(report);
|
||||
return dryRunNow(1L);
|
||||
}
|
||||
|
||||
public SkillCuratorReport dryRunNow(Long workspaceId) {
|
||||
SkillCuratorReport report = sweep(LocalDateTime.now(), true, normalizeWorkspaceId(workspaceId));
|
||||
notifier.onRunComplete(report, normalizeWorkspaceId(workspaceId));
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Flip the activation flag (preview-only ⇄ applying). */
|
||||
public void activate(boolean activate) {
|
||||
systemSettingService.saveBool(FIRST_RUN_KEY, activate, "Skill curator activated");
|
||||
activate(1L, activate);
|
||||
}
|
||||
|
||||
public void activate(Long workspaceId, boolean activate) {
|
||||
systemSettingService.saveBool(key(FIRST_RUN_KEY, workspaceId), activate, "Skill curator activated");
|
||||
}
|
||||
|
||||
/** Set the runtime pause flag. */
|
||||
public void setPaused(boolean paused) {
|
||||
systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused");
|
||||
setPaused(1L, paused);
|
||||
}
|
||||
|
||||
public void setPaused(Long workspaceId, boolean paused) {
|
||||
systemSettingService.saveBool(key(PAUSED_KEY, workspaceId), paused, "Skill curator paused");
|
||||
}
|
||||
|
||||
/** Set the runtime consolidation flag (overrides the config default). */
|
||||
public void setConsolidate(boolean on) {
|
||||
systemSettingService.saveBool(CONSOLIDATE_KEY, on, "Skill curator consolidation enabled");
|
||||
setConsolidate(1L, on);
|
||||
}
|
||||
|
||||
public void setConsolidate(Long workspaceId, boolean on) {
|
||||
systemSettingService.saveBool(key(CONSOLIDATE_KEY, workspaceId), on, "Skill curator consolidation enabled");
|
||||
}
|
||||
|
||||
/** Effective consolidation switch: runtime override, falling back to config. */
|
||||
private boolean effectiveConsolidate() {
|
||||
return systemSettingService.getBool(CONSOLIDATE_KEY, properties.isConsolidate());
|
||||
private boolean effectiveConsolidate(Long workspaceId) {
|
||||
return systemSettingService.getBool(key(CONSOLIDATE_KEY, workspaceId), properties.isConsolidate());
|
||||
}
|
||||
|
||||
/** Aggregated control-panel state for the admin UI. */
|
||||
public Map<String, Object> status() {
|
||||
return status(1L);
|
||||
}
|
||||
|
||||
public Map<String, Object> status(Long workspaceId) {
|
||||
workspaceId = normalizeWorkspaceId(workspaceId);
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("enabled", properties.isEnabled());
|
||||
config.put("scope", properties.getScope());
|
||||
@ -150,32 +183,33 @@ public class SkillCuratorJob {
|
||||
config.put("cron", properties.getCron());
|
||||
|
||||
Map<String, Object> control = new LinkedHashMap<>();
|
||||
control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false));
|
||||
control.put("paused", systemSettingService.getBool(PAUSED_KEY, false));
|
||||
control.put("consolidate", effectiveConsolidate());
|
||||
control.put("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null));
|
||||
control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null));
|
||||
control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null));
|
||||
control.put("activated", systemSettingService.getBool(key(FIRST_RUN_KEY, workspaceId), false));
|
||||
control.put("paused", systemSettingService.getBool(key(PAUSED_KEY, workspaceId), false));
|
||||
control.put("consolidate", effectiveConsolidate(workspaceId));
|
||||
control.put("lastObservedAt", systemSettingService.getString(key(LAST_OBSERVED_KEY, workspaceId), null));
|
||||
control.put("lastDryRunAt", systemSettingService.getString(key(LAST_DRY_RUN_KEY, workspaceId), null));
|
||||
control.put("lastRunAt", systemSettingService.getString(key(LAST_RUN_KEY, workspaceId), null));
|
||||
control.put("nextScheduledRun", nextScheduledRun());
|
||||
|
||||
Map<String, Object> counts = new LinkedHashMap<>();
|
||||
counts.put("active", countState("active"));
|
||||
counts.put("stale", countState("stale"));
|
||||
counts.put("archived", countState("archived"));
|
||||
counts.put("active", countState("active", workspaceId));
|
||||
counts.put("stale", countState("stale", workspaceId));
|
||||
counts.put("archived", countState("archived", workspaceId));
|
||||
counts.put("pinned", skillMapper.selectCount(
|
||||
new LambdaQueryWrapper<SkillEntity>().eq(SkillEntity::getPinned, true)));
|
||||
new LambdaQueryWrapper<SkillEntity>().eq(SkillEntity::getPinned, true)
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId)));
|
||||
// Count only archival-relevant skills held back by a binding — same
|
||||
// set the run report's blockedByBindings array shows, so the status
|
||||
// count and the report stay consistent (builtin / mcp / acp / pinned
|
||||
// skills are exempt regardless of bindings and are not counted here).
|
||||
counts.put("blockedByBindings",
|
||||
agentBindingService.blockedByBindingCandidates(LocalDateTime.now()).size());
|
||||
agentBindingService.blockedByBindingCandidates(LocalDateTime.now(), workspaceId).size());
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("config", config);
|
||||
out.put("control", control);
|
||||
out.put("counts", counts);
|
||||
String latest = reportStore.latestRunId();
|
||||
String latest = reportStore.latestRunId(workspaceId);
|
||||
out.put("lastReport", latest == null ? null : Map.of(
|
||||
"id", latest,
|
||||
"url", "/api/v1/skills/curator/reports/" + latest));
|
||||
@ -184,19 +218,44 @@ public class SkillCuratorJob {
|
||||
|
||||
// ==================== Internals ====================
|
||||
|
||||
private SkillCuratorReport sweep(LocalDateTime now, boolean dryRun) {
|
||||
private SkillCuratorReport sweep(LocalDateTime now, boolean dryRun, Long workspaceId) {
|
||||
SkillCuratorReport.Builder report = SkillCuratorReport.builder()
|
||||
.runAt(now)
|
||||
.dryRun(dryRun)
|
||||
.config(properties.getStaleAfterDays(), properties.getArchiveAfterDays(),
|
||||
properties.getScope());
|
||||
|
||||
reconcileOrphans(now, report, dryRun);
|
||||
// Capture a restore point before anything mutates. A dry run changes
|
||||
// nothing, so it needs none; a real sweep can archive and (with
|
||||
// consolidation on) rewrite skill bodies unattended, and this is the
|
||||
// only chance to record what they looked like beforehand.
|
||||
if (!dryRun) {
|
||||
snapshotService.captureRequired("pre-sweep", workspaceId);
|
||||
}
|
||||
|
||||
List<SkillEntity> candidates = loadCandidates();
|
||||
reconcileOrphans(now, report, dryRun, workspaceId);
|
||||
|
||||
List<SkillEntity> candidates = loadCandidates(workspaceId);
|
||||
int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0;
|
||||
int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0;
|
||||
int newlyObserved = 0;
|
||||
for (SkillEntity skill : candidates) {
|
||||
// A candidate no sweep has seen before starts its idle clock now
|
||||
// rather than being judged on time it spent outside curation.
|
||||
// planTransition already returns NONE for these; stamping the
|
||||
// anchor is what lets the next sweep judge it for real.
|
||||
//
|
||||
// A dry run must not write, but it must still reach the same
|
||||
// verdict a real run would — this report is what an operator reads
|
||||
// to decide whether widening the scope is safe, so predicting
|
||||
// archives that a real run would defer would be a lie.
|
||||
if (SkillLifecycleService.isUnobserved(skill)) {
|
||||
newlyObserved++;
|
||||
if (!dryRun) {
|
||||
lifecycleService.markObserved(skill, now);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
LifecycleTransition t = lifecycleService.planTransition(skill, now);
|
||||
report.add(skill, t);
|
||||
if (t == LifecycleTransition.TO_STALE) {
|
||||
@ -222,39 +281,46 @@ public class SkillCuratorJob {
|
||||
}
|
||||
|
||||
report.scanned(candidates.size())
|
||||
.newlyObserved(newlyObserved)
|
||||
.plannedCounts(plannedStale, plannedArchived, plannedReactivate)
|
||||
.appliedCounts(appliedStale, appliedArchived, appliedReactivate)
|
||||
.blockedByBindings(agentBindingService.blockedByBindingCandidates(now));
|
||||
.blockedByBindings(agentBindingService.blockedByBindingCandidates(now, workspaceId));
|
||||
|
||||
// Consolidation pass (opt-in). Reload candidates so it sees the state
|
||||
// left by the aging pass above and never merges a just-archived skill.
|
||||
if (effectiveConsolidate()) {
|
||||
List<SkillEntity> mergeCandidates = loadCandidates().stream()
|
||||
if (effectiveConsolidate(workspaceId)) {
|
||||
List<SkillEntity> mergeCandidates = loadCandidates(workspaceId).stream()
|
||||
.filter(s -> !"archived".equals(s.getLifecycleState()))
|
||||
.toList();
|
||||
consolidationService.consolidate(mergeCandidates, now, dryRun, report);
|
||||
consolidationService.consolidate(mergeCandidates, now, dryRun, report, workspaceId);
|
||||
}
|
||||
|
||||
return reportStore.write(report.build());
|
||||
return reportStore.write(report.build(), workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate skills for the state machine: not builtin, not pinned, not a
|
||||
* builtin/mcp/acp type, not bound to any enabled agent, and — under the
|
||||
* default {@code AGENT_CREATED} scope — created by an agent.
|
||||
* default {@code AGENT_CREATED} scope — written autonomously.
|
||||
*
|
||||
* <p>The scope filter keys on {@code origin}, not on the presence of a
|
||||
* source conversation. Both a skill the user asked for mid-chat and one
|
||||
* the background reviewer invented carry a conversation id, so the older
|
||||
* filter swept up user-requested work alongside the machine's own.
|
||||
*/
|
||||
private List<SkillEntity> loadCandidates() {
|
||||
Set<Long> bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents();
|
||||
private List<SkillEntity> loadCandidates(Long workspaceId) {
|
||||
Set<Long> bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents(workspaceId);
|
||||
|
||||
LambdaQueryWrapper<SkillEntity> w = new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getBuiltin, false)
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId)
|
||||
.eq(SkillEntity::getPinned, false)
|
||||
.notIn(SkillEntity::getSkillType, List.of("builtin", "mcp", "acp"));
|
||||
if (!bindingProtected.isEmpty()) {
|
||||
w.notIn(SkillEntity::getId, bindingProtected);
|
||||
}
|
||||
if ("AGENT_CREATED".equals(properties.getScope())) {
|
||||
w.isNotNull(SkillEntity::getSourceConversationId);
|
||||
w.in(SkillEntity::getOrigin, SkillOrigin.curatorManagedCodes());
|
||||
}
|
||||
return skillMapper.selectList(w);
|
||||
}
|
||||
@ -265,9 +331,11 @@ public class SkillCuratorJob {
|
||||
* or a re-install ran). The reverse class — workspace moved but the DB
|
||||
* write failed — is handled inline by the archive compensation path.
|
||||
*/
|
||||
private void reconcileOrphans(LocalDateTime now, SkillCuratorReport.Builder report, boolean dryRun) {
|
||||
private void reconcileOrphans(LocalDateTime now, SkillCuratorReport.Builder report, boolean dryRun,
|
||||
Long workspaceId) {
|
||||
List<SkillEntity> archived = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getLifecycleState, "archived"));
|
||||
.eq(SkillEntity::getLifecycleState, "archived")
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId));
|
||||
for (SkillEntity skill : archived) {
|
||||
if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName(), skill.getWorkspaceId())) {
|
||||
continue;
|
||||
@ -285,9 +353,30 @@ public class SkillCuratorJob {
|
||||
}
|
||||
}
|
||||
|
||||
private long countState(String state) {
|
||||
private long countState(String state, Long workspaceId) {
|
||||
return skillMapper.selectCount(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getLifecycleState, state));
|
||||
.eq(SkillEntity::getLifecycleState, state)
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId));
|
||||
}
|
||||
|
||||
private List<Long> curatorWorkspaceIds() {
|
||||
List<Long> ids = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getBuiltin, false)
|
||||
.select(SkillEntity::getWorkspaceId))
|
||||
.stream()
|
||||
.map(SkillEntity::getWorkspaceId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
return ids.isEmpty() ? List.of(1L) : ids;
|
||||
}
|
||||
|
||||
private static Long normalizeWorkspaceId(Long workspaceId) {
|
||||
return workspaceId != null && workspaceId > 0 ? workspaceId : 1L;
|
||||
}
|
||||
|
||||
private static String key(String base, Long workspaceId) {
|
||||
return base + ".workspace." + normalizeWorkspaceId(workspaceId);
|
||||
}
|
||||
|
||||
private String nextScheduledRun() {
|
||||
|
||||
@ -8,6 +8,7 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -26,13 +27,14 @@ import java.util.Optional;
|
||||
@Getter
|
||||
public class SkillCuratorReport {
|
||||
|
||||
private static final DateTimeFormatter RUN_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
|
||||
private static final DateTimeFormatter RUN_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
|
||||
|
||||
private final String runId;
|
||||
private final LocalDateTime runAt;
|
||||
private final boolean dryRun;
|
||||
private final Config config;
|
||||
private final int scanned;
|
||||
private final int newlyObserved;
|
||||
private final Counts planned;
|
||||
private final Counts applied;
|
||||
private final List<TransitionRow> transitions;
|
||||
@ -46,10 +48,12 @@ public class SkillCuratorReport {
|
||||
|
||||
private SkillCuratorReport(Builder b) {
|
||||
this.runAt = b.runAt != null ? b.runAt : LocalDateTime.now();
|
||||
this.runId = this.runAt.format(RUN_ID);
|
||||
this.runId = this.runAt.format(RUN_ID) + "-"
|
||||
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
this.dryRun = b.dryRun;
|
||||
this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope);
|
||||
this.scanned = b.scanned;
|
||||
this.newlyObserved = b.newlyObserved;
|
||||
this.planned = new Counts(b.plannedStale, b.plannedArchived, b.plannedReactivated);
|
||||
this.applied = new Counts(b.appliedStale, b.appliedArchived, b.appliedReactivated);
|
||||
this.transitions = List.copyOf(b.transitions);
|
||||
@ -62,6 +66,16 @@ public class SkillCuratorReport {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates seen by curation for the first time this run. They are
|
||||
* deferred rather than judged, so an operator reading a first sweep
|
||||
* after widening the scope can tell "nothing was archived because it is
|
||||
* all brand new to the curator" from "nothing needed archiving".
|
||||
*/
|
||||
public int newlyObserved() {
|
||||
return newlyObserved;
|
||||
}
|
||||
|
||||
/** Applied count of skills marked stale (0 for a dry-run). */
|
||||
public int markedStale() {
|
||||
return applied.stale();
|
||||
@ -104,6 +118,7 @@ public class SkillCuratorReport {
|
||||
private int archiveAfterDays;
|
||||
private String scope;
|
||||
private int scanned;
|
||||
private int newlyObserved;
|
||||
private int plannedStale, plannedArchived, plannedReactivated;
|
||||
private int appliedStale, appliedArchived, appliedReactivated;
|
||||
private final List<TransitionRow> transitions = new ArrayList<>();
|
||||
@ -128,6 +143,11 @@ public class SkillCuratorReport {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder newlyObserved(int newlyObserved) {
|
||||
this.newlyObserved = newlyObserved;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder scanned(int scanned) {
|
||||
this.scanned = scanned;
|
||||
return this;
|
||||
@ -138,8 +158,9 @@ public class SkillCuratorReport {
|
||||
if (t == null || t == LifecycleTransition.NONE) {
|
||||
return this;
|
||||
}
|
||||
LocalDateTime anchor = skill.getLastActivityAt() != null
|
||||
? skill.getLastActivityAt() : skill.getCreateTime();
|
||||
// Shared with the decision path — a report that computed idle days
|
||||
// its own way could contradict the transition it is describing.
|
||||
LocalDateTime anchor = SkillLifecycleService.anchor(skill);
|
||||
long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays();
|
||||
String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
|
||||
String to = switch (t) {
|
||||
|
||||
@ -9,12 +9,15 @@ import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Persists lifecycle sweep reports to {@code {workspace-root}/.curator/}.
|
||||
* Persists lifecycle sweep reports to
|
||||
* {@code {workspace-root}/{workspaceId}/.curator/}.
|
||||
* Each run gets a {@code {runId}/} directory holding {@code run.json} (the
|
||||
* structured record) and {@code REPORT.md} (a human-readable render); a
|
||||
* {@code latest} symlink points at the newest run.
|
||||
@ -29,14 +32,16 @@ public class SkillCuratorReportStore {
|
||||
/** Number of run directories kept on disk; older ones are pruned. */
|
||||
private static final int KEEP_RUNS = 50;
|
||||
|
||||
/** Run ids are {@code yyyyMMdd-HHmmss} — validated before any path resolve. */
|
||||
private static final Pattern RUN_ID = Pattern.compile("\\d{8}-\\d{6}");
|
||||
/** Accept current collision-resistant ids and legacy second-resolution ids. */
|
||||
private static final Pattern RUN_ID = Pattern.compile(
|
||||
"\\d{8}-\\d{6}(?:-\\d{3}-[a-f0-9]{8})?");
|
||||
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private Path curatorRoot() {
|
||||
return workspaceManager.getWorkspaceRoot().resolve(".curator");
|
||||
private Path curatorRoot(Long workspaceId) {
|
||||
long scoped = workspaceId != null && workspaceId > 0 ? workspaceId : 1L;
|
||||
return workspaceManager.getWorkspaceRoot().resolve(String.valueOf(scoped)).resolve(".curator");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -44,15 +49,27 @@ public class SkillCuratorReportStore {
|
||||
* symlink. The report's {@code path} is populated on success.
|
||||
*/
|
||||
public SkillCuratorReport write(SkillCuratorReport report) {
|
||||
Path runDir = curatorRoot().resolve(report.getRunId());
|
||||
return write(report, 1L);
|
||||
}
|
||||
|
||||
public SkillCuratorReport write(SkillCuratorReport report, Long workspaceId) {
|
||||
Path root = curatorRoot(workspaceId);
|
||||
Path runDir = root.resolve(report.getRunId());
|
||||
try {
|
||||
// Serialize before touching the target directory; a mapper/config
|
||||
// failure must not leave a corrupt run that later looks valid.
|
||||
byte[] runJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(report);
|
||||
String markdown = renderMarkdown(report);
|
||||
Files.createDirectories(runDir);
|
||||
objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValue(runDir.resolve("run.json").toFile(), report);
|
||||
Files.writeString(runDir.resolve("REPORT.md"), renderMarkdown(report));
|
||||
Path jsonTmp = runDir.resolve("run.json.tmp");
|
||||
Path markdownTmp = runDir.resolve("REPORT.md.tmp");
|
||||
Files.write(jsonTmp, runJson);
|
||||
Files.writeString(markdownTmp, markdown);
|
||||
replaceAtomically(jsonTmp, runDir.resolve("run.json"));
|
||||
replaceAtomically(markdownTmp, runDir.resolve("REPORT.md"));
|
||||
report.setPath(runDir);
|
||||
updateLatest(runDir);
|
||||
pruneOld();
|
||||
updateLatest(root, runDir);
|
||||
pruneOld(workspaceId);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to write curator report {}: {}", report.getRunId(), e.getMessage());
|
||||
}
|
||||
@ -61,7 +78,11 @@ public class SkillCuratorReportStore {
|
||||
|
||||
/** Most recent run ids, newest first, capped at {@code limit}. */
|
||||
public List<String> listRunIds(int limit) {
|
||||
Path root = curatorRoot();
|
||||
return listRunIds(1L, limit);
|
||||
}
|
||||
|
||||
public List<String> listRunIds(Long workspaceId, int limit) {
|
||||
Path root = curatorRoot(workspaceId);
|
||||
if (!Files.isDirectory(root)) {
|
||||
return List.of();
|
||||
}
|
||||
@ -81,7 +102,11 @@ public class SkillCuratorReportStore {
|
||||
|
||||
/** Newest run id, or {@code null} when no run has been recorded yet. */
|
||||
public String latestRunId() {
|
||||
List<String> ids = listRunIds(1);
|
||||
return latestRunId(1L);
|
||||
}
|
||||
|
||||
public String latestRunId(Long workspaceId) {
|
||||
List<String> ids = listRunIds(workspaceId, 1);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
@ -91,10 +116,14 @@ public class SkillCuratorReportStore {
|
||||
* before being resolved as a path component.
|
||||
*/
|
||||
public Object readRun(String runId) {
|
||||
return readRun(1L, runId);
|
||||
}
|
||||
|
||||
public Object readRun(Long workspaceId, String runId) {
|
||||
if (runId == null || !RUN_ID.matcher(runId).matches()) {
|
||||
return null;
|
||||
}
|
||||
Path runJson = curatorRoot().resolve(runId).resolve("run.json");
|
||||
Path runJson = curatorRoot(workspaceId).resolve(runId).resolve("run.json");
|
||||
if (!Files.isRegularFile(runJson)) {
|
||||
return null;
|
||||
}
|
||||
@ -106,8 +135,8 @@ public class SkillCuratorReportStore {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLatest(Path runDir) {
|
||||
Path latest = curatorRoot().resolve("latest");
|
||||
private void updateLatest(Path root, Path runDir) {
|
||||
Path latest = root.resolve("latest");
|
||||
try {
|
||||
Files.deleteIfExists(latest);
|
||||
Files.createSymbolicLink(latest, runDir.getFileName());
|
||||
@ -118,13 +147,22 @@ public class SkillCuratorReportStore {
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneOld() {
|
||||
List<String> ids = listRunIds(Integer.MAX_VALUE);
|
||||
private static void replaceAtomically(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneOld(Long workspaceId) {
|
||||
List<String> ids = listRunIds(workspaceId, Integer.MAX_VALUE);
|
||||
if (ids.size() <= KEEP_RUNS) {
|
||||
return;
|
||||
}
|
||||
for (String old : ids.subList(KEEP_RUNS, ids.size())) {
|
||||
deleteRecursively(curatorRoot().resolve(old));
|
||||
deleteRecursively(curatorRoot(workspaceId).resolve(old));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import java.time.LocalDateTime;
|
||||
*/
|
||||
public record SkillCuratorRunCompletedEvent(
|
||||
String runId,
|
||||
Long workspaceId,
|
||||
int markedStale,
|
||||
int archived,
|
||||
int reactivated,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import lombok.Data;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -13,6 +16,7 @@ import java.util.List;
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "mateclaw.skill.curator")
|
||||
public class SkillLifecycleProperties {
|
||||
|
||||
@ -23,9 +27,13 @@ public class SkillLifecycleProperties {
|
||||
private String cron = "0 0 2 * * *";
|
||||
|
||||
/** Days of inactivity after which an active skill becomes {@code stale}. */
|
||||
@Min(1)
|
||||
@Max(36_500)
|
||||
private int staleAfterDays = 30;
|
||||
|
||||
/** Days of inactivity after which a stale skill becomes {@code archived}. */
|
||||
@Min(1)
|
||||
@Max(36_500)
|
||||
private int archiveAfterDays = 90;
|
||||
|
||||
/**
|
||||
@ -51,14 +59,30 @@ public class SkillLifecycleProperties {
|
||||
private boolean consolidate = false;
|
||||
|
||||
/** Minimum candidate skills present before a consolidation pass runs. */
|
||||
@Min(2)
|
||||
@Max(10_000)
|
||||
private int consolidateMinSkills = 4;
|
||||
|
||||
/** Hard cap on merge groups applied in a single consolidation pass. */
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
private int consolidateMaxGroupsPerRun = 2;
|
||||
|
||||
/** Character budget for the catalog handed to the consolidation reviewer. */
|
||||
@Min(1_000)
|
||||
@Max(2_000_000)
|
||||
private int consolidateCatalogCharBudget = 12000;
|
||||
|
||||
/** Consolidation model ID ({@code null} = follow the system default model). */
|
||||
private String consolidateModelId;
|
||||
|
||||
/**
|
||||
* Whether a restore point is captured before each mutating sweep. Gates
|
||||
* both the automatic pre-sweep capture and the manual one, so there is no
|
||||
* configuration in which a mutating run silently skips its snapshot.
|
||||
*/
|
||||
private boolean backupEnabled = true;
|
||||
|
||||
/** Restore points retained; older ones are pruned after each capture. */
|
||||
private int backupKeep = 5;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -9,6 +10,7 @@ import org.springframework.stereotype.Service;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.model.SkillOrigin;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
@ -16,6 +18,8 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@ -68,14 +72,45 @@ public class SkillLifecycleService {
|
||||
|
||||
// ==================== Pure decision functions ====================
|
||||
|
||||
/** Activity anchor: last recorded activity, falling back to creation time. */
|
||||
public LocalDateTime anchor(SkillEntity skill) {
|
||||
/**
|
||||
* Activity anchor, in order of preference: real recorded activity, then
|
||||
* the moment curation first saw the skill, then creation time.
|
||||
*
|
||||
* <p>The middle term is what keeps a newly-eligible skill from being
|
||||
* judged on time it spent outside curation entirely. Creation time stays
|
||||
* as the final fallback so rows predating the column, and bare entities
|
||||
* built in tests, behave exactly as before.
|
||||
*
|
||||
* <p>Single source of truth: the run report renders idle days from this
|
||||
* same method, so a report can never disagree with the decision it
|
||||
* describes.
|
||||
*/
|
||||
public static LocalDateTime anchor(SkillEntity skill) {
|
||||
if (skill.getLastActivityAt() != null) {
|
||||
return skill.getLastActivityAt();
|
||||
}
|
||||
if (skill.getCuratorSeenAt() != null) {
|
||||
return skill.getCuratorSeenAt();
|
||||
}
|
||||
return skill.getCreateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether no sweep has observed this skill yet, so judging it now would
|
||||
* apply the idle thresholds to time it spent outside curation.
|
||||
*/
|
||||
public static boolean isUnobserved(SkillEntity skill) {
|
||||
return skill.getCuratorSeenAt() == null && skill.getLastActivityAt() == null;
|
||||
}
|
||||
|
||||
/** Stamp the observation anchor, starting the skill's idle clock now. */
|
||||
public void markObserved(SkillEntity skill, LocalDateTime now) {
|
||||
skillMapper.update(null, new LambdaUpdateWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getId, skill.getId())
|
||||
.set(SkillEntity::getCuratorSeenAt, now));
|
||||
skill.setCuratorSeenAt(now);
|
||||
}
|
||||
|
||||
/** Skills the curator must never touch (filtered out before the state machine). */
|
||||
public boolean isExempt(SkillEntity skill) {
|
||||
if (Boolean.TRUE.equals(skill.getBuiltin())) {
|
||||
@ -108,6 +143,12 @@ public class SkillLifecycleService {
|
||||
if (isExempt(skill)) {
|
||||
return LifecycleTransition.NONE;
|
||||
}
|
||||
// Never observed: the thresholds would be measured against time this
|
||||
// skill spent outside curation. Defer for a full cycle instead; the
|
||||
// sweep stamps the observation anchor so the next pass has a real one.
|
||||
if (isUnobserved(skill)) {
|
||||
return LifecycleTransition.NONE;
|
||||
}
|
||||
LocalDateTime anchor = anchor(skill);
|
||||
if (anchor == null) {
|
||||
return LifecycleTransition.NONE;
|
||||
@ -208,6 +249,125 @@ public class SkillLifecycleService {
|
||||
return skillMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a skill over to autonomous curation, or take it back.
|
||||
*
|
||||
* <p>Adoption deliberately does <em>not</em> buy a fresh idle window. An
|
||||
* operator hands over a skill knowing it is already idle, so the
|
||||
* observation anchor is set to creation time — the same anchor the skill
|
||||
* would have had if it had been curator-managed all along. Handing over a
|
||||
* library you stopped using therefore ages it out, which is the point of
|
||||
* handing it over. This is the deliberate difference from the implicit
|
||||
* first-sight seeding, where a skill arrives in scope through no decision
|
||||
* of the operator's and must not be judged on time spent outside it.
|
||||
*
|
||||
* <p>Releasing restores user ownership, so adoption is reversible.
|
||||
*
|
||||
* @param adopt {@code true} to hand over, {@code false} to take back
|
||||
* @throws MateClawException when the skill does not exist, or when it is
|
||||
* builtin (never curatable in the first place)
|
||||
*/
|
||||
public SkillEntity setAdopted(Long id, boolean adopt) {
|
||||
return setAdopted(id, adopt, 1L);
|
||||
}
|
||||
|
||||
public SkillEntity setAdopted(Long id, boolean adopt, Long workspaceId) {
|
||||
SkillEntity skill = skillMapper.selectById(id);
|
||||
if (skill == null || !normalizeWorkspaceId(workspaceId).equals(skill.getWorkspaceId())) {
|
||||
throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id);
|
||||
}
|
||||
if (isExempt(skill)) {
|
||||
throw new MateClawException("err.skill.not_adoptable", 400,
|
||||
"Skill '" + skill.getName() + "' is exempt from curation (builtin, pinned, "
|
||||
+ "protected prefix, or a virtual mcp/acp skill)");
|
||||
}
|
||||
LambdaUpdateWrapper<SkillEntity> update = new LambdaUpdateWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getId, id)
|
||||
.eq(SkillEntity::getWorkspaceId, normalizeWorkspaceId(workspaceId))
|
||||
.set(SkillEntity::getOrigin,
|
||||
adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code());
|
||||
if (adopt) {
|
||||
// No fresh window: anchor where an always-managed skill would be.
|
||||
update.set(SkillEntity::getCuratorSeenAt, skill.getCreateTime());
|
||||
}
|
||||
skillMapper.update(null, update);
|
||||
recordAudit(adopt ? "ADOPT" : "RELEASE", skill,
|
||||
Map.of("origin", adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code()));
|
||||
return skillMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills outside autonomous curation, with the reason each one is out.
|
||||
*
|
||||
* <p>Without this a large library can look fully curated while most of it
|
||||
* is invisible to the sweep, and the only lever was widening the scope for
|
||||
* everything at once.
|
||||
*/
|
||||
public List<Map<String, Object>> listUnmanaged() {
|
||||
return listUnmanaged(1L);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listUnmanaged(Long workspaceId) {
|
||||
return roster(false, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills currently under autonomous curation — the set an operator can
|
||||
* hand back. Without it adoption would be one-way from the UI.
|
||||
*/
|
||||
public List<Map<String, Object>> listManaged() {
|
||||
return listManaged(1L);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listManaged(Long workspaceId) {
|
||||
return roster(true, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared roster projection. {@code managed} selects skills the curator may
|
||||
* touch ({@code origin} agent/routine) or the complement; exempt skills are
|
||||
* dropped from both sides because they are not curatable either way, so
|
||||
* offering adopt or release on them would be a lie.
|
||||
*/
|
||||
private List<Map<String, Object>> roster(boolean managed, Long workspaceId) {
|
||||
LambdaQueryWrapper<SkillEntity> q = new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getBuiltin, false)
|
||||
.eq(SkillEntity::getWorkspaceId, normalizeWorkspaceId(workspaceId));
|
||||
if (managed) {
|
||||
q.in(SkillEntity::getOrigin, SkillOrigin.curatorManagedCodes());
|
||||
} else {
|
||||
q.and(w -> w.isNull(SkillEntity::getOrigin)
|
||||
.or().eq(SkillEntity::getOrigin, SkillOrigin.USER.code()));
|
||||
}
|
||||
List<SkillEntity> rows = skillMapper.selectList(q);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (SkillEntity skill : rows) {
|
||||
if (isExempt(skill)) {
|
||||
continue;
|
||||
}
|
||||
LocalDateTime anchor = anchor(skill);
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
// Snowflake id as a string: 19 digits exceed JS Number precision.
|
||||
row.put("id", String.valueOf(skill.getId()));
|
||||
row.put("name", skill.getName());
|
||||
row.put("description", skill.getDescription());
|
||||
row.put("lifecycleState", skill.getLifecycleState());
|
||||
row.put("origin", skill.getOrigin());
|
||||
row.put("reason", managed
|
||||
? skill.getOrigin()
|
||||
: (skill.getOrigin() == null ? "predates-provenance" : "user-authored"));
|
||||
row.put("unobserved", isUnobserved(skill));
|
||||
row.put("daysIdle", anchor == null ? null : Duration.between(anchor, now).toDays());
|
||||
out.add(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Long normalizeWorkspaceId(Long workspaceId) {
|
||||
return workspaceId != null && workspaceId > 0 ? workspaceId : 1L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the activity anchor of a skill to now and pull it back to
|
||||
* {@code active} if it had drifted to {@code stale}. Best-effort: a
|
||||
@ -324,6 +484,6 @@ public class SkillLifecycleService {
|
||||
json = String.valueOf(detail);
|
||||
}
|
||||
auditEventService.record(action, "SKILL",
|
||||
String.valueOf(skill.getId()), skill.getName(), json);
|
||||
String.valueOf(skill.getId()), skill.getName(), json, skill.getWorkspaceId());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,434 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.skill.lifecycle.model.SkillSnapshotEntity;
|
||||
import vip.mate.skill.lifecycle.repository.SkillSnapshotMapper;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Restore points for the skill library, captured before a mutating curator
|
||||
* sweep.
|
||||
*
|
||||
* <p>Autonomous curation makes broad, unattended changes: consolidation
|
||||
* rewrites skill bodies and folds several skills into an umbrella, and the
|
||||
* state machine archives skills out of the active set. Both run overnight with
|
||||
* nobody watching, so the first time anyone notices a bad pass is well after
|
||||
* it finished. A snapshot turns "the curator mangled my library" from an
|
||||
* unrecoverable event into one command.
|
||||
*
|
||||
* <p>Only the fields autonomous curation can actually change are captured.
|
||||
* The curator never deletes a skill — it archives, which is a state change —
|
||||
* so restoring is always an update over rows that still exist, never a
|
||||
* resurrection.
|
||||
*
|
||||
* <p>Restore is itself snapshotted first, so a rollback applied to the wrong
|
||||
* run can be rolled forward again.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillSnapshotService {
|
||||
|
||||
private final SkillMapper skillMapper;
|
||||
private final SkillSnapshotMapper snapshotMapper;
|
||||
private final SkillLifecycleProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillRuntimeService runtimeService;
|
||||
|
||||
private static final DateTimeFormatter LABEL_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/**
|
||||
* Capture the current state of every curatable skill.
|
||||
*
|
||||
* @param reason why the snapshot was taken, shown in listings
|
||||
* @return the persisted snapshot, or {@code null} when snapshots are
|
||||
* disabled or there was nothing to capture
|
||||
*/
|
||||
public SkillSnapshotEntity capture(String reason) {
|
||||
return capture(reason, 1L);
|
||||
}
|
||||
|
||||
public SkillSnapshotEntity capture(String reason, Long workspaceId) {
|
||||
return captureInternal(reason, workspaceId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a mandatory restore point. Unlike the admin-facing best-effort
|
||||
* API, persistence/serialization failures propagate so an autonomous
|
||||
* mutation cannot continue without the rollback point it promised.
|
||||
* Explicitly disabling backups remains an intentional opt-out.
|
||||
*/
|
||||
public SkillSnapshotEntity captureRequired(String reason, Long workspaceId) {
|
||||
return captureInternal(reason, workspaceId, true);
|
||||
}
|
||||
|
||||
private SkillSnapshotEntity captureInternal(String reason, Long workspaceId, boolean required) {
|
||||
long scopedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
||||
if (!properties.isBackupEnabled()) {
|
||||
return null;
|
||||
}
|
||||
List<SkillEntity> skills = skillMapper.selectList(
|
||||
new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getBuiltin, false)
|
||||
.eq(SkillEntity::getWorkspaceId, scopedWorkspaceId));
|
||||
if (skills == null || skills.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
ArrayNode payload = objectMapper.createArrayNode();
|
||||
for (SkillEntity skill : skills) {
|
||||
payload.add(toNode(skill));
|
||||
}
|
||||
SkillSnapshotEntity snapshot = new SkillSnapshotEntity();
|
||||
snapshot.setWorkspaceId(scopedWorkspaceId);
|
||||
snapshot.setReason(reason == null || reason.isBlank() ? "manual" : reason.strip());
|
||||
snapshot.setSkillCount(skills.size());
|
||||
try {
|
||||
snapshot.setPayload(objectMapper.writeValueAsString(payload));
|
||||
int inserted = snapshotMapper.insert(snapshot);
|
||||
if (inserted != 1) {
|
||||
throw new IllegalStateException("snapshot insert affected " + inserted + " rows");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[SkillSnapshot] Capture failed ({}): {}", reason, e.getMessage());
|
||||
if (required) {
|
||||
throw new IllegalStateException("Required skill snapshot could not be captured", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
pruneToRetention(scopedWorkspaceId);
|
||||
log.info("[SkillSnapshot] Captured {} skill(s) — reason='{}', id={}",
|
||||
skills.size(), snapshot.getReason(), snapshot.getId());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the skill library back to a snapshot.
|
||||
*
|
||||
* <p>Takes a {@code pre-restore} snapshot first, so an unwanted rollback
|
||||
* can be undone by restoring that one.
|
||||
*
|
||||
* @param snapshotId snapshot to restore
|
||||
* @return per-skill outcome counts
|
||||
* @throws IllegalArgumentException when the snapshot does not exist or its
|
||||
* payload cannot be read
|
||||
*/
|
||||
public Map<String, Object> restore(Long snapshotId) {
|
||||
return restore(snapshotId, 1L);
|
||||
}
|
||||
|
||||
// Deliberately not one outer DB transaction: restore reports per-skill
|
||||
// success/failure and compensates that skill's filesystem on a failed DB
|
||||
// write. An outer transaction would let one late SQL error roll back all
|
||||
// earlier rows while their already-completed filesystem changes remained.
|
||||
public Map<String, Object> restore(Long snapshotId, Long workspaceId) {
|
||||
long scopedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
||||
SkillSnapshotEntity snapshot = snapshotMapper.selectOne(
|
||||
new LambdaQueryWrapper<SkillSnapshotEntity>()
|
||||
.eq(SkillSnapshotEntity::getId, snapshotId)
|
||||
.eq(SkillSnapshotEntity::getWorkspaceId, scopedWorkspaceId));
|
||||
if (snapshot == null) {
|
||||
throw new IllegalArgumentException("Snapshot " + snapshotId + " not found");
|
||||
}
|
||||
JsonNode payload;
|
||||
try {
|
||||
payload = objectMapper.readTree(snapshot.getPayload() == null ? "[]" : snapshot.getPayload());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Snapshot " + snapshotId + " payload is unreadable", e);
|
||||
}
|
||||
if (!payload.isArray()) {
|
||||
throw new IllegalArgumentException("Snapshot " + snapshotId + " payload is not an array");
|
||||
}
|
||||
|
||||
// Snapshot the current state before overwriting it, so restoring the
|
||||
// wrong run is not itself a one-way door.
|
||||
captureRequired("pre-restore to snapshot " + snapshotId, scopedWorkspaceId);
|
||||
|
||||
int restored = 0;
|
||||
int missing = 0;
|
||||
int failed = 0;
|
||||
Set<Long> snapshotSkillIds = new HashSet<>();
|
||||
for (JsonNode node : payload) {
|
||||
Long id = node.path("id").isNull() ? null : node.path("id").asLong(0);
|
||||
if (id == null || id == 0) {
|
||||
continue;
|
||||
}
|
||||
snapshotSkillIds.add(id);
|
||||
SkillEntity current = skillMapper.selectById(id);
|
||||
if (current == null || !Long.valueOf(scopedWorkspaceId).equals(current.getWorkspaceId())) {
|
||||
// The curator never deletes, so a row that is gone was removed
|
||||
// by something else; re-creating it here would resurrect a
|
||||
// deletion the user meant.
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
ObjectNode previousState = toNode(current);
|
||||
try {
|
||||
restoreWorkspaceState(current, node, scopedWorkspaceId);
|
||||
int rows = skillMapper.update(null, new LambdaUpdateWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getId, id)
|
||||
.eq(SkillEntity::getWorkspaceId, scopedWorkspaceId)
|
||||
.set(SkillEntity::getSkillContent, textOrNull(node, "skillContent"))
|
||||
.set(SkillEntity::getDescription, textOrNull(node, "description"))
|
||||
.set(SkillEntity::getVersion, textOrNull(node, "version"))
|
||||
.set(SkillEntity::getTags, textOrNull(node, "tags"))
|
||||
.set(SkillEntity::getOrigin, textOrNull(node, "origin"))
|
||||
.set(SkillEntity::getLifecycleState, textOrNull(node, "lifecycleState"))
|
||||
.set(SkillEntity::getEnabled, boolOrNull(node, "enabled"))
|
||||
.set(SkillEntity::getPinned, boolOrNull(node, "pinned"))
|
||||
.set(node.has("lastActivityAt"), SkillEntity::getLastActivityAt,
|
||||
dateTimeOrNull(node, "lastActivityAt"))
|
||||
.set(node.has("curatorSeenAt"), SkillEntity::getCuratorSeenAt,
|
||||
dateTimeOrNull(node, "curatorSeenAt"))
|
||||
.set(node.has("archivedAt"), SkillEntity::getArchivedAt,
|
||||
dateTimeOrNull(node, "archivedAt")));
|
||||
if (rows != 1) {
|
||||
throw new IllegalStateException("restore update affected " + rows + " rows");
|
||||
}
|
||||
restored++;
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
log.warn("[SkillSnapshot] Restore failed for skill id={}: {}", id, e.getMessage());
|
||||
try {
|
||||
restoreWorkspaceState(current, previousState, scopedWorkspaceId);
|
||||
} catch (Exception compensationError) {
|
||||
log.error("[SkillSnapshot] Filesystem compensation failed for skill id={}: {}",
|
||||
id, compensationError.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
ArchiveAdditionsResult additions = archivePostSnapshotAdditions(snapshotSkillIds, scopedWorkspaceId);
|
||||
failed += additions.failed();
|
||||
try {
|
||||
runtimeService.refreshActiveSkills();
|
||||
} catch (Exception e) {
|
||||
// The DB/filesystem restore is authoritative. A transient cache
|
||||
// refresh failure must not roll its transaction back after files
|
||||
// have already been reconciled; the next scheduled refresh heals it.
|
||||
log.warn("[SkillSnapshot] Runtime refresh after restore failed: {}", e.getMessage());
|
||||
}
|
||||
log.info("[SkillSnapshot] Restored {} skill(s) from snapshot {} ({} no longer present)",
|
||||
restored, snapshotId, missing);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("snapshotId", String.valueOf(snapshotId));
|
||||
out.put("restored", restored);
|
||||
out.put("missing", missing);
|
||||
out.put("failed", failed);
|
||||
out.put("archivedAdditions", additions.archived());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Recent snapshots, newest first, without their payloads. */
|
||||
public List<Map<String, Object>> list(int limit) {
|
||||
return list(1L, limit);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(Long workspaceId, int limit) {
|
||||
long scopedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
||||
List<SkillSnapshotEntity> rows = snapshotMapper.selectList(
|
||||
new LambdaQueryWrapper<SkillSnapshotEntity>()
|
||||
.eq(SkillSnapshotEntity::getWorkspaceId, scopedWorkspaceId)
|
||||
.select(SkillSnapshotEntity::getId, SkillSnapshotEntity::getReason,
|
||||
SkillSnapshotEntity::getSkillCount, SkillSnapshotEntity::getCreateTime)
|
||||
.orderByDesc(SkillSnapshotEntity::getCreateTime)
|
||||
.last("LIMIT " + Math.max(1, limit)));
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (SkillSnapshotEntity row : rows) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
// Snowflake id as a string: 19 digits exceed JS Number precision.
|
||||
m.put("id", String.valueOf(row.getId()));
|
||||
m.put("reason", row.getReason());
|
||||
m.put("skillCount", row.getSkillCount());
|
||||
m.put("createdAt", row.getCreateTime() == null ? null : row.getCreateTime().format(LABEL_FMT));
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop the oldest snapshots beyond the configured retention count. */
|
||||
private void pruneToRetention(Long workspaceId) {
|
||||
int keep = Math.max(1, properties.getBackupKeep());
|
||||
List<SkillSnapshotEntity> rows = snapshotMapper.selectList(
|
||||
new LambdaQueryWrapper<SkillSnapshotEntity>()
|
||||
.eq(SkillSnapshotEntity::getWorkspaceId, workspaceId)
|
||||
.select(SkillSnapshotEntity::getId)
|
||||
.orderByDesc(SkillSnapshotEntity::getCreateTime));
|
||||
if (rows.size() <= keep) {
|
||||
return;
|
||||
}
|
||||
for (SkillSnapshotEntity stale : rows.subList(keep, rows.size())) {
|
||||
try {
|
||||
snapshotMapper.deleteById(stale.getId());
|
||||
} catch (Exception e) {
|
||||
log.debug("[SkillSnapshot] Prune failed for {}: {}", stale.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectNode toNode(SkillEntity skill) {
|
||||
ObjectNode n = objectMapper.createObjectNode();
|
||||
n.put("id", skill.getId());
|
||||
n.put("name", skill.getName());
|
||||
n.put("description", skill.getDescription());
|
||||
n.put("version", skill.getVersion());
|
||||
n.put("tags", skill.getTags());
|
||||
n.put("origin", skill.getOrigin());
|
||||
n.put("lifecycleState", skill.getLifecycleState());
|
||||
n.put("enabled", skill.getEnabled());
|
||||
n.put("pinned", skill.getPinned());
|
||||
n.put("skillContent", skill.getSkillContent());
|
||||
putDateTime(n, "lastActivityAt", skill.getLastActivityAt());
|
||||
putDateTime(n, "curatorSeenAt", skill.getCuratorSeenAt());
|
||||
putDateTime(n, "archivedAt", skill.getArchivedAt());
|
||||
n.put("workspacePresent", workspaceManager.conventionWorkspaceExists(
|
||||
skill.getName(), skill.getWorkspaceId()));
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* A consolidation can create a new umbrella skill after the snapshot was
|
||||
* captured. Leaving that row active would make a restore only partial, so
|
||||
* additions absent from the snapshot are archived (not deleted) and remain
|
||||
* recoverable through the automatically captured pre-restore point.
|
||||
*/
|
||||
private ArchiveAdditionsResult archivePostSnapshotAdditions(Set<Long> snapshotSkillIds, Long workspaceId) {
|
||||
List<SkillEntity> currentSkills = skillMapper.selectList(
|
||||
new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getBuiltin, false)
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId));
|
||||
int archived = 0;
|
||||
int failed = 0;
|
||||
for (SkillEntity skill : currentSkills == null ? List.<SkillEntity>of() : currentSkills) {
|
||||
if (skill.getId() == null || snapshotSkillIds.contains(skill.getId())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
SkillWorkspaceManager.ArchiveResult fs = workspaceManager.archiveWorkspace(
|
||||
skill.getName(), workspaceId);
|
||||
if (fs == SkillWorkspaceManager.ArchiveResult.FAILED) {
|
||||
throw new IllegalStateException("Failed to archive workspace for '" + skill.getName() + "'");
|
||||
}
|
||||
int rows = skillMapper.update(null, new LambdaUpdateWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getId, skill.getId())
|
||||
.eq(SkillEntity::getWorkspaceId, workspaceId)
|
||||
.set(SkillEntity::getEnabled, false)
|
||||
.set(SkillEntity::getLifecycleState, "archived")
|
||||
.set(SkillEntity::getArchivedAt, LocalDateTime.now()));
|
||||
if (rows != 1) {
|
||||
if (fs == SkillWorkspaceManager.ArchiveResult.MOVED) {
|
||||
workspaceManager.restoreWorkspace(skill.getName(), workspaceId);
|
||||
}
|
||||
throw new IllegalStateException("archive update affected " + rows + " rows");
|
||||
}
|
||||
archived++;
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
log.warn("[SkillSnapshot] Failed to archive post-snapshot skill id={}: {}",
|
||||
skill.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return new ArchiveAdditionsResult(archived, failed);
|
||||
}
|
||||
|
||||
private record ArchiveAdditionsResult(int archived, int failed) {}
|
||||
|
||||
/**
|
||||
* Restore the filesystem half before publishing the corresponding DB row.
|
||||
* New snapshots remember whether a convention workspace existed; legacy
|
||||
* snapshots fall back to the current/archive state so they remain usable.
|
||||
*/
|
||||
private void restoreWorkspaceState(SkillEntity current, JsonNode node, Long workspaceId) {
|
||||
String name = textOrNull(node, "name");
|
||||
if (name == null || name.isBlank()) {
|
||||
name = current.getName();
|
||||
}
|
||||
String content = textOrNull(node, "skillContent");
|
||||
String desiredState = textOrNull(node, "lifecycleState");
|
||||
boolean desiredArchived = "archived".equals(desiredState);
|
||||
boolean workspacePresent = node.has("workspacePresent")
|
||||
? node.path("workspacePresent").asBoolean(false)
|
||||
: workspaceManager.conventionWorkspaceExists(name, workspaceId)
|
||||
|| "archived".equals(current.getLifecycleState());
|
||||
|
||||
if (desiredArchived) {
|
||||
if (workspaceManager.conventionWorkspaceExists(name, workspaceId)) {
|
||||
if (content != null && workspaceManager.exportToWorkspace(name, content, workspaceId) == null) {
|
||||
throw new IllegalStateException("Failed to restore workspace content for '" + name + "'");
|
||||
}
|
||||
if (workspaceManager.archiveWorkspace(name, workspaceId)
|
||||
== SkillWorkspaceManager.ArchiveResult.FAILED) {
|
||||
throw new IllegalStateException("Failed to restore archived workspace for '" + name + "'");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workspacePresent) {
|
||||
if (workspaceManager.conventionWorkspaceExists(name, workspaceId)
|
||||
&& workspaceManager.archiveWorkspace(name, workspaceId)
|
||||
== SkillWorkspaceManager.ArchiveResult.FAILED) {
|
||||
// Preserve the post-snapshot directory in .archived rather than
|
||||
// deleting it; the pre-restore snapshot can then roll forward.
|
||||
throw new IllegalStateException("Failed to remove post-snapshot workspace for '" + name + "'");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
SkillWorkspaceManager.RestoreResult moved = workspaceManager.restoreWorkspace(name, workspaceId);
|
||||
if (moved == SkillWorkspaceManager.RestoreResult.FAILED) {
|
||||
throw new IllegalStateException("Failed to restore workspace for '" + name + "'");
|
||||
}
|
||||
if (content != null && workspaceManager.exportToWorkspace(name, content, workspaceId) == null) {
|
||||
throw new IllegalStateException("Failed to restore workspace content for '" + name + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static String textOrNull(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
|
||||
private static Boolean boolOrNull(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asBoolean();
|
||||
}
|
||||
|
||||
private static LocalDateTime dateTimeOrNull(JsonNode node, String field) {
|
||||
String value = textOrNull(node, field);
|
||||
return value == null || value.isBlank() ? null : LocalDateTime.parse(value);
|
||||
}
|
||||
|
||||
private static void putDateTime(ObjectNode node, String field, LocalDateTime value) {
|
||||
if (value == null) {
|
||||
node.putNull(field);
|
||||
} else {
|
||||
node.put(field, value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static long normalizeWorkspaceId(Long workspaceId) {
|
||||
return workspaceId != null && workspaceId > 0 ? workspaceId : 1L;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package vip.mate.skill.lifecycle.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A restore point for the skill library, captured before a mutating curator
|
||||
* sweep.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_skill_snapshot")
|
||||
public class SkillSnapshotEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** Owning workspace; snapshots are never shared across tenants. */
|
||||
private Long workspaceId;
|
||||
|
||||
/** Why the snapshot was taken — {@code pre-sweep}, {@code pre-restore}, or a manual note. */
|
||||
private String reason;
|
||||
|
||||
/** Number of skills captured, so a listing need not parse the payload. */
|
||||
private Integer skillCount;
|
||||
|
||||
/** JSON array of the captured skill rows. */
|
||||
@TableField(value = "payload", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String payload;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user