Compare commits

..

48 Commits
v2.1.0 ... dev

Author SHA1 Message Date
mateaix
00d774fb85 test(webchat): align stream assertions with delta batching 2026-09-12 18:15:52 +08:00
mateaix
bfc5417508 feat(memory): harden recall and provider resilience 2026-09-12 13:03:39 +08:00
matevip
7e36ac7749 perf(execution): batch evidence attempt queries 2026-09-11 04:39:40 -04:00
mateaix
dfae7edbe0 fix(memory): isolate recall ledger by owner 2026-09-08 22:23:45 +08:00
mateaix
c59b6b6448 fix(chat): settle UI state after stop fallback 2026-09-08 21:39:08 +08:00
matevip
7406fc99bf feat(execution): add persistent execution evidence in observe mode 2026-09-07 06:38:58 -04:00
matevip
26150b68e8 feat(team): add controlled worker intervention 2026-09-04 04:18:41 -04:00
matevip
bd41a71826 fix(cron): harden long-task reliability 2026-09-02 05:56:06 -04:00
matevip
b706f9a610 fix(memory): prevent transient constraints from becoming durable (#625) 2026-09-01 05:52:41 -04:00
matevip
7e2dc55b5d fix(dsh): persist managed runtime settings (#626) 2026-09-01 01:54:51 -04:00
matevip
631b8ab02a chore: bump version to 2.3.0-SNAPSHOT 2026-08-31 05:10:55 -04:00
matevip
d8976ddf1e docs(runtime): document restart-safe long tasks 2026-08-31 03:03:51 -04:00
matevip
dd4db18ed8 fix(runtime): harden long-task recovery and checkpoint writes 2026-08-30 23:13:42 -04:00
mateaix
97125b991e docs(delegation): clarify long-task acceptance 2026-08-30 14:47:40 +08:00
mateaix
701b99b3e9 fix(delegation): bound and cancel async agent tasks 2026-08-30 13:32:22 +08:00
mateaix
fa67d259b7 docs(readme): center v2.2.0 on agent runtimes 2026-08-29 14:35:57 +08:00
mateaix
08a5bf6996 release: v2.2.0 2026-08-29 13:53:11 +08:00
matevip
7eb44731e7 fix(chat): stabilize long-task input recovery 2026-08-27 05:21:59 -04:00
matevip
01ed4a4fcd feat(goal): make long tasks recoverable and pace continuation dispatch 2026-08-27 03:49:15 -04:00
taobig
2090d09704 fix(goal): preserve continuation instructions and verified progress
Keep autonomous prompts separate from persisted user messages while retaining
conversation history and queued user attachment routing. Carry verified
checklist evidence across segments and evaluate only changed criteria.

Cancel model streams without interrupting checkpoint database writes. Fence
late worker admission during shutdown, persist accepted queued messages and
attachments, and leave interrupted execution leases recoverable.

Add regressions for prompt selection, cumulative evidence, cancellation I/O,
queued input durability and shutdown recovery. Verify 250 focused tests,
200 real General Assistant conversation rounds, 12 checkpoints across 13
autonomous segments, and pause/resume/disconnect/restart boundaries.
2026-08-26 06:17:05 -04:00
taobig
2fa2e60170 feat(goal): persist continuous execution across bounded turns
- Add a database-backed supervisor with durable scheduling, fenced leases,
  cooldowns, bounded worker concurrency and expired-lease restart recovery.
- Default new goals to persistent execution with zero meaning unlimited
  cumulative budget; preserve legacy goals and explicit positive limits.
- Yield bounded graph segments to the supervisor instead of ending unfinished
  goals at graph-local continuation limits. Require persisted checklist
  evidence before accepting completion, including concurrent criterion edits.
- Share conversation admission across interactive and background execution;
  preserve partial replies, usage and queued user input during interruption.
- Persist Stop and missing-input pauses, respect approval boundaries, and
  commit resume and approval-denial transitions with correct transactions.
- Retry identifiable transient failures with backoff; retain visible pauses
  for budget limits and errors that require review instead of replaying tools.
- Expose owner-authorized execution status and reconnectable scheduling events;
  add H2, MySQL and Kingbase migrations, API types and bilingual documentation.

Validation: 298 focused backend tests passed, including persistence, restart
scheduling, approval races, cancellation, admission and existing runtime tests.
Frontend type checking, bundled-doc parity and ID precision checks passed.
V188 is registered and all three dialects have unique migration versions;
the migration-map audit still reports 95 pre-existing missing registrations.

Scope: single-backend native runtime. Recovery checks existing state before
repeating effects; this does not promise exactly-once external tool execution.
2026-08-26 05:46:15 -04:00
matevip
fb94027038 fix(dsh): isolate runtime child environment 2026-08-26 05:13:55 -04:00
matevip
ab5a0a651b fix(live): speed up live status loading (#615) 2026-08-25 04:12:19 -04:00
matevip
bc2b4613f7 fix(ui): restore chat state after conversation resume (#618) 2026-08-24 23:17:48 -04:00
matevip
987bc2001a fix(agent): complete long-form responses reliably 2026-08-24 04:16:57 -04:00
matevip
e88be95cd2 fix: pass workspace context to tool guard (#617) 2026-08-24 02:43:57 -04:00
mateaix
dd7e561e48 fix(agent): harden tool completion and team retries (#606) 2026-08-23 21:43:35 +08:00
matevip
2372827762 fix(wiki): open failure records in owning workspace (#614) 2026-08-21 22:32:48 -04:00
matevip
709218e2ac fix(channel): expose execution trace switch for WeChat channels (#613) 2026-08-21 22:24:37 -04:00
matevip
14df331ce6 fix: resolve workspace context for file mutations (#617) 2026-08-21 22:05:26 -04:00
matevip
8d32a60fdb fix(files): restrict generated downloads by workspace (#611) 2026-08-21 06:06:59 -04:00
matevip
bdd51e7b44 fix(conversation): isolate shared conversations (#616) 2026-08-21 05:49:28 -04:00
matevip
c1ba390f25 fix(runtime): scope live admin view to workspace (#615) 2026-08-21 05:03:44 -04:00
matevip
37ddcf185f fix(team): pass lead attachments to workers (#612) 2026-08-21 04:47:03 -04:00
matevip
9ba188b4cb fix(wiki): reject reserved page types (#601) 2026-08-21 02:49:47 -04:00
matevip
770198ba69 fix(team): reject clarifying worker results (#610) 2026-08-20 23:09:22 -04:00
matevip
1eabbb68ac fix(team): allow admins to read completed worker runs (#596) 2026-08-20 03:26:15 -04:00
matevip
656a0b0436 fix(acp): support configurable prompt timeout (#608) 2026-08-20 02:58:09 -04:00
matevip
5b3285c78f fix: expose plugin tools in agent picker (#604) 2026-08-20 02:22:35 -04:00
matevip
e9b3c1697f fix(team): attach generated deliverables from worker replies (#606) 2026-08-20 02:11:48 -04:00
matevip
d1a553ed77 fix(agent): stop repeated plan skill loads (#606) 2026-08-20 01:51:56 -04:00
matevip
281ea53551 fix(models): restore disabled custom providers 2026-08-19 23:16:28 -04:00
matevip
aecc619bef fix(chat): clarify running and stopped states 2026-08-19 22:42:12 -04:00
matevip
1159dcdbf3 feat(a2a): add agent interoperability protocol support
Add an A2A JSON-RPC endpoint with authenticated message/send, message/stream, tasks/get, and tasks/cancel handling. Expose anonymous minimal Agent Cards while keeping the enabled-agent skills list behind existing Bearer authentication.

Bridge inbound calls into the existing agent runtime, add an in-memory task store with duplicate task rejection, JSON-RPC idempotency snapshots, terminal TTL cleanup, and SSE status/artifact event streaming with heartbeat comments.

Add the call_a2a_agent tool and peer adapter with Agent Card discovery fallback, blocking task polling through tasks/get, event-boundary SSE parsing, response caps, timeout limits, redirect refusal, and private-network SSRF protection.

Wire mateclaw.a2a configuration, document deployment settings in English and Chinese, mirror bundled docs, and cover task storage, JSON-RPC validation, card privacy, lifecycle/cancel behavior, SSE parsing, and outbound guardrails with focused tests.
2026-08-19 05:36:51 -04:00
matevip
a8a0b75bfa fix(dsh): complete cancellable runtime stream lifecycle; make JSON-RPC streaming asynchronous; terminate process trees and inherited pipes; handle cancellation races; propagate usage telemetry; add regression coverage 2026-08-19 04:30:06 -04:00
matevip
5660c72e1a fix(agent): isolate DSH prompt sessions 2026-08-18 05:47:48 -04:00
matevip
98d0bb7780 docs: add DeepSeek Harness integration guides 2026-08-18 05:35:37 -04:00
matevip
473ed5786e feat(agent): integrate DeepSeek Harness runtime 2026-08-18 05:25:22 -04:00
456 changed files with 22366 additions and 1157 deletions

1
.gitignore vendored
View File

@ -83,6 +83,7 @@ mateclaw-server/src/main/resources/static/
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit) # mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
mateclaw-server/data/ mateclaw-server/data/
.sessions/
/data/ /data/
# VitePress build output and cache (do not commit) # VitePress build output and cache (do not commit)

View File

@ -8,7 +8,7 @@
<p align="center"><b>Your second brain</b></p> <p align="center"><b>Your second brain</b></p>
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p> <p align="center"><sub><b>Pluggable Agent Runtime · Native + DSH · Spring Boot inside</b></sub></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/mateaix/mateclaw) [![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) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
@ -30,7 +30,7 @@
--- ---
> **Latest stable: v2.1.0 — Team Runs, closed skill evolution, and replayable reasoning.** One team request is now one durable `runId` across Chat, Agents, and Teams; skills can mine recurring requests under explicit controls and restore from snapshots; reasoning, tool calls, and observations can be exported in execution order. Read the [v2.1.0 release notes](https://claw.mate.vip/docs/en/releases/2.1.0). > **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).
--- ---
@ -38,7 +38,7 @@
> >
> 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 in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
> >
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't. > **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.
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. 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.
@ -83,7 +83,17 @@ Same brain. Same memory. Same tools. Different doors.
## What's in the box ## What's in the box
### Digital employees, not chatbots ### Digital employees, not chatbots
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work. 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+) ### 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. 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.
@ -105,7 +115,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 - **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 ### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), each reasoning iteration keeps its real position and wall-clock duration, and linear trajectory export lays out reasoning, calls, observations, and answers for review. Per-event SSE IDs make reconnects safe; Team Runs group member work under one live execution. **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.
### Multimodal creation ### 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. 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.
@ -122,7 +132,7 @@ RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-2
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. 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.
Once AI enters production, the stable layer should not be tied to one supplier. MateClaw absorbs that uncertainty into one runtime through provider priorities, health tracking, cooldown, and failover. 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.
**MateClaw is that layer — built the Spring Boot way.** **MateClaw is that layer — built the Spring Boot way.**
@ -194,7 +204,7 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
``` ```
mateclaw/ mateclaw/
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime) ├── mateclaw-server/ Spring Boot 3.5 backend (Agent Runtime contract, native StateGraph + DSH)
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR) ├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized) ├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized)
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles) ├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
@ -213,7 +223,7 @@ Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway | | Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · closed skill evolution · Team Run + shared task board (2.1.0+) | | Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) | | Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) | | 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) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
@ -230,6 +240,16 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap ## 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**: **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 - **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

View File

@ -8,7 +8,7 @@
<p align="center"><b>你的超级大脑</b></p> <p align="center"><b>你的超级大脑</b></p>
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p> <p align="center"><sub><b>可插拔 Agent Runtime · Native + DSH · Spring Boot 内核</b></sub></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/mateaix/mateclaw) [![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) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
@ -30,7 +30,7 @@
--- ---
> **最新稳定版v2.1.0 —— Team Run、Skill 自进化闭环与可回放执行。** 一次团队请求现在以一个持久化 `runId` 贯穿 Chat、Agents 与 Teams技能可在显式开关和工作空间隔离下发现重复请求、晋升并从快照恢复推理、工具调用、观察与回答可按执行顺序导出。详见 [v2.1.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.1.0)。 > **最新稳定版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)。
--- ---
@ -38,7 +38,7 @@
> >
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。 > 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
> >
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停 > **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run也可以通过认证 JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。 大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
@ -83,7 +83,17 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
## 盒子里有什么 ## 盒子里有什么
### 数字员工,不是聊天机器人 ### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些“不起眼”的基础设施。 你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——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 Run2.1.0+
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面Agents Live 按运行聚合成员并展示实时状态Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。 一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面Agents Live 按运行聚合成员并展示实时状态Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
@ -105,7 +115,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型 - **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型
### 你看得见每位员工正在干什么 ### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式阶段如实区分思考 / 工具 / 回答;每轮推理保留真实发生顺序,界面显示实际耗时,线性 trajectory 导出则按顺序展开推理、调用、观察与回答。SSE 每事件 ID 支持安全重连Team Run 将成员工作聚合到同一次运行下。 **Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、当前回合由哪个 runtime provider 承载、跑到哪一步、占多少 token卡住可一键回收。Native 与 DSH 事件进入同一套思考 / 工具 / 回答投影,完成、失败、用量和取消保持一致的生命周期语义。SSE 每事件 ID 支持安全重连Team Run 将成员工作聚合到同一次运行下。
### 多模态创作 ### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。 语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
@ -122,7 +132,7 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**We
模型供应商会限流网络会抖动Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障。 模型供应商会限流网络会抖动Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障。
当 AI 进入生产环境,稳定的一层不应绑定在一家供应商身上。MateClaw 通过供应商优先级、健康追踪、冷却与故障转移,把这种不确定性收进统一运行时 当 AI 进入生产环境,稳定的一层既不应绑定一家模型供应商,也不应绑定一套 Agent loop。MateClaw 用供应商优先级、健康追踪、冷却与故障转移吸收模型侧不确定性,再把 Native 与外部执行引擎收进同一份受治理的 Agent Runtime contract
**MateClaw 就是那一层——用 Spring Boot 方式盖的。** **MateClaw 就是那一层——用 Spring Boot 方式盖的。**
@ -194,7 +204,7 @@ docker compose up -d # http://localhost:18080
``` ```
mateclaw/ mateclaw/
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时 ├── mateclaw-server/ Spring Boot 3.5 后端(Agent Runtime contract · Native StateGraph + DSH
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA构建产物打进后端 JAR ├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA构建产物打进后端 JAR
├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式) ├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
├── mateclaw-webchat/ 网页嵌入式聊天组件UMD / ES bundle ├── mateclaw-webchat/ 网页嵌入式聊天组件UMD / ES bundle
@ -213,7 +223,7 @@ mateclaw/
| 层次 | 技术 | | 层次 | 技术 |
|---|---| |---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway | | 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · Skill 自进化闭环 · Team Run + 共享任务板2.1.0+| | Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraphReAct + Plan-Execute· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+| | 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex | | 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· PostgreSQL 16Docker 默认)· MySQL 8.0+(支持)· Kingbase按需驱动| | 数据库 | H2开发· PostgreSQL 16Docker 默认)· MySQL 8.0+(支持)· Kingbase按需驱动|
@ -230,6 +240,16 @@ 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 发布)** —— 从“一块摆满任务的看板”到**一次可治理的团队运行** **v2.1.02026-08-15 发布)** —— 从“一块摆满任务的看板”到**一次可治理的团队运行**
- **统一 Team Run** —— 一个 `runId` 串起请求、任务 DAG、成员会话、事件、最终汇总与交付物Chat 交付成果Agents 观察实时执行Teams 管理历史与治理 - **统一 Team Run** —— 一个 `runId` 串起请求、任务 DAG、成员会话、事件、最终汇总与交付物Chat 交付成果Agents 观察实时执行Teams 管理历史与治理

View File

@ -32,9 +32,10 @@
<!-- ===== Center: Agent Core ===== --> <!-- ===== 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="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"/> <circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text> <text x="480" y="264" text-anchor="middle" font-size="15" 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="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">Identity · Goal · Governance</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</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>
<!-- ===== Top: User Surfaces (5 items) ===== --> <!-- ===== 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)"/> <rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -33,9 +33,10 @@
<!-- ===== Center: Agent Core ===== --> <!-- ===== 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="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"/> <circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text> <text x="480" y="264" text-anchor="middle" font-size="15" 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="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">身份 · 目标 · 治理</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</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>
<!-- ===== Top: User Surfaces (5 items) ===== --> <!-- ===== 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)"/> <rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -67,40 +67,40 @@
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text> <text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g> </g>
<!-- ===== Layer 2: Agent Engine ===== --> <!-- ===== Layer 2: Agent Runtime ===== -->
<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="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)"/> <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">DIGITAL EMPLOYEE RUNTIME</text> <text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · NORMALIZED EVENTS &amp; GOVERNANCE</text>
<g transform="translate(56, 222)"> <g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <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">Reasoning Engines</text> <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">ReAct · Think→Act→Observe</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">Plan-Execute · Decompose</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Lifecycle · Usage · Projection</text>
</g> </g>
<g transform="translate(244, 222)"> <g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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">Team · Workflow · Trigger</text> <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">Task-board dispatch (2.0.0+)</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">7 step modes · 6 patterns</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goals · Teams</text>
</g> </g>
<g transform="translate(432, 222)"> <g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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">Skills · Tools</text> <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">Built-in · MCP · ACP · Skills</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">SKILL.md + LESSONS + Approval</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
</g> </g>
<g transform="translate(620, 222)"> <g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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">Memory System</text> <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">Short-term + Extraction</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">Consolidation + Dreaming</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval · Credentials</text>
</g> </g>
<g transform="translate(808, 222)"> <g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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">Wiki KB</text> <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">Knowledge digest</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">+ Transforms (1.3)</text> <text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · Built-in</text>
</g> </g>
<!-- ===== Layer 3: Core Services ===== --> <!-- ===== 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> <text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g> </g>
<!-- ===== Layer 2: Agent Engine ===== --> <!-- ===== Layer 2: Agent Runtime ===== -->
<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="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)"/> <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">数字员工运行时</text> <text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · 统一事件与治理</text>
<g transform="translate(56, 222)"> <g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <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">推理双引擎</text> <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">ReAct · 思考→行动→观察</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">Plan-Execute · 计划分解</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">生命周期 · 用量 · 投影</text>
</g> </g>
<g transform="translate(244, 222)"> <g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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="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">任务板派发 + 并行2.0.0+</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">7 step mode · 6 pattern</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goal · Team</text>
</g> </g>
<g transform="translate(432, 222)"> <g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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="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">内置 · MCP · ACP · 技能</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">SKILL.md + LESSONS + 审批</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
</g> </g>
<g transform="translate(620, 222)"> <g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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">记忆 · Dreaming</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">短期上下文 + 长期提取</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="50" text-anchor="middle" font-size="9" fill="#665245">审批 · 凭证隔离</text>
</g> </g>
<g transform="translate(808, 222)"> <g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <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">Wiki KB</text> <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">知识消化</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">+ 加工器1.3.0</text> <text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · 内置工具</text>
</g> </g>
<!-- ===== Layer 3: Core Services ===== --> <!-- ===== Layer 3: Core Services ===== -->

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,8 +14,11 @@ import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Mem0ProviderTest { class Mem0ProviderTest {
@ -43,6 +46,7 @@ class Mem0ProviderTest {
@AfterEach @AfterEach
void tearDown() { void tearDown() {
if (provider != null) provider.close();
if (server != null) server.stop(0); if (server != null) server.stop(0);
} }
@ -129,16 +133,15 @@ class Mem0ProviderTest {
} }
@Test @Test
void threeArgPrefetch_returnsEmptyOnServerError() { void threeArgPrefetch_propagatesServerErrorToPlatformCircuitBreaker() {
// Replace handler to fail; the provider should swallow and return "".
server.removeContext("/"); server.removeContext("/");
server.createContext("/", ex -> { server.createContext("/", ex -> {
ex.sendResponseHeaders(500, 0); ex.sendResponseHeaders(500, 0);
ex.close(); ex.close();
}); });
String result = provider.prefetch(1L, "q", "user:42"); assertThatThrownBy(() -> provider.prefetch(1L, "q", "user:42"))
assertThat(result).isEmpty(); .isInstanceOf(Mem0Exception.class);
} }
@Test @Test
@ -215,5 +218,42 @@ class Mem0ProviderTest {
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test")); Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty(); assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
assertThat(searchCount.get()).isZero(); 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

@ -60,6 +60,9 @@ public class AcpEndpointEntity {
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */ /** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
private Long stdioBufferLimitBytes; 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. */ /** UNKNOWN / OK / ERROR — last test result. */
private String lastStatus; private String lastStatus;

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import org.springframework.stereotype.Component;
import vip.mate.agent.graph.StateGraphReActAgent; import vip.mate.agent.graph.StateGraphReActAgent;
import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.executor.ToolExecutionExecutor; 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.ObservationDispatcher;
import vip.mate.agent.graph.edge.ReasoningDispatcher; import vip.mate.agent.graph.edge.ReasoningDispatcher;
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener; import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
@ -103,6 +104,13 @@ public class AgentGraphBuilder {
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}") "${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled; 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. */ /** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
@org.springframework.beans.factory.annotation.Value( @org.springframework.beans.factory.annotation.Value(
"${mate.agent.markdown-normalize-enabled:true}") "${mate.agent.markdown-normalize-enabled:true}")
@ -675,6 +683,7 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService); executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker); executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext); executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through // Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test). // the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) { if (auditEventService != null) {
@ -998,6 +1007,7 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService); executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker); executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext); executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through // Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test). // the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) { if (auditEventService != null) {
@ -1069,6 +1079,7 @@ public class AgentGraphBuilder {
// Summarizing // Summarizing
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
// 终止控制 // 终止控制
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)

View File

@ -27,8 +27,9 @@ import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List; import java.util.List;
import java.time.Duration; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
@ -70,6 +71,9 @@ public class AgentService {
@Autowired(required = false) @Autowired(required = false)
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry; 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 * Optional clears leftover auto-recorded ledger entries when a new
* user turn starts. Field-injected so existing test constructors of * user turn starts. Field-injected so existing test constructors of
@ -78,6 +82,13 @@ public class AgentService {
@Autowired(required = false) @Autowired(required = false)
private ProgressLedgerService progressLedgerService; 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 * 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 * key, so a conversation that pins a non-default model gets its own graph
@ -132,6 +143,16 @@ public class AgentService {
if (agent.getAgentType() == null) { if (agent.getAgentType() == null) {
agent.setAgentType("react"); 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); requireUniqueName(agent, null);
agentMapper.insert(agent); agentMapper.insert(agent);
publishLifecycle(agent, "spawned"); publishLifecycle(agent, "spawned");
@ -156,6 +177,9 @@ public class AgentService {
} }
requireUniqueName(agent, agent.getId()); requireUniqueName(agent, agent.getId());
} }
if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) {
validateDshConfiguration(agent);
}
agentMapper.updateById(agent); agentMapper.updateById(agent);
agentInstances.remove(agent.getId()); agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null if (prior != null && prior.getEnabled() != null
@ -275,6 +299,8 @@ public class AgentService {
* work already done before the pause. * work already done before the pause.
*/ */
private void clearAutoRecordedForNewTurn(String conversationId) { 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()) { if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
return; return;
} }
@ -298,7 +324,11 @@ public class AgentService {
*/ */
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId); clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message); if (isDshAgent(agentId)) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try { try {
@ -335,7 +365,13 @@ public class AgentService {
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId); clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message); 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);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux // Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state. // termination so the next reactive subscriber doesn't inherit stale state.
@ -372,7 +408,20 @@ public class AgentService {
String requesterId, String thinkingLevel, String requesterId, String thinkingLevel,
ChatOrigin origin) { ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId); clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, message); 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());
}
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行 // 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
@ -419,7 +468,7 @@ public class AgentService {
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) { public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId); clearAutoRecordedForNewTurn(conversationId);
memoryRecallTracker.trackRecalls(agentId, goal); trackMemoryRecalls(agentId, goal, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try { try {
@ -446,7 +495,7 @@ public class AgentService {
public String chatWithReplay(Long agentId, String userMessage, String conversationId, public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) { String toolCallPayload, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage); trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try { try {
@ -474,23 +523,7 @@ public class AgentService {
* {@code _usage_final} event for token and model attribution. * {@code _usage_final} event for token and model attribution.
*/ */
private ChatResult collectChatResult(Flux<StreamDelta> stream) { private ChatResult collectChatResult(Flux<StreamDelta> stream) {
StringBuilder content = new StringBuilder(); return ChatResultCollector.collect(stream);
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]);
} }
/** /**
@ -510,7 +543,7 @@ public class AgentService {
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId, public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId, String toolCallPayload, String requesterId,
ChatOrigin origin) { ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage); trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> { return Flux.defer(() -> {
@ -618,6 +651,13 @@ public class AgentService {
*/ */
private String withLifecycleSync(Long agentId, String message, String conversationId, private String withLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) { 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); safeRegister(conversationId, agentId);
try { try {
if (!memoryProperties.isLifecycleMediatorEnabled()) { if (!memoryProperties.isLifecycleMediatorEnabled()) {
@ -646,11 +686,26 @@ public class AgentService {
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId, private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke, java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) { 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); safeRegister(conversationId, agentId);
try { try {
if (!memoryProperties.isLifecycleMediatorEnabled()) { if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId) return invoke.apply(message, conversationId)
.doFinally(s -> safeUnregister(conversationId)); .doFinally(s -> safeUnregister(conversationId, goalContinuation));
} }
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
@ -666,11 +721,11 @@ public class AgentService {
}) })
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString())) .doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage())) .doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
.doFinally(s -> safeUnregister(conversationId)); .doFinally(s -> safeUnregister(conversationId, goalContinuation));
} catch (Exception e) { } catch (Exception e) {
// If invoke.apply() throws before the Flux is constructed, the // If invoke.apply() throws before the Flux is constructed, the
// doFinally above never runs clean up here. // doFinally above never runs clean up here.
safeUnregister(conversationId); safeUnregister(conversationId, goalContinuation);
throw e; throw e;
} }
} }
@ -684,9 +739,48 @@ public class AgentService {
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */ /** C5 helper — null-safe unregister so tests without the registry don't NPE. */
private void safeUnregister(String conversationId) { private void safeUnregister(String conversationId) {
safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active());
}
private void safeUnregister(String conversationId, boolean goalContinuation) {
if (runningConversationRegistry != null) { if (runningConversationRegistry != null) {
runningConversationRegistry.unregister(conversationId); 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();
} }
/** /**
@ -866,10 +960,15 @@ public class AgentService {
* post-approval replays). * post-approval replays).
*/ */
public record ChatResult(String content, int promptTokens, int completionTokens, 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) { String runtimeModel, String runtimeProvider) {
this(content, promptTokens, completionTokens, runtimeModel, runtimeProvider, null);
}
public static ChatResult contentOnly(String content) { public static ChatResult contentOnly(String content) {
return new ChatResult(content != null ? content : "", 0, 0, null, null); return new ChatResult(content != null ? content : "", 0, 0, null, null, null);
} }
} }
} }

View File

@ -13,6 +13,7 @@ import org.springframework.util.MimeType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder; import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.approval.ApprovalPlaceholderUtil; import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.MediaCaptionService; import vip.mate.llm.routing.MediaCaptionService;
@ -1264,6 +1265,12 @@ public abstract class BaseAgent {
* the primary model can't already handle. * the primary model can't already handle.
*/ */
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) { 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 // Scheduled-job run (issue #142): the task text is the explicit
// userMessageText argument. Never reconstruct it from the conversation // userMessageText argument. Never reconstruct it from the conversation
// a shared cron conversation under concurrent runs has no reliable // a shared cron conversation under concurrent runs has no reliable

View File

@ -0,0 +1,39 @@
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

@ -730,6 +730,7 @@ public class AgentBindingService implements AgentBindingResolver {
"addGoalCriterion", "addGoalCriterion",
"completeGoal", "completeGoal",
"getGoalStatus", "getGoalStatus",
"waitForGoalInput",
// Conversation-scoped progress ledger same rationale as the // Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting // goal primitives above. Long multi-step research / drafting
// tasks need it on every business agent, not just the planner, // tasks need it on every business agent, not just the planner,
@ -768,6 +769,7 @@ public class AgentBindingService implements AgentBindingResolver {
"read_file", "read_file",
"send_file", "send_file",
"write_file", "write_file",
"append_file",
"edit_file", "edit_file",
"execute_shell_command", "execute_shell_command",
// Inline code execution an agent-wide capability alongside shell. // Inline code execution an agent-wide capability alongside shell.

View File

@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import java.util.Map; import java.util.Map;
import java.util.Objects;
/** /**
* Immutable value object that travels alongside an agent invocation describing * Immutable value object that travels alongside an agent invocation describing
@ -76,9 +77,22 @@ public record ChatOrigin(
* from "this is an external/anonymous identifier" (RFC: identity typing). * from "this is an external/anonymous identifier" (RFC: identity typing).
*/ */
@Nullable Long requesterUserId, @Nullable Long requesterUserId,
@Nullable Long originMessageId @Nullable Long originMessageId,
@Nullable ExecutionAttribution executionAttribution
) { ) {
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, public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId, @Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId, @Nullable String workspaceBasePath, @Nullable Long channelId,
@ -149,27 +163,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) { public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId, return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId); senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
} }
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) { @Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId); senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
} }
public ChatOrigin withConversationId(@Nullable String newConversationId) { public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId, return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId); senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, Objects.equals(conversationId, newConversationId) ? executionAttribution : null);
} }
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */ /** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId); senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId, executionAttribution);
} }
/** /**
@ -183,13 +197,26 @@ public record ChatOrigin(
@Nullable String newChatId) { @Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId); newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
} }
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) { public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId); 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);
} }
// ---------------- Spring AI ToolContext interop ---------------- // ---------------- Spring AI ToolContext interop ----------------

View File

@ -1410,6 +1410,17 @@ public class ConversationWindowManager {
ChatResponse response = chatModel.call(new Prompt(promptMessages, options)); ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null if (response != null && response.getResult() != null
&& response.getResult().getOutput() != 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(); String summary = response.getResult().getOutput().getText();
if (summary != null && !summary.isBlank()) { if (summary != null && !summary.isBlank()) {
// 成功保存摘要供下次迭代更新清除冷却 // 成功保存摘要供下次迭代更新清除冷却

View File

@ -0,0 +1,12 @@
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

@ -0,0 +1,20 @@
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

@ -26,6 +26,7 @@ import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@ -554,6 +555,7 @@ public class NodeStreamingChatHelper {
// ("credit balance is too low") use these phrases in 402-class responses. // ("credit balance is too low") use these phrases in 402-class responses.
// Chinese provider patterns (Zhipu 1113, DashScope, general) same hard // Chinese provider patterns (Zhipu 1113, DashScope, general) same hard
// failure semantics: retrying the same provider won't refill the balance. // failure semantics: retrying the same provider won't refill the balance.
String lowerMsg = msg.toLowerCase(Locale.ROOT);
if (msg.contains("402") || msg.contains("insufficient_quota") if (msg.contains("402") || msg.contains("insufficient_quota")
|| msg.contains("credit balance is too low") || msg.contains("credit balance is too low")
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached") || msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
@ -562,7 +564,13 @@ public class NodeStreamingChatHelper {
|| msg.contains("余额不足") || msg.contains("请充值") || msg.contains("余额不足") || msg.contains("请充值")
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113") || msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|| msg.contains("AccountBalanceNotEnough") || msg.contains("AccountBalanceNotEnough")
|| msg.contains("balance not enough")) { || msg.contains("balance not enough")
|| lowerMsg.contains("invalidsubscription")
|| lowerMsg.contains("subscription has expired")
|| lowerMsg.contains("arrearage")
|| lowerMsg.contains("account is in good standing")
|| lowerMsg.contains("insufficient_balance")
|| lowerMsg.contains("insufficient balance")) {
return ErrorType.BILLING; return ErrorType.BILLING;
} }
// RFC-009 P3.2: MODEL_NOT_FOUND provider rejects the requested model id. // RFC-009 P3.2: MODEL_NOT_FOUND provider rejects the requested model id.
@ -2509,11 +2517,41 @@ public class NodeStreamingChatHelper {
acc.id, acc.id,
acc.type != null ? acc.type : "function", acc.type != null ? acc.type : "function",
acc.name, acc.name,
sanitizeToolCallArguments(acc.name, acc.arguments.toString()))); toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
} }
return result; return result;
} }
/**
* Finalize a streamed tool call for local execution.
*
* <p>Blank arguments are a common zero-argument representation and remain
* normalized to an empty object. Invalid non-blank JSON, however, must be
* preserved until {@code ToolExecutionExecutor} sees it; replacing it with
* {@code {}} loses the distinction between a truncated stream and a real
* empty call and can execute the wrong operation. The outgoing-history
* normalization path still calls {@link #sanitizeToolCallArguments} before
* a later provider request.</p>
*/
private static String toolCallArgumentsForExecution(String toolName, String arguments) {
if (arguments == null || arguments.isBlank()) {
return "{}";
}
try {
TOOL_ARG_JSON_MAPPER.readTree(arguments);
return arguments;
} catch (Exception e) {
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
+ "(len={}, head={}); preserving the payload for safe executor rejection. "
+ "Parse error: {}",
toolName,
arguments.length(),
arguments.substring(0, Math.min(80, arguments.length())),
e.getMessage());
return arguments;
}
}
/** /**
* Ensure {@code function.arguments} is always a well-formed JSON string. * Ensure {@code function.arguments} is always a well-formed JSON string.
* <p> * <p>

View File

@ -310,11 +310,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
lastEmittedStreamedContent.set(streamed); lastEmittedStreamedContent.set(streamed);
boolean completionRetry = output.state().value(CONTINUE_REASONING, false); 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, addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
completionRetry || output.state().value(NEEDS_TOOL_CALL, false), completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
streamed)); streamed));
} }
}
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output); String answer = extractFinalAnswer(output);
@ -503,11 +510,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
lastEmittedStreamedContent.set(streamed); lastEmittedStreamedContent.set(streamed);
boolean completionRetry = output.state().value(CONTINUE_REASONING, false); 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, addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
completionRetry || output.state().value(NEEDS_TOOL_CALL, false), completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
streamed)); streamed));
} }
}
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output); String answer = extractFinalAnswer(output);
@ -633,6 +647,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(TOOL_CALL_COUNT, 0); inputs.put(TOOL_CALL_COUNT, 0);
inputs.put(ERROR_COUNT, 0); inputs.put(ERROR_COUNT, 0);
inputs.put(SHOULD_SUMMARIZE, false); inputs.put(SHOULD_SUMMARIZE, false);
inputs.put(LONG_FORM_DRAFT, "");
inputs.put(LIMIT_EXCEEDED, false); inputs.put(LIMIT_EXCEEDED, false);
inputs.put(CONTENT_STREAMED, false); inputs.put(CONTENT_STREAMED, false);
inputs.put(THINKING_STREAMED, false); inputs.put(THINKING_STREAMED, false);
@ -774,6 +789,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return AgentService.StreamDelta.segmentOnly(streamed, null, kind); 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);
}
private boolean hasFinalAnswer(NodeOutput output) { private boolean hasFinalAnswer(NodeOutput output) {
if (output == null || output.state() == null) { if (output == null || output.state() == null) {
return false; return false;

View File

@ -15,6 +15,7 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.context.StructuredTruncator; import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.agent.graph.state.SourceEvidenceLedger;
@ -23,6 +24,7 @@ import vip.mate.approval.grant.AutoApproveResult;
import vip.mate.approval.grant.WorkspaceLookupCache; import vip.mate.approval.grant.WorkspaceLookupCache;
import vip.mate.approval.grant.service.ApprovalGrantResolver; import vip.mate.approval.grant.service.ApprovalGrantResolver;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.tool.ToolInputValidationException;
import vip.mate.tool.guard.ToolExecutionGuardHelper; import vip.mate.tool.guard.ToolExecutionGuardHelper;
import vip.mate.tool.guard.ToolGuard; import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult; import vip.mate.tool.guard.ToolGuardResult;
@ -84,7 +86,7 @@ public class ToolExecutionExecutor {
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16; static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of( private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
"browser_use", "BrowserUseTool", "write_file", "edit_file" "browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"
); );
/** /**
@ -465,7 +467,32 @@ public class ToolExecutionExecutor {
boolean isReplay, String requesterId, boolean isReplay, String requesterId,
String workspaceBasePath, String workspaceBasePath,
ChatOrigin origin) { 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; 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 // 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 // same turn writes a single audit row; the set is repopulated by the
// denial branch below. // denial branch below.
@ -511,6 +538,15 @@ public class ToolExecutionExecutor {
// Phase 1: 顺序 Guard + 分段 // Phase 1: 顺序 Guard + 分段
List<PreparedToolCall> preparedCalls = new ArrayList<>(); List<PreparedToolCall> preparedCalls = new ArrayList<>();
ApprovalBarrier barrier = null; 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++) { for (int i = 0; i < effectiveCalls.size(); i++) {
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i); AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
@ -582,7 +618,7 @@ public class ToolExecutionExecutor {
} catch (Exception jsonEx) { } catch (Exception jsonEx) {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}", log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage()); toolName, arguments.length(), jsonEx.getMessage());
String truncationError = normalizeToolExecutionError(jsonEx); String truncationError = incompleteToolArgumentsError(toolName);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false)); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse( allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, truncationError)); toolCall.id(), responseName, truncationError));
@ -590,6 +626,26 @@ public class ToolExecutionExecutor {
} }
} }
// 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));
continue;
}
}
// 2. ToolGuard 安全检查replay 模式跳过 // 2. ToolGuard 安全检查replay 模式跳过
if (!isReplay) { if (!isReplay) {
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments, GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
@ -649,7 +705,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe // 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName); boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(), preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef)); conversationId, requesterId, workspaceBasePath, safeOrigin, UUID.randomUUID().toString(), rawEvidenceRef));
// 占位Phase 2 填充 // 占位Phase 2 填充
allResponses.add(null); allResponses.add(null);
} }
@ -678,6 +734,34 @@ public class ToolExecutionExecutor {
rawEvidenceRef.get()); 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 * Execute a pre-approved tool call (used by StepExecutionNode's replay path
* after a user approves a previously-blocked invocation). * after a user approves a previously-blocked invocation).
@ -711,6 +795,15 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events, List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath, String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs) { 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 toolName = resolveToolName(toolCall.name());
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments(); String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
@ -746,10 +839,11 @@ public class ToolExecutionExecutor {
// Origin is method-local (see thread-safety note on execute()); // Origin is method-local (see thread-safety note on execute());
// the legacy ThreadLocal that used to carry it across executePreApproved // the legacy ThreadLocal that used to carry it across executePreApproved
// calls was a cross-conversation footgun and has been removed. // calls was a cross-conversation footgun and has been removed.
ChatOrigin replayOrigin = ChatOrigin.EMPTY ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
.withConversationId(conversationId) .withConversationId(conversationId);
.withWorkspace(null, workspaceBasePath); replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin)); String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
UUID.randomUUID().toString(), toolCall.id());
throwIfStopRequested(conversationId); throwIfStopRequested(conversationId);
int rawLen = result != null ? result.length() : 0; int rawLen = result != null ? result.length() : 0;
@ -767,6 +861,13 @@ public class ToolExecutionExecutor {
} }
events.add(GraphEventPublisher.toolDirectResult( events.add(GraphEventPublisher.toolDirectResult(
toolCall.id(), toolName, fullResult)); 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( return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER); toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
} }
@ -789,7 +890,10 @@ public class ToolExecutionExecutor {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage()); log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
String safeError = isReturnDirect(callback) String validationError = safeInputValidationMessage(e);
String safeError = validationError != null
? validationError
: isReturnDirect(callback)
? "Tool execution failed (details withheld per returnDirect policy)" ? "Tool execution failed (details withheld per returnDirect policy)"
: "Tool execution failed: " + e.getMessage(); : "Tool execution failed: " + e.getMessage();
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false)); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
@ -987,7 +1091,7 @@ public class ToolExecutionExecutor {
toolContext = new ToolContext(ctxMap); toolContext = new ToolContext(ctxMap);
} }
result = pc.callback.call(pc.arguments, toolContext); result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id());
throwIfStopRequested(pc.conversationId); throwIfStopRequested(pc.conversationId);
} finally { } finally {
if (progressToken != null) { if (progressToken != null) {
@ -1019,8 +1123,14 @@ public class ToolExecutionExecutor {
if (streamTracker != null) { if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId, streamTracker.broadcastObject(pc.conversationId,
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data()); 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); 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 // Placeholder keeps the tool_call_id tool_response pairing valid
// for OpenAI-compatible providers, while withholding the data from // for OpenAI-compatible providers, while withholding the data from
// any subsequent LLM round (the graph won't take a next round // any subsequent LLM round (the graph won't take a next round
@ -1080,7 +1190,10 @@ public class ToolExecutionExecutor {
// or other sensitive substrings that should not enter LLM context. // or other sensitive substrings that should not enter LLM context.
// Emit a generic placeholder instead. Full error still goes to logs // Emit a generic placeholder instead. Full error still goes to logs
// for operator diagnosis. // for operator diagnosis.
String reportedError = isReturnDirect(pc.callback) String validationError = safeInputValidationMessage(e);
String reportedError = validationError != null
? validationError
: isReturnDirect(pc.callback)
? "Tool execution failed (details withheld per returnDirect policy)" ? "Tool execution failed (details withheld per returnDirect policy)"
: normalizeToolExecutionError(e); : normalizeToolExecutionError(e);
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false)); events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
@ -1215,6 +1328,10 @@ public class ToolExecutionExecutor {
return GuardDecision.allowed(); 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 * 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 * approve), returning an actionable message so the agent falls back to a
@ -1304,6 +1421,17 @@ public class ToolExecutionExecutor {
return "Tool execution failed: " + message; return "Tool execution failed: " + message;
} }
private String safeInputValidationMessage(Throwable error) {
Throwable current = error;
while (current != null) {
if (current instanceof ToolInputValidationException validation) {
return "Tool input validation failed: " + validation.getMessage();
}
current = current.getCause();
}
return null;
}
/** /**
* Issue #46 when a tool callback miss happens, check whether the * Issue #46 when a tool callback miss happens, check whether the
* unrecognized name actually matches an active skill. If it does, return * unrecognized name actually matches an active skill. If it does, return
@ -1658,6 +1786,18 @@ public class ToolExecutionExecutor {
return new ToolContext(context); 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( private record PreparedToolCall(
@ -1671,6 +1811,7 @@ public class ToolExecutionExecutor {
String requesterId, String requesterId,
String workspaceBasePath, String workspaceBasePath,
ChatOrigin origin, ChatOrigin origin,
String invocationKey,
/** /**
* Shared reference (one per execute() invocation) where each * Shared reference (one per execute() invocation) where each
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger} * concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}

View File

@ -144,7 +144,8 @@ public class ActionNode implements NodeAction {
// 委托 ToolExecutionExecutor 执行两阶段顺序 Guard + 分段并发执行 // 委托 ToolExecutionExecutor 执行两阶段顺序 Guard + 分段并发执行
ToolExecutionExecutor.ToolExecutionResult result = executor.execute( ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin); toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin,
accessor.loadedSkills());
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder() ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses()) .responses(result.responses())
@ -406,7 +407,7 @@ public class ActionNode implements NodeAction {
return names; return names;
} }
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) { public static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) { if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of(); return Set.of();
} }

View File

@ -12,12 +12,16 @@ import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.goal.config.GoalProperties; import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult; import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.service.GoalEvaluationService; import vip.mate.goal.service.GoalEvaluationService;
import vip.mate.goal.service.GoalFollowupService; import vip.mate.goal.service.GoalFollowupService;
import vip.mate.goal.service.GoalService; import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GraphFlavor; import vip.mate.goal.service.GraphFlavor;
import vip.mate.workspace.conversation.ConversationService; 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.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -184,6 +188,12 @@ public class GoalEvaluationNode implements NodeAction {
.build(); .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 // Decision branches. Each terminal write is wrapped so a DB hiccup
// (e.g. optimistic-lock conflict exceeding retries, memory sync // (e.g. optimistic-lock conflict exceeding retries, memory sync
// failure on completion) does not propagate into the chat graph // failure on completion) does not propagate into the chat graph
@ -199,7 +209,7 @@ public class GoalEvaluationNode implements NodeAction {
.events(List.of(goalEvent("goal_completed", Map.of( .events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(completed.getId()), "goalId", String.valueOf(completed.getId()),
"score", result.score(), "score", result.score(),
"goal", goalService.toResponse(completed))))) "goal", stateSafeGoal(goalService.toResponse(completed))))))
.build(); .build();
} }
@ -216,7 +226,7 @@ public class GoalEvaluationNode implements NodeAction {
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(), "evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(), "totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
"reason", reason, "reason", reason,
"goal", goalService.toResponse(exhausted))))) "goal", stateSafeGoal(goalService.toResponse(exhausted))))))
.build(); .build();
} }
} catch (Throwable t) { } catch (Throwable t) {
@ -229,6 +239,21 @@ public class GoalEvaluationNode implements NodeAction {
.build(); .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 followupCountThisRun = accessor.goalFollowupCount();
int hardContinuationCount = accessor.goalHardContinuationCount(); int hardContinuationCount = accessor.goalHardContinuationCount();
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(), int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
@ -289,7 +314,7 @@ public class GoalEvaluationNode implements NodeAction {
.events(List.of(goalEvent("goal_followup", Map.of( .events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()), "goalId", String.valueOf(refreshed.getId()),
"prompt", followup.get(), "prompt", followup.get(),
"goal", goalService.toResponse(refreshed))))); "goal", stateSafeGoal(goalService.toResponse(refreshed))))));
if (flavor == GraphFlavor.REACT) { if (flavor == GraphFlavor.REACT) {
// ReAct: append the followup as a fresh user message via the // ReAct: append the followup as a fresh user message via the
@ -343,10 +368,72 @@ public class GoalEvaluationNode implements NodeAction {
"goalId", String.valueOf(refreshed.getId()), "goalId", String.valueOf(refreshed.getId()),
"score", result.score(), "score", result.score(),
"gap", result.gap() == null ? "" : result.gap(), "gap", result.gap() == null ? "" : result.gap(),
"goal", goalService.toResponse(refreshed))))) "goal", stateSafeGoal(goalService.toResponse(refreshed))))))
.build(); .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 * Resolve the active goal for this run: prefer the turn-start
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation * {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation

View File

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

View File

@ -37,6 +37,8 @@ import vip.mate.team.service.TeamContextBuilder;
import java.util.*; import java.util.*;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static vip.mate.agent.graph.state.MateClawStateKeys.*; import static vip.mate.agent.graph.state.MateClawStateKeys.*;
@ -137,6 +139,17 @@ public class ReasoningNode implements NodeAction {
*/ */
private static final int KEEP_RECENT_TOOL_RESPONSES = 3; 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. */ /** Continuation nudge appended to the prompt when the model returns an empty turn. */
private static final String EMPTY_COMPLETION_NUDGE = private static final String EMPTY_COMPLETION_NUDGE =
"上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:" "上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:"
@ -235,6 +248,100 @@ public class ReasoningNode implements NodeAction {
return false; 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 * Tool-use enforcement clause appended to every ReasoningNode
* system prompt. Treats narration ("I will now …") as a protocol violation * system prompt. Treats narration ("I will now …") as a protocol violation
@ -857,6 +964,7 @@ public class ReasoningNode implements NodeAction {
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools) ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
.activeCallbacks() .activeCallbacks()
: toolCallbacks; : toolCallbacks;
activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks);
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
@ -1136,12 +1244,62 @@ public class ReasoningNode implements NodeAction {
.build(); .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()) { if (result.partial()) {
int partialChars = result.text() != null ? result.text().length() : 0; int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars); log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
} }
if (result.hasToolCalls()) { 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): {}", log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
result.toolCalls().size(), result.toolCalls().size(),
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList()); result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
@ -1219,12 +1377,43 @@ public class ReasoningNode implements NodeAction {
.build(); .build();
} }
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0); 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( pushPhase(conversationId, "drafting_answer", Map.of(
"iteration", accessor.iterationCount(), "iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0 "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() String answerWithSources = accessor.sourceEvidenceLedger()
.appendWikiSourceTable(content != null ? content : ""); .appendWikiSourceTable(accumulatedContent);
SourceEvidenceLedger.Validation validation = SourceEvidenceLedger.Validation validation =
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources); accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
boolean evidenceInsufficient = !validation.valid(); boolean evidenceInsufficient = !validation.valid();
@ -1251,9 +1440,9 @@ public class ReasoningNode implements NodeAction {
.finalThinking(result.thinking()) .finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage())) .messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning") .currentPhase("reasoning")
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "") .streamedContent(evidenceInsufficient ? accumulatedContent : "")
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL) .finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : "")) .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, accumulatedContent))
.thinkingStreamed(!result.thinking().isEmpty()) .thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount) .llmCallCount(nextLlmCallCount)
.mergeUsage(state, result) .mergeUsage(state, result)
@ -1262,6 +1451,10 @@ public class ReasoningNode implements NodeAction {
} }
} }
static boolean isRuntimeErrorPlaceholder(String text) {
return text != null && text.stripLeading().startsWith("[错误]");
}
private static String evidenceWarning(List<String> unsupportedReferences) { private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:" return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
+ String.join(", ", unsupportedReferences) + String.join(", ", unsupportedReferences)

View File

@ -623,7 +623,9 @@ public class PlanGenerationNode implements NodeAction {
+ "相互独立的步骤请不要标注前置,以便并行执行。\n" + "相互独立的步骤请不要标注前置,以便并行执行。\n"
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n" + "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤," + "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。")); + "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n"
+ "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。"
+ "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。"));
} else { } else {
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId); List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) { if (!delegatable.isEmpty()) {

View File

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

View File

@ -140,6 +140,11 @@ public final class PlanStateAccessor {
return state.value(WORKING_CONTEXT, ""); 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() { public static OutputBuilder output() {
@ -251,6 +256,10 @@ public final class PlanStateAccessor {
return put(MateClawStateKeys.PENDING_EVENTS, events); return put(MateClawStateKeys.PENDING_EVENTS, events);
} }
public OutputBuilder loadedSkills(Set<String> names) {
return put(MateClawStateKeys.LOADED_SKILLS, names);
}
// ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE---- // ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE----
public OutputBuilder currentPhase(String phase) { public OutputBuilder currentPhase(String phase) {
return put(MateClawStateKeys.CURRENT_PHASE, phase); return put(MateClawStateKeys.CURRENT_PHASE, phase);

View File

@ -119,6 +119,10 @@ public final class MateClawStateAccessor {
return state.value(FINAL_ANSWER_DRAFT, ""); return state.value(FINAL_ANSWER_DRAFT, "");
} }
public String longFormDraft() {
return state.value(LONG_FORM_DRAFT, "");
}
public boolean limitExceeded() { public boolean limitExceeded() {
return state.value(LIMIT_EXCEEDED, false); return state.value(LIMIT_EXCEEDED, false);
} }
@ -453,6 +457,10 @@ public final class MateClawStateAccessor {
return put(FINAL_ANSWER_DRAFT, draft); return put(FINAL_ANSWER_DRAFT, draft);
} }
public OutputBuilder longFormDraft(String draft) {
return put(LONG_FORM_DRAFT, draft);
}
// ---- 终止 ---- // ---- 终止 ----
public OutputBuilder finalAnswer(String answer) { public OutputBuilder finalAnswer(String answer) {
return put(FINAL_ANSWER, answer); return put(FINAL_ANSWER, answer);

View File

@ -64,6 +64,8 @@ public final class MateClawStateKeys {
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */ /** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft"; 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 阶段 */ /** 是否需要进入 summarizing 阶段 */
public static final String SHOULD_SUMMARIZE = "should_summarize"; public static final String SHOULD_SUMMARIZE = "should_summarize";

View File

@ -26,6 +26,14 @@ public class AgentEntity {
/** Agent 类型react / plan_execute */ /** Agent 类型react / plan_execute */
private String agentType; 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) @TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
private String systemPrompt; private String systemPrompt;

View File

@ -10,6 +10,7 @@ import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot; import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@ -116,18 +117,26 @@ public class AgentRuntimeAggregator {
) {} ) {}
public RuntimeSnapshot snapshot() { public RuntimeSnapshot snapshot() {
return snapshot(null);
}
public RuntimeSnapshot snapshot(Long workspaceId) {
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot(); List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
Set<Long> agentIds = rawRuns.stream() Set<Long> agentIds = rawRuns.stream()
.map(RunSnapshot::agentId) .map(RunSnapshot::agentId)
.filter(java.util.Objects::nonNull) .filter(java.util.Objects::nonNull)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
for (var rec : subagentRegistry.allActive()) { Collection<SubagentRegistry.SubagentRecord> rawSubagents = subagentRegistry.allActive();
for (var rec : rawSubagents) {
if (rec.agentId() != null) agentIds.add(rec.agentId()); if (rec.agentId() != null) agentIds.add(rec.agentId());
} }
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds); Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
Map<String, Long> subagentCountByParent = new HashMap<>(); Map<String, Long> subagentCountByParent = new HashMap<>();
for (var rec : subagentRegistry.allActive()) { for (var rec : rawSubagents) {
if (!belongsToWorkspace(rec.agentId(), agentInfo, workspaceId)) {
continue;
}
String parent = rec.parentConversationId(); String parent = rec.parentConversationId();
if (parent != null) { if (parent != null) {
subagentCountByParent.merge(parent, 1L, Long::sum); subagentCountByParent.merge(parent, 1L, Long::sum);
@ -141,6 +150,7 @@ public class AgentRuntimeAggregator {
int runningCount = 0; int runningCount = 0;
for (RunSnapshot s : rawRuns) { for (RunSnapshot s : rawRuns) {
if (s.done()) continue; if (s.done()) continue;
if (!belongsToWorkspace(s.agentId(), agentInfo, workspaceId)) continue;
runningCount++; runningCount++;
String stuckReason = computeStuckReason(s); String stuckReason = computeStuckReason(s);
boolean orphan = s.subscriberCount() == 0; boolean orphan = s.subscriberCount() == 0;
@ -181,7 +191,8 @@ public class AgentRuntimeAggregator {
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent()); return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
}); });
List<SubagentCard> subCards = subagentRegistry.allActive().stream() List<SubagentCard> subCards = rawSubagents.stream()
.filter(rec -> belongsToWorkspace(rec.agentId(), agentInfo, workspaceId))
.map(rec -> { .map(rec -> {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId()); AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
@ -216,6 +227,33 @@ public class AgentRuntimeAggregator {
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis()); 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 * Returns null when the run looks healthy. The returned tag is a stable
* machine-readable code (not a translated label) so the frontend can * machine-readable code (not a translated label) so the frontend can
@ -244,4 +282,25 @@ public class AgentRuntimeAggregator {
} }
return out; 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.List;
import java.util.Map; import java.util.Map;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin; import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import vip.mate.agent.runtime.dsh.DshRuntimeService;
/** /**
* Admin-only live runtime surface: the global view of every in-flight agent * Admin-only live runtime surface: the workspace view of every in-flight agent
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck * turn plus the controls to friendly-stop, force-recycle, or sweep stuck
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation * runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
* owner-scoped this controller is intentionally cross-tenant for the * owner-scoped.
* operator role.
*/ */
@Slf4j @Slf4j
@Tag(name = "Agent Runtime (Live)") @Tag(name = "Agent Runtime (Live)")
@ -40,21 +40,35 @@ public class AgentRuntimeController {
private final AuditEventService auditEventService; private final AuditEventService auditEventService;
private final ConversationService conversationService; private final ConversationService conversationService;
private final I18nService i18nService; private final I18nService i18nService;
private final DshRuntimeService dshRuntimeService;
@Operation(summary = "Snapshot of every in-flight agent turn") @Operation(summary = "Snapshot of every in-flight agent turn")
@GetMapping("/snapshot") @GetMapping("/snapshot")
@RequireGlobalAdmin @RequireGlobalAdmin
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) { public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
return R.ok(aggregator.snapshot()); 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());
} }
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint") @Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
@PostMapping("/runs/{conversationId}/stop") @PostMapping("/runs/{conversationId}/stop")
@RequireGlobalAdmin @RequireGlobalAdmin
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId, public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) { Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
requireRunInWorkspace(conversationId, workspaceId);
boolean ok = streamTracker.requestStop(conversationId); boolean ok = streamTracker.requestStop(conversationId);
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok)); recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
return R.ok(Map.of("stopped", ok)); return R.ok(Map.of("stopped", ok));
@ -64,8 +78,10 @@ public class AgentRuntimeController {
@PostMapping("/runs/{conversationId}/recycle") @PostMapping("/runs/{conversationId}/recycle")
@RequireGlobalAdmin @RequireGlobalAdmin
public R<Map<String, Object>> recycle(@PathVariable String conversationId, public R<Map<String, Object>> recycle(@PathVariable String conversationId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) { Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
requireRunInWorkspace(conversationId, workspaceId);
boolean ok = streamTracker.forceRecycle(conversationId); boolean ok = streamTracker.forceRecycle(conversationId);
if (ok) { if (ok) {
finalizeRecycledConversation(conversationId); finalizeRecycledConversation(conversationId);
@ -78,8 +94,10 @@ public class AgentRuntimeController {
@PostMapping("/subagents/{subagentId}/interrupt") @PostMapping("/subagents/{subagentId}/interrupt")
@RequireGlobalAdmin @RequireGlobalAdmin
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId, public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) { Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
requireSubagentInWorkspace(subagentId, workspaceId);
boolean ok = subagentRegistry.interrupt(subagentId); boolean ok = subagentRegistry.interrupt(subagentId);
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok)); recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
return R.ok(Map.of("interrupted", ok)); return R.ok(Map.of("interrupted", ok));
@ -93,9 +111,12 @@ public class AgentRuntimeController {
@Operation(summary = "Recycle every run currently flagged as stuck") @Operation(summary = "Recycle every run currently flagged as stuck")
@PostMapping("/sweep") @PostMapping("/sweep")
@RequireGlobalAdmin @RequireGlobalAdmin
public R<Map<String, Object>> sweep(Authentication auth) { public R<Map<String, Object>> sweep(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
requireAdmin(auth); requireAdmin(auth);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(); requireWorkspace(workspaceId);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(workspaceId);
List<String> ids = snap.runs().stream() List<String> ids = snap.runs().stream()
.filter(r -> r.stuckReason() != null) .filter(r -> r.stuckReason() != null)
.map(AgentRuntimeAggregator.RunCard::conversationId) .map(AgentRuntimeAggregator.RunCard::conversationId)
@ -154,6 +175,26 @@ 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, private void recordAudit(Authentication auth, String action,
String resourceId, Map<String, Object> detail) { String resourceId, Map<String, Object> detail) {
try { try {

View File

@ -0,0 +1,36 @@
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

@ -0,0 +1,63 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,25 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,24 @@
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

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

View File

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

View File

@ -0,0 +1,9 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,41 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,41 @@
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

@ -0,0 +1,24 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,75 @@
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

@ -0,0 +1,11 @@
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

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

View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,65 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,26 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,33 @@
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,66 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
package vip.mate.agent.runtime.dsh;
import java.nio.file.Path;
public final class DshToolPolicyEvaluator {
public DshToolDecision decide(DshToolPolicy policy, String toolName, Path targetPath) {
if (policy == null || toolName == null || toolName.isBlank()) return DshToolDecision.DENY;
if (policy.disabledTools().contains(toolName)) return DshToolDecision.DENY;
if (targetPath != null && !withinWorkspace(policy.workspaceRoot(), targetPath)) {
return DshToolDecision.DENY;
}
boolean edit = policy.editTools().contains(toolName);
if (edit && "read-only".equalsIgnoreCase(policy.permissionMode())) {
return DshToolDecision.DENY;
}
if (policy.autoApprovedTools().contains(toolName)) return DshToolDecision.ALLOW;
if (policy.readTools().contains(toolName) && !edit) return DshToolDecision.ALLOW;
return DshToolDecision.APPROVAL;
}
private boolean withinWorkspace(Path root, Path target) {
if (root == null) return false;
Path normalizedRoot = root.toAbsolutePath().normalize();
Path normalizedTarget = target.toAbsolutePath().normalize();
return normalizedTarget.startsWith(normalizedRoot);
}
}

View File

@ -0,0 +1,200 @@
package vip.mate.agent.runtime.dsh.management;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.TimeUnit;
/** Downloads and atomically installs the server-selected DSH artifact. */
@Service
public class DshArtifactInstaller {
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final URI manifestUri;
private final URI githubReleaseUri;
private final Path installRoot;
public DshArtifactInstaller(
ObjectMapper objectMapper,
@Value("${mateclaw.agent.runtime.dsh.manifest-url:}") String manifestUrl,
@Value("${mateclaw.agent.runtime.dsh.github-release-url:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest}") String githubReleaseUrl,
@Value("${mateclaw.agent.runtime.dsh.install-root:${user.home}/.mateclaw/runtimes/deepseek-harness}") String installRoot) {
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
this.manifestUri = manifestUrl == null || manifestUrl.isBlank() ? null : URI.create(manifestUrl.trim());
this.githubReleaseUri = URI.create(githubReleaseUrl.trim());
this.installRoot = Path.of(installRoot).toAbsolutePath().normalize();
}
public boolean isInstalled() {
return Files.isExecutable(installRoot.resolve("dsh-jsonrpc-agent"))
|| Files.isExecutable(installRoot.resolve("bin/dsh-jsonrpc-agent"));
}
public boolean manifestConfigured() {
return manifestUri != null || githubReleaseUri != null;
}
public boolean privateManifestConfigured() {
return manifestUri != null;
}
public Path installedExecutable() {
Path direct = installRoot.resolve("dsh-jsonrpc-agent");
return Files.isExecutable(direct) ? direct : installRoot.resolve("bin/dsh-jsonrpc-agent");
}
public Path installedCordisConfig() {
if (!Files.exists(installRoot)) return null;
try (var paths = Files.walk(installRoot)) {
return paths.filter(path -> path.getFileName().toString().equals("cordis.yml"))
.findFirst().orElse(null);
} catch (Exception ignored) {
return null;
}
}
public DshArtifactManifest loadManifest() throws Exception {
if (manifestUri != null) {
HttpRequest request = HttpRequest.newBuilder(manifestUri).GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 == 2) return objectMapper.readValue(response.body(), DshArtifactManifest.class);
}
return loadGithubManifest();
}
private DshArtifactManifest loadGithubManifest() throws Exception {
HttpRequest request = HttpRequest.newBuilder(githubReleaseUri)
.header("Accept", "application/vnd.github+json").GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH private manifest unavailable and GitHub fallback failed: HTTP " + response.statusCode());
JsonNode root = objectMapper.readTree(response.body());
for (JsonNode asset : root.path("assets")) {
String name = asset.path("name").asText("").toLowerCase();
String digest = asset.path("digest").asText("");
if ((name.contains("macos") || name.contains("darwin")) && name.contains("arm64") && digest.startsWith("sha256:")) {
return new DshArtifactManifest("deepseek-harness", root.path("tag_name").asText("latest"), "macos-arm64",
asset.path("browser_download_url").asText(), digest.substring("sha256:".length()), asset.path("size").asLong(0), null);
}
}
throw new IllegalStateException("GitHub DSH release has no macos-arm64 asset with a SHA-256 digest");
}
public Path install(DshArtifactManifest manifest) throws Exception {
validateManifest(manifest);
Path parent = installRoot.getParent();
Files.createDirectories(parent);
Path archive = Files.createTempFile(parent, ".dsh-download-", ".tar.gz");
Path staging = Files.createTempDirectory(parent, ".dsh-staging-");
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(manifest.downloadUrl())).GET().build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH artifact request failed: HTTP " + response.statusCode());
try (InputStream input = response.body()) {
Files.copy(input, archive, StandardCopyOption.REPLACE_EXISTING);
}
if (manifest.size() > 0 && Files.size(archive) != manifest.size()) {
throw new IllegalStateException("DSH artifact size mismatch");
}
verifyChecksum(archive, manifest.sha256());
verifyArchiveEntries(archive);
runTar(archive, staging);
verifyExtractedTree(staging);
Path executable = findExecutable(staging);
Path executableRelativePath = staging.relativize(executable);
executable.toFile().setExecutable(true, false);
Path backup = parent.resolve(".dsh-previous");
if (Files.exists(installRoot)) Files.move(installRoot, backup, StandardCopyOption.REPLACE_EXISTING);
Files.move(staging, installRoot, StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(backup);
return installRoot.resolve(executableRelativePath);
} finally {
Files.deleteIfExists(archive);
deleteTree(staging);
}
}
private void validateManifest(DshArtifactManifest manifest) {
if (manifest == null || manifest.downloadUrl() == null || manifest.downloadUrl().isBlank()
|| manifest.sha256() == null || !manifest.sha256().matches("[0-9a-fA-F]{64}")) {
throw new IllegalArgumentException("DSH artifact manifest is incomplete or has an invalid checksum");
}
URI uri = URI.create(manifest.downloadUrl());
if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("DSH artifact must use HTTPS");
}
private void verifyChecksum(Path archive, String expected) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(archive)) {
input.transferTo(new java.security.DigestOutputStream(OutputStreamDiscard.INSTANCE, digest));
}
String actual = HexFormat.of().formatHex(digest.digest());
if (!actual.equalsIgnoreCase(expected)) throw new IllegalStateException("DSH artifact checksum mismatch");
}
private void verifyArchiveEntries(Path archive) throws Exception {
Process process = new ProcessBuilder("tar", "-tzf", archive.toString()).redirectErrorStream(true).start();
List<String> entries;
try (InputStream input = process.getInputStream()) {
entries = new String(input.readAllBytes(), StandardCharsets.UTF_8).lines().toList();
}
if (!process.waitFor(30, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive is not a readable tar.gz");
for (String entry : entries) {
Path normalized = Path.of(entry).normalize();
if (entry.startsWith("/") || normalized.startsWith("..")) throw new IllegalArgumentException("DSH archive contains an unsafe path");
}
}
private void runTar(Path archive, Path destination) throws Exception {
Process process = new ProcessBuilder("tar", "-xzf", archive.toString(), "-C", destination.toString()).redirectErrorStream(true).start();
String output;
try (InputStream input = process.getInputStream()) { output = new String(input.readAllBytes(), StandardCharsets.UTF_8); }
if (!process.waitFor(60, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive extraction failed: " + output);
}
private Path findExecutable(Path staging) throws Exception {
try (var paths = Files.walk(staging)) {
return paths.filter(path -> path.getFileName().toString().equals("dsh-jsonrpc-agent"))
.findFirst().orElseThrow(() -> new IllegalStateException("DSH artifact has no dsh-jsonrpc-agent executable"));
}
}
private void verifyExtractedTree(Path staging) throws Exception {
try (var paths = Files.walk(staging)) {
for (Path path : paths.toList()) {
if (!Files.isSymbolicLink(path)) continue;
Path target = path.getParent().resolve(Files.readSymbolicLink(path)).normalize();
if (!target.startsWith(staging)) throw new IllegalArgumentException("DSH archive contains a link outside its staging directory");
}
}
}
private void deleteTree(Path root) throws Exception {
if (root == null || !Files.exists(root)) return;
try (var paths = Files.walk(root)) {
paths.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
try { Files.deleteIfExists(path); } catch (Exception ignored) { }
});
}
}
private static final class OutputStreamDiscard extends java.io.OutputStream {
private static final OutputStreamDiscard INSTANCE = new OutputStreamDiscard();
@Override public void write(int b) { }
@Override public void write(byte[] b, int off, int len) { }
}
}

View File

@ -0,0 +1,13 @@
package vip.mate.agent.runtime.dsh.management;
import java.time.Instant;
public record DshArtifactManifest(
String name,
String version,
String platform,
String downloadUrl,
String sha256,
long size,
Instant releasedAt) {
}

View File

@ -0,0 +1,55 @@
package vip.mate.agent.runtime.dsh.management;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import java.util.Map;
@Tag(name = "DeepSeek Harness Runtime Management")
@RestController
@RequestMapping("/api/v1/admin/dsh")
@RequiredArgsConstructor
public class DshManagementController {
private final DshManagementService managementService;
@Operation(summary = "Get managed DSH runtime status")
@GetMapping("/status")
@RequireGlobalAdmin
public R<Map<String, Object>> status() { return R.ok(managementService.status()); }
@Operation(summary = "Save managed DSH runtime configuration")
@PutMapping("/config")
@RequireGlobalAdmin
public R<Map<String, Object>> saveConfig(@RequestBody Map<String, String> values) {
return R.ok(managementService.saveConfig(values));
}
@Operation(summary = "Install the server-selected DSH artifact")
@PostMapping("/install")
@RequireGlobalAdmin
public R<Map<String, Object>> install() throws Exception { return R.ok(managementService.install()); }
@Operation(summary = "Verify DSH runtime configuration")
@PostMapping("/verify")
@RequireGlobalAdmin
public R<Map<String, Object>> verify() { return R.ok(managementService.verify()); }
@Operation(summary = "Test starting the DSH process")
@PostMapping("/test-connection")
@RequireGlobalAdmin
public R<Map<String, Object>> testConnection() { return R.ok(managementService.testConnection()); }
@Operation(summary = "Enable managed DSH runtime")
@PostMapping("/enable")
@RequireGlobalAdmin
public R<Map<String, Object>> enable() { return R.ok(managementService.enable()); }
@Operation(summary = "Disable managed DSH runtime")
@PostMapping("/disable")
@RequireGlobalAdmin
public R<Map<String, Object>> disable() { return R.ok(managementService.disable()); }
}

View File

@ -0,0 +1,130 @@
package vip.mate.agent.runtime.dsh.management;
import org.springframework.stereotype.Service;
import vip.mate.system.service.SystemSettingService;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
public class DshManagementService {
private static final String ENABLED_KEY = "dsh.enabled";
private final DshRuntimeConfigService configService;
private final DshArtifactInstaller installer;
private final SystemSettingService settings;
public DshManagementService(DshRuntimeConfigService configService,
DshArtifactInstaller installer,
SystemSettingService settings) {
this.configService = configService;
this.installer = installer;
this.settings = settings;
}
public Map<String, Object> status() {
DshRuntimeConfiguration configuration = configService.resolve();
boolean executableAvailable = isExecutable(configuration.executablePath());
boolean workingDirectoryAvailable = configuration.workingDirectory() != null
&& Files.isDirectory(Path.of(configuration.workingDirectory()));
boolean cordisAvailable = configuration.cordisConfigPath() == null
|| configuration.cordisConfigPath().isBlank()
|| Files.isRegularFile(Path.of(configuration.cordisConfigPath()));
// An empty managed key is valid: DshRuntimeService can reuse the
// existing DeepSeek provider key. The page may still store a managed
// key when the operator wants DSH to be independent from model rows.
boolean enabled = settings.getBool(ENABLED_KEY, false);
DshManagementState state;
if (!executableAvailable) state = DshManagementState.NOT_INSTALLED;
else if (!workingDirectoryAvailable || !cordisAvailable) state = DshManagementState.CONFIG_INVALID;
else if (enabled) state = DshManagementState.ENABLED;
else state = DshManagementState.READY;
Map<String, Object> result = new LinkedHashMap<>();
result.put("state", state.name());
result.put("installed", executableAvailable);
result.put("enabled", enabled);
result.put("config", configuration.publicStatus());
result.put("managed", configService.managedValues());
result.put("artifactManifestConfigured", installer.manifestConfigured());
result.put("privateArtifactManifestConfigured", installer.privateManifestConfigured());
result.put("checkedAt", Instant.now().toString());
return result;
}
public Map<String, Object> saveConfig(Map<String, String> values) {
configService.save(values);
return status();
}
public Map<String, Object> install() throws Exception {
DshArtifactManifest manifest = installer.loadManifest();
Path executable = installer.install(manifest);
Map<String, String> installed = new LinkedHashMap<>();
installed.put("dsh.executable_path", executable.toString());
Path cordis = installer.installedCordisConfig();
if (cordis != null) installed.put("dsh.cordis_config_path", cordis.toString());
configService.save(installed);
return status();
}
public Map<String, Object> verify() {
Map<String, Object> result = status();
boolean ok = "ENABLED".equals(result.get("state")) || "READY".equals(result.get("state"));
result.put("verified", ok);
result.put("verificationMessage", ok ? "DSH executable and configuration are available" : "DSH executable or configuration is unavailable");
return result;
}
public Map<String, Object> testConnection() {
DshRuntimeConfiguration configuration = configService.resolve();
if (!isExecutable(configuration.executablePath())) return Map.of("success", false, "message", "DSH executable is unavailable");
if (configuration.cordisConfigPath() == null || configuration.cordisConfigPath().isBlank()) {
return Map.of("success", false, "message", "DSH Cordis configuration is unavailable");
}
try {
ProcessBuilder builder = new ProcessBuilder(configuration.executablePath(), configuration.cordisConfigPath())
.directory(Path.of(configuration.workingDirectory()).toFile())
.redirectErrorStream(true);
builder.environment().put("DSH_CWD", configuration.workingDirectory());
builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
if (configuration.apiKey() != null && !configuration.apiKey().isBlank()) {
builder.environment().put("DEEPSEEK_API_KEY", configuration.apiKey());
}
if (configuration.baseUrl() != null && !configuration.baseUrl().isBlank()) {
builder.environment().put("DEEPSEEK_BASE_URL", configuration.baseUrl());
}
Process process = builder.start();
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
return Map.of("success", true, "message", "DSH process started");
}
String output = new String(process.getInputStream().readAllBytes());
if (process.exitValue() != 0) throw new IllegalStateException(output.isBlank() ? "DSH process exited with code " + process.exitValue() : output.trim());
return Map.of("success", true, "message", output.trim());
} catch (Exception error) {
return Map.of("success", false, "message", "DSH connection test failed: " + error.getMessage());
}
}
public Map<String, Object> enable() {
Map<String, Object> current = verify();
if (!Boolean.TRUE.equals(current.get("verified"))) throw new IllegalStateException("DSH must pass verification before enabling");
settings.saveBool(ENABLED_KEY, true, "Enable managed DeepSeek Harness runtime");
return status();
}
public Map<String, Object> disable() {
settings.saveBool(ENABLED_KEY, false, "Enable managed DeepSeek Harness runtime");
return status();
}
private boolean isExecutable(String path) {
return path != null && !path.isBlank() && Files.isExecutable(Path.of(path));
}
}

View File

@ -0,0 +1,21 @@
package vip.mate.agent.runtime.dsh.management;
/** Lifecycle states exposed by the DSH runtime management screen. */
public enum DshManagementState {
NOT_INSTALLED,
INSTALLING,
INSTALLED_UNCONFIGURED,
CONFIG_INVALID,
CHECKING,
CHECK_FAILED,
READY,
ENABLED;
public boolean canEnable() {
return this == READY;
}
public boolean isOperational() {
return this == ENABLED;
}
}

View File

@ -0,0 +1,55 @@
package vip.mate.agent.runtime.dsh.management;
import java.util.Map;
/** Resolves managed settings first, then application properties, then legacy environment variables. */
public final class DshRuntimeConfigResolver {
private DshRuntimeConfigResolver() {
}
public static DshRuntimeConfiguration resolve(
Map<String, String> managed,
Map<String, String> properties,
Map<String, String> environment) {
return new DshRuntimeConfiguration(
firstNonBlank(managed, properties, environment,
"dsh.executable_path", "mateclaw.agent.runtime.dsh.command", "DSH_JSONRPC_AGENT"),
firstNonBlank(managed, properties, environment,
"dsh.cordis_config_path", "mateclaw.agent.runtime.dsh.cordis-config", "DSH_CORDIS_CONFIG"),
firstNonBlank(managed, properties, environment,
"dsh.working_directory", "mateclaw.agent.runtime.dsh.working-directory", "DSH_CWD"),
firstNonBlank(managed, properties, environment,
"dsh.base_url", "mateclaw.agent.runtime.dsh.base-url", "DEEPSEEK_BASE_URL"),
firstNonBlank(managed, properties, environment,
"dsh.model_name", "mateclaw.agent.runtime.dsh.model-name", "DEEPSEEK_MODEL"),
firstNonBlank(managed, properties, environment,
"dsh.api_key", "mateclaw.agent.runtime.dsh.api-key", "DEEPSEEK_API_KEY"));
}
private static String firstNonBlank(
Map<String, String> managed,
Map<String, String> properties,
Map<String, String> environment,
String managedKey,
String propertyKey,
String environmentKey) {
String value = value(managed, managedKey);
if (value != null) {
return value;
}
value = value(properties, propertyKey);
if (value != null) {
return value;
}
return value(environment, environmentKey);
}
private static String value(Map<String, String> values, String key) {
if (values == null) {
return null;
}
String value = values.get(key);
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@ -0,0 +1,112 @@
package vip.mate.agent.runtime.dsh.management;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import vip.mate.system.service.SystemSettingService;
import java.util.LinkedHashMap;
import java.util.Map;
import java.nio.file.Files;
import java.nio.file.Path;
/** Reads the current DSH configuration without requiring a backend restart. */
@Service
public class DshRuntimeConfigService {
private static final String[] MANAGED_KEYS = {
"dsh.executable_path", "dsh.cordis_config_path", "dsh.working_directory",
"dsh.base_url", "dsh.model_name", SystemSettingService.DSH_API_KEY_KEY
};
private final SystemSettingService settings;
private final Map<String, String> properties;
public DshRuntimeConfigService(
SystemSettingService settings,
@Value("${mateclaw.agent.runtime.dsh.command:}") String command,
@Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String cordisConfig,
@Value("${mateclaw.agent.runtime.dsh.working-directory:}") String workingDirectory,
@Value("${mateclaw.agent.runtime.dsh.base-url:}") String baseUrl,
@Value("${mateclaw.agent.runtime.dsh.model-name:}") String modelName,
@Value("${mateclaw.agent.runtime.dsh.api-key:}") String apiKey) {
this.settings = settings;
this.properties = Map.of(
"mateclaw.agent.runtime.dsh.command", command,
"mateclaw.agent.runtime.dsh.cordis-config", cordisConfig,
"mateclaw.agent.runtime.dsh.working-directory", workingDirectory,
"mateclaw.agent.runtime.dsh.base-url", baseUrl,
"mateclaw.agent.runtime.dsh.model-name", modelName,
"mateclaw.agent.runtime.dsh.api-key", apiKey);
}
public DshRuntimeConfiguration resolve() {
Map<String, String> managed = new LinkedHashMap<>();
for (String key : MANAGED_KEYS) {
String defaultValue = key.equals("dsh.working_directory") ? "" : null;
managed.put(key, settings.getString(key, defaultValue));
}
DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(managed, properties, System.getenv());
String workingDirectory = resolved.workingDirectory();
if (workingDirectory == null || workingDirectory.isBlank()) workingDirectory = System.getProperty("user.dir");
String cordisConfig = normalizeCordisConfig(resolved.cordisConfigPath());
if (cordisConfig.isBlank()) cordisConfig = discoverCordisConfig(resolved.executablePath());
return new DshRuntimeConfiguration(resolved.executablePath(), cordisConfig, workingDirectory,
resolved.baseUrl(), resolved.modelName(), resolved.apiKey());
}
private String discoverCordisConfig(String executable) {
if (executable == null || executable.isBlank()) return "";
Path binary = Path.of(executable.split("\\s+")[0]).toAbsolutePath().normalize();
Path packageRoot = binary.getParent();
if (packageRoot == null) return "";
Path[] candidates = {
packageRoot.resolve("runtime/cordis.yml"),
packageRoot.resolve("../runtime/cordis.yml").normalize(),
packageRoot.resolve("../examples/jsonrpc-agent/cordis.yml").normalize(),
packageRoot.resolve("../../examples/jsonrpc-agent/cordis.yml").normalize()
};
for (Path candidate : candidates) if (Files.isRegularFile(candidate)) return candidate.toString();
return "";
}
private String normalizeCordisConfig(String configured) {
if (configured == null || configured.isBlank()) return "";
Path path = Path.of(configured).toAbsolutePath().normalize();
if (Files.isRegularFile(path)) return path.toString();
Path packageDirectory = Files.isDirectory(path) ? path : path.getParent();
if (packageDirectory == null) return path.toString();
Path packagedConfig = packageDirectory.resolve("runtime/cordis.yml");
return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString();
}
public Map<String, String> managedValues() {
Map<String, String> values = new LinkedHashMap<>();
for (String key : MANAGED_KEYS) {
String value = settings.getString(key, "");
if (SystemSettingService.DSH_API_KEY_KEY.equals(key)) {
values.put(key, settings.maskSecret(value));
} else {
values.put(key, value == null ? "" : value);
}
}
return values;
}
public void save(Map<String, String> values) {
if (values == null) return;
save(values, "dsh.executable_path");
save(values, "dsh.cordis_config_path");
save(values, "dsh.working_directory");
save(values, "dsh.base_url");
save(values, "dsh.model_name");
String apiKey = values.get(SystemSettingService.DSH_API_KEY_KEY);
if (apiKey != null && !apiKey.isBlank() && !apiKey.startsWith("****")) {
settings.saveString(SystemSettingService.DSH_API_KEY_KEY, apiKey.trim(), "DeepSeek API key for DSH");
}
}
private void save(Map<String, String> values, String key) {
if (values.containsKey(key)) {
settings.saveString(key, values.get(key), "Managed DeepSeek Harness runtime setting");
}
}
}

View File

@ -0,0 +1,25 @@
package vip.mate.agent.runtime.dsh.management;
import java.util.LinkedHashMap;
import java.util.Map;
/** Resolved DSH settings. The API key is deliberately omitted from public projections. */
public record DshRuntimeConfiguration(
String executablePath,
String cordisConfigPath,
String workingDirectory,
String baseUrl,
String modelName,
String apiKey) {
public Map<String, Object> publicStatus() {
Map<String, Object> status = new LinkedHashMap<>();
status.put("executablePath", executablePath);
status.put("cordisConfigPath", cordisConfigPath);
status.put("workingDirectory", workingDirectory);
status.put("baseUrl", baseUrl);
status.put("modelName", modelName);
status.put("apiKeyConfigured", apiKey != null && !apiKey.isBlank());
return status;
}
}

View File

@ -412,6 +412,24 @@ public class ApprovalWorkflowService implements ApplicationRunner {
"consumed", /* removeFromMap */ true); "consumed", /* removeFromMap */ true);
} }
/** Claim one exact approval before a team worker executes its guarded tool. */
@Transactional
public ResolveOutcome claimForReplay(String pendingId, String userId) {
return performResolve(pendingId, userId, "APPROVED", MetadataDecision.APPROVED,
"approved", /* removeFromMap */ false);
}
/** Consume an approval previously claimed by {@link #claimForReplay}. */
@Transactional
public ResolveOutcome consumeReplayClaim(String pendingId, String userId) {
PendingApproval target = getReplayClaim(pendingId).orElse(null);
if (target == null) {
return ResolveOutcome.alreadyResolved(pendingId);
}
return performResolveOnSnapshot(target, userId, "APPROVED", "CONSUMED",
MetadataDecision.APPROVED, "consumed", /* removeFromMap */ true);
}
/** /**
* Consume the earliest already-{@code approved} record for the conversation + * Consume the earliest already-{@code approved} record for the conversation +
* tool used when an out-of-band approval (e.g. /approve text command flow that * tool used when an out-of-band approval (e.g. /approve text command flow that
@ -423,7 +441,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
if (target == null) { if (target == null) {
return ResolveOutcome.alreadyResolved(null); return ResolveOutcome.alreadyResolved(null);
} }
return performResolveOnSnapshot(target, null, "CONSUMED", MetadataDecision.APPROVED, return performResolveOnSnapshot(target, null, "APPROVED", "CONSUMED", MetadataDecision.APPROVED,
"consumed", /* removeFromMap */ true); "consumed", /* removeFromMap */ true);
} }
@ -447,7 +465,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size()); List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
for (PendingApproval target : targets) { for (PendingApproval target : targets) {
try { try {
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "DENIED", ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "PENDING", "DENIED",
MetadataDecision.DENIED, "denied", /* removeFromMap */ true); MetadataDecision.DENIED, "denied", /* removeFromMap */ true);
if (outcome.dbSynced()) outcomes.add(outcome); if (outcome.dbSynced()) outcomes.add(outcome);
} catch (Exception e) { } catch (Exception e) {
@ -475,7 +493,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
if (targets.isEmpty()) return List.of(); if (targets.isEmpty()) return List.of();
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size()); List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
for (PendingApproval target : targets) { for (PendingApproval target : targets) {
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "SUPERSEDED", ResolveOutcome outcome = performResolveOnSnapshot(target, null, "PENDING", "SUPERSEDED",
MetadataDecision.DENIED, "superseded", /* removeFromMap */ true); MetadataDecision.DENIED, "superseded", /* removeFromMap */ true);
if (outcome.dbSynced()) outcomes.add(outcome); if (outcome.dbSynced()) outcomes.add(outcome);
} }
@ -644,12 +662,13 @@ public class ApprovalWorkflowService implements ApplicationRunner {
pendingId, snapshot != null, snapshot != null ? snapshot.getStatus() : "n/a"); pendingId, snapshot != null, snapshot != null ? snapshot.getStatus() : "n/a");
return ResolveOutcome.alreadyResolved(pendingId); return ResolveOutcome.alreadyResolved(pendingId);
} }
return performResolveOnSnapshot(snapshot, userId, dbStatus, metaDecision, return performResolveOnSnapshot(snapshot, userId, "PENDING", dbStatus, metaDecision,
snapshotStatus, removeFromMap); snapshotStatus, removeFromMap);
} }
private ResolveOutcome performResolveOnSnapshot(PendingApproval snapshot, String userId, private ResolveOutcome performResolveOnSnapshot(PendingApproval snapshot, String userId,
String dbStatus, MetadataDecision metaDecision, String expectedDbStatus, String dbStatus,
MetadataDecision metaDecision,
String snapshotStatus, boolean removeFromMap) { String snapshotStatus, boolean removeFromMap) {
// Phase 1 DB UPDATE (conditional). The eq("PENDING") guard makes the call // Phase 1 DB UPDATE (conditional). The eq("PENDING") guard makes the call
// idempotent: if another path already won, we get rows=0 and bail without // idempotent: if another path already won, we get rows=0 and bail without
@ -658,7 +677,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
try { try {
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>() LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId()) .eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId())
.eq(ToolApprovalEntity::getStatus, "PENDING") .eq(ToolApprovalEntity::getStatus, expectedDbStatus)
.set(ToolApprovalEntity::getStatus, dbStatus) .set(ToolApprovalEntity::getStatus, dbStatus)
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now()); .set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now());
if (userId != null) { if (userId != null) {
@ -672,8 +691,8 @@ public class ApprovalWorkflowService implements ApplicationRunner {
throw e; throw e;
} }
if (rows == 0) { if (rows == 0) {
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in PENDING (concurrent resolve)", log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in {} (concurrent resolve)",
snapshot.getPendingId()); snapshot.getPendingId(), expectedDbStatus);
return ResolveOutcome.alreadyResolved(snapshot.getPendingId()); return ResolveOutcome.alreadyResolved(snapshot.getPendingId());
} }
@ -809,6 +828,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
return approvalService.getPending(pendingId); return approvalService.getPending(pendingId);
} }
/**
* Recover an exact APPROVED replay claim from memory or DB. APPROVED claims are
* intentionally durable so a worker replay can be finalized after a restart
* without reopening the approval to denial.
*/
public java.util.Optional<PendingApproval> getReplayClaim(String pendingId) {
PendingApproval inMemory = approvalService.getPending(pendingId)
.filter(pending -> "approved".equals(pending.getStatus()))
.orElse(null);
if (inMemory != null) {
return java.util.Optional.of(inMemory);
}
ToolApprovalEntity entity = approvalMapper.selectOne(
new LambdaQueryWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getPendingId, pendingId)
.eq(ToolApprovalEntity::getStatus, "APPROVED"));
if (entity == null) {
return java.util.Optional.empty();
}
Instant createdAt = entity.getCreatedAt() == null
? Instant.now()
: entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant();
PendingApproval snapshot = new PendingApproval(entity.getPendingId(),
entity.getConversationId(), entity.getUserId(), entity.getToolName(),
entity.getToolArguments(), entity.getSummary(), createdAt, "approved");
snapshot.setToolCallPayload(entity.getToolCallPayload());
snapshot.setSiblingToolCalls(entity.getSiblingToolCalls());
snapshot.setAgentId(entity.getAgentId());
snapshot.setChannelType(entity.getChannelType());
snapshot.setRequesterName(entity.getRequesterName());
snapshot.setReplyTarget(entity.getReplyTarget());
snapshot.setFindingsJson(entity.getFindingsJson());
snapshot.setMaxSeverity(entity.getMaxSeverity());
snapshot.setSummary(entity.getSummary());
snapshot.setChatOrigin(entity.getChatOrigin());
return java.util.Optional.of(snapshot);
}
public PendingApproval findPendingByConversation(String conversationId) { public PendingApproval findPendingByConversation(String conversationId) {
return approvalService.findPendingByConversation(conversationId); return approvalService.findPendingByConversation(conversationId);
} }

View File

@ -1469,6 +1469,7 @@ public class ChannelMessageRouter {
replayOrigin = chatOriginFactory.from( replayOrigin = chatOriginFactory.from(
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null); channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
} }
replayOrigin = replayOrigin.withApprovalId(consumed.getPendingId());
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage( AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin); agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
String reply = replayResult.content(); String reply = replayResult.content();

View File

@ -19,6 +19,7 @@ import vip.mate.common.result.R;
import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.MetadataDecision; import vip.mate.approval.MetadataDecision;
import vip.mate.approval.PendingApproval; import vip.mate.approval.PendingApproval;
@ -43,6 +44,8 @@ import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* Web 渠道聊天接口 * Web 渠道聊天接口
@ -66,6 +69,10 @@ public class ChatController {
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService; private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
private final ConversationInputQueueStore inputQueue;
@org.springframework.beans.factory.annotation.Autowired
private ConversationTurnGate turnGate = new ConversationTurnGate();
// Virtual thread per SSE task: matches the app-wide virtual-thread model // Virtual thread per SSE task: matches the app-wide virtual-thread model
// (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread // (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread
@ -200,13 +207,29 @@ public class ChatController {
return emitter; return emitter;
} }
if (conversationService.conversationExists(conversationId)
&& !conversationService.isConversationOwner(conversationId, username)) {
sendErrorDoneAndComplete(emitter, "无权操作该会话");
return emitter;
}
// Reserve before approval consumption, regeneration or stream mutation.
// Once registered, RunState protects the setup-to-subscription gap:
// autonomous admission checks isRunning while holding this same gate.
try (var setupPermit = turnGate.tryAcquire(conversationId)) {
if (setupPermit == null || streamTracker.isRunning(conversationId)) {
sendErrorDoneAndComplete(emitter, "正在生成回复,请先停止或排队后续消息");
return emitter;
}
// ---- 审批命令拦截/approve/deny SSE 流式 replay ---- // ---- 审批命令拦截/approve/deny SSE 流式 replay ----
String normalizedMsg = requestMessage.trim().toLowerCase(); String normalizedMsg = requestMessage.trim().toLowerCase();
boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg); boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg);
boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg); boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg);
if (isApprovalCommand || isDenyCommand) { if (isApprovalCommand || isDenyCommand) {
PendingApproval pending = approvalService.findPendingByConversation(conversationId); PendingApproval pending = findRequestedPendingApproval(
conversationId, request.getPendingApprovalId());
if (pending == null) { if (pending == null) {
try { try {
sendEvent(emitter, "error", Map.of("message", "当前没有待审批的工具调用")); sendEvent(emitter, "error", Map.of("message", "当前没有待审批的工具调用"));
@ -249,6 +272,7 @@ public class ChatController {
final String decision = isApprovalCommand ? "approved" : "denied"; final String decision = isApprovalCommand ? "approved" : "denied";
streamTracker.register(conversationId); streamTracker.register(conversationId);
setupPermit.close();
Long approvalAgentId = parseLongOrNull(pending.getAgentId()); Long approvalAgentId = parseLongOrNull(pending.getAgentId());
streamTracker.bindRunMeta(conversationId, approvalAgentId, username); streamTracker.bindRunMeta(conversationId, approvalAgentId, username);
registerEmitterCallbacks(emitter, conversationId); registerEmitterCallbacks(emitter, conversationId);
@ -279,8 +303,8 @@ public class ChatController {
conversationService.getMessageCount(conversationId))); conversationService.getMessageCount(conversationId)));
// deny 是正常 turn 终结用户可能在 awaiting_approval 阶段排了消息 // deny 是正常 turn 终结用户可能在 awaiting_approval 阶段排了消息
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
if (denyCr.allDone() && denyCr.queuedInput() != null) { if (denyCr.allDone() && shouldDrainQueuedInput(conversationId, "completed")) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
} else { } else {
completeEmitterQuietly(emitter, approvalEmitterDone); completeEmitterQuietly(emitter, approvalEmitterDone);
} }
@ -293,8 +317,8 @@ public class ChatController {
broadcastEvent(conversationId, "done", Map.of("status", "completed")); broadcastEvent(conversationId, "done", Map.of("status", "completed"));
// 审批记录被另一个请求消费但用户可能在等待期间排了消息 // 审批记录被另一个请求消费但用户可能在等待期间排了消息
ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId);
if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) { if (consumedNullCr.allDone() && shouldDrainQueuedInput(conversationId, "completed")) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
} else { } else {
completeEmitterQuietly(emitter, approvalEmitterDone); completeEmitterQuietly(emitter, approvalEmitterDone);
} }
@ -322,7 +346,7 @@ public class ChatController {
} }
// Carry the request-thread base URL so any file a replayed // Carry the request-thread base URL so any file a replayed
// tool generates gets an absolute download link. // tool generates gets an absolute download link.
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl); replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl).withApprovalId(finalConsumed.getPendingId());
Disposable disposable = agentService.chatWithReplayStream( Disposable disposable = agentService.chatWithReplayStream(
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin) replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
.doOnNext(delta -> { .doOnNext(delta -> {
@ -398,8 +422,8 @@ public class ChatController {
} finally { } finally {
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { if (cr.allDone()) {
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, persistStatus)) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, approvalEmitterDone); completeEmitterQuietly(emitter, approvalEmitterDone);
@ -499,8 +523,8 @@ public class ChatController {
streamTracker.clearInterruptState(conversationId); streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { if (cr.allDone()) {
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, errStatus)) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, approvalEmitterDone); completeEmitterQuietly(emitter, approvalEmitterDone);
@ -555,6 +579,7 @@ public class ChatController {
// ---- 正常请求注册流状态并附着首个订阅者 ---- // ---- 正常请求注册流状态并附着首个订阅者 ----
streamTracker.register(conversationId); streamTracker.register(conversationId);
setupPermit.close();
streamTracker.bindRunMeta(conversationId, agentId, username); streamTracker.bindRunMeta(conversationId, agentId, username);
registerEmitterCallbacks(emitter, conversationId); registerEmitterCallbacks(emitter, conversationId);
streamTracker.attach(conversationId, emitter); streamTracker.attach(conversationId, emitter);
@ -743,7 +768,7 @@ public class ChatController {
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { if (cr.allDone()) {
// RFC follow-up (2026-04-27): the previous guard // RFC follow-up (2026-04-27): the previous guard
// cr.queuedInput() != null && (isInterruptFollowup || !wasStopped) // hasQueuedInput(conversationId) && (isInterruptFollowup || !wasStopped)
// dropped legitimate queued messages when the user stopped // dropped legitimate queued messages when the user stopped
// the running turn and then sent a new message via the // the running turn and then sent a new message via the
// enqueue path (not the interrupt-with-followup path) // enqueue path (not the interrupt-with-followup path)
@ -754,8 +779,8 @@ public class ChatController {
// run it" condition; align with them. If the user // run it" condition; align with them. If the user
// genuinely doesn't want continuation, no message would // genuinely doesn't want continuation, no message would
// have been in messageQueue to begin with. // have been in messageQueue to begin with.
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, persistStatus)) {
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
// 延迟关闭 emitter确保最后的事件都已发送 // 延迟关闭 emitter确保最后的事件都已发送
@ -847,9 +872,9 @@ public class ChatController {
streamTracker.clearInterruptState(conversationId); streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { if (cr.allDone()) {
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, status)) {
// 无论中断类型都消费排队消息修复 Disposable 不可用时队列被丢弃的 bug // 无论中断类型都消费排队消息修复 Disposable 不可用时队列被丢弃的 bug
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone); completeEmitterQuietly(emitter, emitterDone);
@ -957,7 +982,7 @@ public class ChatController {
streamTracker.clearInterruptState(conversationId); streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
log.info("SSE doOnError cleanup: conversationId={}, allDone={}, isInterruptFollowup={}, hasQueued={}", log.info("SSE doOnError cleanup: conversationId={}, allDone={}, isInterruptFollowup={}, hasQueued={}",
conversationId, cr.allDone(), isInterruptFollowup, cr.queuedInput() != null); conversationId, cr.allDone(), isInterruptFollowup, hasQueuedInput(conversationId));
if (cr.allDone()) { if (cr.allDone()) {
// RFC follow-up (2026-04-27): the previous guard // RFC follow-up (2026-04-27): the previous guard
// cr.queuedInput()!=null && !(isUserStop && !isInterruptFollowup) // cr.queuedInput()!=null && !(isUserStop && !isInterruptFollowup)
@ -971,8 +996,8 @@ public class ChatController {
// follow-up. Whoever puts a message in messageQueue means it // follow-up. Whoever puts a message in messageQueue means it
// just run it. Aligns with doOnComplete and the 4 other // just run it. Aligns with doOnComplete and the 4 other
// queue-launch sites in this controller. // queue-launch sites in this controller.
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, status)) {
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone); completeEmitterQuietly(emitter, emitterDone);
@ -1006,6 +1031,7 @@ public class ChatController {
return emitter; return emitter;
} }
}
/** /**
* 停止指定会话的流式生成 * 停止指定会话的流式生成
@ -1086,17 +1112,24 @@ public class ChatController {
// 判断当前阶段仅用于 reason 字段行为对所有阶段一致仅入队 // 判断当前阶段仅用于 reason 字段行为对所有阶段一致仅入队
boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null; boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null;
// 仅入队 dispose延迟持久化到 startQueuedMessage Asst-N 先在 doOnComplete 落库 // Commit the payload before publishing acceptance. The stream tracker is
// 否则 listMessages ORDER BY create_time ASC 会把 Q(N+1) 排到 Asst-N 前面 // only a wake signal; the database row remains authoritative on restart.
boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, false, contentParts); var stored = inputQueue.enqueue(conversationId, agentId, username, message, contentParts,
LocalDateTime.now());
boolean queued = streamTracker.notifyQueuedInput(conversationId);
if (!queued) {
inputQueue.cancel(stored.id(), "stream_finished_before_queue_registration",
LocalDateTime.now());
}
log.info("Enqueued follow-up message during running turn: conversationId={}, user={}, queueSize={}, awaitingApproval={}", log.info("Enqueued follow-up message during running turn: conversationId={}, user={}, queueSize={}, awaitingApproval={}",
conversationId, username, streamTracker.getQueueSize(conversationId), isAwaitingApproval); conversationId, username, inputQueue.countQueued(conversationId), isAwaitingApproval);
return R.ok(Map.of( return R.ok(Map.of(
"interrupted", false, "interrupted", false,
"queued", queued, "queued", queued,
"queueSize", streamTracker.getQueueSize(conversationId), "queueItemId", stored.id().toString(),
"reason", isAwaitingApproval ? "awaiting_approval" : "queued" "queueSize", inputQueue.countQueued(conversationId),
"reason", queued ? (isAwaitingApproval ? "awaiting_approval" : "queued") : "no_active_stream"
)); ));
} }
@ -1123,6 +1156,10 @@ public class ChatController {
if (username == null) { if (username == null) {
return R.fail(401, "未登录,请先登录"); return R.fail(401, "未登录,请先登录");
} }
try (var permit = turnGate.tryAcquire(request.getConversationId())) {
if (permit == null || streamTracker.isRunning(request.getConversationId())) {
return R.fail(409, "正在生成回复,请先停止或排队后续消息");
}
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId);
MessageEntity savedUser = conversationService.saveMessage( MessageEntity savedUser = conversationService.saveMessage(
request.getConversationId(), "user", request.getMessage(), request.getContentParts()); request.getConversationId(), "user", request.getMessage(), request.getContentParts());
@ -1134,7 +1171,8 @@ public class ChatController {
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId,
request.getEndUserId()).withOriginMessageId( request.getEndUserId()).withOriginMessageId(
savedUser == null ? null : savedUser.getId()); savedUser == null ? null : savedUser.getId());
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); AgentService.ChatResult result = turnGate.withPermit(permit, () ->
agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin));
String response = result.content(); String response = result.content();
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
result.promptTokens(), result.completionTokens(), result.promptTokens(), result.completionTokens(),
@ -1143,6 +1181,7 @@ public class ChatController {
memoryOwnerResolver.resolve(webOrigin)); memoryOwnerResolver.resolve(webOrigin));
return R.ok(response); return R.ok(response);
} }
}
@Operation(summary = "上传聊天附件") @Operation(summary = "上传聊天附件")
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ -1362,6 +1401,8 @@ public class ChatController {
private String message; private String message;
private String conversationId = "default"; private String conversationId = "default";
private List<MessageContentPart> contentParts; private List<MessageContentPart> contentParts;
/** Exact approval selected by the UI; absent for legacy FIFO clients. */
private String pendingApprovalId;
/** true 表示断线重连,不发送新消息,只附着到已有的流 */ /** true 表示断线重连,不发送新消息,只附着到已有的流 */
private Boolean reconnect; private Boolean reconnect;
/** /**
@ -1396,21 +1437,30 @@ public class ChatController {
private Boolean regenerate; private Boolean regenerate;
} }
/** /** Claims and starts the next durable input after the current stream finishes. */
* 自动启动排队消息interrupt-with-followup 或自然完成后的续跑逻辑
* 接受由 {@link ChatStreamTracker#completeAndConsumeIfLast} 预先消费的 QueuedInput 快照
* 快照已脱离 RunState 生命周期不受后续 complete/register 影响
* 支持链式续跑queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用
*/
private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone, private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone,
ChatStreamTracker.QueuedInput preConsumedInput, String requesterId, String requesterId, String baseUrl) {
String baseUrl) { String queueClaimId = UUID.randomUUID().toString();
ConversationInputQueueStore.QueuedInput preConsumedInput = inputQueue
.claimNext(conversationId, queueClaimId, LocalDateTime.now())
.orElse(null);
if (preConsumedInput == null) { if (preConsumedInput == null) {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone); completeEmitterQuietly(emitter, emitterDone);
return; return;
} }
Long agentId = preConsumedInput.agentId() != null ? preConsumedInput.agentId() : 1L;
var queuedConversation = conversationService.findByConversationId(conversationId);
if (queuedConversation == null || !agentId.equals(queuedConversation.getAgentId())) {
inputQueue.release(preConsumedInput.id(), queueClaimId, LocalDateTime.now());
broadcastEvent(conversationId, "warning", Map.of(
"message", "排队消息对应的助手已变化,请确认后重试"));
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
return;
}
// Rate Limit 防护如果上一轮以 rate limit 错误结束不立即续跑排队消息必然再次 429 // Rate Limit 防护如果上一轮以 rate limit 错误结束不立即续跑排队消息必然再次 429
// 改为持久化用户消息 + 通知前端"稍后重试"避免连锁 429 浪费配额 // 改为持久化用户消息 + 通知前端"稍后重试"避免连锁 429 浪费配额
String lastMessage = conversationService.getLastMessage(conversationId); String lastMessage = conversationService.getLastMessage(conversationId);
@ -1418,11 +1468,14 @@ public class ChatController {
|| lastMessage.contains("429") || lastMessage.contains("速率限制"))) { || lastMessage.contains("429") || lastMessage.contains("速率限制"))) {
log.warn("Skipping queued message after rate limit error: conversationId={}, lastMessage={}", log.warn("Skipping queued message after rate limit error: conversationId={}, lastMessage={}",
conversationId, lastMessage.substring(0, Math.min(50, lastMessage.length()))); conversationId, lastMessage.substring(0, Math.min(50, lastMessage.length())));
// 持久化用户消息不丢失 if (preConsumedInput.persistedMessageId() == null) {
if (preConsumedInput.message() != null && !preConsumedInput.message().isBlank() MessageEntity saved = conversationService.saveMessage(conversationId, "user",
&& !preConsumedInput.persisted()) { preConsumedInput.message(), preConsumedInput.contentParts(), "queued");
conversationService.saveMessage(conversationId, "user", preConsumedInput.message()); if (saved != null) {
inputQueue.bindMessage(preConsumedInput.id(), queueClaimId, saved.getId(), LocalDateTime.now());
} }
}
inputQueue.consume(preConsumedInput.id(), queueClaimId, LocalDateTime.now());
broadcastEvent(conversationId, "warning", Map.of( broadcastEvent(conversationId, "warning", Map.of(
"message", "上一轮请求触发了频率限制,排队消息已保存,请稍后重新发送")); "message", "上一轮请求触发了频率限制,排队消息已保存,请稍后重新发送"));
broadcastEvent(conversationId, "done", Map.of("status", "rate_limited")); broadcastEvent(conversationId, "done", Map.of("status", "rate_limited"));
@ -1432,18 +1485,26 @@ public class ChatController {
} }
String queuedMessage = preConsumedInput.message(); String queuedMessage = preConsumedInput.message();
Long agentId = preConsumedInput.agentId() != null ? preConsumedInput.agentId() : 1L;
log.info("Starting queued message: conversationId={}, agentId={}, message={}", log.info("Starting queued message: conversationId={}, agentId={}, message={}",
conversationId, agentId, queuedMessage.substring(0, Math.min(30, queuedMessage.length()))); conversationId, agentId, queuedMessage == null ? "" : queuedMessage.substring(0, Math.min(30, queuedMessage.length())));
// 持久化排队的用户消息 contentParts幂等如果 /interrupt 已提前持久化则跳过 // 持久化排队的用户消息 contentParts幂等如果 /interrupt 已提前持久化则跳过
// 这里持久化是为了确保 user 消息在 assistant 消息doOnError/doOnCancel 已写入之后落库 // 这里持久化是为了确保 user 消息在 assistant 消息doOnError/doOnCancel 已写入之后落库
// listMessages ORDER BY create_time ASC 后顺序正确Q1 Asst1 Q2 Asst2 // listMessages ORDER BY create_time ASC 后顺序正确Q1 Asst1 Q2 Asst2
Long queuedOriginMessageId = null; Long queuedOriginMessageId = preConsumedInput.persistedMessageId();
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) { if (queuedOriginMessageId == null) {
MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage, MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage,
preConsumedInput.contentParts(), "queued"); preConsumedInput.contentParts(), "queued");
queuedOriginMessageId = savedUser == null ? null : savedUser.getId(); queuedOriginMessageId = savedUser == null ? null : savedUser.getId();
if (queuedOriginMessageId == null
|| !inputQueue.bindMessage(preConsumedInput.id(), queueClaimId,
queuedOriginMessageId, LocalDateTime.now())) {
inputQueue.release(preConsumedInput.id(), queueClaimId, LocalDateTime.now());
throw new IllegalStateException("Queued input could not be bound to its persisted message");
}
}
if (!inputQueue.consume(preConsumedInput.id(), queueClaimId, LocalDateTime.now())) {
throw new IllegalStateException("Queued input claim was lost before execution");
} }
// 广播 queued_input_started 事件 // 广播 queued_input_started 事件
@ -1530,9 +1591,9 @@ public class ChatController {
} finally { } finally {
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { if (cr.allDone()) {
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, persistStatus)) {
// 链式续跑queued stream 期间又排了新消息 // 链式续跑queued stream 期间又排了新消息
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); startQueuedMessage(conversationId, emitter, emitterDone, requesterId, baseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
sseExecutor.execute(() -> { sseExecutor.execute(() -> {
@ -1576,8 +1637,8 @@ public class ChatController {
} }
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { if (cr.allDone()) {
if (cr.queuedInput() != null) { if (shouldDrainQueuedInput(conversationId, "failed")) {
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); startQueuedMessage(conversationId, emitter, emitterDone, requesterId, baseUrl);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone); completeEmitterQuietly(emitter, emitterDone);
@ -1590,6 +1651,10 @@ public class ChatController {
() -> emergencySaveAccumulator(conversationId, accumulator)); () -> emergencySaveAccumulator(conversationId, accumulator));
} }
private boolean hasQueuedInput(String conversationId) {
return inputQueue.countQueued(conversationId) > 0;
}
/** /**
* Terminal error path for requests rejected before a stream is registered: * Terminal error path for requests rejected before a stream is registered:
* emit an {@code error} + terminal {@code done} pair and complete the * emit an {@code error} + terminal {@code done} pair and complete the
@ -1660,6 +1725,31 @@ public class ChatController {
return "[本次没有输出]"; return "[本次没有输出]";
} }
private boolean shouldDrainQueuedInput(String conversationId, String persistStatus) {
return shouldDrainQueuedInput(
persistStatus,
hasQueuedInput(conversationId),
approvalService.findPendingByConversation(conversationId) != null);
}
private PendingApproval findRequestedPendingApproval(String conversationId, String pendingApprovalId) {
if (pendingApprovalId == null || pendingApprovalId.isBlank()) {
return approvalService.findPendingByConversation(conversationId);
}
return approvalService.getPending(pendingApprovalId)
.filter(pending -> conversationId.equals(pending.getConversationId()))
.filter(pending -> "pending".equals(pending.getStatus()))
.orElse(null);
}
static boolean shouldDrainQueuedInput(String persistStatus,
boolean hasQueuedInput,
boolean hasPendingApproval) {
return hasQueuedInput
&& !hasPendingApproval
&& !"awaiting_approval".equals(persistStatus);
}
static boolean isAssistantPersisted(MessageEntity savedAssistant) { static boolean isAssistantPersisted(MessageEntity savedAssistant) {
return savedAssistant != null; return savedAssistant != null;
} }

View File

@ -1,5 +1,6 @@
package vip.mate.channel.web; package vip.mate.channel.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -88,6 +89,18 @@ public class ChatStreamTracker {
@Value("${mateclaw.stream.iteration-events:true}") @Value("${mateclaw.stream.iteration-events:true}")
private boolean iterationEventsEnabled = true; private boolean iterationEventsEnabled = true;
/**
* Coalesce the tiny token fragments produced by streaming model clients
* before assigning an SSE id and touching the replay buffer. This keeps
* rendering responsive while avoiding thousands of emitter writes for a
* single long answer.
*/
@Value("${mateclaw.stream.content-batch-ms:25}")
private long contentBatchMs = 25L;
@Value("${mateclaw.stream.content-batch-chars:256}")
private int contentBatchChars = 256;
/** /**
* Heartbeat cadence (seconds) before the first model token arrives. Short * Heartbeat cadence (seconds) before the first model token arrives. Short
* because pre-token gaps strand the UI on a blank "正在生成中" placeholder * because pre-token gaps strand the UI on a blank "正在生成中" placeholder
@ -127,6 +140,11 @@ public class ChatStreamTracker {
this.iterationEventsEnabled = enabled; this.iterationEventsEnabled = enabled;
} }
void setContentBatchingForTesting(long flushMs, int maxChars) {
this.contentBatchMs = Math.max(1L, flushMs);
this.contentBatchChars = Math.max(1, maxChars);
}
public boolean isIterationEventsEnabled() { public boolean isIterationEventsEnabled() {
return iterationEventsEnabled; return iterationEventsEnabled;
} }
@ -190,8 +208,8 @@ public class ChatStreamTracker {
/** 等待原因(审批等待时有值) */ /** 等待原因(审批等待时有值) */
volatile String waitingReason; volatile String waitingReason;
/** 排队的用户消息队列(支持多条排队消息,按序消费) */ /** Wake signal only; queued input payloads live in the database. */
final java.util.Queue<QueuedInput> messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>(); final AtomicBoolean queuedInputPending = new AtomicBoolean(false);
/** /**
* Emergency save callback registered by the SSE chain owner (ChatController). * Emergency save callback registered by the SSE chain owner (ChatController).
@ -219,6 +237,11 @@ public class ChatStreamTracker {
/** 已广播的 pending approval ID 集合(用于幂等去重) */ /** 已广播的 pending approval ID 集合(用于幂等去重) */
final java.util.Set<String> broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet(); final java.util.Set<String> broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet();
/** Pending visible answer text waiting for the SSE coalescing window. Guarded by lock. */
String pendingContentField;
final StringBuilder pendingContent = new StringBuilder();
ScheduledFuture<?> pendingContentFlush;
/** 创建时间(用于 stale 检测和清理) */ /** 创建时间(用于 stale 检测和清理) */
final long createdAt = System.currentTimeMillis(); final long createdAt = System.currentTimeMillis();
@ -500,18 +523,7 @@ public class ChatStreamTracker {
} }
if (current.done) { if (current.done) {
stopHeartbeat(current); stopHeartbeat(current);
RunState nextState = new RunState(id); return new RunState(id);
int carried = 0;
QueuedInput queued;
while ((queued = current.messageQueue.poll()) != null) {
nextState.messageQueue.offer(queued);
carried++;
}
if (carried > 0) {
log.info("[ChatStreamTracker] Carried {} queued message(s) into next run: {}",
carried, id);
}
return nextState;
} }
// Registration is a fresh lifecycle entrance. Refresh every // Registration is a fresh lifecycle entrance. Refresh every
// stale-run input while holding the same lock cleanup uses to // stale-run input while holding the same lock cleanup uses to
@ -544,17 +556,41 @@ public class ChatStreamTracker {
*/ */
public void setDisposable(String conversationId, Disposable disposable) { public void setDisposable(String conversationId, Disposable disposable) {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
if (state != null) { if (state == null || disposable == null) return;
boolean disposeImmediately;
synchronized (state.lock) {
if (!isCurrent(state)) return;
state.disposable = disposable; state.disposable = disposable;
// Stop can win before the asynchronous SSE setup has subscribed
// and registered its Disposable. Do not let that late subscription
// escape the cancellation request.
disposeImmediately = state.done || state.stopRequested.get();
}
if (disposeImmediately) {
disposeSafely(conversationId, disposable);
} }
} }
public void setDisposable(RunHandle handle, Disposable disposable) { public void setDisposable(RunHandle handle, Disposable disposable) {
if (handle == null) return; if (handle == null || disposable == null) return;
RunState state = handle.state; RunState state = handle.state;
boolean disposeImmediately;
synchronized (state.lock) { synchronized (state.lock) {
if (!isCurrent(state)) return; if (!isCurrent(state)) return;
state.disposable = disposable; state.disposable = disposable;
disposeImmediately = state.done || state.stopRequested.get();
}
if (disposeImmediately) {
disposeSafely(state.conversationId, disposable);
}
}
private void disposeSafely(String conversationId, Disposable disposable) {
try {
disposable.dispose();
} catch (Exception e) {
log.warn("Late stream disposable cancellation failed for {}: {}",
conversationId, e.getMessage());
} }
} }
@ -630,8 +666,32 @@ public class ChatStreamTracker {
* 取消 Flux 订阅底层 HTTP 连接也会随之关闭返回 true 表示确实停止了正在运行的流 * 取消 Flux 订阅底层 HTTP 连接也会随之关闭返回 true 表示确实停止了正在运行的流
*/ */
public boolean requestStop(String conversationId) { public boolean requestStop(String conversationId) {
RunState state = runs.get(conversationId); // A goal may be between finite segments, with no live RunState to cancel.
// Persist the user's intent before looking up that ephemeral state.
try {
if (applicationContext != null) {
applicationContext.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.Stop(conversationId));
}
} catch (RuntimeException persistenceFailure) {
// Still cancel live work, but do not acknowledge a durable Stop that failed.
requestStopLive(conversationId);
throw persistenceFailure;
}
return requestStopLive(conversationId);
}
private boolean requestStopLive(String conversationId) {
return requestStopLive(runs.get(conversationId));
}
/** Cancel only this generation, without publishing a new user Stop intent. */
public boolean cancelRun(RunHandle handle) {
return handle != null && requestStopLive(handle.state);
}
private boolean requestStopLive(RunState state) {
if (state == null) return false; if (state == null) return false;
String conversationId = state.conversationId;
final boolean firstRequest; final boolean firstRequest;
final Disposable d; final Disposable d;
@ -711,14 +771,109 @@ public class ChatStreamTracker {
return true; return true;
} }
private record ContentDelta(String field, String text) {}
/**
* Buffer only the two established visible-content wire shapes:
* {@code {"delta":"..."}} (workspace chat) and
* {@code {"text":"..."}} (embedded webchat). Payloads with extra
* metadata stay on the ordinary path so batching never discards fields.
*/
private boolean tryBufferContentDelta(RunState state, String eventName,
String jsonData, boolean skipBuffer) {
if (!"content_delta".equals(eventName) || skipBuffer || state == null) {
return false;
}
ContentDelta delta = parseContentDelta(jsonData);
if (delta == null) {
return false;
}
boolean flushNow = false;
synchronized (state.lock) {
if (!isCurrent(state) || state.done) {
return true;
}
// A conversation uses one wire field for a run. If a caller does
// switch shapes, flush the old batch and deliver the new payload
// unchanged rather than mixing contracts.
if (state.pendingContentField != null
&& !state.pendingContentField.equals(delta.field())) {
return false;
}
state.lastEventAt = System.currentTimeMillis();
state.pendingContentField = delta.field();
state.pendingContent.append(delta.text());
if (state.pendingContent.length() >= Math.max(1, contentBatchChars)) {
flushNow = true;
} else if (state.pendingContentFlush == null
|| state.pendingContentFlush.isDone()) {
state.pendingContentFlush = heartbeatScheduler.schedule(
() -> flushPendingContent(state),
Math.max(1L, contentBatchMs), TimeUnit.MILLISECONDS);
}
}
if (flushNow) {
flushPendingContent(state);
}
return true;
}
private ContentDelta parseContentDelta(String jsonData) {
if (jsonData == null || jsonData.isEmpty()) return null;
try {
JsonNode node = objectMapper.readTree(jsonData);
if (node == null || !node.isObject() || node.size() != 1) return null;
String field = node.has("delta") ? "delta" : node.has("text") ? "text" : null;
if (field == null || !node.path(field).isTextual()) return null;
String text = node.path(field).textValue();
return text == null || text.isEmpty() ? null : new ContentDelta(field, text);
} catch (Exception ignored) {
return null;
}
}
/** Snapshot under the run lock, then emit through the fenced raw path. */
private void flushPendingContent(RunState state) {
String field;
String text;
synchronized (state.lock) {
if (state.pendingContent.length() == 0) {
if (state.pendingContentFlush != null) {
state.pendingContentFlush.cancel(false);
state.pendingContentFlush = null;
}
state.pendingContentField = null;
return;
}
field = state.pendingContentField;
text = state.pendingContent.toString();
state.pendingContent.setLength(0);
state.pendingContentField = null;
if (state.pendingContentFlush != null) {
state.pendingContentFlush.cancel(false);
state.pendingContentFlush = null;
}
}
try {
String json = objectMapper.writeValueAsString(Map.of(field, text));
broadcastNow(new RunHandle(state), "content_delta", json, false);
} catch (Exception e) {
log.warn("Failed to flush content batch for {}: {}",
state.conversationId, e.getMessage());
}
}
/** /**
* 广播事件到所有订阅者并缓存到 buffer. * 广播事件到所有订阅者并缓存到 buffer.
* <p> * <p>
* Two event categories survive {@code state.done=true}: * Lifecycle event categories survive {@code state.done=true}:
* <ul> * <ul>
* <li>{@code "done"} the lifecycle marker itself. If a client missed * <li>{@code "done"} the lifecycle marker itself. If a client missed
* this on a broken pipe and reconnects within the 5-minute retention * this on a broken pipe and reconnects within the 5-minute retention
* window, replay surfaces it so the UI exits "生成中" state.</li> * window, replay surfaces it so the UI exits "生成中" state.</li>
* <li>{@code "goal_continuation"} durable scheduling is settled after
* the graph segment completes, and remains available on reconnect.</li>
* <li>{@code "async_task_*"} task lifecycle events from * <li>{@code "async_task_*"} task lifecycle events from
* {@code AsyncTaskService} (image/video/music generation). These * {@code AsyncTaskService} (image/video/music generation). These
* routinely fire <em>after</em> the agent's reasoning turn finishes * routinely fire <em>after</em> the agent's reasoning turn finishes
@ -738,9 +893,21 @@ public class ChatStreamTracker {
public void broadcast(RunHandle handle, String eventName, String jsonData, boolean skipBuffer) { public void broadcast(RunHandle handle, String eventName, String jsonData, boolean skipBuffer) {
if (handle == null) return; if (handle == null) return;
RunState state = handle.state;
if (tryBufferContentDelta(state, eventName, jsonData, skipBuffer)) {
return;
}
if (!"heartbeat".equals(eventName)) {
flushPendingContent(state);
}
broadcastNow(handle, eventName, jsonData, skipBuffer);
}
private void broadcastNow(RunHandle handle, String eventName, String jsonData, boolean skipBuffer) {
RunState state = handle.state; RunState state = handle.state;
boolean isDone = "done".equals(eventName); boolean isDone = "done".equals(eventName);
boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_"); boolean isPostTurnEvent = "goal_continuation".equals(eventName)
|| (eventName != null && eventName.startsWith("async_task_"));
boolean isHeartbeat = "heartbeat".equals(eventName); boolean isHeartbeat = "heartbeat".equals(eventName);
List<SseEmitter> targets; List<SseEmitter> targets;
long eventId = 0L; long eventId = 0L;
@ -751,10 +918,10 @@ public class ChatStreamTracker {
if (!isHeartbeat) { if (!isHeartbeat) {
state.lastEventAt = System.currentTimeMillis(); state.lastEventAt = System.currentTimeMillis();
} }
if (!isDone && !isAsyncTask && !isHeartbeat && state.done) { if (!isDone && !isPostTurnEvent && !isHeartbeat && state.done) {
return; return;
} }
if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) { if ((isDone || isPostTurnEvent) || (!isHeartbeat && !skipBuffer)) {
eventId = EVENT_IDS.nextId(); eventId = EVENT_IDS.nextId();
state.buffer.add(new SseEvent(eventId, eventName, jsonData)); state.buffer.add(new SseEvent(eventId, eventName, jsonData));
if (state.buffer.size() > MAX_BUFFER_SIZE) { if (state.buffer.size() > MAX_BUFFER_SIZE) {
@ -762,7 +929,7 @@ public class ChatStreamTracker {
} }
} }
targets = new ArrayList<>(state.subscribers); targets = new ArrayList<>(state.subscribers);
forwardRelays = !isDone && !isAsyncTask && !isHeartbeat; forwardRelays = !isDone && !isPostTurnEvent && !isHeartbeat;
} }
List<SseEmitter> dead = new ArrayList<>(); List<SseEmitter> dead = new ArrayList<>();
@ -814,9 +981,22 @@ public class ChatStreamTracker {
*/ */
public void broadcast(String conversationId, String eventName, String jsonData, boolean skipBuffer) { public void broadcast(String conversationId, String eventName, String jsonData, boolean skipBuffer) {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
if (state == null) return;
if (tryBufferContentDelta(state, eventName, jsonData, skipBuffer)) {
return;
}
if (!"heartbeat".equals(eventName)) {
flushPendingContent(state);
}
broadcastNow(conversationId, eventName, jsonData, skipBuffer);
}
private void broadcastNow(String conversationId, String eventName, String jsonData, boolean skipBuffer) {
RunState state = runs.get(conversationId);
boolean isDone = "done".equals(eventName); boolean isDone = "done".equals(eventName);
boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_"); boolean isPostTurnEvent = "goal_continuation".equals(eventName)
|| (eventName != null && eventName.startsWith("async_task_"));
boolean isHeartbeat = "heartbeat".equals(eventName); boolean isHeartbeat = "heartbeat".equals(eventName);
// Stamp last activity for stuck detection. Heartbeats are excluded // Stamp last activity for stuck detection. Heartbeats are excluded
@ -826,7 +1006,7 @@ public class ChatStreamTracker {
state.lastEventAt = System.currentTimeMillis(); state.lastEventAt = System.currentTimeMillis();
} }
if (isDone || isAsyncTask) { if (isDone || isPostTurnEvent) {
if (state == null) return; if (state == null) return;
synchronized (state.lock) { synchronized (state.lock) {
long id = EVENT_IDS.nextId(); long id = EVENT_IDS.nextId();
@ -850,7 +1030,7 @@ public class ChatStreamTracker {
} }
} }
} }
// done events do not flow through eventRelays; async_task_* should // done events do not flow through eventRelays; post-turn events should
// also short-circuit since relays exist for delta-style streaming // also short-circuit since relays exist for delta-style streaming
// events, not lifecycle markers. // events, not lifecycle markers.
return; return;
@ -1244,10 +1424,8 @@ public class ChatStreamTracker {
} }
} }
/** /** Completion result for the current in-memory stream generation. */
* 完成结果包含是否全部完成排队消息快照 public record CompletionResult(boolean allDone) {}
*/
public record CompletionResult(boolean allDone, QueuedInput queuedInput) {}
/** /**
* 标记一个 Flux 完成仅在所有 Flux 都完成时才真正移除 RunState * 标记一个 Flux 完成仅在所有 Flux 都完成时才真正移除 RunState
@ -1273,6 +1451,10 @@ public class ChatStreamTracker {
private boolean complete(RunState state) { private boolean complete(RunState state) {
String conversationId = state.conversationId; String conversationId = state.conversationId;
// Some terminal paths do not publish a done envelope. Flush visible
// text while the run is still live so the scheduled batch cannot be
// rejected after state.done flips below.
flushPendingContent(state);
ScheduledFuture<?> oldHeartbeat; ScheduledFuture<?> oldHeartbeat;
synchronized (state.lock) { synchronized (state.lock) {
if (!isCurrent(state)) { if (!isCurrent(state)) {
@ -1313,22 +1495,20 @@ public class ChatStreamTracker {
public CompletionResult completeAndConsumeIfLast(String conversationId) { public CompletionResult completeAndConsumeIfLast(String conversationId) {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
if (state == null) { if (state == null) {
return new CompletionResult(true, null); return new CompletionResult(true);
} }
QueuedInput consumed = null; flushPendingContent(state);
ScheduledFuture<?> oldHeartbeat; ScheduledFuture<?> oldHeartbeat;
synchronized (state.lock) { synchronized (state.lock) {
if (!isCurrent(state)) { if (!isCurrent(state)) {
return new CompletionResult(false, null); return new CompletionResult(false);
} }
state.activeFluxCount = Math.max(0, state.activeFluxCount - 1); state.activeFluxCount = Math.max(0, state.activeFluxCount - 1);
if (state.activeFluxCount > 0) { if (state.activeFluxCount > 0) {
log.debug("Stream partially completed: {} (remaining flux={}, queuePreserved={})", log.debug("Stream partially completed: {} (remaining flux={}, queuedInputPending={})",
conversationId, state.activeFluxCount, !state.messageQueue.isEmpty()); conversationId, state.activeFluxCount, state.queuedInputPending.get());
return new CompletionResult(false, null); return new CompletionResult(false);
} }
// 最后一个 Flux在同一个锁内消费排队消息取队首
consumed = state.messageQueue.poll();
state.done = true; state.done = true;
state.cancellationHooks.clear(); state.cancellationHooks.clear();
state.termination.complete(null); state.termination.complete(null);
@ -1340,9 +1520,9 @@ public class ChatStreamTracker {
if (oldHeartbeat != null) { if (oldHeartbeat != null) {
oldHeartbeat.cancel(false); oldHeartbeat.cancel(false);
} }
log.debug("Stream fully completed: {} (hasQueuedSnapshot={}, kept in map for {}ms reconnect window)", log.debug("Stream fully completed: {} (queuedInputPending={}, kept in map for {}ms reconnect window)",
conversationId, consumed != null, DONE_RETENTION_MS); conversationId, state.queuedInputPending.get(), DONE_RETENTION_MS);
return new CompletionResult(true, consumed); return new CompletionResult(true);
} }
/** /**
@ -1445,7 +1625,7 @@ public class ChatStreamTracker {
"currentPhase", safe(state.currentPhase), "currentPhase", safe(state.currentPhase),
"waitingReason", safe(state.waitingReason), "waitingReason", safe(state.waitingReason),
"runningToolName", safe(state.runningToolName), "runningToolName", safe(state.runningToolName),
"queueLength", state.messageQueue.size(), "queueLength", state.queuedInputPending.get() ? 1 : 0,
"timestamp", System.currentTimeMillis() "timestamp", System.currentTimeMillis()
)); ));
} catch (Exception e) { } catch (Exception e) {
@ -1590,8 +1770,7 @@ public class ChatStreamTracker {
synchronized (state.lock) { synchronized (state.lock) {
Disposable d = state.disposable; Disposable d = state.disposable;
canInterrupt = d != null && !d.isDisposed(); canInterrupt = d != null && !d.isDisposed();
// 无论是否可中断都入队支持多条排队消息 state.queuedInputPending.set(true);
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted, contentParts));
if (canInterrupt) { if (canInterrupt) {
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP; state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
state.stopRequested.set(true); state.stopRequested.set(true);
@ -1658,7 +1837,7 @@ public class ChatStreamTracker {
if (state == null || state.done) { if (state == null || state.done) {
return false; return false;
} }
state.messageQueue.offer(new QueuedInput(message, agentId, persisted, contentParts)); state.queuedInputPending.set(true);
// broadcast 在锁外 // broadcast 在锁外
try { try {
String json = objectMapper.writeValueAsString(Map.of( String json = objectMapper.writeValueAsString(Map.of(
@ -1688,9 +1867,7 @@ public class ChatStreamTracker {
* 从队列头部取出一条消息 * 从队列头部取出一条消息
*/ */
public QueuedInput consumeQueuedInput(String conversationId) { public QueuedInput consumeQueuedInput(String conversationId) {
RunState state = runs.get(conversationId); return null;
if (state == null) return null;
return state.messageQueue.poll();
} }
/** /**
@ -1734,7 +1911,7 @@ public class ChatStreamTracker {
*/ */
public boolean hasQueuedMessage(String conversationId) { public boolean hasQueuedMessage(String conversationId) {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
return state != null && !state.messageQueue.isEmpty(); return state != null && state.queuedInputPending.get();
} }
/** /**
@ -1742,7 +1919,20 @@ public class ChatStreamTracker {
*/ */
public int getQueueSize(String conversationId) { public int getQueueSize(String conversationId) {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
return state != null ? state.messageQueue.size() : 0; return state != null && state.queuedInputPending.get() ? 1 : 0;
}
/** Notify the live stream that durable queued input is ready to consume. */
public boolean notifyQueuedInput(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null || state.done) return false;
state.queuedInputPending.set(true);
return true;
}
boolean hasQueuedInputNotification(String conversationId) {
RunState state = runs.get(conversationId);
return state != null && state.queuedInputPending.get();
} }
// ===== Approval idempotency ===== // ===== Approval idempotency =====
@ -2172,7 +2362,7 @@ public class ChatStreamTracker {
int queue; int queue;
synchronized (s.lock) { synchronized (s.lock) {
subs = s.subscribers.size(); subs = s.subscribers.size();
queue = s.messageQueue.size(); queue = s.queuedInputPending.get() ? 1 : 0;
} }
out.add(new RunSnapshot( out.add(new RunSnapshot(
s.conversationId, s.conversationId,

View File

@ -0,0 +1,187 @@
package vip.mate.channel.web;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/** Database-backed FIFO for user input accepted while a conversation is busy. */
@Repository
public class ConversationInputQueueStore {
private static final TypeReference<List<MessageContentPart>> PARTS_TYPE = new TypeReference<>() {};
private final JdbcTemplate jdbc;
private final ObjectMapper mapper;
public ConversationInputQueueStore(JdbcTemplate jdbc, ObjectMapper mapper) {
this.jdbc = jdbc;
this.mapper = mapper;
}
public QueuedInput enqueue(String conversationId, Long agentId, String createdBy,
String message, List<MessageContentPart> contentParts,
LocalDateTime now) {
long id = IdWorker.getId();
jdbc.update("""
INSERT INTO mate_conversation_input_queue(
id,conversation_id,agent_id,created_by,message,content_parts,state,
created_at,updated_at)
VALUES(?,?,?,?,?,?,'queued',?,?)
""", id, conversationId, agentId, createdBy, message == null ? "" : message,
writeParts(contentParts), now, now);
return get(id);
}
public Optional<QueuedInput> claimNext(String conversationId, String attemptId,
LocalDateTime now) {
for (int tries = 0; tries < 8; tries++) {
List<Long> ids = jdbc.queryForList("""
SELECT id FROM mate_conversation_input_queue
WHERE conversation_id=? AND state='queued' ORDER BY id LIMIT 1
""", Long.class, conversationId);
if (ids.isEmpty()) return Optional.empty();
long id = ids.getFirst();
if (jdbc.update("""
UPDATE mate_conversation_input_queue
SET state='claimed',claimed_by_attempt_id=?,updated_at=?
WHERE id=? AND state='queued'
""", attemptId, now, id) == 1) {
return Optional.of(get(id));
}
}
return Optional.empty();
}
public boolean bindMessage(Long id, String attemptId, Long messageId, LocalDateTime now) {
return jdbc.update("""
UPDATE mate_conversation_input_queue
SET persisted_message_id=COALESCE(persisted_message_id,?),updated_at=?
WHERE id=? AND claimed_by_attempt_id=? AND state='claimed'
""", messageId, now, id, attemptId) == 1;
}
public boolean consume(Long id, String attemptId, LocalDateTime now) {
return jdbc.update("""
UPDATE mate_conversation_input_queue SET state='consumed',updated_at=?
WHERE id=? AND claimed_by_attempt_id=? AND state='claimed'
""", now, id, attemptId) == 1;
}
public boolean release(Long id, String attemptId, LocalDateTime now) {
return jdbc.update("""
UPDATE mate_conversation_input_queue
SET state='queued',claimed_by_attempt_id=NULL,updated_at=?
WHERE id=? AND claimed_by_attempt_id=? AND state='claimed'
""", now, id, attemptId) == 1;
}
public int releaseClaims(String attemptId,LocalDateTime now) {
return jdbc.update("""
UPDATE mate_conversation_input_queue
SET state='queued',claimed_by_attempt_id=NULL,updated_at=?
WHERE claimed_by_attempt_id=? AND state='claimed'
""",now,attemptId);
}
public int releaseClaimsBefore(LocalDateTime cutoff,LocalDateTime now) {
return jdbc.update("""
UPDATE mate_conversation_input_queue
SET state='queued',claimed_by_attempt_id=NULL,updated_at=?
WHERE state='claimed' AND updated_at<=?
""",now,cutoff);
}
public boolean cancel(Long id, String reason, LocalDateTime now) {
return jdbc.update("""
UPDATE mate_conversation_input_queue
SET state='cancelled',cancel_reason=?,updated_at=?
WHERE id=? AND state='queued'
""", bounded(reason), now, id) == 1;
}
public QueuedInput get(Long id) {
List<QueuedInput> rows = jdbc.query("""
SELECT * FROM mate_conversation_input_queue WHERE id=?
""", (rs, row) -> read(rs), id);
return rows.isEmpty() ? null : rows.getFirst();
}
public List<QueuedInput> listQueued(String conversationId) {
return jdbc.query("""
SELECT * FROM mate_conversation_input_queue
WHERE conversation_id=? AND state='queued' ORDER BY id
""", (rs, row) -> read(rs), conversationId);
}
public int countQueued(String conversationId) {
Integer count = jdbc.queryForObject("""
SELECT COUNT(*) FROM mate_conversation_input_queue
WHERE conversation_id=? AND state='queued'
""", Integer.class, conversationId);
return count == null ? 0 : count;
}
private QueuedInput read(ResultSet rs) throws SQLException {
return new QueuedInput(rs.getLong("id"), rs.getString("conversation_id"),
nullableLong(rs, "agent_id"), rs.getString("created_by"),
rs.getString("message"), readParts(rs.getString("content_parts")),
rs.getString("state"), rs.getString("claimed_by_attempt_id"),
nullableLong(rs, "persisted_message_id"), rs.getString("cancel_reason"),
time(rs, "created_at"), time(rs, "updated_at"));
}
private String writeParts(List<MessageContentPart> parts) {
try {
return mapper.writeValueAsString(parts == null ? List.of() : parts);
} catch (JsonProcessingException error) {
throw new IllegalArgumentException("Queued input contains invalid content parts", error);
}
}
private List<MessageContentPart> readParts(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return mapper.readValue(json, PARTS_TYPE);
} catch (JsonProcessingException error) {
throw new IllegalStateException("Persisted queued input contains invalid content parts", error);
}
}
private static LocalDateTime time(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toLocalDateTime();
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
private static String bounded(String text) {
return text == null ? null : text.substring(0, Math.min(128, text.length()));
}
public record QueuedInput(
Long id,
String conversationId,
Long agentId,
String createdBy,
String message,
List<MessageContentPart> contentParts,
String state,
String claimedByAttemptId,
Long persistedMessageId,
String cancelReason,
LocalDateTime createdAt,
LocalDateTime updatedAt) {}
}

View File

@ -1362,6 +1362,8 @@ public class WebChatController {
conversationId, actor, wsId, null).withSender(null, "api", null); conversationId, actor, wsId, null).withSender(null, "api", null);
} }
replayOrigin = replayOrigin.withApprovalId(snapshot.getPendingId());
// Neutral replay prompt (aligned with IM + web channels naming a // Neutral replay prompt (aligned with IM + web channels naming a
// tool here can mislead the LLM on fallthrough). // tool here can mislead the LLM on fallthrough).
String replayPrompt = "继续执行已批准的工具调用。"; String replayPrompt = "继续执行已批准的工具调用。";

View File

@ -3,6 +3,7 @@ package vip.mate.common.result;
import lombok.Data; import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* 统一响应结果封装 * 统一响应结果封装
@ -24,12 +25,18 @@ public class R<T> implements Serializable {
private T data; private T data;
/** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */ /** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */
private static volatile vip.mate.i18n.I18nService i18n; private static final AtomicReference<vip.mate.i18n.I18nService> I18N = new AtomicReference<>();
public static void setI18n(vip.mate.i18n.I18nService service) { i18n = service; } public static void setI18n(vip.mate.i18n.I18nService service) { I18N.set(service); }
/** Clear a closing context's service without clobbering a newer context. */
public static void clearI18n(vip.mate.i18n.I18nService service) {
I18N.compareAndSet(service, null);
}
private static String resolveMsg(ResultCode rc) { private static String resolveMsg(ResultCode rc) {
return i18n != null ? rc.getMsg(i18n) : rc.getMsg(); vip.mate.i18n.I18nService service = I18N.get();
return service != null ? rc.getMsg(service) : rc.getMsg();
} }
public static <T> R<T> ok() { public static <T> R<T> ok() {

View File

@ -100,15 +100,13 @@ public class SecurityConfig {
// KB Open API: authenticated by KbOpenApiAuthFilter (API key), // KB Open API: authenticated by KbOpenApiAuthFilter (API key),
// not JWT must be permitAll so the filter is the sole gatekeeper (R1). // not JWT must be permitAll so the filter is the sole gatekeeper (R1).
"/api/v1/open/kb/**", "/api/v1/open/kb/**",
"/api/a2a/card",
"/.well-known/agent-card.json",
"/api/v1/talk/ws", "/api/v1/talk/ws",
// Desktop local-tool tunnel the handshake interceptor // Desktop local-tool tunnel the handshake interceptor
// authenticates the ?token= query param itself, so the // authenticates the ?token= query param itself, so the
// upgrade request is opened to the filter chain like talk/ws. // upgrade request is opened to the filter chain like talk/ws.
"/api/v1/desktop/ws", "/api/v1/desktop/ws"
// RFC-045: tool-generated files served via unguessable UUID; entries
// expire after GeneratedFileCache.TTL (7 days) delayed access (e.g. an
// IM-delivered link opened later) is intentional, the UUID is the guard.
"/api/v1/files/generated/**"
).permitAll(); ).permitAll();
// Swagger UI / OpenAPI document explicit rule rather than the // Swagger UI / OpenAPI document explicit rule rather than the
// permitAll() fallthrough. Public for local dev, admin-only in // permitAll() fallthrough. Public for local dev, admin-only in

View File

@ -34,6 +34,7 @@ public class ToolTimeoutProperties {
"web_fetch", "web", "web_fetch", "web",
"url_fetch", "web", "url_fetch", "web",
"write_file", "file", "write_file", "file",
"append_file", "file",
"edit_file", "file", "edit_file", "file",
"read_file", "file" "read_file", "file"
); );

View File

@ -96,36 +96,44 @@ public abstract class AbstractCronResultDelivery implements CronResultDelivery {
// ---------- SQL state-machine helpers ---------- // ---------- SQL state-machine helpers ----------
/** /**
* Atomic SQL CAS: transition delivery_status from {@code NONE} or * Atomic SQL CAS: transition delivery_status from {@code NONE} (or legacy
* {@code PENDING} {@code PENDING}. Returns true iff this instance won * {@code NULL}) to {@code PENDING}. An already-pending row is owned by the
* the race. NONE-eligibility lets fresh runs claim without a separate * worker that claimed it and must never be claimable again.
* "first-time" branch; PENDING-eligibility covers the rare same-instance
* retry inside the listener.
* *
* <p>SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy * <p>SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy
* rows from before V57 (pre-RFC) may have null delivery_status, so the * rows from before V57 (pre-RFC) may have null delivery_status, so the
* predicate explicitly tests {@code IS NULL OR IN (NONE, PENDING)} via * predicate explicitly tests {@code IS NULL OR = NONE} via
* a nested OR group rather than putting null inside the IN list. * a nested OR group rather than putting null inside the IN list.
*/ */
private boolean claimRun(CronJobRunEntity run) { private boolean claimRun(CronJobRunEntity run) {
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>() return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId()) .eq(CronJobRunEntity::getId, run.getId())
.and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus) .and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus)
.or().in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING")) .or().eq(CronJobRunEntity::getDeliveryStatus, "NONE"))
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1; .set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
} }
private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) { private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) {
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>() int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId()) .eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
.set(CronJobRunEntity::getDeliveryStatus, "DELIVERED") .set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
.set(CronJobRunEntity::getDeliveryTarget, o.target())); .set(CronJobRunEntity::getDeliveryTarget, o.target()));
if (updated == 0) {
log.warn("[CronDelivery] Run {} lost its PENDING fence before success was persisted",
run.getId());
}
} }
private void markNotDelivered(CronJobRunEntity run, Exception e) { private void markNotDelivered(CronJobRunEntity run, Exception e) {
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>() int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId()) .eq(CronJobRunEntity::getId, run.getId())
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED") .set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
.set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500))); .set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500)));
if (updated == 0) {
log.warn("[CronDelivery] Run {} lost its PENDING fence before failure was persisted",
run.getId());
}
} }
} }

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