Compare commits

..

12 Commits
dev ... v2.0.0

Author SHA1 Message Date
matevip
4cd7ccaead release: v2.0.0 2026-07-31 03:54:21 -04:00
mateaix
c6f6f10fd0 release: v1.8.0 2026-07-12 17:30:23 +08:00
mateaix
c8bf4e0f89 release: v1.7.0 2026-07-04 20:28:15 +08:00
matevip
db6caea824 release: v1.6.0 2026-06-22 15:17:06 +08:00
matevip
84375da3c5 release: v1.5.0 2026-06-05 07:55:59 +08:00
matevip
68c010ecf9 release: v1.4.0 2026-05-25 09:58:38 +08:00
matevip
493910bf5a release: v1.3.0 (hotfix bundle — #120 + UI/build fixes) 2026-05-14 09:30:43 +08:00
matevip
da8005a8cb release: v1.3.0 (pnpm build fix) 2026-05-13 11:30:28 +08:00
matevip
1d64194e15 release: v1.3.0 2026-05-13 10:14:27 +08:00
matevip
f47cf8c6be release: v1.3.0 2026-05-13 10:05:53 +08:00
matevip
d994be3d04 release: v1.2.0 2026-05-05 20:09:58 +08:00
matevip
9ada305b8a release: v1.1.137 2026-04-29 16:40:05 +08:00
888 changed files with 4230 additions and 62753 deletions

4
.gitignore vendored
View File

@ -78,12 +78,8 @@ 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/
.sessions/
/data/
# VitePress build output and cache (do not commit)

View File

@ -8,7 +8,7 @@
<p align="center"><b>Your second brain</b></p>
<p align="center"><sub><b>Pluggable Agent Runtime · Native + DSH · Spring Boot inside</b></sub></p>
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
@ -30,19 +30,15 @@
---
> **Latest stable: v2.2.0 — a pluggable, recoverable Agent Runtime.** Digital employees can now run on MateClaw's native StateGraph engine or the managed DeepSeek Harness (DSH) runtime while keeping one conversation, policy, tool, persistence, and observability plane. Persistent Goals survive bounded turns and backend restarts, and A2A connects governed employees across systems. Read the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.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 in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
> 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.
>
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native StateGraph runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over authenticated JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
> **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 is unavailable, the next healthy provider retries the current request.
**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.
---
@ -52,7 +48,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 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.
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.
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.
@ -60,7 +56,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 preserves traceable citations for generated content. Open the citation drawer to inspect the corresponding source chunk and verify page or answer references.
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.
This is the difference between a warehouse and a library.
@ -83,20 +79,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 runtime, 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). Employee identity and governance stay stable even when the execution engine changes.
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### Agent Runtime: native or DSH (2.2.0+)
The `AgentRuntimeProvider` contract separates an employee from the engine that runs its turn. The **native runtime** keeps ReAct, Plan-and-Execute, persistent Goals, and Team Runs inside MateClaw. The **DSH runtime** manages `dsh-jsonrpc-agent` as an authenticated child process and streams thinking, text, tool calls, usage, completion, and cancellation back as normalized runtime events. DSH owns the external Agent loop; MateClaw still owns the session, workspace, credentials, tools, approvals, messages, and UI projection. Runtime availability and capabilities are validated before startup, and DSH can be installed, verified, connection-tested, enabled, or disabled from the console. [Configure DeepSeek Harness →](https://claw.mate.vip/docs/en/deepseek-harness)
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
Persistent Goals turn work that takes hours into bounded, recoverable segments. The database preserves the goal checklist, continuation state, attempts, cooldowns, leases, and user input accepted while the worker is busy. After a single backend instance restarts, the supervisor reconciles the interrupted attempt, reads persisted checkpoints and artifacts, and schedules the next safe segment instead of asking you to repeat the task.
For file-producing work, ask the employee to keep a progress ledger, append small verifiable units, inspect the existing tail after recovery, and complete the Goal only after reproducible acceptance checks pass. The runtime does not promise exactly-once behavior for arbitrary external side effects; payments, sends, publishes, and destructive calls still need provider idempotency or review. [Run and verify durable Goals →](https://claw.mate.vip/docs/en/goals)
> Prompt pattern: “Create a persistent Goal first. Save the plan and progress in the workspace, write in small checkpoints, resume from existing evidence after errors or restart, and call `completeGoal` only after every criterion has verifiable evidence.”
### 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.
### Agent Teams (2.0.0+)
One lead, a crew of employees, one **shared task board**. Tell the lead a goal and it breaks the goal into tasks on the board (`blockedBy` declares dependencies); the dispatch engine hands tasks to members in parallel, prerequisite results hand off to downstream tasks automatically, and settled results are announced back to the lead for synthesis. Execution leases + heartbeats eliminate double execution, **cancel actually interrupts** a running member session, and sensitive tasks park at `in_review` for a human. Deliverables (docx / pptx / xlsx / pdf) register on tasks for download, timelines record everything, and you can jump into any member's child conversation to watch it execute word by word. A Plan-Execute lead hands its **whole plan over to the board** — a lead that can plan turns planning into orchestration.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
@ -104,7 +90,7 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
- **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**. 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
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **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
@ -115,7 +101,7 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
- **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, which runtime provider owns the turn, what step it is on, how many tokens it uses, and one-click force-recycle when stuck. Native and DSH events enter the same thinking / tool / answer projection; completion, failure, usage, and cancellation retain consistent lifecycle semantics. Per-event SSE IDs make reconnects safe, and Team Runs group member work under one live execution.
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 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.
@ -124,15 +110,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. 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.
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.
---
## AI is becoming infrastructure
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.
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.
Once AI enters production, the stable layer should not be tied to one model supplier or one Agent loop. MateClaw absorbs model uncertainty through provider priorities, health tracking, cooldown, and failover, then places native and external execution engines behind one governed Agent Runtime contract.
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.
**MateClaw is that layer — built the Spring Boot way.**
@ -152,7 +138,7 @@ Once AI enters production, the stable layer should not be tied to one model supp
**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.** 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.
**MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
Same "whole widget" philosophy. Different center of gravity.
@ -167,7 +153,7 @@ mvn spring-boot:run # http://localhost:18088
# Frontend
cd mateclaw-ui
npm install && npm run dev # http://localhost:5173
pnpm install && pnpm dev # http://localhost:5173
```
Login: `admin` / `admin123`
@ -204,14 +190,11 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 backend (Agent Runtime contract, native StateGraph + DSH)
├── 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
```
@ -223,10 +206,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 |
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution · Team task board (2.0.0+) |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
@ -240,27 +223,7 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
**v2.2.0 (shipped 2026-08-29)** — from one built-in reasoning loop to **a pluggable and recoverable Agent Runtime**:
- **Runtime contract** — provider registry, session factory, capability validation, normalized event stream, lifecycle, usage, and UI projection decouple employees from execution engines
- **DeepSeek Harness runtime** — managed installation and configuration, authenticated JSON-RPC process bridge, Cordis composition, cancellable streaming, isolated child environment, and host-governed tool dispatch
- **Durable long work** — bounded Goal segments, persisted continuation and input queues, attempts, cooldown, retry, leases, restart recovery, and explicit pause / resume semantics
- **Agent interoperability** — inbound and outbound A2A with Agent Cards, JSON-RPC / SSE tasks, authentication, idempotency, and guarded network boundaries
- **Runtime hardening** — tighter workspace ownership, reliable Team Run recovery and deliverable gates, plus consistent long-form output and input handling across approval, stop, and recovery
Full story in the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
**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:
**v2.0.0 (shipped 2026-07-26)** — from "one person who gets things done" to "a team that collaborates": **Agent Teams** become a standing roster around a shared task board:
- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation
- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale
@ -311,7 +274,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 && npm install && npm run dev
cd ../mateclaw-ui && pnpm install && pnpm dev
```
---

View File

@ -8,7 +8,7 @@
<p align="center"><b>你的超级大脑</b></p>
<p align="center"><sub><b>可插拔 Agent Runtime · Native + DSH · Spring Boot 内核</b></sub></p>
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
@ -30,19 +30,15 @@
---
> **最新稳定版v2.2.0 —— 可插拔、可恢复的 Agent Runtime。** 数字员工现在可以选择 MateClaw 原生 StateGraph 引擎或受管理的 DeepSeek HarnessDSH运行时同时复用同一套会话、策略、工具、持久化与可观测面Persistent Goal 可跨有界回合和后端重启继续A2A 则让受治理的员工跨系统互联。详见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
---
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
>
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
>
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run也可以通过认证 JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型不可用时,系统会按优先级改由下一家健康供应商重新完成当前请求
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完
---
@ -52,7 +48,7 @@
Key 过期。厂商返回 401。网络抖动。配额耗尽。
别的工具丢你一张红色错误卡。MateClaw 会按配置顺序尝试下一家健康供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等内置或 OpenAI 兼容供应商——尽可能恢复当前请求;仅当可用链路全部失败时才返回错误。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
@ -60,7 +56,7 @@ Key 过期。厂商返回 401。网络抖动。配额耗尽。
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`生成内容保留可追踪引用。点开引用抽屉,就能看到对应的原始 chunk页面与回答中的引用可以回到来源核对
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题得到的页面是从对应片段拼出来的——带可核对的出处
这是**仓库**和**图书馆**的区别。
@ -83,20 +79,10 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
## 盒子里有什么
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施
### Agent RuntimeNative 或 DSH2.2.0+
`AgentRuntimeProvider` contract 把员工与实际执行回合的引擎分开。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run**DSH Runtime** 把 `dsh-jsonrpc-agent` 作为认证子进程管理,并将思考、文本、工具调用、用量、完成与取消统一映射为 runtime event。DSH 掌管外部 Agent loopMateClaw 继续掌管 session、workspace、凭证、工具、审批、消息和 UI 投影。启动前会校验 runtime 可用性与能力;控制台可完成 DSH 的安装、配置、校验、连接测试和启停。[配置 DeepSeek Harness →](https://claw.mate.vip/docs/zh/deepseek-harness)
### 持久长任务检查点、重启、继续2.2.0+
Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段。数据库会保存目标清单、continuation 状态、attempt、冷却、lease以及员工忙碌期间已经接收的用户输入。单后端实例重启后supervisor 会先核对被中断的 attempt读取持久检查点和已有产物再调度下一段安全工作不要求用户重新描述任务。
对于写文件的任务,应要求员工维护进度账本、以小块追加可验证内容、恢复时先检查文件尾部,并且只有在可复现验收全部通过后才完成 Goal。运行时不承诺任意外部副作用严格一次付款、发送、发布和破坏性操作仍需使用服务商幂等键或人工复核。[运行并验证持久目标 →](https://claw.mate.vip/docs/zh/goals)
> 提示词模板:“第一步创建持续目标;把计划和进度保存在工作区;按小检查点写入;发生错误或重启后从已有证据继续;只有每条验收标准都有可验证证据时才调用 `completeGoal`。”
### Team Run2.1.0+
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面Agents Live 按运行聚合成员并展示实时状态Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
### 团队协作2.0.0+
一个 Lead 带一群员工,围着一块**共享任务板**干活。你对 Lead 说一句目标,它拆成任务上板(`blockedBy` 声明依赖);派发引擎把任务并行分给成员,前置结果自动传给下游,完成结果自动通报回 Lead 汇总。执行租约 + 心跳杜绝双重执行,**取消即中断**正在跑的成员会话,敏感任务停在 `in_review` 等人批。交付物docx / pptx / xlsx / pdf登记到任务可下载任务时间线记录全程还能跳进任意成员的子会话看它逐字执行。Plan-Execute 型 Lead 的计划**整体移交任务板**——会规划的 Lead规划能力就是编排能力。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
@ -104,7 +90,7 @@ Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md**。2.1 可通过对话反思与跨会话重复请求挖掘形成可复用改进并以候选晋升、受约束自动绑定、curator 治理、来源策略、快照和恢复点保证过程可观察、按工作空间隔离且可回滚;所有自动能力均由独立开关控制。另有 8 个起步模板、5 步创作向导和安装前 **Pre-flight 检查**
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
@ -115,24 +101,24 @@ Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、当前回合由哪个 runtime provider 承载、跑到哪一步、占多少 token卡住可一键回收。Native 与 DSH 事件进入同一套思考 / 工具 / 回答投影完成、失败、用量和取消保持一致的生命周期语义。SSE 每事件 ID 支持安全重连Team Run 将成员工作聚合到同一次运行下
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 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 交付。开发环境可用 H2公开 Docker 栈默认使用 PostgreSQL 16同时保留 MySQL profileKingbase 驱动为按需启用
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动
---
## AI 正在变成基础设施
模型供应商会限流网络会抖动Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障
2026 年 3 月 2 日Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡
当 AI 进入生产环境,稳定的一层既不应绑定一家模型供应商,也不应绑定一套 Agent loop。MateClaw 用供应商优先级、健康追踪、冷却与故障转移吸收模型侧不确定性,再把 Native 与外部执行引擎收进同一份受治理的 Agent Runtime contract
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
@ -152,7 +138,7 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**We
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优选它们没问题。两家的社区规模今天都大于 MateClaw。
**MateClaw 是那个给团队用的版本。** 数字员工、模型与工具都纳入权限和工作空间边界。危险动作可暂停等待审批关键操作进入审计事件流。Admin 运行时控制台集中展示正在执行的员工与供应商状态,卡住时可回收。底座是 Spring Boot适合并入已有 Java 服务体系
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入
**同一套"完整一整套"哲学,不同的重心。**
@ -167,7 +153,7 @@ mvn spring-boot:run # http://localhost:18088
# 前端
cd mateclaw-ui
npm install && npm run dev # http://localhost:5173
pnpm install && pnpm dev # http://localhost:5173
```
默认登录:`admin` / `admin123`
@ -204,14 +190,11 @@ docker compose up -d # http://localhost:18080
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 后端(Agent Runtime contract · Native StateGraph + DSH
├── 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
```
@ -223,10 +206,10 @@ mateclaw/
| 层次 | 技术 |
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraphReAct + Plan-Execute· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 · 团队任务板2.0.0+|
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· PostgreSQL 16Docker 默认)· MySQL 8.0+(支持)· Kingbase按需驱动|
| 数据库 | H2开发· MySQL 8.0+(生产|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
@ -240,27 +223,7 @@ mateclaw/
## 路线图
**v2.2.02026-08-29 发布)** —— 从一套内置推理循环走向**可插拔、可恢复的 Agent Runtime**
- **Runtime contract** —— provider registry、session factory、能力校验、统一事件流、生命周期、用量与 UI 投影,让员工身份与执行引擎解耦
- **DeepSeek Harness runtime** —— 受管理的安装与配置、认证 JSON-RPC 进程桥、Cordis composition、可取消流、子进程环境隔离以及由宿主治理的工具派发
- **持久长任务** —— 有界 Goal segment、持久化 continuation / 输入队列、attempt、冷却、重试、租约、重启恢复和显式暂停 / 恢复语义
- **Agent 互操作** —— A2A 入站与出站、Agent Card、JSON-RPC / SSE task、认证、幂等与受控网络边界
- **Runtime 加固** —— 工作空间归属进一步收口Team Run 恢复和交付门更可靠,长文本及审批、停止、恢复期间的输入处理更一致
完整内容见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
**v2.1.02026-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.02026-07-31 发布)** —— 从“一个能干活的人”到“一支能协作的队伍”:**Agent 团队**成为常设编制,围绕共享任务板工作:
**v2.0.02026-07-26 发布)** — 从"一个能干活的人"到"一支能协作的队伍"**Agent 团队**成为常设编制,围着一块共享任务板干活:
- **Agent 团队与共享任务板** — 团队 / 角色lead · member · reviewer、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 LeadTeams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
@ -311,7 +274,7 @@ mateclaw/
git clone https://github.com/mateaix/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && npm install && npm run dev
cd ../mateclaw-ui && pnpm install && pnpm dev
```
---

159
UPGRADING.md Normal file
View File

@ -0,0 +1,159 @@
# Upgrading MateClaw
## 1.0.x → 1.1.0
**TL;DR** — Most users have nothing to do. Restart with 1.1.0, Flyway's built-in repair heals known checksum drift, Ollama auto-discovery rewrites the bad `:latest` defaults, and everything else self-converges. Docker Compose deployments need a one-time `.env` update.
See `docs/en/releases/1.1.0.md` for the feature changelog.
---
## For everyone
### ⚠️ What happens automatically (no action)
- **Flyway migration self-heal** — 1.1.0 rewrote all MySQL migrations V2V14 to replace unsupported `ADD COLUMN IF NOT EXISTS` syntax (Gitee #IIYHLJ). `FlywayRepairConfig` runs `flyway.repair()` on every boot, so the new checksums auto-accept and migration resumes from wherever your schema is.
- **Ollama default model** — if your 1.0.x run auto-picked a model tag Ollama no longer has (commonly `deepseek-r1:latest`), on 1.1.0 restart `OllamaAutoDiscoveryRunner` detects the broken default and re-picks a tag-capable model (e.g. `deepseek-r1:7b`, `qwen3:latest`), preferring one that supports function calling.
- **Stale `mate_model_config` rows** — idempotent seed data reconciles on each startup.
### 📋 Recommended pre-upgrade steps
1. Back up your database — `mateclaw` schema on MySQL, or `data/mateclaw.mv.db` on H2.
2. Back up `data/` directory (skill workspaces, uploaded files, memory files).
3. Note your current default model in Settings → Models in case you want to switch back.
### 🚀 Upgrade
```bash
git pull
cd mateclaw-server
mvn clean package -DskipTests
# then restart your service per your deployment method
```
Or for Desktop app users: just update to 1.1.0 via the in-app updater or re-download.
---
## For Docker Compose deployments
**One-time migration step required** — 1.1.0 refuses to start with default hardcoded passwords.
### 1. Copy-paste merge the new `.env.example` keys
```bash
cp .env .env.backup
# open .env.example — it has new required keys:
# DB_PASSWORD= (was default 'mateclaw123', now MUST be overridden)
# DB_ROOT_PASSWORD= (new, required for MySQL root)
# JWT_SECRET= (new, strongly recommended)
# MATECLAW_CORS_ALLOWED_ORIGINS= (new, strongly recommended for prod)
```
### 2. Set strong values in your `.env`
```env
# STRONG passwords — at least 16 chars, mixed case + digits + symbols
DB_PASSWORD=<your-strong-db-user-password>
DB_ROOT_PASSWORD=<different-strong-root-password>
# 32+ char random string — generate with: openssl rand -base64 48
JWT_SECRET=<your-jwt-secret>
# Production CORS allowlist — comma-separated, no wildcards
MATECLAW_CORS_ALLOWED_ORIGINS=https://mateclaw.example.com
```
If any of `DB_PASSWORD` / `DB_ROOT_PASSWORD` / `DASHSCOPE_API_KEY` is missing, `docker compose up` will fail fast with a clear error — this is intentional.
### 3. Existing MySQL volume compatibility
If you already ran 1.0.x with the old default password (`mateclaw123`), **your existing MySQL volume still has the old root password inside**. You have two options:
**Option A — keep existing password** (fastest, least secure):
Set `DB_ROOT_PASSWORD=mateclaw123` and `DB_PASSWORD=mateclaw123` in `.env` to match. Upgrade works. Then rotate after upgrade using `ALTER USER ... IDENTIFIED BY ...` inside the MySQL container.
**Option B — fresh volume with new password** (cleanest, loses DB if not backed up):
```bash
docker compose down -v # ⚠️ deletes mysql_data volume; back up first
# edit .env with new strong password
docker compose up -d
```
Then re-import your backup if you kept one.
### 4. Restart
```bash
docker compose up -d
docker compose logs -f mateclaw-server # watch for "Flyway Successfully applied N migrations"
```
Expected log lines during boot:
- `Flyway Successfully applied N migrations to schema mateclaw`
- `Ollama: auto-activated default model '<actual-tag>'` (if you use Ollama — should NOT say `:latest` any more)
- `[Security] Using default JWT secret!` → means you forgot to set `JWT_SECRET` — fix and restart
---
## For local dev / H2 deployments
No action required. `mvn spring-boot:run` picks up the latest migrations on next start, Flyway repair handles checksum drift, H2 file at `data/mateclaw.mv.db` is preserved.
---
## Known migration quirks
### 1. If you manually fiddled with `flyway_schema_history`
In 1.0.x some users hit Flyway version collisions (V8/V9 and V9/V10) which 1.1.0 fixes by renumbering. If you manually deleted rows from `flyway_schema_history` you may see `Validate failed` on 1.1.0 startup — run:
```sql
-- MySQL
DELETE FROM flyway_schema_history WHERE success = 0;
```
Then restart. `FlywayRepairConfig` will rebuild history from current schema state.
### 2. If your Ollama models are all in the no-tools family
After upgrade, agents that require tool calling will log a warning on first invocation:
```
Ollama: auto-activated default model '...' but its family does not support tool calling
```
Fix — pull a tool-capable model, or switch default in Settings → Models:
```bash
ollama pull qwen3
# or
ollama pull llama3.1:8b
# or
ollama pull mistral-nemo
```
### 3. If you had custom tools using `extract_document_text` / wiki tools
Wiki chunk schema changed (new `embedding` + `embedding_model` columns on `mate_wiki_chunk`). Your existing wiki pages work unchanged; only semantic search is new and requires an embedding model to be configured in Settings → Models (a default DashScope embedding is seeded).
---
## Rolling back to 1.0.x
Not recommended (some new tables / columns don't exist in 1.0.x), but possible if you backed up the DB before upgrade:
```bash
git checkout v1.0.418
# restore DB backup
docker compose up -d # or mvn spring-boot:run
```
If you need to keep the new data but downgrade the app, you're in unsupported territory — open a Gitee issue.
---
## Getting help
- **Logs first**: `mateclaw-server/logs/mateclaw.log` + `mateclaw-error.log` have everything. Flyway decisions are at INFO level in main log.
- **Doctor tab**: in-app Settings → Doctor runs basic health checks
- **Gitee**: https://gitee.com/matevip_admin/mateclaw/issues — include your upgrade path (1.0.?? → 1.1.0), profile (H2 / MySQL), and the last 100 lines of startup log

View File

@ -32,10 +32,9 @@
<!-- ===== Center: Agent Core ===== -->
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">Digital Employee</text>
<text x="480" y="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">Identity · Goal · Governance</text>
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">One policy + event plane</text>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
<!-- ===== Top: User Surfaces (5 items) ===== -->
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -33,10 +33,9 @@
<!-- ===== Center: Agent Core ===== -->
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">数字员工</text>
<text x="480" y="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">身份 · 目标 · 治理</text>
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">同一策略与事件平面</text>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
<!-- ===== Top: User Surfaces (5 items) ===== -->
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -67,40 +67,40 @@
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g>
<!-- ===== Layer 2: Agent Runtime ===== -->
<!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · NORMALIZED EVENTS &amp; GOVERNANCE</text>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
<g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Runtime Contract</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · Capability</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Lifecycle · Usage · Projection</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
</g>
<g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Native Runtime</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">StateGraph · ReAct</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goals · Teams</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Team · Workflow · Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Task-board dispatch (2.0.0+)</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step modes · 6 patterns</text>
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">DSH Runtime</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Managed JSON-RPC Process</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
</g>
<g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Host Governance</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval · Credentials</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Memory System</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Short-term + Extraction</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Consolidation + Dreaming</text>
</g>
<g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool Plane</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Skills · MCP</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · Built-in</text>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -70,40 +70,40 @@
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g>
<!-- ===== Layer 2: Agent Runtime ===== -->
<!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · 统一事件与治理</text>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
<g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Runtime Contract</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · 能力</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">生命周期 · 用量 · 投影</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
</g>
<g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Native Runtime</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">StateGraph · ReAct</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goal · Team</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">团队 · 工作流 · 触发器</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">任务板派发 + 并行2.0.0+</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step mode · 6 pattern</text>
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">DSH Runtime</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">受管理 JSON-RPC 进程</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
</g>
<g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">宿主治理</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">审批 · 凭证隔离</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
</g>
<g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具平面</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">技能 · MCP</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · 内置工具</text>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器1.3.0</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,117 +0,0 @@
-- ============================================================
-- 修复:知识库原始材料重复入库
-- 适用MySQL 8.0+(使用 JSON 函数处理 source_raw_ids
-- 说明:同一 (kb_id, source_path) 可能因文件内容变更
-- 被多次 INSERT 而形成多行。本脚本保留最新行,
-- 并级联清理其关联的 chunk、citation、page。
-- ============================================================
-- ──────────────────────────────────────────────────────────
-- STEP 0预览只读不改数据先跑这一步确认影响范围
-- ──────────────────────────────────────────────────────────
-- 0-A查看所有重复组按 kb_id + source_path 分组count > 1
SELECT
kb_id,
source_path,
COUNT(*) AS duplicate_count,
MAX(id) AS keep_id,
GROUP_CONCAT(id ORDER BY id DESC) AS all_ids
FROM mate_wiki_raw_material
WHERE source_path IS NOT NULL
GROUP BY kb_id, source_path
HAVING COUNT(*) > 1;
-- 0-B查看待删除的具体行排除每组最新的那一行
SELECT
r.id, r.kb_id, r.source_path,
r.content_hash, r.processing_status, r.create_time
FROM mate_wiki_raw_material r
WHERE r.source_path IS NOT NULL
AND r.id NOT IN (
SELECT MAX(id)
FROM mate_wiki_raw_material
WHERE source_path IS NOT NULL
GROUP BY kb_id, source_path
)
ORDER BY r.kb_id, r.source_path, r.id;
-- ──────────────────────────────────────────────────────────
-- STEP 1开事务执行清理确认 STEP 0 结果后再运行)
-- ──────────────────────────────────────────────────────────
START TRANSACTION;
-- 1-A把待删除的 raw id 暂存到临时表,后续步骤复用
CREATE TEMPORARY TABLE IF NOT EXISTS _stale_raw_ids AS
SELECT id AS raw_id, kb_id
FROM mate_wiki_raw_material
WHERE source_path IS NOT NULL
AND id NOT IN (
SELECT MAX(id)
FROM mate_wiki_raw_material
WHERE source_path IS NOT NULL
GROUP BY kb_id, source_path
);
-- 1-B删除这些 raw 产生的 citation通过 chunk_id 关联)
DELETE c
FROM mate_wiki_page_citation c
INNER JOIN mate_wiki_chunk ch ON c.chunk_id = ch.id
INNER JOIN _stale_raw_ids s ON ch.raw_id = s.raw_id;
-- 1-C删除 chunk
DELETE ch
FROM mate_wiki_chunk ch
INNER JOIN _stale_raw_ids s ON ch.raw_id = s.raw_id;
-- 1-D删除仅由该 raw 派生的 pagesource_raw_ids 数组长度为 1
-- 使用 JSON_CONTAINS 判断 page 是否引用了待删 raw
DELETE p
FROM mate_wiki_page p
WHERE JSON_LENGTH(p.source_raw_ids) = 1
AND EXISTS (
SELECT 1
FROM _stale_raw_ids s
WHERE JSON_CONTAINS(p.source_raw_ids, CAST(s.raw_id AS CHAR))
);
-- 1-E对多来源 page将待删 raw 从 source_raw_ids 中移除
-- 通过 JSON_TABLE 把数组展开再重组,排除掉 stale raw id
UPDATE mate_wiki_page p
SET p.source_raw_ids = (
SELECT JSON_ARRAYAGG(jt.v)
FROM JSON_TABLE(p.source_raw_ids, '$[*]' COLUMNS (v BIGINT PATH '$')) jt
WHERE jt.v NOT IN (SELECT raw_id FROM _stale_raw_ids)
)
WHERE JSON_LENGTH(p.source_raw_ids) > 1
AND EXISTS (
SELECT 1
FROM _stale_raw_ids s
WHERE JSON_CONTAINS(p.source_raw_ids, CAST(s.raw_id AS CHAR))
);
-- 1-F删除 stale raw 行
DELETE r
FROM mate_wiki_raw_material r
INNER JOIN _stale_raw_ids s ON r.id = s.raw_id;
-- 1-G确认结果
SELECT
'stale raws deleted' AS action,
ROW_COUNT() AS affected_rows;
SELECT
'remaining duplicates' AS check_item,
COUNT(*) AS count
FROM mate_wiki_raw_material
WHERE source_path IS NOT NULL
GROUP BY kb_id, source_path
HAVING COUNT(*) > 1;
-- 确认无误后提交;如有问题改为 ROLLBACK
COMMIT;
-- ROLLBACK;
DROP TEMPORARY TABLE IF EXISTS _stale_raw_ids;

File diff suppressed because it is too large Load Diff

View File

@ -1,144 +0,0 @@
# 插件化搜索 Provider + 搜索设置页重构 设计文档
日期2026-07-03
状态:待评审
相关:`vip.mate.tool.search`(现有搜索 provider 链)、`mateclaw-plugin-api`(插件 SDK、`/settings/system` 搜索设置区块
## 1. 背景与问题
### 1.1 自定义搜索 provider 没有插件化路径
当前 `SearchProviderRegistry` 通过 Spring 构造器注入 `List<SearchProvider>` 收集 provider只认同一 `ApplicationContext` 里的 bean。要新增一个搜索源唯一办法是**在 `vip.mate.tool.search` 源码树里加 `@Component` 类并重新编译部署整个 server**。
而项目已有一套真正的运行时插件系统(`mateclaw-plugin-api` + `PluginManager`):独立 jar 丢进 `~/.mateclaw/plugins/` 或工作区 `plugins/``URLClassLoader` 隔离加载,支持运行时 enable/disable配置走 manifest 声明的 schema`mateclaw-plugin.json` 的 `config` 字段)+ `plugin``config_json` 持久化 + `PUT /api/v1/plugins/{name}/config` 接口。但 `PluginType` 只有 `TOOL / PROVIDER(LLM) / CHANNEL / MEMORY` 四类,**没有 SEARCH**`PluginContext` 也没有对应注册方法。
LLM provider 已有"内置 `@Component` 链 + 插件注册表"双轨并存的先例(`ModelProviderService.pluginChatModels`),搜索 provider 缺的就是同构的第二轨。
### 1.2 搜索设置 UI 平铺、下拉菜单硬编码
`/settings/system` 的搜索区块把 4 个 provider 的开关/key/url 共 9 个配置项拍平在一个列表里;主 provider 下拉菜单是写死的两个 `<option>`serper/tavily`searxng`/`duckduckgo` 无法显式选中,只能靠后端自动探测兜底;管理员也无法看到"当前实际生效的是哪个 provider"。
### 1.3 插件配置表单缺失(前端)
后端 `PluginInfo` 已返回 `configSchema`(来自 manifest和脱敏后的 `currentConfig``updateConfig()` 已有 schema 白名单 + required 校验,前端 `pluginApi.updateConfig` 客户端也已存在——但 `Plugins.vue` 没有任何配置编辑 UI这条链路在前端是死代码。所有类型的插件目前都无法在界面上配置。
## 2. 目标 / 非目标
**目标**
1. 第三方以独立 jar 形式提供搜索 provider实现 SDK 接口 + manifest 声明,丢进 plugins 目录即用,**mateclaw-server 源码零改动**。
2. 搜索设置页:主 provider 选择动态化(含插件 provider 与"自动选择")、按 provider 分组折叠、显示当前实际生效的 provider。
3. 补上 schema 驱动的插件配置表单(服务所有插件类型,不只 search
**非目标**
- 不改内置 4 个 provider 的配置存储方式(继续走 `SystemSettingsDTO` / `mate_system_setting`)。
- 不删除、不重命名 `GET/PUT /api/v1/settings` 现有字段(无破坏性改动)。
- 不做搜索结果聚合/多 provider 并发查询。
## 3. 设计
### 3.1 SDK 侧(`mateclaw-plugin-api`
新增 `vip.mate.plugin.api.search` 包,接口**不依赖任何 server 类**jar 隔离加载下的硬约束;对比核心 `SearchProvider` 依赖 `SystemSettingsDTO`SDK 版必须自包含):
```java
public interface PluginSearchProvider {
String id(); // 全局唯一,如 "my-search"
String label(); // 显示名
default boolean requiresCredential() { return true; }
default int autoDetectOrder() { return 500; } // 默认排在内置 provider50~400之后
boolean isAvailable(); // 插件自查:如 context.getConfig 拿 key 判空
List<PluginSearchResult> search(PluginSearchQuery query);
}
public record PluginSearchQuery(String query, String freshness, String language, Integer count) {}
public record PluginSearchResult(String title, String url, String snippet, String source, String date) {}
```
- `PluginType` 增加 `SEARCH`
- `PluginContext` 增加 `void registerSearchProvider(PluginSearchProvider provider);`
(接口新增方法对已编译的存量插件无影响——它们不调用即可。)
- 插件的配置API key 等)**不进搜索设置页**走插件系统自己的机制manifest `config` 声明 schema运行时 `context.getConfig(key, type)` 读取。职责天然分离:搜索设置页只管"选谁",插件页管"配它"。
### 3.2 Server 桥接侧
**`bridge/PluginSearchBridge.java`**(模式照抄 `PluginChannelBridge`):把 `PluginSearchProvider` 适配成核心 `SearchProvider`
- `search(SearchQuery, SystemSettingsDTO)` → 转调插件 `search(PluginSearchQuery)`,忽略 DTO
- 结果转核心 `SearchResult``providerId` 填插件 provider id
- `isAvailable(SystemSettingsDTO)` → 委托插件无参 `isAvailable()`
- 插件抛出的异常原样上抛(`WebSearchService.tryProvider()` 已有 catch-and-fallback 语义)。
**`SearchProviderRegistry` 可变化**:从"构造时定死的 immutable list"改为两层合并视图:
- 基底Spring 注入的内置 provider不变
- 插件区:`ConcurrentHashMap<String, SearchProvider>`,新增 `registerPluginProvider(SearchProvider)` / `unregisterPluginProvider(String id)`
- `allSorted()` / `getById()` / `resolve()` 全部查合并视图,排序仍按 `autoDetectOrder`
- **id 冲突拒绝注册**(插件 id 与内置或已注册插件 id 重复时抛 `PluginException`,不允许顶掉 serper 等内置项)。
**生命周期**(与现有四类完全对称):
- `PluginContextImpl.registerSearchProvider()` → 包 bridge 后调 registry 注册,记录到 `LoadedPlugin`
- `disablePlugin()` 与加载失败 rollback 路径各加一个 `searchProviderRegistry.unregisterPluginProvider(...)`best-effort同现有风格
- 插件被 disable 后,若它正是 `searchProvider` 显式指定项,`resolve()` 因 `getById()` 查不到而自动落入 auto-detect 分支——行为安全,无需额外处理。
### 3.3 动态 provider catalog 接口
`GET /api/v1/settings/search-providers``SystemSettingController``@RequireWorkspaceRole("admin")`),只读:
```json
{
"providers": [
{ "id": "serper", "label": "Serper (Google)", "builtin": true, "requiresCredential": true, "available": false },
{ "id": "my-search","label": "My Search", "builtin": false, "requiresCredential": true, "available": true,
"pluginName": "my-search-plugin" }
],
"resolved": { "id": "my-search", "source": "configured" }
}
```
- 数据源:`SearchProviderRegistry.allSorted()`(合并视图,插件 provider 自动出现)+ `resolve(config)`(暴露"当前实际生效"与原因:`configured` / `auto-detect` / `keyless-fallback`)。
- `pluginName` 供前端渲染"去插件页配置"跳转。
- 不含任何敏感值。
### 3.4 搜索设置页重构(`views/Settings/System/index.vue`
- **主 provider 选择**:选项从 catalog 接口动态渲染,新增首项"自动选择(推荐)"——对应 `searchProvider=""`(后端 `resolve()` 对空值本就走 auto-detect无需引入 `"auto"` 特殊值)。下方常驻一行状态提示:`✓ 当前实际生效: Xxx原因`。
- **分组折叠卡片**:每个 provider 一张可折叠卡片,标题行 = 名称 + 徽标(已配置/未配置/生效中),默认只展开"当前生效"的那张。
- 内置 provider卡片内是现有的 key/url 输入框(字段与保存逻辑不变,仍走 `PUT /api/v1/settings`
- 插件 provider卡片内不放表单显示"该 Provider 由插件 {pluginName} 提供,请在插件页配置" + 跳转链接。
- 现有保存语义不变API key 仅在用户输入新值时提交)。
### 3.5 插件配置表单(`views/Plugins.vue`,纯前端)
插件卡片增加"配置"入口(有 `configSchema` 时显示),弹出 schema 驱动的通用表单:
- 按 `configSchema` 渲染字段:`secret=true` → password 输入框placeholder 显示脱敏值,留空表示不修改);其余按 `type` 渲染 text/number/boolean`required` 标星并做前端必填校验(后端已有兜底校验);`description` 作为字段提示。
- 提交走已存在的 `pluginApi.updateConfig`;保存后刷新列表。
- 该表单对所有 `PluginType` 通用,非 search 专属。
- 注意manifest `ConfigField.type` 是自由字符串,前端对未知 type 一律降级为 text 输入。
### 3.6 参考实现(`mateclaw-plugin-sample`
sample 模块增加一个最小 `PluginSearchProvider` 实现(如包装一个可配 baseUrl+apiKey 的通用 HTTP 搜索 APImanifest 声明 `type: "search"` + config schema——同时充当文档示例与集成测试素材。
## 4. 交付拆分(遵循上游单一关注点规范)
- **上游 issue 先行**:动手前在 mateaix/mateclaw 提 issue 说明设计(本文档摘要),获认可后实施。
- **PR-1后端 + SDK**`PluginType.SEARCH` + SDK 接口/record + `PluginSearchBridge` + registry 可变化 + `PluginContextImpl`/`PluginManager` 生命周期 + sample 参考实现 + 单测。
- **PR-2接口 + 前端)**catalog 接口 + 搜索设置页分组折叠重构 + Plugins.vue schema 配置表单。PR-2 不依赖 PR-1 合并catalog 对纯内置 provider 同样成立),但先后合并时插件 provider 自动出现在下拉中。
## 5. 测试
**PR-1**
- registry注册/反注册/合并排序/`resolve()` 三分支含插件项/id 冲突拒绝。
- bridge`SearchQuery`↔`PluginSearchQuery`、`SearchResult` 转换、异常透传。
- 生命周期disable 后 registry 查不到该 id显式指定的插件 provider 被 disable 后 resolve 落回 auto-detect。
- sample 插件 jar 端到端:打包 → 放插件目录 → 启动加载 → `getAllToolCallbacks` 路径外单独验证 `web_search` 走插件 provider。
**PR-2**
- catalog 接口:内置/插件混合列表、resolved 三种 source、无敏感值泄露。
- 前端:下拉动态渲染、"自动选择"存空串、折叠展开状态、secret 字段留空不覆盖。
## 6. 兼容性与风险
- 存量插件:`PluginType` 加枚举值 + `PluginContext` 加方法,均为增量,不影响已编译插件。
- `GET/PUT /api/v1/settings` 字段不动,旧前端/脚本不受影响。
- `SearchProviderRegistry` 由不可变转可变:并发读多写少,`ConcurrentHashMap` + 每次读时合并排序provider 总数 <10无性能顾虑)。
- 插件 provider 质量不可控:`WebSearchService` 现有 15s 超时属于各 provider 自身实现插件侧超时由插件自负catch-and-fallback 链保证坏插件不拖垮搜索功能(最多浪费一次尝试)。
- 安全:插件 jar 本身即任意代码执行现有插件系统的既定信任模型本设计不扩大攻击面catalog 接口仅 admin 可见。

View File

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

View File

@ -11,7 +11,7 @@ import java.util.List;
*
* @author MateClaw Team
*/
public interface PluginMemoryProvider extends AutoCloseable {
public interface PluginMemoryProvider {
/**
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
@ -114,9 +114,4 @@ public interface PluginMemoryProvider extends AutoCloseable {
*/
default void onSessionEnd(Long agentId, String conversationId) {
}
/** Release provider-owned resources when the plugin is unloaded. */
@Override
default void close() {
}
}

View File

@ -13,7 +13,6 @@ package vip.mate.plugin.mem0;
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
* @param maxResults cap on memories returned per recall
* @param timeoutMs HTTP timeout for both recall and sync
* @param syncQueueCapacity maximum number of turns waiting for asynchronous sync
* @author MateClaw Team
*/
record Mem0Config(
@ -22,18 +21,10 @@ record Mem0Config(
boolean searchEnabled,
boolean syncEnabled,
int maxResults,
int timeoutMs,
int syncQueueCapacity
int timeoutMs
) {
static final int DEFAULT_MAX_RESULTS = 5;
static final int DEFAULT_TIMEOUT_MS = 3000;
static final int DEFAULT_SYNC_QUEUE_CAPACITY = 256;
Mem0Config(String baseUrl, String apiKey, boolean searchEnabled, boolean syncEnabled,
int maxResults, int timeoutMs) {
this(baseUrl, apiKey, searchEnabled, syncEnabled, maxResults, timeoutMs,
DEFAULT_SYNC_QUEUE_CAPACITY);
}
/**
* Whether this provider should participate at all.

View File

@ -3,8 +3,9 @@ package vip.mate.plugin.mem0;
/**
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
* <p>
* Sync failures are caught by {@link Mem0Provider}; recall failures propagate
* to the platform provider boundary for timeout/circuit-breaker accounting.
* Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade
* gracefully (empty recall / dropped sync) without affecting the agent's
* response path.
*
* @author MateClaw Team
*/

View File

@ -39,7 +39,6 @@ public class Mem0Plugin implements MateClawPlugin {
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
private static final String CONFIG_MAX_RESULTS = "maxResults";
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
private static final String CONFIG_SYNC_QUEUE_CAPACITY = "syncQueueCapacity";
private Logger log;
@ -55,16 +54,11 @@ public class Mem0Plugin implements MateClawPlugin {
Mem0Client client = new Mem0Client(config);
Mem0Provider provider = new Mem0Provider(config, client, log);
try {
context.registerMemoryProvider(provider);
} catch (RuntimeException e) {
provider.close();
throw e;
}
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}, syncQueueCapacity={}",
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}",
maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(),
config.maxResults(), config.timeoutMs(), config.syncQueueCapacity());
config.maxResults(), config.timeoutMs());
}
@Override
@ -84,7 +78,6 @@ public class Mem0Plugin implements MateClawPlugin {
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
Integer syncQueueCapacity = ctx.getConfig(CONFIG_SYNC_QUEUE_CAPACITY, Integer.class);
return new Mem0Config(
baseUrl,
@ -92,9 +85,7 @@ public class Mem0Plugin implements MateClawPlugin {
searchEnabled == null ? true : searchEnabled,
syncEnabled == null ? true : syncEnabled,
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs,
syncQueueCapacity == null ? Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY
: Math.max(1, syncQueueCapacity)
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs
);
}

View File

@ -4,11 +4,9 @@ import org.slf4j.Logger;
import vip.mate.plugin.api.memory.PluginMemoryProvider;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
@ -19,14 +17,13 @@ import java.util.concurrent.atomic.AtomicLong;
* <li>{@code systemPromptBlock} no-op (returns ""), aligns with SessionSearchProvider</li>
* <li>{@code prefetch(agentId, query, ownerKey)} when {@code searchEnabled}
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
* and returns a {@code [Mem0 Recall]} block. Failures propagate to the
* platform's timeout/circuit-breaker boundary.</li>
* and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
* or when disabled.</li>
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} when
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
* pushes the turn to {@code POST /memories/} under {@code user_id =
* ownerKey}, the same identifier prefetch recalls by. Failures are
* logged and swallowed; never blocks the response path. The bounded
* queue drops new writes when saturated. The four-arg
* logged and swallowed; never blocks the response path. The four-arg
* variant (no ownerKey) skips writing under any other identifier
* would produce memories that owner-scoped recall can never surface.</li>
* <li>{@code getToolBeans} empty (no agent-facing tools in v1)</li>
@ -37,8 +34,8 @@ import java.util.concurrent.atomic.AtomicLong;
* When {@code ownerKey} is null/blank, both recall and sync are skipped Mem0
* requires {@code user_id}.
*
* <p>Asynchronous sync: a single-thread daemon executor with a bounded queue
* prevents an unavailable Mem0 service from growing heap usage without limit.
* <p>Asynchronous sync: a single-thread daemon executor is used
* so that bursts of turns don't pile up on the platform's request thread.
*
* @author MateClaw Team
*/
@ -49,19 +46,21 @@ class Mem0Provider implements PluginMemoryProvider {
private final Mem0Config config;
private final Mem0Client client;
private final Logger log;
private final ThreadPoolExecutor async;
private final AtomicLong droppedSyncCount = new AtomicLong();
private final Executor async;
Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
this.config = config;
this.client = client;
this.log = log;
this.async = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(Math.max(1, config.syncQueueCapacity())), r -> {
// Single-thread executor is enough syncTurn calls are sequential per
// agent and not latency-sensitive; the platform's request thread must
// not be blocked. A bounded single-thread queue keeps memory footprint
// predictable even under burst load.
this.async = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "mem0-sync");
t.setDaemon(true);
return t;
}, new ThreadPoolExecutor.AbortPolicy());
});
}
@Override
@ -105,12 +104,20 @@ class Mem0Provider implements PluginMemoryProvider {
if (userQuery == null || userQuery.isBlank()) {
return "";
}
try {
List<String> memories = client.searchMemories(
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
if (memories.isEmpty()) {
return "";
}
return formatRecallBlock(memories);
} catch (Exception e) {
// Fault isolation: log and return empty so the platform falls back
// to the other (local) providers without affecting the response.
log.warn("[Mem0] prefetch failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
return "";
}
}
@Override
@ -136,8 +143,7 @@ class Mem0Provider implements PluginMemoryProvider {
&& (assistantReply == null || assistantReply.isBlank())) {
return;
}
try {
async.execute(() -> {
CompletableFuture.runAsync(() -> {
try {
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
conversationId, userMessage, assistantReply);
@ -145,43 +151,7 @@ class Mem0Provider implements PluginMemoryProvider {
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
}
});
} catch (RejectedExecutionException e) {
long dropped = droppedSyncCount.incrementAndGet();
log.warn("[Mem0] sync queue full or provider closed; dropped turn for agent={} owner={} (totalDropped={})",
agentId, ownerKey, dropped);
}
}
int queuedSyncCount() {
return async.getQueue().size();
}
long droppedSyncCount() {
return droppedSyncCount.get();
}
boolean isClosed() {
return async.isShutdown();
}
@Override
public void close() {
async.shutdown();
List<Runnable> dropped = List.of();
try {
long drainMs = Math.min(1000L, Math.max(100L, config.timeoutMs()));
if (!async.awaitTermination(drainMs, TimeUnit.MILLISECONDS)) {
dropped = async.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
dropped = async.shutdownNow();
}
if (!dropped.isEmpty()) {
droppedSyncCount.addAndGet(dropped.size());
log.warn("[Mem0] provider closed with {} queued sync turn(s) discarded", dropped.size());
}
}, async);
}
@Override

View File

@ -43,12 +43,6 @@
"required": false,
"secret": false,
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
},
"syncQueueCapacity": {
"type": "integer",
"required": false,
"secret": false,
"description": "Maximum pending asynchronous sync turns. New writes are dropped when full. Default 256."
}
}
}

View File

@ -35,10 +35,4 @@ class Mem0ConfigTest {
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
}
@Test
void legacyConstructorUsesBoundedQueueDefault() {
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
assertThat(c.syncQueueCapacity()).isEqualTo(Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY);
}
}

View File

@ -90,7 +90,6 @@ class Mem0PluginTest {
PluginContext ctx = new StubContext(config, registered) {
@Override
public void registerMemoryProvider(PluginMemoryProvider provider) {
registered.set(provider);
throw new PluginException("Only one external memory provider allowed");
}
};
@ -99,7 +98,6 @@ class Mem0PluginTest {
assertThatThrownBy(() -> plugin.onLoad(ctx))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Only one");
assertThat(((Mem0Provider) registered.get()).isClosed()).isTrue();
}
/**

View File

@ -14,11 +14,8 @@ import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Mem0ProviderTest {
@ -46,7 +43,6 @@ class Mem0ProviderTest {
@AfterEach
void tearDown() {
if (provider != null) provider.close();
if (server != null) server.stop(0);
}
@ -133,15 +129,16 @@ class Mem0ProviderTest {
}
@Test
void threeArgPrefetch_propagatesServerErrorToPlatformCircuitBreaker() {
void threeArgPrefetch_returnsEmptyOnServerError() {
// Replace handler to fail; the provider should swallow and return "".
server.removeContext("/");
server.createContext("/", ex -> {
ex.sendResponseHeaders(500, 0);
ex.close();
});
assertThatThrownBy(() -> provider.prefetch(1L, "q", "user:42"))
.isInstanceOf(Mem0Exception.class);
String result = provider.prefetch(1L, "q", "user:42");
assertThat(result).isEmpty();
}
@Test
@ -218,42 +215,5 @@ class Mem0ProviderTest {
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
assertThat(searchCount.get()).isZero();
p.close();
}
@Test
void syncQueueIsBoundedAndCloseReleasesExecutor() throws Exception {
CountDownLatch firstStarted = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
AtomicInteger writes = new AtomicInteger();
Mem0Config cfg = new Mem0Config("http://localhost:8080", null,
false, true, 3, 3000, 1);
Mem0Client blockingClient = new Mem0Client(cfg) {
@Override
void addMemories(String userId, String agentId, String conversationId,
String userMessage, String assistantReply) {
writes.incrementAndGet();
firstStarted.countDown();
try {
releaseFirst.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
Mem0Provider bounded = new Mem0Provider(cfg, blockingClient, LoggerFactory.getLogger("test"));
try {
bounded.syncTurn(1L, "one", "u", "a", "user:1");
assertThat(firstStarted.await(1, TimeUnit.SECONDS)).isTrue();
bounded.syncTurn(1L, "two", "u", "a", "user:1");
bounded.syncTurn(1L, "three", "u", "a", "user:1");
assertThat(bounded.queuedSyncCount()).isEqualTo(1);
assertThat(bounded.droppedSyncCount()).isEqualTo(1);
} finally {
releaseFirst.countDown();
bounded.close();
}
assertThat(bounded.isClosed()).isTrue();
}
}

View File

@ -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.62.0-noble
FROM mcr.microsoft.com/playwright:v1.59.0-noble
WORKDIR /app
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).

View File

@ -60,9 +60,6 @@ public class AcpEndpointEntity {
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
private Long stdioBufferLimitBytes;
/** Max wait for session/prompt, in seconds. Defaults to 300, capped at 3600. */
private Integer promptTimeoutSeconds;
/** UNKNOWN / OK / ERROR — last test result. */
private String lastStatus;

View File

@ -11,6 +11,7 @@ import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.exception.MateClawException;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@ -46,6 +47,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class AcpDelegationService {
/** Hard ceiling on a single ACP delegation. Long enough for a
* multi-turn coding session, short enough that a hung agent can't
* permanently block an LLM tool call. */
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
@ -83,7 +89,6 @@ public class AcpDelegationService {
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
long promptTimeoutMillis = resolvePromptTimeoutMillis(endpoint);
// Always resolve cwd to a real directory: Zed's ACP Zod schema
// marks cwd as a required string and rejects {@code undefined}
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
@ -119,7 +124,7 @@ public class AcpDelegationService {
ObjectNode promptParams = objectMapper.createObjectNode();
promptParams.put("sessionId", sessionId);
promptParams.set("prompt", buildPromptArray(userPrompt));
autoClose.sendRequest("session/prompt", promptParams, promptTimeoutMillis);
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
@ -139,12 +144,6 @@ public class AcpDelegationService {
return accumulator.toString().trim();
}
static long resolvePromptTimeoutMillis(AcpEndpointEntity endpoint) {
int seconds = AcpEndpointService.normalizePromptTimeoutSeconds(
endpoint != null ? endpoint.getPromptTimeoutSeconds() : null);
return seconds * 1000L;
}
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
boolean trusted, String endpointName) {
// Notifications carry session/update messages; agent_message_chunk

View File

@ -36,9 +36,6 @@ import java.util.Map;
@RequiredArgsConstructor
public class AcpEndpointService {
public static final int DEFAULT_PROMPT_TIMEOUT_SECONDS = 300;
public static final int MAX_PROMPT_TIMEOUT_SECONDS = 3600;
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
@ -94,7 +91,6 @@ public class AcpEndpointService {
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
}
input.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(input.getPromptTimeoutSeconds()));
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
@ -122,9 +118,6 @@ public class AcpEndpointService {
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
if (patch.getPromptTimeoutSeconds() != null) {
existing.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(patch.getPromptTimeoutSeconds()));
}
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
@ -187,13 +180,6 @@ public class AcpEndpointService {
}
}
public static int normalizePromptTimeoutSeconds(Integer seconds) {
if (seconds == null || seconds <= 0) {
return DEFAULT_PROMPT_TIMEOUT_SECONDS;
}
return Math.min(seconds, MAX_PROMPT_TIMEOUT_SECONDS);
}
private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {

View File

@ -12,13 +12,11 @@ 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;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.graph.edge.ObservationDispatcher;
import vip.mate.agent.graph.edge.ReasoningDispatcher;
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
@ -33,13 +31,9 @@ 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;
@ -104,13 +98,6 @@ public class AgentGraphBuilder {
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled;
private ExecutionEvidenceRecorder executionEvidenceRecorder;
@Autowired
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
@org.springframework.beans.factory.annotation.Value(
"${mate.agent.markdown-normalize-enabled:true}")
@ -182,24 +169,11 @@ public class AgentGraphBuilder {
*/
private vip.mate.audit.service.AuditEventService auditEventService;
@Autowired(required = false)
@org.springframework.beans.factory.annotation.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
@ -412,7 +386,7 @@ public class AgentGraphBuilder {
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider);
} else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider)
&& Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(providerKwargs, "enableSearch"))) {
&& Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) {
builtinSearchEnabled = true;
}
if (builtinSearchEnabled) {
@ -464,11 +438,15 @@ public class AgentGraphBuilder {
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
entity, boundTools, effectiveMaxInputTokens);
// 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.
// 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());
Set<String> autoDemotedTools = Set.of();
if (!isPlanExecute) {
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
@ -478,6 +456,7 @@ public class AgentGraphBuilder {
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
}
// 当前仅支持 DashScope OpenAI-compatible其他协议直接拒绝
if (!supportsStateGraph(protocol)) {
@ -488,8 +467,7 @@ public class AgentGraphBuilder {
BaseAgent agent;
boolean toolCallingEnabled;
if ("plan_execute".equals(entity.getAgentType())) {
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(),
skillCatalogRenderer, autoDemotedTools);
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
toolCallingEnabled = true;
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
@ -593,12 +571,8 @@ public class AgentGraphBuilder {
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
return 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) {
@ -613,19 +587,11 @@ 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, autoDemotedTools);
runtimeModel, agentId, skillCatalogRenderer);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager, toolSet);
}
@ -649,14 +615,6 @@ 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(
@ -671,8 +629,6 @@ public class AgentGraphBuilder {
primaryModelConfig.getProvider(),
primaryModelConfig.getModelName(), errorMessage));
}
streamingHelper.setStreamIdleTimeoutSec(
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@ -683,7 +639,6 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -693,13 +648,7 @@ 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);
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);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
// Per-step delegation: route a step assigned to a specialist agent
// through DelegateAgentTool (null when delegation deps aren't wired).
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
@ -734,7 +683,6 @@ 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)
@ -781,10 +729,6 @@ 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
@ -947,13 +891,6 @@ 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);
}
@ -995,8 +932,6 @@ public class AgentGraphBuilder {
primaryModelConfig.getProvider(),
primaryModelConfig.getModelName(), errorMessage));
}
streamingHelper.setStreamIdleTimeoutSec(
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@ -1007,7 +942,6 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -1079,7 +1013,6 @@ public class AgentGraphBuilder {
// Summarizing
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
// 终止控制
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
@ -1136,10 +1069,6 @@ 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
@ -1192,8 +1121,7 @@ public class AgentGraphBuilder {
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
Map.of(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))
@ -1754,7 +1682,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. 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.
- 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.
- 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.
@ -1788,7 +1716,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 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.
- **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.
- **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.

View File

@ -27,9 +27,8 @@ import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List;
import java.util.Locale;
import java.time.Duration;
import java.util.Map;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
@ -71,9 +70,6 @@ public class AgentService {
@Autowired(required = false)
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
@Autowired
private vip.mate.agent.runtime.ConversationTurnGate turnGate = new vip.mate.agent.runtime.ConversationTurnGate();
/**
* Optional clears leftover auto-recorded ledger entries when a new
* user turn starts. Field-injected so existing test constructors of
@ -82,13 +78,6 @@ public class AgentService {
@Autowired(required = false)
private ProgressLedgerService progressLedgerService;
/** Runtime SPI coordinator. Native agents remain the default. */
@Autowired(required = false)
private vip.mate.agent.runtime.contract.AgentRuntimeCoordinator runtimeCoordinator;
@Autowired(required = false)
private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
@ -143,16 +132,6 @@ public class AgentService {
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}
if (!StringUtils.hasText(agent.getRuntimeType())) {
agent.setRuntimeType("native");
} else {
agent.setRuntimeType(agent.getRuntimeType().trim().toLowerCase(Locale.ROOT));
}
if (!"native".equals(agent.getRuntimeType()) && !"dsh".equals(agent.getRuntimeType())) {
throw new MateClawException("err.agent.runtime_unsupported", 400,
"Unsupported runtime provider: " + agent.getRuntimeType());
}
validateDshConfiguration(agent);
requireUniqueName(agent, null);
agentMapper.insert(agent);
publishLifecycle(agent, "spawned");
@ -177,9 +156,6 @@ public class AgentService {
}
requireUniqueName(agent, agent.getId());
}
if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) {
validateDshConfiguration(agent);
}
agentMapper.updateById(agent);
agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null
@ -260,13 +236,15 @@ public class AgentService {
}
/**
* 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.
* 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.
*/
@org.springframework.context.event.EventListener
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
if (event.agentId() != null && event.affectsSystemPrompt()) {
if (event.agentId() != null) {
agentInstances.remove(event.agentId());
}
}
@ -299,8 +277,6 @@ public class AgentService {
* work already done before the pause.
*/
private void clearAutoRecordedForNewTurn(String conversationId) {
// Autonomous segments resume the same objective; retain authoritative tool progress.
if (vip.mate.agent.context.GoalContinuationContext.active()) return;
if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
return;
}
@ -324,11 +300,7 @@ public class AgentService {
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
if (isDshAgent(agentId)) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
}
trackMemoryRecalls(agentId, message, origin);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
@ -365,13 +337,7 @@ public class AgentService {
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
if (isDshAgent(agentId)) {
return chatStructuredStream(agentId, message, conversationId, "", null,
origin != null ? origin : ChatOrigin.EMPTY)
.filter(delta -> delta.content() != null)
.map(StreamDelta::content);
}
trackMemoryRecalls(agentId, message, origin);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
@ -408,20 +374,7 @@ public class AgentService {
String requesterId, String thinkingLevel,
ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
trackMemoryRecalls(agentId, message, origin);
if (isDshAgent(agentId)) {
AgentEntity dshAgent = getAgent(agentId);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> Flux.using(
() -> runtimeCoordinator.start(dshAgent, convId, convId,
dshAgent.getModelName(), dshWorkingDirectory(dshAgent),
dshWorkingDirectory(dshAgent)),
connection -> vip.mate.agent.runtime.RuntimeEventStreamAdapter.adapt(
connection.prompt(msg)),
connection -> connection.close()),
StreamDelta::content)
.doFinally(signal -> ThinkingLevelHolder.clear());
}
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
@ -468,7 +421,7 @@ public class AgentService {
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
trackMemoryRecalls(agentId, goal, origin);
memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
@ -495,7 +448,7 @@ public class AgentService {
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
trackMemoryRecalls(agentId, userMessage, origin);
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
@ -523,7 +476,23 @@ public class AgentService {
* {@code _usage_final} event for token and model attribution.
*/
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
return ChatResultCollector.collect(stream);
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
}
/**
@ -543,7 +512,7 @@ public class AgentService {
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId,
ChatOrigin origin) {
trackMemoryRecalls(agentId, userMessage, origin);
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> {
@ -651,13 +620,6 @@ public class AgentService {
*/
private String withLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
try (var permit = acquireTurn(conversationId)) {
return invokeWithLifecycleSync(agentId,message,conversationId,invoke);
}
}
private String invokeWithLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
@ -686,26 +648,11 @@ public class AgentService {
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
return Flux.using(() -> acquireTurn(conversationId),
permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor),
vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
}
private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) {
var permit = turnGate.tryAcquire(conversationId);
if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running");
return permit;
}
private <T> Flux<T> invokeWithLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active();
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId)
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
.doFinally(s -> safeUnregister(conversationId));
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
@ -721,11 +668,11 @@ public class AgentService {
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
.doFinally(s -> safeUnregister(conversationId));
} catch (Exception e) {
// If invoke.apply() throws before the Flux is constructed, the
// doFinally above never runs clean up here.
safeUnregister(conversationId, goalContinuation);
safeUnregister(conversationId);
throw e;
}
}
@ -739,48 +686,9 @@ public class AgentService {
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
private void safeUnregister(String conversationId) {
safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active());
}
private void safeUnregister(String conversationId, boolean goalContinuation) {
if (runningConversationRegistry != null) {
runningConversationRegistry.unregister(conversationId);
}
if (events != null && !goalContinuation) events.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished(conversationId));
}
private boolean isDshAgent(Long agentId) {
if (runtimeCoordinator == null || agentId == null) return false;
AgentEntity entity = getAgent(agentId);
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
}
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
: null;
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
}
private void validateDshConfiguration(AgentEntity agent) {
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
if (dshRuntimeService == null) {
throw new MateClawException("err.agent.runtime_unavailable", 503,
"DSH runtime provider is unavailable");
}
try {
dshRuntimeService.validateAgentConfiguration(agent);
} catch (IllegalArgumentException error) {
throw new MateClawException("err.agent.runtime_invalid", 400, error.getMessage());
}
}
private Path dshWorkingDirectory(AgentEntity agent) {
String configured = System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"));
if (agent.getWorkspaceBasePath() != null && !agent.getWorkspaceBasePath().isBlank()) {
configured = agent.getWorkspaceBasePath().trim();
}
return Path.of(configured).toAbsolutePath().normalize();
}
/**
@ -856,42 +764,22 @@ public class AgentService {
// ==================== StreamDelta ====================
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
boolean persistenceOnly, boolean segmentOnly, ContentKind kind) {
boolean persistenceOnly, boolean segmentOnly) {
// 兼容构造器广播+持久化
public StreamDelta(String content, String thinking) {
this(content, thinking, null, null, false, false, null);
this(content, thinking, null, null, false, false);
}
// 显式 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, 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);
this(content, thinking, eventType, eventData, persistenceOnly, false);
}
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
public static StreamDelta persistOnly(String content, String thinking) {
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);
return new StreamDelta(content, thinking, null, null, true, false);
}
/**
@ -915,20 +803,15 @@ 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, 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);
return new StreamDelta(content, thinking, null, null, true, true);
}
public static StreamDelta empty() {
return new StreamDelta(null, null, null, null, false, false, null);
return new StreamDelta(null, null, null, null, false, false);
}
public static StreamDelta event(String type, Map<String, Object> data) {
return new StreamDelta(null, null, type, data, false, false, null);
return new StreamDelta(null, null, type, data, false, false);
}
public boolean isEvent() {
@ -960,15 +843,10 @@ public class AgentService {
* post-approval replays).
*/
public record ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider, String finishReason) {
public ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider) {
this(content, promptTokens, completionTokens, runtimeModel, runtimeProvider, null);
}
public static ChatResult contentOnly(String content) {
return new ChatResult(content != null ? content : "", 0, 0, null, null, null);
return new ChatResult(content != null ? content : "", 0, 0, null, null);
}
}
}

View File

@ -179,14 +179,6 @@ 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 列表
*/

View File

@ -13,7 +13,6 @@ import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.MediaCaptionService;
@ -22,7 +21,6 @@ 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;
@ -813,15 +811,8 @@ public abstract class BaseAgent {
if (msg == null) return List.of();
String metadata = msg.getMetadata();
if (metadata == null || metadata.isEmpty()) return List.of();
// 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 (!metadata.contains("\"directToolNames\"")) return List.of();
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
if (!arrayMatcher.find()) return List.of();
String inner = arrayMatcher.group(1);
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
@ -896,13 +887,7 @@ public abstract class BaseAgent {
}
return switch (message.getRole()) {
case "assistant" -> new AssistantMessage(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);
case "system" -> 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
@ -1265,12 +1250,6 @@ public abstract class BaseAgent {
* the primary model can't already handle.
*/
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
// Autonomous segments have no new persisted user row. Reconstructing
// from the last user would replace the continuation/recovery instruction.
// History is still loaded normally; queued user turns retain attachment routing.
if (GoalContinuationContext.explicitPrompt()) {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
// Scheduled-job run (issue #142): the task text is the explicit
// userMessageText argument. Never reconstruct it from the conversation
// a shared cron conversation under concurrent runs has no reliable

View File

@ -1,39 +0,0 @@
package vip.mate.agent;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.Map;
/** Collapses a structured agent stream without discarding terminal metadata. */
final class ChatResultCollector {
private ChatResultCollector() {
}
static AgentService.ChatResult collect(Flux<AgentService.StreamDelta> stream) {
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
final String[] finishReason = {null};
stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData() != null ? delta.eventData() : Map.of();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.isEvent() && "finish_reason".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
Object reason = data != null ? data.get("reason") : null;
if (reason != null) finishReason[0] = reason.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new AgentService.ChatResult(content.toString(), usage[0], usage[1],
modelInfo[0], modelInfo[1], finishReason[0]);
}
}

View File

@ -1,42 +0,0 @@
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);
}
}

View File

@ -1,92 +0,0 @@
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());
}
}
}

View File

@ -254,11 +254,7 @@ public class AgentBindingService implements AgentBindingResolver {
* silently undoes a user's explicit skill picks.
*/
public Set<Long> skillIdsBoundToEnabledAgents() {
return skillIdsBoundToEnabledAgents(null);
}
public Set<Long> skillIdsBoundToEnabledAgents(Long workspaceId) {
Set<Long> enabledAgentIds = enabledAgentIds(workspaceId);
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return Set.of();
}
@ -277,11 +273,7 @@ public class AgentBindingService implements AgentBindingResolver {
* archival candidates regardless of bindings.
*/
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
return blockedByBindingCandidates(now, null);
}
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now, Long workspaceId) {
Set<Long> enabledAgentIds = enabledAgentIds(workspaceId);
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return List.of();
}
@ -298,9 +290,6 @@ 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;
}
@ -345,17 +334,9 @@ public class AgentBindingService implements AgentBindingResolver {
/** Ids of every currently-enabled agent. */
private Set<Long> enabledAgentIds() {
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)
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getEnabled, true)
.select(AgentEntity::getId))
.stream()
.map(AgentEntity::getId)
.collect(Collectors.toSet());
@ -691,12 +672,6 @@ 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",
@ -730,7 +705,6 @@ public class AgentBindingService implements AgentBindingResolver {
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
"waitForGoalInput",
// Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting
// tasks need it on every business agent, not just the planner,
@ -769,7 +743,6 @@ public class AgentBindingService implements AgentBindingResolver {
"read_file",
"send_file",
"write_file",
"append_file",
"edit_file",
"execute_shell_command",
// Inline code execution an agent-wide capability alongside shell.

View File

@ -5,7 +5,6 @@ import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable;
import java.util.Map;
import java.util.Objects;
/**
* Immutable value object that travels alongside an agent invocation describing
@ -76,42 +75,15 @@ 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 originMessageId,
@Nullable ExecutionAttribution executionAttribution
@Nullable Long requesterUserId
) {
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, @Nullable Long originMessageId) {
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType,
chatId, baseUrl, requesterUserId, originMessageId, null);
}
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, null);
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
// ---------------- Factories per entry point ----------------
@ -145,7 +117,7 @@ public record ChatOrigin(
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
requesterUserId, null);
requesterUserId);
}
public static ChatOrigin cron(@Nullable String conversationId,
@ -154,8 +126,7 @@ 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, null);
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
}
// ---------------- Wither-style updates ----------------
@ -163,27 +134,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, originMessageId, executionAttribution);
senderName, channelType, chatId, baseUrl, requesterUserId);
}
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, originMessageId, executionAttribution);
senderName, channelType, chatId, baseUrl, requesterUserId);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, Objects.equals(conversationId, newConversationId) ? executionAttribution : null);
senderName, channelType, chatId, baseUrl, requesterUserId);
}
/** 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, originMessageId, executionAttribution);
senderName, channelType, chatId, newBaseUrl, requesterUserId);
}
/**
@ -197,26 +168,7 @@ public record ChatOrigin(
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
}
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId, executionAttribution);
}
public ChatOrigin withApprovalId(String pendingId) {
ExecutionAttribution attribution = executionAttribution == null
? new ExecutionAttribution(null, null, null, pendingId, null)
: executionAttribution.withApproval(pendingId);
return withExecutionAttribution(attribution);
}
public ChatOrigin withExecutionAttribution(ExecutionAttribution attribution) {
return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl,
requesterUserId, originMessageId, attribution);
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
}
// ---------------- Spring AI ToolContext interop ----------------

View File

@ -1410,17 +1410,6 @@ public class ConversationWindowManager {
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
String finishReason = response.getResult().getMetadata() != null
? response.getResult().getMetadata().getFinishReason() : null;
if ("length".equalsIgnoreCase(finishReason)) {
// A non-empty response can still be structurally incomplete
// when the provider exhausts max tokens. Persisting it would
// poison every later iterative summary.
log.warn("[ConversationWindow] LLM 摘要因 token 上限被截断,丢弃结果, conv={}",
conversationId);
setSummaryCooldown(conversationId);
return null;
}
String summary = response.getResult().getOutput().getText();
if (summary != null && !summary.isBlank()) {
// 成功保存摘要供下次迭代更新清除冷却

View File

@ -1,12 +0,0 @@
package vip.mate.agent.context;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/** Server-issued execution linkage. This record is never a tool argument or an HTTP request body. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExecutionAttribution(Long goalId, String goalAttemptId, Long cronRunId,
String approvalId, String ownerFence) {
public ExecutionAttribution withApproval(String pendingId) {
return new ExecutionAttribution(goalId, goalAttemptId, cronRunId, pendingId, ownerFence);
}
}

View File

@ -1,20 +0,0 @@
package vip.mate.agent.context;
import java.util.function.Supplier;
/** Subscription-time marker; callers capture it before asynchronous lifecycle callbacks. */
public final class GoalContinuationContext {
private static final ThreadLocal<Boolean> EXPLICIT_PROMPT = new ThreadLocal<>();
private GoalContinuationContext() {}
public static boolean active() { return EXPLICIT_PROMPT.get() != null; }
public static boolean explicitPrompt() { return Boolean.TRUE.equals(EXPLICIT_PROMPT.get()); }
public static <T> T call(Supplier<T> action) { return call(true, action); }
/** Queued user input keeps normal attachment reconstruction within the same worker. */
public static <T> T call(boolean explicitPrompt, Supplier<T> action) {
Boolean previous=EXPLICIT_PROMPT.get();
EXPLICIT_PROMPT.set(explicitPrompt);
try { return action.get(); }
finally { if(previous==null) EXPLICIT_PROMPT.remove(); else EXPLICIT_PROMPT.set(previous); }
}
}

View File

@ -87,8 +87,7 @@ public class PrefixBudgetPlanner {
(int) (injectionBudget * shares.getSkill() / sum),
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
(int) (injectionBudget * shares.getLedger() / sum),
Math.min((int) (effectiveMax * properties.getToolSchemaRatio()),
Math.max(1, properties.getToolSchemaMaxTokens())));
(int) (effectiveMax * properties.getToolSchemaRatio()));
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"

View File

@ -27,9 +27,6 @@ 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;
@ -50,7 +47,6 @@ 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;
@ -215,13 +211,12 @@ 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, origin)
agentService.chatStream(id, message, conversationId)
.doOnNext(chunk -> {
try {
emitter.send(SseEmitter.event().name("message").data(chunk));
@ -256,9 +251,7 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
request.getConversationId(), workspaceId);
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId(), origin));
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
}
@Operation(summary = "执行复杂任务Plan-Execute")
@ -271,21 +264,7 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent);
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());
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
}
@Operation(summary = "获取Agent运行状态")

View File

@ -14,25 +14,20 @@ 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;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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;
@ -96,26 +91,6 @@ 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);
}
@ -409,7 +384,6 @@ 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
@ -470,17 +444,6 @@ 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();
/**
@ -555,7 +518,6 @@ public class NodeStreamingChatHelper {
// ("credit balance is too low") use these phrases in 402-class responses.
// Chinese provider patterns (Zhipu 1113, DashScope, general) same hard
// failure semantics: retrying the same provider won't refill the balance.
String lowerMsg = msg.toLowerCase(Locale.ROOT);
if (msg.contains("402") || msg.contains("insufficient_quota")
|| msg.contains("credit balance is too low")
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
@ -564,13 +526,7 @@ public class NodeStreamingChatHelper {
|| msg.contains("余额不足") || msg.contains("请充值")
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|| msg.contains("AccountBalanceNotEnough")
|| msg.contains("balance not enough")
|| lowerMsg.contains("invalidsubscription")
|| lowerMsg.contains("subscription has expired")
|| lowerMsg.contains("arrearage")
|| lowerMsg.contains("account is in good standing")
|| lowerMsg.contains("insufficient_balance")
|| lowerMsg.contains("insufficient balance")) {
|| msg.contains("balance not enough")) {
return ErrorType.BILLING;
}
// RFC-009 P3.2: MODEL_NOT_FOUND provider rejects the requested model id.
@ -897,7 +853,6 @@ 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
@ -1118,7 +1073,6 @@ 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) {
@ -1129,8 +1083,6 @@ 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
@ -1148,7 +1100,7 @@ public class NodeStreamingChatHelper {
log.warn("[{}] Retry attempt {}/{} after {}ms (prev type={}) for conversation {}",
phase, attempt, MAX_RETRIES, delay, retryTypeRef.get(), conversationId);
// 广播给前端用户可见的重试倒计时
if (broadcast && !emptyResponse) {
if (broadcast) {
String cause = overloaded ? "模型服务繁忙" : "请求频率受限";
broadcastDelta(conversationId, "warning",
buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
@ -1230,63 +1182,9 @@ 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);
// 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
Disposable subscription = chatModel.stream(prompt)
.doOnNext(chatResponse -> {
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
return;
@ -1300,29 +1198,8 @@ public class NodeStreamingChatHelper {
return;
}
// 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> 内文本
// 1. 提取 content delta
String contentDelta = msg.getText();
if (contentDelta != null && !contentDelta.isEmpty()) {
// First content delta closes the thinking phase if one
// was open, and arms first-token heartbeat relaxation.
@ -1343,19 +1220,43 @@ public class NodeStreamingChatHelper {
}
}
// 4. 结构化 thinking delta. Do not cancel the stream for
// 2. 提取 thinking delta. Do not cancel the stream for
// repeated thinking phrases: some models emit repetitive
// internal planning while still making valid tool progress.
if (nativeThinking != null && !nativeThinking.isEmpty()) {
onThinkingDelta.accept(nativeThinking);
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);
}
}
// 5. 累积 tool calls处理分片
// 3. 累积 tool calls处理分片
if (msg.hasToolCalls()) {
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
}
// 6. Thinking-only no-progress guard. MUST run after both
// 4. Thinking-only no-progress guard. MUST run after both
// content delta and tool call accumulation, otherwise a
// chunk that carries thinking AND a tool_call together
// (some Anthropic / DeepSeek-thinking responses do this)
@ -1380,7 +1281,7 @@ public class NodeStreamingChatHelper {
return;
}
// 7. Content-repetition guard. Some reasoning-mode models
// 5. 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
@ -1410,7 +1311,7 @@ public class NodeStreamingChatHelper {
}
}
// 8. 提取 token usage通常最后一个 chunk 携带完整 usage
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
@ -1474,7 +1375,6 @@ 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(),
@ -1496,11 +1396,6 @@ 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();
@ -1601,10 +1496,7 @@ 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);
// 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();
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
}
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
@ -1930,12 +1822,6 @@ 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("{");
@ -2517,41 +2403,11 @@ public class NodeStreamingChatHelper {
acc.id,
acc.type != null ? acc.type : "function",
acc.name,
toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
}
return result;
}
/**
* Finalize a streamed tool call for local execution.
*
* <p>Blank arguments are a common zero-argument representation and remain
* normalized to an empty object. Invalid non-blank JSON, however, must be
* preserved until {@code ToolExecutionExecutor} sees it; replacing it with
* {@code {}} loses the distinction between a truncated stream and a real
* empty call and can execute the wrong operation. The outgoing-history
* normalization path still calls {@link #sanitizeToolCallArguments} before
* a later provider request.</p>
*/
private static String toolCallArgumentsForExecution(String toolName, String arguments) {
if (arguments == null || arguments.isBlank()) {
return "{}";
}
try {
TOOL_ARG_JSON_MAPPER.readTree(arguments);
return arguments;
} catch (Exception e) {
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
+ "(len={}, head={}); preserving the payload for safe executor rejection. "
+ "Parse error: {}",
toolName,
arguments.length(),
arguments.substring(0, Math.min(80, arguments.length())),
e.getMessage());
return arguments;
}
}
/**
* Ensure {@code function.arguments} is always a well-formed JSON string.
* <p>
@ -2599,19 +2455,6 @@ 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) {}
/**

View File

@ -13,7 +13,6 @@ 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;
@ -63,20 +62,6 @@ 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,
@ -222,7 +207,6 @@ 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);
@ -244,48 +228,6 @@ 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
@ -309,26 +251,24 @@ 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);
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
boolean longFormAccumulation = !output.state()
.value(LONG_FORM_DRAFT, "").isEmpty();
String resolvedFinalAnswer = isFinalAnswerTurn
? extractFinalAnswer(output) : "";
if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation,
streamed, resolvedFinalAnswer)) {
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
streamed));
}
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
}
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(answer, null)
: new AgentService.StreamDelta(answer, null));
}
}
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));
@ -426,9 +366,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// compareAndSet 保证只取第一次避免 content/thinking 被重复追加
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
// STREAMED_CONTENTSTREAMED_THINKING 也是 REPLACE用独立游标
// 跟踪已持久化的每轮 thinking避免后续节点的 NodeOutput 重复发送
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
// STREAMED_CONTENT REPLACE 策略每轮 ReasoningNode/SummarizingNode 覆写
// lastEmitted 跟踪已发送的值避免在 ActionNode/ObservationNode NodeOutput 上重复发送同一段内容
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
@ -465,31 +402,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean thinkingAlreadyStreamed = output.state()
.value(THINKING_STREAMED, false);
// 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
// 2a. 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
@ -509,27 +422,26 @@ 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);
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
boolean longFormAccumulation = !output.state()
.value(LONG_FORM_DRAFT, "").isEmpty();
String resolvedFinalAnswer = isFinalAnswerTurn
? extractFinalAnswer(output) : "";
if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation,
streamed, resolvedFinalAnswer)) {
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
streamed));
}
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
}
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(answer, null)
: new AgentService.StreamDelta(answer, null));
}
}
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));
@ -647,7 +559,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(TOOL_CALL_COUNT, 0);
inputs.put(ERROR_COUNT, 0);
inputs.put(SHOULD_SUMMARIZE, false);
inputs.put(LONG_FORM_DRAFT, "");
inputs.put(LIMIT_EXCEEDED, false);
inputs.put(CONTENT_STREAMED, false);
inputs.put(THINKING_STREAMED, false);
@ -730,74 +641,15 @@ 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.
*/
/**
* 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);
}
static boolean shouldEmitStreamedContent(boolean isFinalAnswerTurn,
boolean longFormAccumulation,
String streamed,
String finalAnswer) {
if (longFormAccumulation) {
return false;
}
return !isFinalAnswerTurn || finalAnswer == null || streamed == null
|| !finalAnswer.contains(streamed);
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
return isFinalAnswerTurn
? AgentService.StreamDelta.persistOnly(streamed, null)
: AgentService.StreamDelta.segmentOnly(streamed, null);
}
private boolean hasFinalAnswer(NodeOutput output) {

View File

@ -1,129 +0,0 @@
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;
}
}

View File

@ -41,11 +41,6 @@ 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 非空)

View File

@ -7,7 +7,6 @@ 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;
@ -15,7 +14,6 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
@ -24,7 +22,6 @@ import vip.mate.approval.grant.AutoApproveResult;
import vip.mate.approval.grant.WorkspaceLookupCache;
import vip.mate.approval.grant.service.ApprovalGrantResolver;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.tool.ToolInputValidationException;
import vip.mate.tool.guard.ToolExecutionGuardHelper;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
@ -86,7 +83,7 @@ public class ToolExecutionExecutor {
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
"browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"
"browser_use", "BrowserUseTool", "write_file", "edit_file"
);
/**
@ -467,32 +464,7 @@ public class ToolExecutionExecutor {
boolean isReplay, String requesterId,
String workspaceBasePath,
ChatOrigin origin) {
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
workspaceBasePath, origin, Set.of());
}
/**
* Preferred graph overload. {@code loadedSkills} is the conversation/run
* state captured before this batch, allowing the shared executor to reject
* both cross-iteration and same-batch duplicate {@code load_skill} calls
* before parallel execution starts.
*/
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
boolean isReplay, String requesterId,
String workspaceBasePath,
ChatOrigin origin,
Set<String> loadedSkills) {
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
if (!isReplay && safeOrigin.executionAttribution() != null) {
safeOrigin = safeOrigin.withApprovalId(null);
}
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
safeOrigin = safeOrigin.withConversationId(conversationId);
}
if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) {
safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), workspaceBasePath);
}
// Reset per-turn audit dedupe state. A retried denied tool inside the
// same turn writes a single audit row; the set is repopulated by the
// denial branch below.
@ -538,42 +510,9 @@ public class ToolExecutionExecutor {
// Phase 1: 顺序 Guard + 分段
List<PreparedToolCall> preparedCalls = new ArrayList<>();
ApprovalBarrier barrier = null;
Set<String> seenSkillLoads = new LinkedHashSet<>();
if (loadedSkills != null) {
loadedSkills.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(name -> !name.isEmpty())
.map(name -> name.toLowerCase(Locale.ROOT))
.forEach(seenSkillLoads::add);
}
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.
@ -606,7 +545,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(), responseName, msg));
toolCall.id(), toolName, msg));
continue;
}
}
@ -618,30 +557,10 @@ public class ToolExecutionExecutor {
} catch (Exception jsonEx) {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage());
String truncationError = incompleteToolArgumentsError(toolName);
String truncationError = normalizeToolExecutionError(jsonEx);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, truncationError));
continue;
}
}
// load_skill is retrieval-only and concurrency-safe, so identical
// calls in one model response would otherwise race through the
// parallel phase and read/record the same skill twice. Keep this in
// the shared executor so both ActionNode and plan execution receive
// identical protection while preserving one response per call id.
if ("load_skill".equals(toolName)) {
String requestedSkill = requestedSkillName(arguments);
if (requestedSkill != null
&& !seenSkillLoads.add(requestedSkill.toLowerCase(Locale.ROOT))) {
String message = "Skill '" + requestedSkill + "' was already loaded earlier in this run. "
+ "Reuse the SKILL.md content already present in the conversation; "
+ "do not call load_skill for this skill again.";
log.debug("[ToolExecutor] Skipping duplicate load_skill({})", requestedSkill);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, message, true));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, message));
toolCall.id(), toolName, truncationError));
continue;
}
}
@ -653,13 +572,13 @@ public class ToolExecutionExecutor {
if (decision.blocked) {
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, decision.response));
toolCall.id(), toolName, decision.response));
continue;
}
if (decision.needsApproval) {
// Barrier: 当前工具创建审批后续工具不执行
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, decision.response));
toolCall.id(), toolName, decision.response));
// 标记后续工具为等待审批
for (int j = i + 1; j < effectiveCalls.size(); j++) {
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
@ -678,7 +597,7 @@ public class ToolExecutionExecutor {
if (toolName.startsWith("$")) {
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, "Provider builtin tool executed server-side"));
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
continue;
}
ToolCallback callback = toolCallbackMap.get(toolName);
@ -691,21 +610,21 @@ public class ToolExecutionExecutor {
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), toolName, redirect.response(), true));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, redirect.response()));
toolCall.id(), toolName, 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(), responseName, msg));
toolCall.id(), toolName, msg));
continue;
}
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, UUID.randomUUID().toString(), rawEvidenceRef));
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -734,34 +653,6 @@ public class ToolExecutionExecutor {
rawEvidenceRef.get());
}
private static String incompleteToolArgumentsError(String toolName) {
var error = OBJECT_MAPPER.createObjectNode();
error.put("error", true);
error.put("code", "TOOL_ARGUMENTS_INCOMPLETE");
error.put("recoverable", true);
error.put("toolName", toolName == null ? "" : toolName);
error.put("message", "Tool arguments were incomplete or invalid JSON; the tool was not executed.");
error.put("hint", "Retry with a smaller payload. For file updates, prefer edit_file or append_file instead of rewriting the whole file.");
return error.toString();
}
private static String requestedSkillName(String arguments) {
if (arguments == null || arguments.isBlank()) {
return null;
}
try {
var node = OBJECT_MAPPER.readTree(arguments);
var value = node.get("skillName");
if (value == null || value.isNull() || value.asText().isBlank()) {
value = node.get("name");
}
return value == null || value.isNull() || value.asText().isBlank()
? null : value.asText().trim();
} catch (Exception ignored) {
return null;
}
}
/**
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
* after a user approves a previously-blocked invocation).
@ -795,15 +686,6 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs) {
return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath,
directOutputs, ChatOrigin.EMPTY);
}
public ToolResponseMessage.ToolResponse executePreApproved(
AssistantMessage.ToolCall toolCall, String storedArguments,
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
String toolName = resolveToolName(toolCall.name());
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
@ -827,24 +709,17 @@ 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).
// Origin is method-local (see thread-safety note on execute());
// the legacy ThreadLocal that used to carry it across executePreApproved
// calls was a cross-conversation footgun and has been removed.
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
.withConversationId(conversationId);
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
UUID.randomUUID().toString(), toolCall.id());
throwIfStopRequested(conversationId);
ChatOrigin replayOrigin = ChatOrigin.EMPTY
.withConversationId(conversationId)
.withWorkspace(null, workspaceBasePath);
String result = callback.call(callArguments, replayOrigin.toToolContext());
int rawLen = result != null ? result.length() : 0;
// RFC-052: pre-approved tool may itself be returnDirect in that
@ -861,13 +736,6 @@ public class ToolExecutionExecutor {
}
events.add(GraphEventPublisher.toolDirectResult(
toolCall.id(), toolName, fullResult));
// A direct result replaces the tool card's body, but the
// started event still needs a terminal pair so live clients
// do not leave the card spinning forever. The placeholder is
// deliberately used here: the full result remains confined to
// tool_direct_result / DIRECT_TOOL_OUTPUTS.
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
@ -886,23 +754,13 @@ 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 validationError = safeInputValidationMessage(e);
String safeError = validationError != null
? validationError
: isReturnDirect(callback)
String safeError = isReturnDirect(callback)
? "Tool execution failed (details withheld per returnDirect policy)"
: "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();
}
}
}
@ -943,7 +801,6 @@ 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);
@ -1011,31 +868,18 @@ 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.responseName : "unknown";
String toolName = pc != null ? pc.toolCall.name() : "unknown";
String toolId = pc != null ? pc.toolCall.id() : "";
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
@ -1051,12 +895,7 @@ 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,
@ -1078,7 +917,7 @@ public class ToolExecutionExecutor {
runtimeOrigin = runtimeOrigin
.withConversationId(pc.conversationId)
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
ToolContext toolContext = runtimeOrigin.toToolContext();
// MCP progress: generate progressToken and inject into ToolContext
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
@ -1091,8 +930,7 @@ public class ToolExecutionExecutor {
toolContext = new ToolContext(ctxMap);
}
result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id());
throwIfStopRequested(pc.conversationId);
result = pc.callback.call(pc.arguments, toolContext);
} finally {
if (progressToken != null) {
progressContext.remove(progressToken);
@ -1123,20 +961,14 @@ public class ToolExecutionExecutor {
if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId,
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
streamTracker.broadcastObject(pc.conversationId,
GraphEventPublisher.EVENT_TOOL_COMPLETE,
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName,
DIRECT_TOOL_PLACEHOLDER, true).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
events.add(GraphEventPublisher.toolComplete(
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
// Placeholder keeps the tool_call_id tool_response pairing valid
// for OpenAI-compatible providers, while withholding the data from
// 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(), pc.responseName, DIRECT_TOOL_PLACEHOLDER);
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
// Capture SourceEvidenceLedger from the RAW result, before truncate/
@ -1176,13 +1008,7 @@ 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(), pc.responseName,
withProductCardDirective(toolName, result != null ? result : ""));
} catch (CancellationException e) {
if (streamTracker != null) {
streamTracker.updateRunningTool(pc.conversationId, null);
}
throw e;
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
} catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
// RFC-052: for returnDirect tools, even the error message is
@ -1190,10 +1016,7 @@ public class ToolExecutionExecutor {
// or other sensitive substrings that should not enter LLM context.
// Emit a generic placeholder instead. Full error still goes to logs
// for operator diagnosis.
String validationError = safeInputValidationMessage(e);
String reportedError = validationError != null
? validationError
: isReturnDirect(pc.callback)
String reportedError = isReturnDirect(pc.callback)
? "Tool execution failed (details withheld per returnDirect policy)"
: normalizeToolExecutionError(e);
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
@ -1203,21 +1026,7 @@ public class ToolExecutionExecutor {
streamTracker.updateRunningTool(pc.conversationId, null);
}
return new ToolResponseMessage.ToolResponse(
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");
pc.toolCall.id(), toolName, reportedError);
}
}
@ -1328,10 +1137,6 @@ public class ToolExecutionExecutor {
return GuardDecision.allowed();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
/**
* Deny an approval-required tool when the run is non-interactive (no human can
* approve), returning an actionable message so the agent falls back to a
@ -1421,17 +1226,6 @@ public class ToolExecutionExecutor {
return "Tool execution failed: " + message;
}
private String safeInputValidationMessage(Throwable error) {
Throwable current = error;
while (current != null) {
if (current instanceof ToolInputValidationException validation) {
return "Tool input validation failed: " + validation.getMessage();
}
current = current.getCause();
}
return null;
}
/**
* Issue #46 when a tool callback miss happens, check whether the
* unrecognized name actually matches an active skill. If it does, return
@ -1668,7 +1462,7 @@ public class ToolExecutionExecutor {
+ "\",\"filePath\":\"SKILL.md\"}";
String skillMd;
try {
ToolContext ctx = toolContextWithScopedCatalog(origin != null ? origin : ChatOrigin.EMPTY);
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
skillMd = readSkillFile.call(redirectArgs, ctx);
} catch (Exception e) {
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
@ -1693,116 +1487,10 @@ 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 ExecutionEvidenceRecorder executionEvidenceRecorder;
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
private String invokeObserved(ToolCallback callback, String arguments, ToolContext context,
String invocationKey, String providerCallId) {
return executionEvidenceRecorder == null ? callback.call(arguments, context)
: executionEvidenceRecorder.invoke(callback, arguments, context, invocationKey, providerCallId);
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(
AssistantMessage.ToolCall toolCall,
String responseName,
ToolCallback callback,
String arguments,
boolean concurrencySafe,
@ -1811,7 +1499,6 @@ public class ToolExecutionExecutor {
String requesterId,
String workspaceBasePath,
ChatOrigin origin,
String invocationKey,
/**
* Shared reference (one per execute() invocation) where each
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
@ -1833,16 +1520,6 @@ 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 {

View File

@ -1,23 +0,0 @@
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;
}
}

View File

@ -12,7 +12,6 @@ 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;
@ -52,10 +51,6 @@ 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";
@ -75,7 +70,7 @@ public class ActionNode implements NodeAction {
* </ul>
*/
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
"listAvailableSkills", "readSkillFile", "runSkillScript",
// read-only / status-query tools
"read_file", "web_search",
@ -144,8 +139,7 @@ public class ActionNode implements NodeAction {
// 委托 ToolExecutionExecutor 执行两阶段顺序 Guard + 分段并发执行
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin,
accessor.loadedSkills());
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses())
@ -154,14 +148,12 @@ 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))
.actionExecutionLedger(accessor.actionExecutionLedger().merge(actionLedger));
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
if (result.awaitingApproval()) {
output.awaitingApproval(true);
@ -199,10 +191,6 @@ 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
@ -218,7 +206,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(), actionLedger);
autoRecordToolCalls(conversationId, result.responses());
return output.build();
}
@ -272,41 +260,6 @@ 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 ====================
/**
@ -321,12 +274,6 @@ 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;
@ -335,12 +282,6 @@ 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;
@ -407,7 +348,7 @@ public class ActionNode implements NodeAction {
return names;
}
public static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}

View File

@ -139,12 +139,7 @@ public class FinalAnswerNode implements NodeAction {
} else if (!existingAnswer.isEmpty()) {
// 来自 reasoning 直接回答 stopped partial
finalAnswer = existingAnswer;
// 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;
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
// 尊重上游已设的 finishReason STOPPED只有未设时才默认 NORMAL
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",

View File

@ -12,16 +12,12 @@ import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.service.GoalEvaluationService;
import vip.mate.goal.service.GoalFollowupService;
import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GraphFlavor;
import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -188,12 +184,6 @@ public class GoalEvaluationNode implements NodeAction {
.build();
}
if (Boolean.TRUE.equals(refreshed.getPersistentExecution())
&& refreshed.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) {
return MateClawStateAccessor.output().goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(refreshed.getId(), "goal_no_longer_active"))).build();
}
// Decision branches. Each terminal write is wrapped so a DB hiccup
// (e.g. optimistic-lock conflict exceeding retries, memory sync
// failure on completion) does not propagate into the chat graph
@ -209,7 +199,7 @@ public class GoalEvaluationNode implements NodeAction {
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(completed.getId()),
"score", result.score(),
"goal", stateSafeGoal(goalService.toResponse(completed))))))
"goal", goalService.toResponse(completed)))))
.build();
}
@ -226,7 +216,7 @@ public class GoalEvaluationNode implements NodeAction {
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
"reason", reason,
"goal", stateSafeGoal(goalService.toResponse(exhausted))))))
"goal", goalService.toResponse(exhausted)))))
.build();
}
} catch (Throwable t) {
@ -239,21 +229,6 @@ public class GoalEvaluationNode implements NodeAction {
.build();
}
// A persistent goal yields a finite segment. Its durable supervisor owns
// the next turn, cooldown and recovery; never consume graph recursion here.
if (Boolean.TRUE.equals(refreshed.getPersistentExecution())) {
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_evaluated", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score(),
"decision", result.decision(),
"gap", result.gap() == null ? "" : result.gap(),
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
.build();
}
int followupCountThisRun = accessor.goalFollowupCount();
int hardContinuationCount = accessor.goalHardContinuationCount();
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
@ -314,7 +289,7 @@ public class GoalEvaluationNode implements NodeAction {
.events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"prompt", followup.get(),
"goal", stateSafeGoal(goalService.toResponse(refreshed))))));
"goal", goalService.toResponse(refreshed)))));
if (flavor == GraphFlavor.REACT) {
// ReAct: append the followup as a fresh user message via the
@ -368,72 +343,10 @@ public class GoalEvaluationNode implements NodeAction {
"goalId", String.valueOf(refreshed.getId()),
"score", result.score(),
"gap", result.gap() == null ? "" : result.gap(),
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
"goal", goalService.toResponse(refreshed)))))
.build();
}
/**
* Graph state may be checkpointed and restored through a generic map
* serializer. Keep event payloads limited to JSON primitives, maps and
* lists so a restored checklist cannot contain raw maps inside a typed
* {@link GoalResponse} bean and fail during SSE serialization.
*/
private static Map<String, Object> stateSafeGoal(GoalResponse goal) {
if (goal == null) {
return Map.of();
}
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", stringId(goal.getId()));
snapshot.put("conversationId", goal.getConversationId());
snapshot.put("agentId", stringId(goal.getAgentId()));
snapshot.put("workspaceId", stringId(goal.getWorkspaceId()));
snapshot.put("createdBy", goal.getCreatedBy());
snapshot.put("title", goal.getTitle());
snapshot.put("description", goal.getDescription());
snapshot.put("exitCriteria", goal.getExitCriteria());
snapshot.put("successCheckPrompt", goal.getSuccessCheckPrompt());
snapshot.put("status", goal.getStatus() == null ? null : goal.getStatus().getValue());
snapshot.put("persistentExecution", goal.getPersistentExecution());
snapshot.put("turnBudget", goal.getTurnBudget());
snapshot.put("turnsUsed", goal.getTurnsUsed());
snapshot.put("llmCallBudget", goal.getLlmCallBudget());
snapshot.put("agentLlmCallsUsed", goal.getAgentLlmCallsUsed());
snapshot.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed());
snapshot.put("totalLlmCallsUsed", goal.getTotalLlmCallsUsed());
snapshot.put("progressSummary", goal.getProgressSummary());
snapshot.put("completionScore", goal.getCompletionScore());
snapshot.put("lastEvaluationAt", stringTime(goal.getLastEvaluationAt()));
snapshot.put("autoFollowupEnabled", goal.getAutoFollowupEnabled());
snapshot.put("followupCooldownSeconds", goal.getFollowupCooldownSeconds());
snapshot.put("lastFollowupAt", stringTime(goal.getLastFollowupAt()));
snapshot.put("version", goal.getVersion());
snapshot.put("createTime", stringTime(goal.getCreateTime()));
snapshot.put("updateTime", stringTime(goal.getUpdateTime()));
List<Map<String, Object>> criteria = new ArrayList<>();
if (goal.getCriteria() != null) {
goal.getCriteria().forEach(criterion -> {
if (criterion == null) return;
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", criterion.id() == null ? "" : criterion.id());
item.put("text", criterion.text() == null ? "" : criterion.text());
item.put("passed", criterion.passed());
item.put("evidence", criterion.evidence() == null ? "" : criterion.evidence());
criteria.add(Collections.unmodifiableMap(item));
});
}
snapshot.put("criteria", List.copyOf(criteria));
return Collections.unmodifiableMap(snapshot);
}
private static String stringId(Long value) {
return value == null ? null : value.toString();
}
private static String stringTime(java.time.LocalDateTime value) {
return value == null ? null : value.toString();
}
/**
* Resolve the active goal for this run: prefer the turn-start
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation

View File

@ -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", "tool_search", "tool_describe");
java.util.Set.of("load_skill", "enable_tool");
/** 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;
@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction {
* determined statically, and a false reminder is worse than none.
*/
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
java.util.Set.of("write_file", "append_file", "edit_file");
java.util.Set.of("write_file", "edit_file");
private static final String VERIFICATION_REMINDER =
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +

View File

@ -30,15 +30,12 @@ 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;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
@ -62,7 +59,7 @@ public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
return MateClawStateAccessor.output().continueReasoning(false);
return MateClawStateAccessor.output();
}
/**
@ -139,21 +136,11 @@ public class ReasoningNode implements NodeAction {
*/
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000;
private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile(
"(\\d{1,3}(?:[,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)");
private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile(
"(一万|1万|十千)\\s*(字|字符|中文字|汉字)");
private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile(
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile",
"write_file", "append_file", "local_write_file", "edit_file", "local_edit_file");
/** 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
@ -248,100 +235,6 @@ public class ReasoningNode implements NodeAction {
return false;
}
static OptionalInt requestedLongFormChars(String userMessage) {
if (userMessage == null || userMessage.isBlank()) {
return OptionalInt.empty();
}
Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage);
if (tenThousand.find()) {
return OptionalInt.of(10_000);
}
Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage);
int best = 0;
while (matcher.find()) {
String rawNumber = matcher.group(1).replace(",", "").replace("", "");
double value;
try {
value = Double.parseDouble(rawNumber);
} catch (NumberFormatException ignored) {
continue;
}
String unit = matcher.group(2);
if ("".equals(unit)) {
value *= 10_000;
} else if ("".equals(unit) || "k".equals(unit) || "K".equals(unit)) {
value *= 1_000;
}
best = Math.max(best, (int) Math.round(value));
}
return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty();
}
static List<ToolCallback> filterLongFormArtifactTools(String userMessage,
List<ToolCallback> callbacks) {
String currentRequest = currentUserRequest(userMessage);
if (callbacks == null || callbacks.isEmpty()
|| requestedLongFormChars(currentRequest).isEmpty()
|| EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) {
return callbacks;
}
return callbacks.stream()
.filter(callback -> {
String name = callback.getToolDefinition().name();
return ARTIFACT_DELIVERY_TOOL_PREFIXES.stream().noneMatch(name::startsWith);
})
.toList();
}
static boolean hasDisallowedLongFormArtifactCall(String userMessage,
List<AssistantMessage.ToolCall> toolCalls) {
String currentRequest = currentUserRequest(userMessage);
if (toolCalls == null || toolCalls.isEmpty()
|| requestedLongFormChars(currentRequest).isEmpty()
|| EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) {
return false;
}
return toolCalls.stream().anyMatch(call -> ARTIFACT_DELIVERY_TOOL_PREFIXES.stream()
.anyMatch(prefix -> call.name().startsWith(prefix)));
}
private static String currentUserRequest(String userMessage) {
if (userMessage == null) {
return "";
}
int memoryEnd = userMessage.lastIndexOf("</memory-context>");
return memoryEnd >= 0
? userMessage.substring(memoryEnd + "</memory-context>".length()).trim()
: userMessage;
}
private static String appendLongFormChunk(String draft, String currentContent) {
return (draft != null ? draft : "") + (currentContent != null ? currentContent : "");
}
private static boolean shouldContinueLongForm(String userMessage, String longFormDraft,
String currentContent, int iteration, int maxIterations) {
OptionalInt requested = requestedLongFormChars(userMessage);
if (requested.isEmpty()) {
return false;
}
if (maxIterations > 0 && iteration + 1 >= maxIterations) {
return false;
}
return appendLongFormChunk(longFormDraft, currentContent).length() < requested.getAsInt();
}
private static UserMessage longFormContinuationPrompt(String userMessage, String longFormDraft,
String currentContent) {
int written = appendLongFormChunk(longFormDraft, currentContent).length();
int requested = requestedLongFormChars(userMessage).orElse(0);
return new UserMessage("""
[Runtime long-form continuation]
用户明确要求长篇输出目标约 %d 目前累计约 %d 尚未达到目标
请从上一段结尾自然继续写不要重写开头不要总结不要说明原因直接续写正文
""".formatted(requested, written));
}
/**
* Tool-use enforcement clause appended to every ReasoningNode
* system prompt. Treats narration ("I will now …") as a protocol violation
@ -364,8 +257,8 @@ public class ReasoningNode implements NodeAction {
+ "\"调研 10 个模型\"\"逐节起草报告\"\"批量生成 N 份文档\"\n"
+ "\"依次调用 N 个 API\"\"对每个文件执行同一操作\"等。\n\n"
+ "**必须做的事**\n"
+ "1. **第一轮回复就用并行 tool_calls 批量注册子目标为 `pending`**\n"
+ " 每批最多 16 个 `progress_update`;超过 16 个时分批注册,避免超过执行器上限\n"
+ "1. **第一轮回复就用并行 tool_calls 批量注册全部子目标为 `pending`**\n"
+ " 一条回复里 N 个 `progress_update` 同时发出(不要串行)\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"
@ -380,30 +273,6 @@ 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"
@ -417,8 +286,8 @@ public class ReasoningNode implements NodeAction {
+ "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n"
+ "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n";
static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
String prompt = basePrompt + TOOL_USE_ENFORCEMENT + STALE_CONTEXT_GUARD + LANGUAGE_CONSISTENCY_GUARD;
private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
String prompt = basePrompt + TOOL_USE_ENFORCEMENT;
return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt;
}
@ -964,7 +833,6 @@ public class ReasoningNode implements NodeAction {
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
.activeCallbacks()
: toolCallbacks;
activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks);
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
@ -1244,62 +1112,12 @@ public class ReasoningNode implements NodeAction {
.build();
}
// Compatibility safety net for providers/adapters that return the
// runtime's reserved error placeholder as an HTTP-successful content
// response. Without this guard the long-form completion gate treats
// the placeholder as a short draft and can repeat it until the graph's
// iteration cap. Cron and other synchronous callers consume the
// resulting structured ERROR_FALLBACK; they do not need to infer from
// user-facing text.
if (isRuntimeErrorPlaceholder(result.text())) {
String errorText = result.text();
log.error("[ReasoningNode] Runtime error placeholder returned as normal content; failing turn");
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(errorText)
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.ERROR_FALLBACK)
.contentStreamed(true)
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
.mergeUsage(state, result)
.build();
}
if (result.partial()) {
int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
}
if (result.hasToolCalls()) {
if (hasDisallowedLongFormArtifactCall(accessor.userMessage(), result.toolCalls())) {
log.warn("[ReasoningNode] Rejecting artifact tool call for plain long-form response: {}",
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
UserMessage continuation = new UserMessage("""
[Runtime long-form delivery gate]
The user requested the long-form text directly in chat and did not request a file,
document, attachment, export, or download. Do not call rendering or file-writing tools.
Continue writing the requested text directly in the response.
""");
return reasonOutput()
.continueReasoning(true)
.iterationCount(accessor.iterationCount() + 1)
.needsToolCall(false)
.shouldSummarize(false)
.toolCalls(List.of())
.finalAnswer("")
.clearFinishReason()
.messages(List.of((Message) continuation))
.currentPhase("reasoning")
.streamedContent("")
.streamedThinking(result.thinking())
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
.events(buildEvents(phaseEvent, iterStartEvent))
.build();
}
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
result.toolCalls().size(),
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
@ -1325,95 +1143,13 @@ 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);
if (shouldContinueLongForm(accessor.userMessage(), accessor.longFormDraft(), content,
accessor.iterationCount(), accessor.maxIterations())) {
String accumulatedDraft = appendLongFormChunk(accessor.longFormDraft(), content);
int written = accumulatedDraft.length();
int requested = requestedLongFormChars(accessor.userMessage()).orElse(0);
log.info("[ReasoningNode] Long-form answer below requested length ({} / {} chars), continuing",
written, requested);
return reasonOutput()
.continueReasoning(true)
.iterationCount(accessor.iterationCount() + 1)
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("")
.longFormDraft(accumulatedDraft)
.clearFinishReason()
.messages(List.of((Message) result.assistantMessage(),
longFormContinuationPrompt(accessor.userMessage(), accessor.longFormDraft(), content)))
.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();
}
pushPhase(conversationId, "drafting_answer", Map.of(
"iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0
));
boolean longFormRequest = requestedLongFormChars(accessor.userMessage()).isPresent();
String accumulatedContent = longFormRequest
? appendLongFormChunk(accessor.longFormDraft(), content)
: (content != null ? content : "");
String answerWithSources = accessor.sourceEvidenceLedger()
.appendWikiSourceTable(accumulatedContent);
.appendWikiSourceTable(content != null ? content : "");
SourceEvidenceLedger.Validation validation =
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
boolean evidenceInsufficient = !validation.valid();
@ -1440,9 +1176,9 @@ public class ReasoningNode implements NodeAction {
.finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.streamedContent(evidenceInsufficient ? accumulatedContent : "")
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, accumulatedContent))
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : ""))
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
@ -1451,10 +1187,6 @@ public class ReasoningNode implements NodeAction {
}
}
static boolean isRuntimeErrorPlaceholder(String text) {
return text != null && text.stripLeading().startsWith("[错误]");
}
private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
+ String.join(", ", unsupportedReferences)

View File

@ -152,17 +152,9 @@ 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 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.
// 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
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 -> {
@ -184,22 +176,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
boolean thinkingAlreadyStreamed = output.state()
.value(MateClawStateKeys.THINKING_STREAMED, false);
// 2·0 规划阶段的推理它先于计划本身发出且是整轮唯一必然发生的
// 一段推理 步骤被派发到别处执行时step / summary 两段
// 根本不会产生此前这一轮就一段思考都不落库
output.state().<String>value(PlanStateKeys.PLAN_THINKING)
// 2a. 各步骤执行结果StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE
// 这里仅作为 persistOnly 送入 Accumulator确保写入 mate_message
// 利用内容本身去重避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
.filter(s -> !s.isEmpty())
.filter(s -> !s.equals(lastPersistedPlanThinking.get()))
.ifPresent(planThinking -> {
lastPersistedPlanThinking.set(planThinking);
deltas.add(AgentService.StreamDelta.persistOnly(null, planThinking));
.filter(s -> !s.equals(lastPersistedStepResult.get()))
.ifPresent(stepContent -> {
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
lastPersistedStepResult.set(stepContent);
});
// 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()))
@ -208,31 +195,18 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
lastPersistedStepThinking.set(stepThinking);
});
// 2b. 最终汇总同样 thinking 先于 content
// 两个 key 都是 REPLACE值会滞留在后续每个 NodeOutput
// 没有游标时每批都会重发一次 汇总正文被反复追加进
// mate_message.content thinking 会在正文之后再落一段
// 于是气泡末尾挂出一个孤立的思考框 2a step
// 去重保持同一套写法
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
.filter(s -> !s.isEmpty())
.filter(s -> !s.equals(lastPersistedSummaryThinking.get()))
.ifPresent(thinking -> {
lastPersistedSummaryThinking.set(thinking);
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
});
// 2b. 最终汇总
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
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(summary, null)
: new AgentService.StreamDelta(summary, null));
});
: new AgentService.StreamDelta(summary, null)));
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)));
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));

View File

@ -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, persistGoal);
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId);
if (parked instanceof TeamPlanBridge.Settled settled) {
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
settled.planId(), settled.completedResults().size());
@ -520,7 +520,7 @@ public class PlanGenerationNode implements NodeAction {
.build();
}
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
log.info("[PlanGeneration] Answering from delegated team state without triage LLM");
log.info("[PlanGeneration] Delegated plan still in flight — answering with progress");
if (streamingHelper != null) {
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
}
@ -590,26 +590,6 @@ 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() : ""))
@ -621,11 +601,7 @@ public class PlanGenerationNode implements NodeAction {
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号1 起始,逗号分隔;无前置填空字符串)。"
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n"
+ "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。"
+ "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。"));
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
} else {
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
@ -688,8 +664,7 @@ public class PlanGenerationNode implements NodeAction {
String llmResponse = result.text();
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
log.debug("[PlanGeneration] LLM response received ({} chars)",
llmResponse == null ? 0 : llmResponse.length());
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
// D-6: emit triage perf summary
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
@ -698,17 +673,7 @@ public class PlanGenerationNode implements NodeAction {
"completion_tokens", result.completionTokens()
)));
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);
}
TriageResult triage = converter.convert(llmResponse);
boolean needsPlanning = triage != null && triage.needsPlanning();
if (!needsPlanning) {
@ -734,7 +699,6 @@ public class PlanGenerationNode implements NodeAction {
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
.planThinking(result.thinking())
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
@ -750,7 +714,6 @@ public class PlanGenerationNode implements NodeAction {
.directAnswer(directAnswer)
.currentPhase("direct_answer")
.contentStreamed(true)
.planThinking(result.thinking())
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
@ -792,7 +755,6 @@ public class PlanGenerationNode implements NodeAction {
.directAnswer(announcement)
.currentPhase("direct_answer")
.contentStreamed(true)
.planThinking(result.thinking())
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
@ -837,7 +799,6 @@ public class PlanGenerationNode implements NodeAction {
.currentStepIndex(0)
.currentPhase("plan_generated")
.contentStreamed(true)
.planThinking(result.thinking())
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events);

View File

@ -85,68 +85,32 @@ 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, truncate(summary, 100));
planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary);
return PlanStateAccessor.output()
.finalSummary(summary)
.finalSummaryThinking(thinking)
.finalSummaryThinking(result.thinking())
.contentStreamed(true)
.thinkingStreamed(!thinking.isEmpty())
.thinkingStreamed(!result.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, SUMMARY_FAILED_NOTE);
String fallbackSummary = buildFallbackSummary(goal, completedResults);
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 汇总失败,以下为步骤原始结果";
/**
* 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 final String SUMMARY_EMPTY_NOTE = "模型未产出汇总正文,以下为步骤原始结果";
/**
* LLM 汇总不可用时生成本地 fallback 摘要
* 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();
}
private static String buildFallbackSummary(String goal, List<String> completedResults) {
StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要LLM 汇总失败,以下为步骤原始结果):\n");
for (String r : completedResults) {
sb.append(truncate(r, 300)).append("\n");
}

View File

@ -18,7 +18,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.node.ActionNode;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.DirectToolOutput;
@ -36,7 +35,6 @@ import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -197,7 +195,6 @@ public class StepExecutionNode implements NodeAction {
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
Set<String> loadedSkills = new LinkedHashSet<>(accessor.loadedSkills());
if (stepIndex >= steps.size()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@ -205,7 +202,6 @@ public class StepExecutionNode implements NodeAction {
.currentStepResult("步骤索引越界")
.completedResults(formatStepResult(stepIndex, "步骤索引越界"))
.currentStepIndex(stepIndex + 1)
.loadedSkills(Set.copyOf(loadedSkills))
.build();
}
@ -362,14 +358,13 @@ public class StepExecutionNode implements NodeAction {
// (instead of leaking into the next LLM round).
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, storedArguments, events, conversationId, workspaceBasePath,
stepDirectOutputs, chatOrigin);
stepDirectOutputs);
toolResponses.add(response);
preApprovedPayload = ""; // 只消费一次
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
@ -384,10 +379,8 @@ public class StepExecutionNode implements NodeAction {
}
} else {
// 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier
if (!allToolCalls.isEmpty()) {
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
@ -399,12 +392,6 @@ public class StepExecutionNode implements NodeAction {
? execResult.barrierToolName() : "unknown";
}
}
}
Set<String> requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls);
if (!requestedSkills.isEmpty() && loadedSkills.addAll(requestedSkills)) {
log.debug("[StepExecution] pinned loaded skills in plan state: {}", requestedSkills);
}
// 将工具响应追加到消息
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
@ -463,7 +450,6 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("awaiting_approval")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
@ -500,7 +486,6 @@ public class StepExecutionNode implements NodeAction {
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
@ -551,7 +536,6 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
@ -610,7 +594,6 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
@ -622,12 +605,7 @@ 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)
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
@ -672,7 +650,6 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("step_completed")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
@ -822,9 +799,10 @@ public class StepExecutionNode implements NodeAction {
""";
messages.add(new SystemMessage(enhancedSystemPrompt));
// Runtime skill catalog (rendered here instead of baked into the system
// prompt), ranked with skills already loaded during this graph run.
// prompt). The Plan path never pins per-run loads, so render with an
// empty loaded set this reproduces the pre-disclosure DB ordering.
if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of());
if (skillCatalog != null && !skillCatalog.isBlank()) {
messages.add(new SystemMessage(skillCatalog));
}

View File

@ -94,10 +94,6 @@ 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, "");
}
@ -140,11 +136,6 @@ public final class PlanStateAccessor {
return state.value(WORKING_CONTEXT, "");
}
@SuppressWarnings("unchecked")
public Set<String> loadedSkills() {
return state.<Set<String>>value(MateClawStateKeys.LOADED_SKILLS).orElse(Set.of());
}
// ===== 输出构建器 =====
public static OutputBuilder output() {
@ -234,10 +225,6 @@ 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);
}
@ -256,10 +243,6 @@ public final class PlanStateAccessor {
return put(MateClawStateKeys.PENDING_EVENTS, events);
}
public OutputBuilder loadedSkills(Set<String> names) {
return put(MateClawStateKeys.LOADED_SKILLS, names);
}
// ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE----
public OutputBuilder currentPhase(String phase) {
return put(MateClawStateKeys.CURRENT_PHASE, phase);

View File

@ -56,15 +56,6 @@ 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";

View File

@ -1,85 +0,0 @@
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);
}
}

View File

@ -25,12 +25,6 @@ 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"),

View File

@ -119,10 +119,6 @@ public final class MateClawStateAccessor {
return state.value(FINAL_ANSWER_DRAFT, "");
}
public String longFormDraft() {
return state.value(LONG_FORM_DRAFT, "");
}
public boolean limitExceeded() {
return state.value(LIMIT_EXCEEDED, false);
}
@ -220,22 +216,6 @@ 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() {
@ -457,10 +437,6 @@ public final class MateClawStateAccessor {
return put(FINAL_ANSWER_DRAFT, draft);
}
public OutputBuilder longFormDraft(String draft) {
return put(LONG_FORM_DRAFT, draft);
}
// ---- 终止 ----
public OutputBuilder finalAnswer(String answer) {
return put(FINAL_ANSWER, answer);
@ -547,22 +523,6 @@ 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);

View File

@ -64,8 +64,6 @@ public final class MateClawStateKeys {
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft";
/** Accumulated visible body for an explicit long-form generation request. */
public static final String LONG_FORM_DRAFT = "long_form_draft";
/** 是否需要进入 summarizing 阶段 */
public static final String SHOULD_SUMMARIZE = "should_summarize";
@ -186,18 +184,6 @@ 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 =====
/**

View File

@ -26,14 +26,6 @@ public class AgentEntity {
/** Agent 类型react / plan_execute */
private String agentType;
/** Runtime provider type: native / dsh / other registered providers. */
@TableField(value = "runtime_type")
private String runtimeType;
/** Runtime-specific JSON configuration. Null means provider defaults. */
@TableField(value = "runtime_config", updateStrategy = FieldStrategy.ALWAYS)
private String runtimeConfig;
/** 系统提示词 */
@TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
private String systemPrompt;

View File

@ -10,7 +10,6 @@ import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@ -117,26 +116,18 @@ public class AgentRuntimeAggregator {
) {}
public RuntimeSnapshot snapshot() {
return snapshot(null);
}
public RuntimeSnapshot snapshot(Long workspaceId) {
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
Set<Long> agentIds = rawRuns.stream()
.map(RunSnapshot::agentId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
Collection<SubagentRegistry.SubagentRecord> rawSubagents = subagentRegistry.allActive();
for (var rec : rawSubagents) {
for (var rec : subagentRegistry.allActive()) {
if (rec.agentId() != null) agentIds.add(rec.agentId());
}
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
Map<String, Long> subagentCountByParent = new HashMap<>();
for (var rec : rawSubagents) {
if (!belongsToWorkspace(rec.agentId(), agentInfo, workspaceId)) {
continue;
}
for (var rec : subagentRegistry.allActive()) {
String parent = rec.parentConversationId();
if (parent != null) {
subagentCountByParent.merge(parent, 1L, Long::sum);
@ -150,7 +141,6 @@ public class AgentRuntimeAggregator {
int runningCount = 0;
for (RunSnapshot s : rawRuns) {
if (s.done()) continue;
if (!belongsToWorkspace(s.agentId(), agentInfo, workspaceId)) continue;
runningCount++;
String stuckReason = computeStuckReason(s);
boolean orphan = s.subscriberCount() == 0;
@ -191,8 +181,7 @@ public class AgentRuntimeAggregator {
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
});
List<SubagentCard> subCards = rawSubagents.stream()
.filter(rec -> belongsToWorkspace(rec.agentId(), agentInfo, workspaceId))
List<SubagentCard> subCards = subagentRegistry.allActive().stream()
.map(rec -> {
long now = System.currentTimeMillis();
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
@ -227,33 +216,6 @@ public class AgentRuntimeAggregator {
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis());
}
public boolean runBelongsToWorkspace(String conversationId, Long workspaceId) {
if (conversationId == null || workspaceId == null) {
return false;
}
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
for (RunSnapshot run : rawRuns) {
if (run.done() || !conversationId.equals(run.conversationId())) {
continue;
}
AgentEntity agent = resolveAgent(run.agentId());
return agent != null && workspaceId.equals(agent.getWorkspaceId());
}
return false;
}
public boolean subagentBelongsToWorkspace(String subagentId, Long workspaceId) {
if (subagentId == null || workspaceId == null) {
return false;
}
return subagentRegistry.get(subagentId)
.map(rec -> {
AgentEntity agent = resolveAgent(rec.agentId());
return agent != null && workspaceId.equals(agent.getWorkspaceId());
})
.orElse(false);
}
/**
* Returns null when the run looks healthy. The returned tag is a stable
* machine-readable code (not a translated label) so the frontend can
@ -282,25 +244,4 @@ public class AgentRuntimeAggregator {
}
return out;
}
private AgentEntity resolveAgent(Long id) {
if (id == null) {
return null;
}
try {
return agentService.getAgent(id);
} catch (Exception e) {
log.debug("agent lookup failed for id={}: {}", id, e.getMessage());
return null;
}
}
private static boolean belongsToWorkspace(Long agentId, Map<Long, AgentEntity> agentInfo,
Long workspaceId) {
if (workspaceId == null) {
return true;
}
AgentEntity agent = agentId == null ? null : agentInfo.get(agentId);
return agent != null && workspaceId.equals(agent.getWorkspaceId());
}
}

View File

@ -19,13 +19,13 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import vip.mate.agent.runtime.dsh.DshRuntimeService;
/**
* Admin-only live runtime surface: the workspace view of every in-flight agent
* Admin-only live runtime surface: the global view of every in-flight agent
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
* owner-scoped.
* owner-scoped this controller is intentionally cross-tenant for the
* operator role.
*/
@Slf4j
@Tag(name = "Agent Runtime (Live)")
@ -40,35 +40,21 @@ public class AgentRuntimeController {
private final AuditEventService auditEventService;
private final ConversationService conversationService;
private final I18nService i18nService;
private final DshRuntimeService dshRuntimeService;
@Operation(summary = "Snapshot of every in-flight agent turn")
@GetMapping("/snapshot")
@RequireGlobalAdmin
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
requireAdmin(auth);
requireWorkspace(workspaceId);
return R.ok(aggregator.snapshot(workspaceId));
}
@Operation(summary = "DSH runtime availability and capability diagnostics")
@GetMapping("/dsh/diagnostics")
@RequireGlobalAdmin
public R<Map<String, Object>> dshDiagnostics(Authentication auth) {
requireAdmin(auth);
return R.ok(dshRuntimeService.diagnostics());
return R.ok(aggregator.snapshot());
}
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
@PostMapping("/runs/{conversationId}/stop")
@RequireGlobalAdmin
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
requireAdmin(auth);
requireRunInWorkspace(conversationId, workspaceId);
boolean ok = streamTracker.requestStop(conversationId);
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
return R.ok(Map.of("stopped", ok));
@ -78,10 +64,8 @@ public class AgentRuntimeController {
@PostMapping("/runs/{conversationId}/recycle")
@RequireGlobalAdmin
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
requireAdmin(auth);
requireRunInWorkspace(conversationId, workspaceId);
boolean ok = streamTracker.forceRecycle(conversationId);
if (ok) {
finalizeRecycledConversation(conversationId);
@ -94,10 +78,8 @@ public class AgentRuntimeController {
@PostMapping("/subagents/{subagentId}/interrupt")
@RequireGlobalAdmin
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
requireAdmin(auth);
requireSubagentInWorkspace(subagentId, workspaceId);
boolean ok = subagentRegistry.interrupt(subagentId);
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
return R.ok(Map.of("interrupted", ok));
@ -111,12 +93,9 @@ public class AgentRuntimeController {
@Operation(summary = "Recycle every run currently flagged as stuck")
@PostMapping("/sweep")
@RequireGlobalAdmin
public R<Map<String, Object>> sweep(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
public R<Map<String, Object>> sweep(Authentication auth) {
requireAdmin(auth);
requireWorkspace(workspaceId);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(workspaceId);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
List<String> ids = snap.runs().stream()
.filter(r -> r.stuckReason() != null)
.map(AgentRuntimeAggregator.RunCard::conversationId)
@ -175,26 +154,6 @@ public class AgentRuntimeController {
}
}
private void requireWorkspace(Long workspaceId) {
if (workspaceId == null) {
throw new MateClawException(400, "workspace id required");
}
}
private void requireRunInWorkspace(String conversationId, Long workspaceId) {
requireWorkspace(workspaceId);
if (!aggregator.runBelongsToWorkspace(conversationId, workspaceId)) {
throw new MateClawException(404, "runtime run not found in workspace");
}
}
private void requireSubagentInWorkspace(String subagentId, Long workspaceId) {
requireWorkspace(workspaceId);
if (!aggregator.subagentBelongsToWorkspace(subagentId, workspaceId)) {
throw new MateClawException(404, "subagent not found in workspace");
}
}
private void recordAudit(Authentication auth, String action,
String resourceId, Map<String, Object> detail) {
try {

View File

@ -1,36 +0,0 @@
package vip.mate.agent.runtime;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
/** Atomic local admission shared by interactive, replay and autonomous turns. */
@Component
public class ConversationTurnGate {
private final ConcurrentHashMap<String, Permit> owners = new ConcurrentHashMap<>();
private final ThreadLocal<Permit> admitted = new ThreadLocal<>();
public Permit tryAcquire(String conversationId) {
if (conversationId == null || conversationId.isBlank()) return new Permit(null);
Permit current = admitted.get();
if (current != null && conversationId.equals(current.conversationId)
&& owners.get(conversationId) == current) return new Permit(null);
Permit permit = new Permit(conversationId);
return owners.putIfAbsent(conversationId, permit) == null ? permit : null;
}
/** Enter the already-admitted call synchronously; inner lifecycle cleanup must not release its owner. */
public <T> T withPermit(Permit permit, java.util.function.Supplier<T> call) {
Permit previous=admitted.get();
admitted.set(permit);
try { return call.get(); }
finally { if (previous==null) admitted.remove(); else admitted.set(previous); }
}
public final class Permit implements AutoCloseable {
private final String conversationId;
private Permit(String conversationId) { this.conversationId = conversationId; }
@Override public void close() {
if (conversationId != null) owners.remove(conversationId, this);
}
}
}

View File

@ -1,63 +0,0 @@
package vip.mate.agent.runtime;
import vip.mate.agent.AgentService;
import vip.mate.agent.runtime.contract.RuntimeEvent;
import vip.mate.agent.runtime.contract.RuntimeEventType;
import java.util.LinkedHashMap;
import java.util.Map;
/** Projects normalized runtime events onto the existing chat stream vocabulary. */
public final class RuntimeEventProjector {
private RuntimeEventProjector() {}
public static AgentService.StreamDelta project(RuntimeEvent event) {
if (event == null) return AgentService.StreamDelta.empty();
Map<String, Object> data = new LinkedHashMap<>(event.data());
data.putIfAbsent("runtimeSessionId", event.sessionId());
data.putIfAbsent("runtimeSequence", event.sequence());
return switch (event.type()) {
case RUNTIME_READY -> AgentService.StreamDelta.event("phase",
with(data, "phase", "runtime_ready"));
case ASSISTANT_DELTA -> new AgentService.StreamDelta(
text(event, data), null, null, null, false, false, null);
case THINKING_DELTA -> new AgentService.StreamDelta(
null, text(event, data), null, null, false, false, null);
case TOOL_STARTED -> AgentService.StreamDelta.event("tool_call_started",
rename(data, "toolName", "name", "callId", "toolCallId"));
case TOOL_APPROVAL_REQUIRED -> AgentService.StreamDelta.event("tool_approval_requested",
rename(data, "requestId", "pendingId", "toolName", "toolName"));
case TOOL_FINISHED -> AgentService.StreamDelta.event("tool_call_completed",
rename(data, "callId", "toolCallId", "toolName", "toolName"));
case SUBAGENT_STARTED -> AgentService.StreamDelta.event("subagent_start", data);
case SUBAGENT_FINISHED -> AgentService.StreamDelta.event("subagent_complete", data);
case CONTEXT_USAGE -> AgentService.StreamDelta.event("_usage_final", data);
case COMPLETED -> AgentService.StreamDelta.event("done", data);
case FAILED -> AgentService.StreamDelta.event("error", data);
case CANCELLED -> AgentService.StreamDelta.event("cancelled", data);
};
}
private static Map<String, Object> with(Map<String, Object> source, String key, Object value) {
Map<String, Object> result = new LinkedHashMap<>(source);
result.put(key, value);
return result;
}
private static String text(RuntimeEvent event, Map<String, Object> data) {
Object delta = data.get("delta");
return delta != null ? String.valueOf(delta) : event.text() == null ? "" : event.text();
}
private static Map<String, Object> rename(Map<String, Object> source, String from, String to,
String secondFrom, String secondTo) {
Map<String, Object> result = new LinkedHashMap<>(source);
copyIfPresent(result, from, to);
copyIfPresent(result, secondFrom, secondTo);
return result;
}
private static void copyIfPresent(Map<String, Object> data, String from, String to) {
if (!data.containsKey(to) && data.containsKey(from)) data.put(to, data.get(from));
}
}

View File

@ -1,15 +0,0 @@
package vip.mate.agent.runtime;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
import vip.mate.agent.runtime.contract.RuntimeEvent;
/** Adapts provider event streams to the native chat stream contract. */
public final class RuntimeEventStreamAdapter {
private RuntimeEventStreamAdapter() {}
public static Flux<AgentService.StreamDelta> adapt(Flux<RuntimeEvent> events) {
if (events == null) return Flux.empty();
return events.map(RuntimeEventProjector::project);
}
}

View File

@ -1,25 +0,0 @@
package vip.mate.agent.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import vip.mate.agent.runtime.contract.AgentRuntimeCoordinator;
import vip.mate.agent.runtime.contract.AgentRuntimeProvider;
import vip.mate.agent.runtime.contract.RuntimeProviderRegistry;
import java.util.List;
/** Spring wiring for the runtime SPI. Native execution remains owned by AgentService. */
@Configuration
public class RuntimeProviderConfiguration {
@Bean
RuntimeProviderRegistry runtimeProviderRegistry(List<AgentRuntimeProvider> providers) {
return new RuntimeProviderRegistry(providers);
}
@Bean
AgentRuntimeCoordinator agentRuntimeCoordinator(RuntimeProviderRegistry registry,
ObjectMapper objectMapper) {
return new AgentRuntimeCoordinator(registry, objectMapper);
}
}

View File

@ -1,17 +0,0 @@
package vip.mate.agent.runtime.contract;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface AgentRuntimeConnection extends AutoCloseable {
Flux<RuntimeEvent> prompt(String message);
Mono<Void> cancel();
Mono<RuntimeContextUsage> contextUsage();
@Override
default void close() {
cancel().block();
}
}

View File

@ -1,24 +0,0 @@
package vip.mate.agent.runtime.contract;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.agent.model.AgentEntity;
import java.nio.file.Path;
/** Selects, validates, and starts the provider chosen by an employee. */
public final class AgentRuntimeCoordinator {
private final RuntimeProviderRegistry providerRegistry;
private final RuntimeSessionFactory sessionFactory;
public AgentRuntimeCoordinator(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) {
this.providerRegistry = providerRegistry;
this.sessionFactory = new RuntimeSessionFactory(providerRegistry, objectMapper);
}
public AgentRuntimeConnection start(AgentEntity agent, String conversationId, String sessionId,
String modelName, Path workspaceRoot, Path workingDirectory) {
RuntimeSession session = sessionFactory.create(agent, conversationId, sessionId,
modelName, workspaceRoot, workingDirectory);
return providerRegistry.resolve(agent.getRuntimeType()).start(session);
}
}

View File

@ -1,11 +0,0 @@
package vip.mate.agent.runtime.contract;
public interface AgentRuntimeProvider {
String type();
RuntimeValidation validate(RuntimeSession session);
RuntimeCapabilities capabilities();
AgentRuntimeConnection start(RuntimeSession session);
}

View File

@ -1,8 +0,0 @@
package vip.mate.agent.runtime.contract;
public record RuntimeCapabilities(
boolean supportsCancellation,
boolean supportsApprovals,
boolean supportsSubagents,
boolean supportsContextUsage
) {}

View File

@ -1,9 +0,0 @@
package vip.mate.agent.runtime.contract;
public record RuntimeContextUsage(long inputTokens, long outputTokens, long contextWindow) {
public RuntimeContextUsage {
if (inputTokens < 0 || outputTokens < 0 || contextWindow < 0) {
throw new IllegalArgumentException("usage values must be non-negative");
}
}
}

View File

@ -1,38 +0,0 @@
package vip.mate.agent.runtime.contract;
import java.util.Map;
public record RuntimeEvent(
String sessionId,
long sequence,
RuntimeEventType type,
String text,
Map<String, Object> data,
boolean terminal
) {
public RuntimeEvent {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId is required");
}
if (sequence < 0) {
throw new IllegalArgumentException("sequence must be non-negative");
}
if (type == null) {
throw new IllegalArgumentException("type is required");
}
if (terminal != type.terminal()) {
throw new IllegalArgumentException("terminal flag does not match event type");
}
data = data == null ? Map.of() : Map.copyOf(data);
}
public static RuntimeEvent of(String sessionId, long sequence, RuntimeEventType type,
String text, Map<String, Object> data) {
return new RuntimeEvent(sessionId, sequence, type, text, data, false);
}
public static RuntimeEvent terminal(String sessionId, long sequence, RuntimeEventType type,
Map<String, Object> data) {
return new RuntimeEvent(sessionId, sequence, type, null, data, true);
}
}

View File

@ -1,41 +0,0 @@
package vip.mate.agent.runtime.contract;
import java.util.ArrayList;
import java.util.List;
public final class RuntimeEventLog {
private final String sessionId;
private final List<RuntimeEvent> events = new ArrayList<>();
private boolean terminal;
private long lastSequence = -1;
public RuntimeEventLog(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId is required");
}
this.sessionId = sessionId;
}
public synchronized void append(RuntimeEvent event) {
if (!sessionId.equals(event.sessionId())) {
throw new IllegalArgumentException("event belongs to another session");
}
if (event.sequence() <= lastSequence) {
throw new IllegalArgumentException("event sequence must increase");
}
if (terminal) {
throw new IllegalStateException("terminal event already appended");
}
events.add(event);
lastSequence = event.sequence();
terminal = event.terminal();
}
public synchronized List<RuntimeEvent> snapshot() {
return List.copyOf(events);
}
public synchronized boolean terminal() {
return terminal;
}
}

View File

@ -1,20 +0,0 @@
package vip.mate.agent.runtime.contract;
public enum RuntimeEventType {
RUNTIME_READY,
ASSISTANT_DELTA,
THINKING_DELTA,
TOOL_STARTED,
TOOL_APPROVAL_REQUIRED,
TOOL_FINISHED,
SUBAGENT_STARTED,
SUBAGENT_FINISHED,
CONTEXT_USAGE,
COMPLETED,
FAILED,
CANCELLED;
public boolean terminal() {
return this == COMPLETED || this == FAILED || this == CANCELLED;
}
}

View File

@ -1,41 +0,0 @@
package vip.mate.agent.runtime.contract;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class RuntimeProviderRegistry {
public static final String DEFAULT_RUNTIME = "native";
private final Map<String, AgentRuntimeProvider> providers;
public RuntimeProviderRegistry(List<AgentRuntimeProvider> providers) {
Map<String, AgentRuntimeProvider> registered = new LinkedHashMap<>();
for (AgentRuntimeProvider provider : providers == null ? List.<AgentRuntimeProvider>of() : providers) {
if (provider == null || provider.type() == null || provider.type().isBlank()) {
throw new IllegalArgumentException("runtime provider type is required");
}
String type = normalize(provider.type());
if (registered.putIfAbsent(type, provider) != null) {
throw new IllegalArgumentException("duplicate runtime provider: " + type);
}
}
this.providers = Map.copyOf(registered);
}
public AgentRuntimeProvider resolve(String requestedType) {
String type = requestedType == null || requestedType.isBlank()
? DEFAULT_RUNTIME
: normalize(requestedType);
AgentRuntimeProvider provider = providers.get(type);
if (provider == null) {
throw new IllegalArgumentException("unknown runtime provider: " + type);
}
return provider;
}
private static String normalize(String type) {
return type.trim().toLowerCase(Locale.ROOT);
}
}

View File

@ -1,24 +0,0 @@
package vip.mate.agent.runtime.contract;
public record RuntimeResult(Status status, String answer, String errorCode, String errorMessage) {
public enum Status { COMPLETED, FAILED, CANCELLED }
public RuntimeResult {
if (status == null) throw new IllegalArgumentException("status is required");
if (status == Status.COMPLETED && (errorCode != null || errorMessage != null)) {
throw new IllegalArgumentException("completed result cannot contain an error");
}
}
public static RuntimeResult completed(String answer) {
return new RuntimeResult(Status.COMPLETED, answer, null, null);
}
public static RuntimeResult failed(String code, String message) {
return new RuntimeResult(Status.FAILED, null, code, message);
}
public static RuntimeResult cancelled() {
return new RuntimeResult(Status.CANCELLED, null, null, null);
}
}

View File

@ -1,20 +0,0 @@
package vip.mate.agent.runtime.contract;
import java.nio.file.Path;
import java.util.Map;
public record RuntimeSession(
String sessionId,
String conversationId,
Long agentId,
Long workspaceId,
String modelName,
Path workingDirectory,
Map<String, Object> configuration
) {
public RuntimeSession {
if (sessionId == null || sessionId.isBlank()) throw new IllegalArgumentException("sessionId is required");
if (conversationId == null || conversationId.isBlank()) throw new IllegalArgumentException("conversationId is required");
configuration = configuration == null ? Map.of() : Map.copyOf(configuration);
}
}

View File

@ -1,75 +0,0 @@
package vip.mate.agent.runtime.contract;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.agent.model.AgentEntity;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
/** Builds and validates the runtime-neutral session boundary for an employee turn. */
public final class RuntimeSessionFactory {
private static final TypeReference<Map<String, Object>> CONFIG_TYPE = new TypeReference<>() {};
private final RuntimeProviderRegistry providerRegistry;
private final ObjectMapper objectMapper;
public RuntimeSessionFactory(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) {
this.providerRegistry = providerRegistry;
this.objectMapper = objectMapper;
}
public RuntimeSession create(AgentEntity agent, String conversationId, String sessionId,
String modelName, Path workspaceRoot, Path workingDirectory) {
if (agent == null) throw new IllegalArgumentException("agent is required");
AgentRuntimeProvider provider = providerRegistry.resolve(agent.getRuntimeType());
String runtimeType = agent.getRuntimeType() == null || agent.getRuntimeType().isBlank()
? RuntimeProviderRegistry.DEFAULT_RUNTIME : agent.getRuntimeType().trim().toLowerCase();
Path normalizedRoot = normalize(workspaceRoot);
Path normalizedWorkingDirectory = normalize(workingDirectory);
if ("dsh".equals(runtimeType)) {
if (agent.getWorkspaceId() == null) {
throw new IllegalArgumentException("dsh runtime requires a workspace");
}
if (normalizedRoot == null || normalizedWorkingDirectory == null
|| !normalizedWorkingDirectory.startsWith(normalizedRoot)) {
throw new IllegalArgumentException("dsh working directory must stay inside workspace");
}
}
RuntimeSession session = new RuntimeSession(sessionId, conversationId, agent.getId(),
agent.getWorkspaceId(), modelName, normalizedWorkingDirectory,
parseConfig(agent.getRuntimeConfig()));
RuntimeValidation validation = provider.validate(session);
if (validation == null || !validation.valid()) {
String code = validation == null ? "runtime.invalid" : validation.code();
String message = validation == null ? "runtime provider rejected session" : validation.message();
throw new IllegalArgumentException(code + ": " + message);
}
return session;
}
private Map<String, Object> parseConfig(String raw) {
if (raw == null || raw.isBlank()) return Map.of();
try {
JsonNode node = objectMapper.readTree(raw);
if (node == null || !node.isObject()) {
throw new IllegalArgumentException("runtime config must be a JSON object");
}
return objectMapper.convertValue(node, CONFIG_TYPE);
} catch (IOException | IllegalArgumentException e) {
if (e instanceof IllegalArgumentException iae
&& "runtime config must be a JSON object".equals(iae.getMessage())) {
throw iae;
}
throw new IllegalArgumentException("runtime config must be valid JSON", e);
}
}
private static Path normalize(Path path) {
return path == null ? null : path.toAbsolutePath().normalize();
}
}

View File

@ -1,11 +0,0 @@
package vip.mate.agent.runtime.contract;
public record RuntimeValidation(boolean valid, String code, String message) {
public static RuntimeValidation success() {
return new RuntimeValidation(true, null, null);
}
public static RuntimeValidation invalid(String code, String message) {
return new RuntimeValidation(false, code, message);
}
}

View File

@ -1,9 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.nio.file.Path;
import java.util.Optional;
@FunctionalInterface
public interface DshBinaryResolver {
Optional<Path> resolve();
}

View File

@ -1,21 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public final class DshBridgeAuthenticator {
private final byte[] expectedToken;
public DshBridgeAuthenticator(String expectedToken) {
if (expectedToken == null || expectedToken.isBlank()) {
throw new IllegalArgumentException("bridge token is required");
}
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
}
public boolean accepts(String providedToken) {
if (providedToken == null) return false;
return MessageDigest.isEqual(expectedToken,
providedToken.getBytes(StandardCharsets.UTF_8));
}
}

View File

@ -1,65 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
public final class DshBridgeConnection implements AutoCloseable {
private static final int MAX_LINE_BYTES = 1_048_576;
private final BufferedReader reader;
private final BufferedWriter writer;
private final DshBridgeProtocol protocol;
private final DshBridgeAuthenticator authenticator;
private boolean authenticated;
public DshBridgeConnection(InputStream input, OutputStream output,
DshBridgeProtocol protocol,
DshBridgeAuthenticator authenticator) {
this.reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
this.writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
this.protocol = protocol;
this.authenticator = authenticator;
}
public boolean authenticate(String token) {
authenticated = authenticator.accepts(token);
return authenticated;
}
public DshBridgeMessage receive() throws IOException {
requireAuthenticated();
String line = reader.readLine();
if (line == null) throw new IOException("DSH bridge closed");
if (line.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) {
throw new IOException("DSH bridge message exceeds size limit");
}
return protocol.decode(line);
}
public void send(DshBridgeMessage message) throws IOException {
requireAuthenticated();
String encoded = protocol.encode(message);
if (encoded.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) {
throw new IOException("DSH bridge message exceeds size limit");
}
writer.write(encoded);
writer.flush();
}
private void requireAuthenticated() throws IOException {
if (!authenticated) throw new IOException("DSH bridge authentication required");
}
@Override
public void close() throws IOException {
reader.close();
writer.close();
authenticated = false;
}
}

View File

@ -1,45 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.util.LinkedHashMap;
import java.util.Map;
public final class DshBridgeEvents {
private DshBridgeEvents() {}
public static DshBridgeMessage ready(String sessionId) {
return DshBridgeMessage.notification("ready", Map.of("sessionId", require(sessionId, "sessionId")));
}
public static DshBridgeMessage toolCall(String callId, String toolName, Map<String, Object> arguments) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("toolName", require(toolName, "toolName"));
params.put("arguments", arguments == null ? Map.of() : Map.copyOf(arguments));
return DshBridgeMessage.request(require(callId, "callId"), "tool/call", params);
}
public static DshBridgeMessage approvalAsk(String requestId, String toolName, String reason) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("toolName", require(toolName, "toolName"));
params.put("reason", reason == null ? "" : reason);
return DshBridgeMessage.request(require(requestId, "requestId"), "approval/ask", params);
}
public static DshBridgeMessage subagentLifecycle(String subagentId, String phase,
Map<String, Object> data) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("subagentId", require(subagentId, "subagentId"));
params.put("phase", require(phase, "phase"));
if (data != null) params.putAll(Map.copyOf(data));
return DshBridgeMessage.notification("subagent/lifecycle", params);
}
public static DshBridgeMessage toolCancel(String callId) {
return DshBridgeMessage.notification("tool/cancel",
Map.of("callId", require(callId, "callId")));
}
private static String require(String value, String name) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required");
return value;
}
}

View File

@ -1,26 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.util.Map;
public record DshBridgeMessage(
String id,
String method,
Map<String, Object> params,
Object result,
String errorCode,
String errorMessage
) {
public DshBridgeMessage {
if (method == null || method.isBlank()) throw new IllegalArgumentException("method is required");
params = params == null ? Map.of() : Map.copyOf(params);
}
public static DshBridgeMessage request(String id, String method, Map<String, Object> params) {
if (id == null || id.isBlank()) throw new IllegalArgumentException("request id is required");
return new DshBridgeMessage(id, method, params, null, null, null);
}
public static DshBridgeMessage notification(String method, Map<String, Object> params) {
return new DshBridgeMessage(null, method, params, null, null, null);
}
}

View File

@ -1,15 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.util.Set;
public final class DshBridgeMethods {
private static final Set<String> SUPPORTED = Set.of(
"session/open", "session/prompt", "session/cancel", "policy/update", "context/usage",
"ready", "tool/call", "approval/ask", "subagent/lifecycle", "tool/cancel");
private DshBridgeMethods() {}
public static boolean isSupported(String method) {
return method != null && SUPPORTED.contains(method);
}
}

View File

@ -1,33 +0,0 @@
package vip.mate.agent.runtime.dsh;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class DshBridgeProtocol {
private final ObjectMapper objectMapper;
public DshBridgeProtocol(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public String encode(DshBridgeMessage message) {
try {
return objectMapper.writeValueAsString(message) + "\n";
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Unable to encode DSH bridge message", e);
}
}
public DshBridgeMessage decode(String line) {
if (line == null || line.isBlank()) throw new IllegalArgumentException("bridge message is empty");
try {
return objectMapper.readValue(line.trim(), DshBridgeMessage.class);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Invalid DSH bridge message", e);
}
}
public boolean isNotification(DshBridgeMessage message) {
return message.id() == null;
}
}

View File

@ -1,56 +0,0 @@
package vip.mate.agent.runtime.dsh;
import vip.mate.agent.runtime.contract.RuntimeSession;
import java.util.LinkedHashMap;
import java.util.Map;
public final class DshBridgeRequests {
private DshBridgeRequests() {}
public static DshBridgeMessage sessionOpen(RuntimeSession session, Map<String, Object> policy) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("sessionId", session.sessionId());
params.put("conversationId", session.conversationId());
putIfPresent(params, "agentId", session.agentId());
putIfPresent(params, "workspaceId", session.workspaceId());
putIfPresent(params, "model", session.modelName());
putIfPresent(params, "cwd", session.workingDirectory() == null
? null : session.workingDirectory().toString());
params.putAll(session.configuration());
if (policy != null) params.put("policy", Map.copyOf(policy));
return DshBridgeMessage.request("open-" + session.sessionId(), "session/open", params);
}
public static DshBridgeMessage prompt(String requestId, String message) {
require(requestId, "requestId");
if (message == null) throw new IllegalArgumentException("message is required");
return DshBridgeMessage.request(requestId, "session/prompt", Map.of("message", message));
}
public static DshBridgeMessage cancel(String requestId, String sessionId) {
require(requestId, "requestId");
require(sessionId, "sessionId");
return DshBridgeMessage.request(requestId, "session/cancel", Map.of("sessionId", sessionId));
}
public static DshBridgeMessage policyUpdate(String requestId, Map<String, Object> policy) {
require(requestId, "requestId");
return DshBridgeMessage.request(requestId, "policy/update",
Map.of("policy", policy == null ? Map.of() : Map.copyOf(policy)));
}
public static DshBridgeMessage contextUsage(String requestId, String sessionId) {
require(requestId, "requestId");
require(sessionId, "sessionId");
return DshBridgeMessage.request(requestId, "context/usage", Map.of("sessionId", sessionId));
}
private static void require(String value, String name) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required");
}
private static void putIfPresent(Map<String, Object> target, String key, Object value) {
if (value != null) target.put(key, value);
}
}

View File

@ -1,66 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.concurrent.atomic.AtomicBoolean;
public final class DshManagedProcess implements AutoCloseable {
private final DshProcessHandle process;
private final String sessionId;
private final Path binary;
private final Path sessionHome;
private final String bridgeToken;
private final Runnable onClosed;
private final AtomicBoolean closed = new AtomicBoolean();
DshManagedProcess(DshProcessHandle process, String sessionId, Path binary,
Path sessionHome, String bridgeToken) {
this(process, sessionId, binary, sessionHome, bridgeToken, () -> { });
}
DshManagedProcess(DshProcessHandle process, String sessionId, Path binary,
Path sessionHome, String bridgeToken, Runnable onClosed) {
this.process = process;
this.sessionId = sessionId;
this.binary = binary;
this.sessionHome = sessionHome;
this.bridgeToken = bridgeToken;
this.onClosed = onClosed == null ? () -> { } : onClosed;
}
public DshProcessDiagnostics diagnostics() {
return new DshProcessDiagnostics(sessionId, binary, sessionHome,
process.isAlive(), bridgeToken != null && !bridgeToken.isBlank());
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) return;
if (process.isAlive()) {
process.destroy();
if (!process.awaitExit(1_000L) && process.isAlive()) {
process.destroyForcibly();
process.awaitExit(1_000L);
}
}
deleteRecursively(sessionHome);
onClosed.run();
}
private static void deleteRecursively(Path root) {
if (root == null || !Files.exists(root)) return;
try (var paths = Files.walk(root)) {
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// Cleanup is best effort; the process is already stopped.
}
});
} catch (IOException ignored) {
// Cleanup is best effort; diagnostics retain the path for operators.
}
}
}

View File

@ -1,11 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.nio.file.Path;
public record DshProcessDiagnostics(
String sessionId,
Path binary,
Path sessionHome,
boolean alive,
boolean bridgeTokenRedacted
) {}

View File

@ -1,11 +0,0 @@
package vip.mate.agent.runtime.dsh;
public interface DshProcessHandle {
boolean isAlive();
void destroy();
void destroyForcibly();
boolean awaitExit(long millis);
}

View File

@ -1,10 +0,0 @@
package vip.mate.agent.runtime.dsh;
import vip.mate.agent.runtime.contract.RuntimeSession;
import java.nio.file.Path;
@FunctionalInterface
public interface DshProcessLauncher {
DshProcessHandle launch(Path binary, RuntimeSession session, Path sessionHome, String bridgeToken);
}

View File

@ -1,70 +0,0 @@
package vip.mate.agent.runtime.dsh;
import vip.mate.agent.runtime.contract.RuntimeSession;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class DshProcessManager {
private final DshBinaryResolver binaryResolver;
private final DshProcessLauncher launcher;
private final Map<String, DshManagedProcess> active = new ConcurrentHashMap<>();
public DshProcessManager(DshBinaryResolver binaryResolver, DshProcessLauncher launcher) {
this.binaryResolver = binaryResolver;
this.launcher = launcher;
}
public DshManagedProcess start(RuntimeSession session) {
stop(session.sessionId());
Path binary = binaryResolver.resolve()
.filter(Files::isExecutable)
.orElseThrow(() -> new IllegalStateException("DSH binary is unavailable"));
Path sessionHome;
try {
sessionHome = Files.createTempDirectory("mateclaw-dsh-" + safeSessionId(session.sessionId()) + "-");
} catch (IOException e) {
throw new IllegalStateException("Unable to create DSH session home", e);
}
String bridgeToken = UUID.randomUUID().toString();
try {
DshProcessHandle process = launcher.launch(binary, session, sessionHome, bridgeToken);
if (process == null) throw new IllegalStateException("DSH launcher returned no process");
DshManagedProcess managed = new DshManagedProcess(process, session.sessionId(), binary,
sessionHome, bridgeToken, () -> active.remove(session.sessionId()));
active.put(session.sessionId(), managed);
return managed;
} catch (RuntimeException e) {
deleteSessionHome(sessionHome);
throw e;
}
}
public boolean stop(String sessionId) {
DshManagedProcess process = active.remove(sessionId);
if (process == null) return false;
process.close();
return true;
}
public Set<String> activeSessionIds() {
return Set.copyOf(active.keySet());
}
private static String safeSessionId(String sessionId) {
return sessionId.replaceAll("[^A-Za-z0-9._-]", "_");
}
private static void deleteSessionHome(Path path) {
try (var paths = Files.walk(path)) {
paths.sorted(java.util.Comparator.reverseOrder()).forEach(candidate -> {
try { Files.deleteIfExists(candidate); } catch (IOException ignored) { }
});
} catch (IOException ignored) { }
}
}

View File

@ -1,655 +0,0 @@
package vip.mate.agent.runtime.dsh;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.runtime.RuntimeEventProjector;
import vip.mate.agent.runtime.contract.RuntimeEvent;
import vip.mate.agent.runtime.contract.RuntimeEventType;
import vip.mate.agent.runtime.contract.RuntimeSession;
import vip.mate.agent.runtime.contract.AgentRuntimeConnection;
import vip.mate.agent.runtime.contract.AgentRuntimeProvider;
import vip.mate.agent.runtime.contract.RuntimeCapabilities;
import vip.mate.agent.runtime.contract.RuntimeContextUsage;
import vip.mate.agent.runtime.contract.RuntimeValidation;
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
import vip.mate.agent.AgentService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
/**
* Adapter for the official DeepSeek Harness SDK JSON-RPC runtime.
*
* <p>The runtime is intentionally an external process. This keeps the Node
* plugin graph out of the Spring classpath and lets deployments pin the DSH
* runtime independently from MateClaw.</p>
*/
@Service
@Slf4j
public class DshRuntimeService implements AgentRuntimeProvider {
private final ObjectMapper objectMapper;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final DshRuntimeConfigService runtimeConfigService;
public DshRuntimeService(
ObjectMapper objectMapper,
ModelConfigService modelConfigService,
ModelProviderService modelProviderService,
DshRuntimeConfigService runtimeConfigService) {
this.objectMapper = objectMapper;
this.modelConfigService = modelConfigService;
this.modelProviderService = modelProviderService;
this.runtimeConfigService = runtimeConfigService;
DshRuntimeConfiguration configuration = runtimeConfig();
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
}
private DshRuntimeConfiguration runtimeConfig() {
DshRuntimeConfiguration raw = runtimeConfigService.resolve();
String command = raw.executablePath();
if (command == null || command.isBlank()) command = "dsh-jsonrpc-agent";
String cordis = resolveCordisConfig(raw.cordisConfigPath());
String cwd = raw.workingDirectory();
if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir");
return new DshRuntimeConfiguration(command, cordis, cwd, raw.baseUrl(), raw.modelName(), raw.apiKey());
}
private String resolveCordisConfig(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) return "";
Path path = Path.of(configuredPath).toAbsolutePath().normalize();
if (Files.isRegularFile(path)) return path.toString();
// The documented source checkout path points at the package directory;
// the checked-in composition lives below its runtime subdirectory.
Path packageDirectory = Files.isDirectory(path) ? path : path.getParent();
Path packagedConfig = packageDirectory == null
? path
: packageDirectory.resolve("runtime").resolve("cordis.yml");
return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString();
}
@Override
public String type() {
return "dsh";
}
@Override
public RuntimeValidation validate(RuntimeSession session) {
DshRuntimeConfiguration configuration = runtimeConfig();
if (session == null || session.workspaceId() == null) {
return RuntimeValidation.invalid("dsh.workspace_required", "DSH runtime requires a workspace");
}
if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) {
return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable");
}
if (configuration.executablePath().isBlank()) {
return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured");
}
Path executable = Path.of(commandLine(configuration.executablePath()).get(0));
if (!executable.isAbsolute() || !Files.isExecutable(executable)) {
return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable");
}
if (!configuration.cordisConfigPath().isBlank() && !Files.isRegularFile(Path.of(configuration.cordisConfigPath()))) {
return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable");
}
return RuntimeValidation.success();
}
@Override
public RuntimeCapabilities capabilities() {
return new RuntimeCapabilities(true, false, true, true);
}
public Map<String, Object> diagnostics() {
DshRuntimeConfiguration configuration = runtimeConfig();
Path executable = configuration.executablePath().isBlank() ? null : Path.of(commandLine(configuration.executablePath()).get(0));
return Map.of(
"type", type(),
"commandConfigured", !configuration.executablePath().isBlank(),
"command", configuration.executablePath(),
"executable", executable == null ? "" : executable.toString(),
"executableAvailable", executable != null && Files.isExecutable(executable),
"cordisConfig", configuration.cordisConfigPath(),
"cordisConfigAvailable", !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())),
"workingDirectory", configuration.workingDirectory(),
"apiKeyConfigured", configuration.apiKey() != null && !configuration.apiKey().isBlank(),
"capabilities", Map.of(
"cancellation", true,
"approvals", false,
"subagents", true,
"contextUsage", true));
}
public void validateAgentConfiguration(AgentEntity agent) {
if (agent == null || agent.getWorkspaceId() == null) {
throw new IllegalArgumentException("dsh.workspace_required: DSH runtime requires a workspace");
}
if (agent.getRuntimeConfig() != null && !agent.getRuntimeConfig().isBlank()) {
try {
JsonNode node = objectMapper.readTree(agent.getRuntimeConfig());
if (node == null || !node.isObject()) throw new IllegalArgumentException();
} catch (Exception error) {
throw new IllegalArgumentException("dsh.runtime_config_invalid: runtime config must be a JSON object", error);
}
}
}
@Override
public AgentRuntimeConnection start(RuntimeSession session) {
RuntimeValidation validation = validate(session);
if (!validation.valid()) {
throw new IllegalArgumentException(validation.code() + ": " + validation.message());
}
AgentEntity agent = new AgentEntity();
agent.setId(session.agentId());
agent.setWorkspaceId(session.workspaceId());
agent.setModelName(session.modelName());
AtomicReference<Process> activeProcess = new AtomicReference<>();
AtomicReference<RuntimeContextUsage> latestUsage = new AtomicReference<>(
new RuntimeContextUsage(0, 0, 0));
return new AgentRuntimeConnection() {
@Override
public Flux<RuntimeEvent> prompt(String message) {
return stream(agent, message, session.conversationId(), session.modelName(),
session.workingDirectory(), activeProcess, latestUsage)
.map(DshRuntimeService.this::toRuntimeEvent);
}
@Override
public reactor.core.publisher.Mono<Void> cancel() {
return reactor.core.publisher.Mono.fromRunnable(
() -> cancelProcess(activeProcess.get()));
}
@Override
public reactor.core.publisher.Mono<RuntimeContextUsage> contextUsage() {
return reactor.core.publisher.Mono.just(latestUsage.get());
}
};
}
private RuntimeEvent toRuntimeEvent(AgentService.StreamDelta delta) {
if (delta == null) return RuntimeEvent.of("dsh", 0, RuntimeEventType.RUNTIME_READY, null, Map.of());
if (delta.content() != null) {
return RuntimeEvent.of("dsh", 0, RuntimeEventType.ASSISTANT_DELTA, delta.content(), Map.of());
}
if (delta.thinking() != null) {
return RuntimeEvent.of("dsh", 0, RuntimeEventType.THINKING_DELTA, delta.thinking(), Map.of());
}
RuntimeEventType type = switch (delta.eventType() == null ? "" : delta.eventType()) {
case "done" -> RuntimeEventType.COMPLETED;
case "error" -> RuntimeEventType.FAILED;
case "cancelled" -> RuntimeEventType.CANCELLED;
case "tool_call_started" -> RuntimeEventType.TOOL_STARTED;
case "tool_call_completed" -> RuntimeEventType.TOOL_FINISHED;
case "tool_approval_requested" -> RuntimeEventType.TOOL_APPROVAL_REQUIRED;
default -> RuntimeEventType.RUNTIME_READY;
};
return type.terminal()
? RuntimeEvent.terminal("dsh", 0, type, delta.eventData())
: RuntimeEvent.of("dsh", 0, type, null, delta.eventData());
}
public Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
String conversationId, String modelName) {
DshRuntimeConfiguration configuration = runtimeConfig();
return stream(agent, message, conversationId, modelName,
resolveWorkingDirectory(null, configuration), new AtomicReference<>(),
new AtomicReference<>(new RuntimeContextUsage(0, 0, 0)));
}
private Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
String conversationId, String modelName,
Path workingDirectory,
AtomicReference<Process> processRef,
AtomicReference<RuntimeContextUsage> latestUsage) {
return Flux.<AgentService.StreamDelta>create(sink -> {
Process process = null;
try {
if (sink.isCancelled()) return;
DshRuntimeConfiguration configuration = runtimeConfig();
RuntimeSession session = new RuntimeSession(
conversationId,
conversationId,
agent.getId(),
agent.getWorkspaceId(),
modelName,
workingDirectory,
Map.of());
// Each prompt runs in a fresh child process. DSH persists its
// own session log, so reusing the MateClaw conversation id
// would make the next turn look like a conflicting live session.
String dshSessionId = conversationId + "-" + UUID.randomUUID();
Files.createDirectories(session.workingDirectory());
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
ModelProviderEntity provider = resolveProvider(requestedModel);
String effectiveModelName = resolveModelName(requestedModel);
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
modelName == null || modelName.isBlank() ? "<default>" : modelName,
effectiveModelName,
provider == null ? "<missing>" : provider.getProviderId(),
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
List<String> command = commandLine(configuration.executablePath());
ProcessBuilder builder = new ProcessBuilder(command)
.directory(session.workingDirectory().toFile())
.redirectError(ProcessBuilder.Redirect.PIPE);
Map<String, String> environment = builder.environment();
Map<String, String> childEnvironment = childEnvironment(environment, session, configuration, provider);
environment.clear();
environment.putAll(childEnvironment);
log.debug("[DSH] child environment: keys={}, cordisConfig={}, exists={}",
environment.keySet(),
environment.getOrDefault("DSH_CORDIS_CONFIG", "<empty>"),
!configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())));
process = builder.start();
processRef.set(process);
if (sink.isCancelled()) {
cancelProcess(process);
return;
}
Process startedProcess = process;
Thread stderrLogger = new Thread(() -> logProcessStderr(startedProcess),
"dsh-runtime-stderr-" + conversationId);
stderrLogger.setDaemon(true);
stderrLogger.start();
sink.onCancel(() -> cancelProcess(startedProcess));
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
process.getOutputStream(), StandardCharsets.UTF_8));
BufferedReader reader = new BufferedReader(new InputStreamReader(
process.getInputStream(), StandardCharsets.UTF_8))) {
send(writer, request("initialize", "init-" + conversationId, Map.of(
"cwd", session.workingDirectory().toString(),
"provider", "deepseek-official",
"model", effectiveModelName)));
awaitResponse(reader, "init-" + conversationId);
long sequence = 0;
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null,
Map.of("runtimeProvider", "dsh", "runtimeCommand", configuration.executablePath()))));
String promptId = "prompt-" + conversationId;
send(writer, request("session/prompt", promptId, Map.of(
"sessionId", dshSessionId,
"contentBlocks", List.of(Map.of("type", "text", "text", message)))));
// DSH may emit session events before the JSON-RPC response
// for session/prompt. Read both on the same loop so those
// notifications are not discarded while waiting for id.
boolean terminal = false;
boolean promptResponseReceived = false;
String line;
while (!terminal && (line = reader.readLine()) != null) {
JsonNode payload = objectMapper.readTree(line);
if (payload == null) continue;
if (payload.has("id") && promptId.equals(payload.path("id").asText(null))) {
promptResponseReceived = true;
log.debug("[DSH] prompt response received: id={}, error={}", promptId,
payload.has("error"));
if (payload.has("error")) {
throw new IllegalStateException(payload.path("error").path("message")
.asText("DSH prompt failed"));
}
continue;
}
if (!payload.has("method")) continue;
String method = payload.path("method").asText();
JsonNode params = payload.path("params");
if (payload.has("id")) {
send(writer, errorResponse(payload.get("id"), -32601, "MateClaw does not support runtime request: " + method));
continue;
}
if ("session.event".equals(method)) {
JsonNode event = params.path("event");
log.debug("[DSH] event: type={}", event.path("type").asText("<missing>"));
logChunkMetadata(event);
logTerminalReason(event);
RuntimeEvent mapped = mapEvent(conversationId, sequence++, event);
if (mapped != null) {
if (mapped.type() == RuntimeEventType.CONTEXT_USAGE) {
latestUsage.set(usageFrom(mapped));
}
sink.next(RuntimeEventProjector.project(mapped));
terminal = mapped.terminal();
}
} else if ("session.status".equals(method)
&& promptResponseReceived
&& "idle".equals(params.path("status").asText())) {
log.debug("[DSH] session idle after prompt");
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
conversationId, sequence++, RuntimeEventType.COMPLETED, Map.of())));
terminal = true;
}
}
if (!terminal) {
int exitCode = process.waitFor();
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
conversationId, sequence, RuntimeEventType.FAILED,
Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")"))));
}
sink.complete();
}
} catch (Exception error) {
sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error));
if (process != null) process.destroyForcibly();
} finally {
if (process != null) processRef.compareAndSet(process, null);
}
}).subscribeOn(Schedulers.boundedElastic());
}
static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) {
if (session != null && session.workingDirectory() != null) {
return session.workingDirectory().toAbsolutePath().normalize();
}
return Path.of(configuration.workingDirectory()).toAbsolutePath().normalize();
}
static void cancelProcess(Process process) {
if (process == null || !process.isAlive()) return;
// DSH tools can spawn commands such as `sleep` that inherit the
// JSON-RPC process' stdout pipe. Close the pipes and terminate the
// descendants first; otherwise the parent may die while readLine()
// remains blocked until the child exits naturally.
try {
var descendants = process.descendants();
if (descendants != null) {
descendants.toList().forEach(DshRuntimeService::cancelProcessHandle);
}
} catch (Exception ignored) {
// The parent teardown below is still the best-effort fallback.
}
closeQuietly(process.getInputStream());
closeQuietly(process.getErrorStream());
closeQuietly(process.getOutputStream());
process.destroy();
if (process.isAlive()) process.destroyForcibly();
}
private static void cancelProcessHandle(ProcessHandle process) {
if (process == null || !process.isAlive()) return;
process.destroy();
if (process.isAlive()) process.destroyForcibly();
}
private static void closeQuietly(java.io.Closeable stream) {
if (stream == null) return;
try {
stream.close();
} catch (Exception ignored) {
// Cancellation is best effort; the process termination is authoritative.
}
}
private void logProcessStderr(Process process) {
try (BufferedReader errors = new BufferedReader(new InputStreamReader(
process.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = errors.readLine()) != null) {
log.warn("[DSH] {}", line);
}
} catch (IOException error) {
log.debug("[DSH] stderr reader closed: {}", error.getMessage());
}
}
static List<String> commandLine(String commandLine) {
List<String> result = new ArrayList<>();
StringBuilder token = new StringBuilder();
char quote = 0;
boolean escaped = false;
for (char current : commandLine == null ? "".toCharArray() : commandLine.toCharArray()) {
if (escaped) {
token.append(current);
escaped = false;
} else if (current == '\\') {
escaped = true;
} else if (quote != 0) {
if (current == quote) quote = 0;
else token.append(current);
} else if (current == '\'' || current == '"') {
quote = current;
} else if (Character.isWhitespace(current)) {
if (!token.isEmpty()) {
result.add(token.toString());
token.setLength(0);
}
} else {
token.append(current);
}
}
if (escaped) token.append('\\');
if (quote != 0) throw new IllegalArgumentException("DSH runtime command has an unterminated quote");
if (!token.isEmpty()) result.add(token.toString());
if (result.isEmpty()) throw new IllegalStateException("DSH runtime command is empty");
log.debug("[DSH] launching command: {}", result);
return result;
}
static Map<String, String> childEnvironment(Map<String, String> inherited,
RuntimeSession session,
DshRuntimeConfiguration configuration,
ModelProviderEntity provider) {
Map<String, String> environment = new LinkedHashMap<>();
copyIfPresent(inherited, environment, "PATH");
copyIfPresent(inherited, environment, "HOME");
copyIfPresent(inherited, environment, "USERPROFILE");
copyIfPresent(inherited, environment, "TMPDIR");
copyIfPresent(inherited, environment, "TEMP");
copyIfPresent(inherited, environment, "TMP");
copyIfPresent(inherited, environment, "SystemRoot");
copyIfPresent(inherited, environment, "WINDIR");
environment.put("DSH_CWD", session.workingDirectory().toString());
putIfPresent(environment, "DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
putIfPresent(environment, "DEEPSEEK_API_KEY",
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()));
putIfPresent(environment, "DEEPSEEK_BASE_URL",
firstNonBlank(configuration.baseUrl(), provider == null ? null : provider.getBaseUrl()));
return environment;
}
private static void copyIfPresent(Map<String, String> source, Map<String, String> target, String key) {
if (source == null) return;
putIfPresent(target, key, source.get(key));
}
private static void putIfPresent(Map<String, String> target, String key, String value) {
if (value == null || value.isBlank()) return;
target.put(key, value);
}
private static String firstNonBlank(String primary, String fallback) {
return primary != null && !primary.isBlank() ? primary : fallback;
}
private ModelProviderEntity resolveProvider(String modelName) {
ModelConfigEntity model = null;
try {
model = modelConfigService.resolveModel(modelName);
} catch (RuntimeException ignored) {
// Fall back to the dedicated DeepSeek provider below.
}
if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) {
try {
return modelProviderService.getProviderConfig(model.getProvider());
} catch (RuntimeException ignored) {
// The model row may outlive its provider row; use the runtime default.
}
}
try {
return modelProviderService.getProviderConfig("deepseek");
} catch (RuntimeException ignored) {
return null;
}
}
private String resolveModelName(String modelName) {
try {
ModelConfigEntity model = modelConfigService.resolveModel(modelName);
if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) {
return model.getModelName();
}
} catch (RuntimeException ignored) {
// Fall back to the DSH catalog default for a not-yet-configured agent.
}
return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName;
}
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
String type = event.path("type").asText("");
JsonNode data = event.path("data");
if ("assistant/chunk".equals(type)) {
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
if ("usage".equals(chunk.path("type").asText())) {
JsonNode usage = chunk.path("usage");
long inputTokens = usage.path("inputTokens").asLong(0);
long outputTokens = usage.path("outputTokens").asLong(0);
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.CONTEXT_USAGE,
null, Map.of(
"promptTokens", inputTokens,
"completionTokens", outputTokens,
"inputTokens", inputTokens,
"outputTokens", outputTokens));
}
String text = firstText(chunk, data);
if (text != null && !text.isEmpty()) {
RuntimeEventType eventType = "reasoning-delta".equals(chunk.path("type").asText())
? RuntimeEventType.THINKING_DELTA
: RuntimeEventType.ASSISTANT_DELTA;
return RuntimeEvent.of(sessionId, sequence, eventType, text,
Map.of("chunkType", chunk.path("type").asText("unknown")));
}
if ("finish".equals(chunk.path("type").asText())
&& "error".equals(chunk.path("reason").path("kind").asText())) {
JsonNode failure = chunk.path("reason").path("failure");
return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED,
Map.of("error", failure.path("message").asText("DSH assistant failed"),
"code", failure.path("code").asText("DSH_RUNTIME_ERROR")));
}
}
// The DSH stream emits text-delta chunks followed by an assistant/message
// snapshot. Mapping both would append the same answer twice to the UI.
if ("text-delta".equals(type)) {
String text = firstText(data, event);
if (text != null && !text.isEmpty()) {
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.ASSISTANT_DELTA, text, Map.of());
}
}
if (type.contains("tool") && (type.contains("start") || type.contains("call"))) {
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_STARTED, null,
Map.of("toolName", data.path("toolName").asText("dsh-tool")));
}
if (type.contains("tool") && (type.contains("end") || type.contains("result"))) {
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_FINISHED, null, Map.of());
}
if ("turn/end".equals(type)) {
String kind = data.path("reason").path("kind").asText("");
if ("error".equals(kind)) {
return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED,
Map.of("error", data.path("reason").path("error").path("message").asText("DSH turn failed")));
}
}
return null;
}
private RuntimeContextUsage usageFrom(RuntimeEvent event) {
return new RuntimeContextUsage(
number(event.data().get("inputTokens")),
number(event.data().get("outputTokens")),
number(event.data().get("contextWindow")));
}
private long number(Object value) {
return value instanceof Number number ? Math.max(0, number.longValue()) : 0;
}
private String firstText(JsonNode primary, JsonNode fallback) {
String text = primary.path("text").asText(null);
if (text != null) return text;
text = primary.path("delta").path("text").asText(null);
if (text != null) return text;
text = fallback.path("text").asText(null);
if (text != null) return text;
return fallback.path("delta").path("text").asText(null);
}
private void logChunkMetadata(JsonNode event) {
if (!"assistant/chunk".equals(event.path("type").asText())) return;
JsonNode data = event.path("data");
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
log.debug("[DSH] assistant chunk: type={}, fields={}, dataFields={}, textPresent={}, textLength={}",
chunk.path("type").asText("<missing>"),
chunk.fieldNames().hasNext(), data.fieldNames().hasNext(),
chunk.has("text"), chunk.path("text").isTextual() ? chunk.path("text").textValue().length() : 0);
}
private void logTerminalReason(JsonNode event) {
String type = event.path("type").asText("");
if (!"assistant/chunk".equals(type) && !"turn/end".equals(type)) return;
JsonNode reason = "assistant/chunk".equals(type)
? event.path("data").path("chunk").path("reason")
: event.path("data").path("reason");
if (reason.isMissingNode() || reason.isNull()) return;
JsonNode failure = reason.path("failure").isMissingNode()
? reason.path("error") : reason.path("failure");
log.warn("[DSH] terminal reason: eventType={}, kind={}, code={}, message={}",
type,
reason.path("kind").asText("<missing>"),
failure.path("code").asText("<none>"),
failure.path("message").asText("<none>"));
}
private void awaitResponse(BufferedReader reader, String id) throws IOException {
String line;
while ((line = reader.readLine()) != null) {
JsonNode payload = objectMapper.readTree(line);
if (payload != null && id.equals(payload.path("id").asText(null))) {
if (payload.has("error")) {
throw new IllegalStateException(payload.path("error").path("message").asText("DSH JSON-RPC error"));
}
return;
}
}
throw new IOException("DSH runtime closed while waiting for " + id);
}
private Map<String, Object> request(String method, String id, Map<String, Object> params) {
return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params);
}
private Map<String, Object> errorResponse(JsonNode id, int code, String message) {
return Map.of("jsonrpc", "2.0", "id", objectMapper.convertValue(id, Object.class),
"error", Map.of("code", code, "message", message));
}
private void send(BufferedWriter writer, Map<String, Object> payload) throws IOException {
writer.write(objectMapper.writeValueAsString(payload));
writer.newLine();
writer.flush();
}
}

View File

@ -1,22 +0,0 @@
package vip.mate.agent.runtime.dsh;
import org.springframework.ai.tool.ToolCallback;
import java.util.LinkedHashMap;
import java.util.List;
public final class DshToolCatalog {
private DshToolCatalog() {}
public static List<DshToolDescriptor> fromCallbacks(List<ToolCallback> callbacks) {
LinkedHashMap<String, DshToolDescriptor> descriptors = new LinkedHashMap<>();
if (callbacks == null) return List.of();
for (ToolCallback callback : callbacks) {
if (callback == null || callback.getToolDefinition() == null) continue;
var definition = callback.getToolDefinition();
descriptors.putIfAbsent(definition.name(), new DshToolDescriptor(
definition.name(), definition.description(), definition.inputSchema()));
}
return List.copyOf(descriptors.values());
}
}

View File

@ -1,7 +0,0 @@
package vip.mate.agent.runtime.dsh;
public enum DshToolDecision {
ALLOW,
APPROVAL,
DENY
}

View File

@ -1,9 +0,0 @@
package vip.mate.agent.runtime.dsh;
public record DshToolDescriptor(String name, String description, String inputSchema) {
public DshToolDescriptor {
if (name == null || name.isBlank()) throw new IllegalArgumentException("tool name is required");
description = description == null ? "" : description;
inputSchema = inputSchema == null || inputSchema.isBlank() ? "{}" : inputSchema;
}
}

View File

@ -1,15 +0,0 @@
package vip.mate.agent.runtime.dsh;
public record DshToolDispatchResult(DshToolDecision decision, String output, String error) {
public static DshToolDispatchResult allowed(String output) {
return new DshToolDispatchResult(DshToolDecision.ALLOW, output, null);
}
public static DshToolDispatchResult denied(String error) {
return new DshToolDispatchResult(DshToolDecision.DENY, null, error);
}
public static DshToolDispatchResult approval(String reason) {
return new DshToolDispatchResult(DshToolDecision.APPROVAL, null, reason);
}
}

View File

@ -1,42 +0,0 @@
package vip.mate.agent.runtime.dsh;
import org.springframework.ai.tool.ToolCallback;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class DshToolDispatcher {
private final Map<String, ToolCallback> callbacks;
private final DshToolPolicy policy;
private final DshToolPolicyEvaluator policyEvaluator;
public DshToolDispatcher(List<ToolCallback> callbacks, DshToolPolicy policy,
DshToolPolicyEvaluator policyEvaluator) {
Map<String, ToolCallback> byName = new LinkedHashMap<>();
if (callbacks != null) {
for (ToolCallback callback : callbacks) {
if (callback != null && callback.getToolDefinition() != null) {
byName.putIfAbsent(callback.getToolDefinition().name(), callback);
}
}
}
this.callbacks = Map.copyOf(byName);
this.policy = policy;
this.policyEvaluator = policyEvaluator;
}
public DshToolDispatchResult dispatch(String toolName, String argumentsJson, Path targetPath) {
ToolCallback callback = callbacks.get(toolName);
if (callback == null) return DshToolDispatchResult.denied("unknown tool");
DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath);
if (decision == DshToolDecision.DENY) return DshToolDispatchResult.denied("tool denied by policy");
if (decision == DshToolDecision.APPROVAL) return DshToolDispatchResult.approval("tool approval required");
try {
return DshToolDispatchResult.allowed(callback.call(argumentsJson == null ? "{}" : argumentsJson));
} catch (RuntimeException e) {
return DshToolDispatchResult.denied("tool execution failed");
}
}
}

View File

@ -1,21 +0,0 @@
package vip.mate.agent.runtime.dsh;
import java.nio.file.Path;
import java.util.Set;
public record DshToolPolicy(
Path workspaceRoot,
String permissionMode,
Set<String> disabledTools,
Set<String> readTools,
Set<String> editTools,
Set<String> autoApprovedTools
) {
public DshToolPolicy {
permissionMode = permissionMode == null ? "read-only" : permissionMode;
disabledTools = disabledTools == null ? Set.of() : Set.copyOf(disabledTools);
readTools = readTools == null ? Set.of() : Set.copyOf(readTools);
editTools = editTools == null ? Set.of() : Set.copyOf(editTools);
autoApprovedTools = autoApprovedTools == null ? Set.of() : Set.copyOf(autoApprovedTools);
}
}

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