Compare commits

..

11 Commits
dev ... v1.8.0

Author SHA1 Message Date
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
1093 changed files with 5598 additions and 88706 deletions

19
.gitattributes vendored
View File

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

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

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,7 @@ 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.
### 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.
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; 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 +87,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 +98,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 +107,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 +135,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 +150,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 +187,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 +203,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 |
| 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,39 +220,6 @@ 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:
- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation
- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale
- **Plan-Execute plans hand over to the board** — steps become tasks, dependencies become parallelism, a parked-plan resume gate synthesizes deterministically
- **Workspace isolation fully sealed** — channel-scoped conversation ids; same-named skills coexist per workspace with conversation-scoped runtime resolution
- **Channel experience** — magic commands on every channel (`/new` `/clear` `/status` `/stop` `/model` `/help`), WeCom's event-driven progress bubble (live tool trace + per-stage rolling narration)
- **Server-side rewind / regenerate** · **explainable auto-approval misses** (reason codes on audit rows + one-click grant creation) · **policy-driven LLM error recovery** (overload vs rate-limit split · `Retry-After`-aware backoff · provider TTL readmission)
Plus: in-chat attachment preview (pdf / docx / xlsx / html / text), single-source SKILL.md + console bundle-file management, the optional Mem0 plugin memory provider, and the knowledge-graph relation schema whitelist.
Full story in the [v2.0.0 release notes](https://claw.mate.vip/docs/en/releases/2.0.0).
**v1.8.0 (shipped 2026-07-12)** — the employee turns *outward and does a whole job*: **Content Studio**, the first flagship scene built end-to-end on MateClaw's own primitives:
- **Content Studio — one sentence to a publishable post** — a seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver. **WeChat Official Account (公众号)** image-text articles (inline-style HTML → draft box) and **Xiaohongshu (小红书)** image-first notes (≥3 vertical 3:4 cards + online preview) ship first-class
@ -311,7 +258,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,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
## 盒子里有什么
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
### 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 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
@ -104,7 +87,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 +98,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 +135,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 +150,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 +187,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 +203,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 自我进化 |
| 业务编排 | 工作流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,39 +220,6 @@ 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 团队**成为常设编制,围绕共享任务板工作:
- **Agent 团队与共享任务板** — 团队 / 角色lead · member · reviewer、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 LeadTeams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
- **Plan-Execute 计划整体移交任务板** — 步骤变任务、依赖变并行、停靠恢复门确定性汇总
- **工作空间隔离全面收口** — 渠道会话 id 编入渠道标识、同名技能跨工作空间共存且运行时按会话工作空间解析
- **渠道体验** — 全渠道魔法命令(`/new` `/clear` `/status` `/stop` `/model` `/help`)、企业微信事件驱动进度气泡(实时工具轨迹 + 分阶段滚动叙述)
- **会话回退 / 重新生成服务端语义** · **自动批准未命中可解释**(原因码落审计行 + 一键补策略) · **LLM 错误恢复策略化**(过载/限流分治 · `Retry-After` 回馈退避 · provider TTL 回收)
外加聊天附件在线预览pdf / docx / xlsx / html / 文本、SKILL.md 单一事实源 + 捆绑文件控制台管理、Mem0 可选插件记忆 provider、知识图谱关系模式白名单。
完整故事见 [v2.0.0 release notes](https://claw.mate.vip/docs/zh/releases/2.0.0)。
**v1.8.02026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景:
- **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民
@ -311,7 +258,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)"/>
@ -99,10 +98,10 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Orchestration tier: Team board + Workflow + Trigger ===== -->
<rect x="300" y="358" width="360" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Team Board (2.0.0+) + Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Lead decomposes → members run in parallel → approve / deliver</text>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>

Before

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

Before

Width:  |  Height:  |  Size: 9.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">Workflow + Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
</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">7 step mode · 6 pattern</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排1.3.0+</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

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

View File

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

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

View File

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

View File

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

View File

@ -1,20 +0,0 @@
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.
*
* @author MateClaw Team
*/
class Mem0Exception extends RuntimeException {
Mem0Exception(String message) {
super(message);
}
Mem0Exception(String message, Throwable cause) {
super(message, cause);
}
}

View File

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

View File

@ -1,207 +0,0 @@
package vip.mate.plugin.mem0;
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;
/**
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
* Mem0 service.
* <p>
* Behavior matrix:
* <ul>
* <li>{@code systemPromptBlock} no-op (returns ""), aligns with SessionSearchProvider</li>
* <li>{@code prefetch(agentId, query, ownerKey)} when {@code searchEnabled}
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
* and returns a {@code [Mem0 Recall]} block. Failures propagate to the
* platform's timeout/circuit-breaker boundary.</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
* variant (no ownerKey) skips writing under any other identifier
* would produce memories that owner-scoped recall can never surface.</li>
* <li>{@code getToolBeans} empty (no agent-facing tools in v1)</li>
* </ul>
*
* <p>Per-owner isolation: {@code ownerKey} (e.g. {@code "user:42"}) is passed
* verbatim as Mem0's {@code user_id}; {@code agentId} as Mem0's {@code agent_id}.
* When {@code ownerKey} is null/blank, both recall and sync are skipped Mem0
* requires {@code user_id}.
*
* <p>Asynchronous sync: a single-thread daemon executor with a bounded queue
* prevents an unavailable Mem0 service from growing heap usage without limit.
*
* @author MateClaw Team
*/
class Mem0Provider implements PluginMemoryProvider {
static final String ID = "mem0";
private final Mem0Config config;
private final Mem0Client client;
private final Logger log;
private final ThreadPoolExecutor async;
private final AtomicLong droppedSyncCount = new AtomicLong();
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 -> {
Thread t = new Thread(r, "mem0-sync");
t.setDaemon(true);
return t;
}, new ThreadPoolExecutor.AbortPolicy());
}
@Override
public String id() {
return ID;
}
@Override
public int order() {
// Same as the SPI default; declared explicitly for clarity.
return 200;
}
@Override
public boolean isAvailable() {
// Provider is "available" if at least one of recall/sync can fire.
return config.isUsable() && (config.searchEnabled() || config.syncEnabled());
}
@Override
public String systemPromptBlock(Long agentId) {
return "";
}
@Override
public String prefetch(Long agentId, String userQuery) {
// Two-arg variant: no owner key cannot isolate per-user skip.
// Mem0 requires user_id; without it the call would either fail or
// return global memories breaking per-owner isolation.
return "";
}
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
if (!config.searchEnabled()) {
return "";
}
if (ownerKey == null || ownerKey.isBlank()) {
return "";
}
if (userQuery == null || userQuery.isBlank()) {
return "";
}
List<String> memories = client.searchMemories(
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
if (memories.isEmpty()) {
return "";
}
return formatRecallBlock(memories);
}
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply) {
// Four-arg variant: no owner key skip. Mem0 keys memories by user_id;
// writing under any fallback identifier (e.g. agentId) would store
// memories that owner-scoped prefetch can never recall.
}
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply, String ownerKey) {
if (!config.syncEnabled()) {
return;
}
if (ownerKey == null || ownerKey.isBlank()) {
// Same guard as prefetch: Mem0 requires user_id; without the owner
// key the write would break per-owner isolation.
return;
}
if ((userMessage == null || userMessage.isBlank())
&& (assistantReply == null || assistantReply.isBlank())) {
return;
}
try {
async.execute(() -> {
try {
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
conversationId, userMessage, assistantReply);
} catch (Exception e) {
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
agentId, ownerKey, e.getMessage());
}
});
} 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());
}
}
@Override
public void onSessionEnd(Long agentId, String conversationId) {
// No Mem0-specific session cleanup needed in v1.
}
/**
* Format the recalled memories into a labeled block.
* <p>
* The {@code [Mem0 Recall]} label is intentional: it lets the LLM
* distinguish this block from the local providers' output and avoid
* treating it as authoritative PROFILE.md content.
*/
private String formatRecallBlock(List<String> memories) {
StringBuilder sb = new StringBuilder();
sb.append("[Mem0 Recall — semantic matches from external service, treat as hints]\n");
for (int i = 0; i < memories.size(); i++) {
sb.append(i + 1).append(". ").append(memories.get(i)).append('\n');
}
return sb.toString();
}
}

View File

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

View File

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

View File

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

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

View File

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

@ -42,19 +42,12 @@ ARG MAVEN_FLAGS=""
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
# Copy the root parent plus module POMs first for Docker layer caching.
#
# This list MUST mirror <modules> in the root pom.xml, even for modules this
# image never builds. Maven fails while constructing the reactor if a declared
# module directory is missing ("Child module /build/<name> does not exist"),
# so `-pl mateclaw-server -am` aborts before it ever gets to dependency
# resolution. When a module is added to the root POM, add its pom.xml here too.
WORKDIR /build
COPY pom.xml ./pom.xml
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml
COPY mateclaw-plugin-mem0/pom.xml mateclaw-plugin-mem0/pom.xml
# Pre-fetch backend dependencies through the reactor so the parent POM,
# dependencyManagement, and internal module versions all resolve consistently.
@ -78,7 +71,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;
@ -66,8 +60,6 @@ import vip.mate.tool.guard.service.ToolGuardService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamContextBuilder;
import vip.mate.team.service.TeamPlanBridge;
import vip.mate.wiki.service.WikiContextService;
import java.lang.reflect.Field;
@ -104,20 +96,11 @@ 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}")
private boolean markdownNormalizeEnabled;
private final ConversationService conversationService;
private final TeamContextBuilder teamContextBuilder;
private final TeamPlanBridge teamPlanBridge;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver;
@ -182,24 +165,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 +382,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,19 +434,24 @@ 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 (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
if (!isPlanExecute) {
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
}
// 当前仅支持 DashScope OpenAI-compatible其他协议直接拒绝
@ -488,8 +463,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 +567,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 +583,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 +611,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 +625,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,23 +635,13 @@ 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) {
executor.setAuditEventService(auditEventService);
}
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
// the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
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 +676,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 +722,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
@ -848,10 +785,7 @@ public class AgentGraphBuilder {
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE,
// Board-delegated plan settled: step results were
// rebuilt from team tasks summarize directly.
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE))
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
Map.of(
@ -947,13 +881,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 +922,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 +932,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) {
@ -1036,9 +960,6 @@ public class AgentGraphBuilder {
// C4: wire the environment-notification registry so ReasoningNode
// can drain pending MCP/skill events and inject them as a SystemMessage.
reasoningNode.setRunningConversationRegistry(runningConversationRegistry);
// Live team-board snapshot for leads, injected per turn as a meta
// user message; no-op for agents outside any team.
reasoningNode.setTeamContextBuilder(teamContextBuilder);
ActionNode actionNode = new ActionNode(executor, streamTracker);
// B2/B5: wire optional collaborators so ActionNode can pin skill
// constraints and auto-record tool completions into ProgressLedger.
@ -1079,7 +1000,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 +1056,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 +1108,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 +1669,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 +1703,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.
@ -1828,20 +1743,10 @@ public class AgentGraphBuilder {
""";
}
// Wiki 知识库上下文注入Share the same prefix budget as the memory
// block so a large KB's page listing can't consume a fixed
// maxContextChars-sized slice of a small model's window every turn
// (issue #521). Integer.MAX_VALUE (the unbudgeted default path) keeps
// the legacy chars-only cap for large cloud models.
Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens;
String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens);
// Wiki 知识库上下文注入
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
// Team context (role-specific board playbook, or a negative notice for
// agents outside any team). Baked here so it shares the prompt-cache
// prefix; TeamChangedEvent evicts the cached agent on composition changes.
String teamContext = teamContextBuilder.buildTeamContext(entity.getId());
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext;
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext;
}
/**
@ -1874,7 +1779,7 @@ public class AgentGraphBuilder {
boolean anyHasConstraints = false;
for (String skillName : loaded) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName, workspaceId);
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName);
if (skill != null && skill.getManifest() != null) {
List<String> constraints = skill.getManifest().getConstraints();
if (constraints != null && !constraints.isEmpty()) {

View File

@ -13,7 +13,6 @@ import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.progress.ProgressLedgerService;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
@ -22,14 +21,12 @@ import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.team.event.TeamChangedEvent;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
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,24 +68,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
* {@code AgentService} don't need to supply it.
*/
@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 +122,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 +146,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,59 +226,21 @@ 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());
}
}
/**
* Invalidate cached agents whenever their team's composition or settings
* change. The team context block is baked into the system prompt at build
* time, so membership edits would otherwise stay invisible until restart.
*/
@EventListener
public void onTeamChanged(TeamChangedEvent event) {
if (event.agentIds() != null) {
event.agentIds().forEach(agentInstances::remove);
}
}
// ==================== 运行时入口 ====================
/**
* New-user-turn housekeeping: drop auto-recorded ledger entries left
* over from the previous turn. They mark past tool calls as DONE, and
* the ledger snapshot's "已完成的步骤不要重复执行" instruction would
* otherwise stop the agent from re-running status-query tools when the
* user repeats a question that needs fresh data.
*
* <p>Only the fresh-turn entries ({@code chat} / {@code chatStream} /
* {@code chatStructuredStream} / {@code execute}) call this. The
* approval-replay entries ({@code chatWithReplay*}) resume the SAME
* logical turn after a tool approval and must keep the safety net for
* work already done before the pause.
*/
private void clearAutoRecordedForNewTurn(String conversationId) {
// 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;
}
try {
progressLedgerService.clearAutoRecorded(conversationId);
} catch (Exception e) {
// Ledger housekeeping must never block the chat itself.
log.warn("Failed to clear auto-recorded ledger entries for {}: {}",
conversationId, e.getMessage());
}
}
public String chat(Long agentId, String message, String conversationId) {
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
}
@ -323,12 +251,7 @@ public class AgentService {
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
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 {
@ -364,14 +287,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.
@ -407,21 +323,7 @@ public class AgentService {
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
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 执行
@ -467,8 +369,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 +396,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 +424,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 +460,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 +568,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 +596,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 +616,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 +634,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 +712,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 +751,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 +791,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);
}
String runtimeModel, String runtimeProvider) {
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

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

View File

@ -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

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

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

View File

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

View File

@ -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,37 +51,17 @@ 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";
/**
* Tools whose results should NOT be auto-recorded into the ledger.
* Two groups:
* <ul>
* <li><b>Meta-tools</b> (load_skill, enable_tool, progress_update,
* skill helpers) they either have their own ledger side-effects
* or are the ledger itself.</li>
* <li><b>Read-only / status-query tools</b> querying live state is
* not a task step that must not be repeated. Recording it as DONE
* (with a frozen result excerpt in the note) pushes the model to
* answer follow-up questions from stale output instead of
* re-checking, because the snapshot instructs "已完成的步骤不要
* 重复执行".</li>
* </ul>
* Meta-tools (load_skill, enable_tool, progress_update) either have
* their own ledger side-effects or are the ledger itself.
*/
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
"listAvailableSkills", "readSkillFile", "runSkillScript",
// read-only / status-query tools
"read_file", "web_search",
"extract_document_text", "extract_pdf_text", "extract_docx_text",
"detect_file_type",
"getCurrentDateTime", "getCurrentDate", "getCurrentTime",
"listSubagents"
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
"listAvailableSkills", "readSkillFile", "runSkillScript"
);
private final ToolExecutionExecutor executor;
@ -144,8 +123,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 +132,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 +175,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 +190,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();
}
@ -241,8 +213,7 @@ public class ActionNode implements NodeAction {
}
for (String skillName : skillNames) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(
skillName, executor.workspaceIdForConversation(conversationId));
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName);
if (skill == null || skill.getManifest() == null) {
continue;
}
@ -272,41 +243,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 ====================
/**
@ -319,14 +255,8 @@ public class ActionNode implements NodeAction {
* avoid collisions between servers that expose tools with the same slug.
* The display label uses the simplified slug for readability.
*/
void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> responses) {
autoRecordToolCalls(conversationId, responses, null);
}
void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> responses,
ActionExecutionLedger executionLedger) {
private void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> responses) {
if (progressLedgerService == null || conversationId == null
|| conversationId.isBlank() || responses == null || responses.isEmpty()) {
return;
@ -335,12 +265,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 +331,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,11 @@ 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 +58,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 +135,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 +234,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 +256,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 +272,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 +285,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;
}
@ -522,18 +390,6 @@ public class ReasoningNode implements NodeAction {
this.runningConversationRegistry = runningConversationRegistry;
}
/**
* Live team-board snapshot source for agents leading a team. When non-null,
* each turn's prompt prefix carries the board's in-flight tasks as a meta
* user message so the lead never duplicates or prematurely closes work.
* Null in tests / legacy paths injection is simply skipped.
*/
private TeamContextBuilder teamContextBuilder;
public void setTeamContextBuilder(TeamContextBuilder teamContextBuilder) {
this.teamContextBuilder = teamContextBuilder;
}
/** Floor for the window-aware output clamp — an answer needs at least this much room. */
private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512;
@ -964,7 +820,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 +1099,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 +1130,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 +1163,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 +1174,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)
@ -1599,20 +1318,6 @@ public class ReasoningNode implements NodeAction {
// agentId not numeric skip wiki injection (matches prior behavior).
}
}
// Live team-board snapshot for leads: a UserMessage (never SystemMessage,
// per the runtime-context cache discipline) listing in-flight tasks, so a
// lead mid-conversation neither duplicates nor prematurely closes work.
// buildBoardSnapshot returns null for non-leads and idle boards.
if (teamContextBuilder != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
String boardSnapshot = teamContextBuilder.buildBoardSnapshot(Long.parseLong(agentIdStr));
if (boardSnapshot != null && !boardSnapshot.isBlank()) {
prefix.add(new UserMessage(boardSnapshot));
}
} catch (NumberFormatException ignored) {
// agentId not numeric skip board injection.
}
}
return prefix;
}

View File

@ -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
? AgentService.StreamDelta.persistOnly(summary, null)
: new AgentService.StreamDelta(summary, null));
});
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(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

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

View File

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

View File

@ -74,9 +74,7 @@ public class PlanSummaryNode implements NodeAction {
Prompt prompt = new Prompt(List.of(
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
+ "直接回答用户的原始问题,不要罗列步骤。"
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"
+ "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。"
+ "若某些步骤未完成,如实说明未完成的部分及原因。"),
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
new UserMessage(userContent.toString())
));
@ -85,68 +83,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,26 +379,18 @@ public class StepExecutionNode implements NodeAction {
}
} else {
// 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier
if (!allToolCalls.isEmpty()) {
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
? execResult.barrierToolName() : "unknown";
}
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
? 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);
}
// 将工具响应追加到消息
@ -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

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

View File

@ -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
) {}

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