Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d774fb85 | ||
|
|
bfc5417508 | ||
|
|
7e36ac7749 | ||
|
|
dfae7edbe0 | ||
|
|
c59b6b6448 | ||
|
|
7406fc99bf | ||
|
|
26150b68e8 | ||
|
|
bd41a71826 | ||
|
|
b706f9a610 | ||
|
|
7e2dc55b5d | ||
|
|
631b8ab02a | ||
|
|
d8976ddf1e | ||
|
|
dd4db18ed8 | ||
|
|
97125b991e | ||
|
|
701b99b3e9 | ||
|
|
fa67d259b7 | ||
|
|
08a5bf6996 | ||
|
|
7eb44731e7 | ||
|
|
01ed4a4fcd | ||
|
|
2090d09704 | ||
|
|
2fa2e60170 | ||
|
|
fb94027038 | ||
|
|
ab5a0a651b | ||
|
|
bc2b4613f7 | ||
|
|
987bc2001a | ||
|
|
e88be95cd2 | ||
|
|
dd7e561e48 | ||
|
|
2372827762 | ||
|
|
709218e2ac | ||
|
|
14df331ce6 | ||
|
|
8d32a60fdb | ||
|
|
bdd51e7b44 | ||
|
|
c1ba390f25 | ||
|
|
37ddcf185f | ||
|
|
9ba188b4cb | ||
|
|
770198ba69 | ||
|
|
1eabbb68ac | ||
|
|
656a0b0436 | ||
|
|
5b3285c78f | ||
|
|
e9b3c1697f | ||
|
|
d1a553ed77 | ||
|
|
281ea53551 | ||
|
|
aecc619bef | ||
|
|
1159dcdbf3 | ||
|
|
a8a0b75bfa | ||
|
|
5660c72e1a | ||
|
|
98d0bb7780 | ||
|
|
473ed5786e |
1
.gitignore
vendored
@ -83,6 +83,7 @@ mateclaw-server/src/main/resources/static/
|
||||
|
||||
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
||||
mateclaw-server/data/
|
||||
.sessions/
|
||||
/data/
|
||||
|
||||
# VitePress build output and cache (do not commit)
|
||||
|
||||
36
README.md
@ -8,7 +8,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](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.
|
||||
>
|
||||
> **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.
|
||||
|
||||
@ -83,7 +83,17 @@ Same brain. Same memory. Same tools. Different doors.
|
||||
## What's in the box
|
||||
|
||||
### Digital employees, not chatbots
|
||||
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — 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+)
|
||||
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
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
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.**
|
||||
|
||||
@ -194,7 +204,7 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
|
||||
|
||||
```
|
||||
mateclaw/
|
||||
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
|
||||
├── mateclaw-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-desktop/ Electron desktop app (local-embedded / remote-centralized)
|
||||
├── 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 |
|
||||
|---|---|
|
||||
| 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+) |
|
||||
| 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) |
|
||||
@ -230,6 +240,16 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
|
||||
|
||||
## Roadmap
|
||||
|
||||
**v2.2.0 (shipped 2026-08-29)** — from one built-in reasoning loop to **a pluggable and recoverable Agent Runtime**:
|
||||
|
||||
- **Runtime contract** — provider registry, session factory, capability validation, normalized event stream, lifecycle, usage, and UI projection decouple employees from execution engines
|
||||
- **DeepSeek Harness runtime** — managed installation and configuration, authenticated JSON-RPC process bridge, Cordis composition, cancellable streaming, isolated child environment, and host-governed tool dispatch
|
||||
- **Durable long work** — bounded Goal segments, persisted continuation and input queues, attempts, cooldown, retry, leases, restart recovery, and explicit pause / resume semantics
|
||||
- **Agent interoperability** — inbound and outbound A2A with Agent Cards, JSON-RPC / SSE tasks, authentication, idempotency, and guarded network boundaries
|
||||
- **Runtime hardening** — tighter workspace ownership, reliable Team Run recovery and deliverable gates, plus consistent long-form output and input handling across approval, stop, and recovery
|
||||
|
||||
Full story in the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
|
||||
|
||||
**v2.1.0 (shipped 2026-08-15)** — from “a board full of tasks” to **one governable team run**:
|
||||
|
||||
- **Unified Team Runs** — one `runId` links request, task DAG, worker conversations, events, final synthesis, and deliverables; Chat delivers outcomes, Agents observes live work, Teams governs history
|
||||
|
||||
36
README_zh.md
@ -8,7 +8,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](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 Harness(DSH)运行时,同时复用同一套会话、策略、工具、持久化与可观测面;Persistent Goal 可跨有界回合和后端重启继续,A2A 则让受治理的员工跨系统互联。详见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
|
||||
|
||||
---
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
>
|
||||
> 多用户工作空间。敏感操作走审批。完整审计日志。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 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||
|
||||
@ -83,7 +83,17 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
||||
## 盒子里有什么
|
||||
|
||||
### 数字员工,不是聊天机器人
|
||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些“不起眼”的基础设施。
|
||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
|
||||
|
||||
### Agent Runtime:Native 或 DSH(2.2.0+)
|
||||
`AgentRuntimeProvider` contract 把员工与实际执行回合的引擎分开。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run;**DSH Runtime** 把 `dsh-jsonrpc-agent` 作为认证子进程管理,并将思考、文本、工具调用、用量、完成与取消统一映射为 runtime event。DSH 掌管外部 Agent loop,MateClaw 继续掌管 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 Run(2.1.0+)
|
||||
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面,Agents Live 按运行聚合成员并展示实时状态,Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
|
||||
@ -105,7 +115,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
||||
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合,reverse-citation 绑定到源 chunk,JSON 输出 + 可选 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。
|
||||
@ -122,7 +132,7 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**We
|
||||
|
||||
模型供应商会限流,网络会抖动,Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障。
|
||||
|
||||
当 AI 进入生产环境,稳定的一层不应绑定在一家供应商身上。MateClaw 通过供应商优先级、健康追踪、冷却与故障转移,把这种不确定性收进统一运行时。
|
||||
当 AI 进入生产环境,稳定的一层既不应绑定一家模型供应商,也不应绑定一套 Agent loop。MateClaw 用供应商优先级、健康追踪、冷却与故障转移吸收模型侧不确定性,再把 Native 与外部执行引擎收进同一份受治理的 Agent Runtime contract。
|
||||
|
||||
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
||||
|
||||
@ -194,7 +204,7 @@ docker compose up -d # http://localhost:18080
|
||||
|
||||
```
|
||||
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-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
|
||||
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
||||
@ -213,7 +223,7 @@ mateclaw/
|
||||
| 层次 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | 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 StateGraph(ReAct + Plan-Execute)· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
|
||||
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
|
||||
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
||||
| 数据库 | H2(开发)· PostgreSQL 16(Docker 默认)· MySQL 8.0+(支持)· Kingbase(按需驱动)|
|
||||
@ -230,6 +240,16 @@ mateclaw/
|
||||
|
||||
## 路线图
|
||||
|
||||
**v2.2.0(2026-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.0(2026-08-15 发布)** —— 从“一块摆满任务的看板”到**一次可治理的团队运行**:
|
||||
|
||||
- **统一 Team Run** —— 一个 `runId` 串起请求、任务 DAG、成员会话、事件、最终汇总与交付物;Chat 交付成果,Agents 观察实时执行,Teams 管理历史与治理
|
||||
|
||||
@ -32,9 +32,10 @@
|
||||
<!-- ===== Center: Agent Core ===== -->
|
||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
|
||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">Digital Employee</text>
|
||||
<text x="480" y="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">Identity · Goal · Governance</text>
|
||||
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
|
||||
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">One policy + event plane</text>
|
||||
|
||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.9 KiB |
@ -33,9 +33,10 @@
|
||||
<!-- ===== Center: Agent Core ===== -->
|
||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
|
||||
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
|
||||
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">数字员工</text>
|
||||
<text x="480" y="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">身份 · 目标 · 治理</text>
|
||||
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
|
||||
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">同一策略与事件平面</text>
|
||||
|
||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.2 KiB |
@ -67,40 +67,40 @@
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||
</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="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 & GOVERNANCE</text>
|
||||
|
||||
<g transform="translate(56, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Runtime Contract</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · Capability</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Lifecycle · Usage · Projection</text>
|
||||
</g>
|
||||
<g transform="translate(244, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Team · Workflow · Trigger</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Task-board dispatch (2.0.0+)</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step modes · 6 patterns</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Native Runtime</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">StateGraph · ReAct</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goals · Teams</text>
|
||||
</g>
|
||||
<g transform="translate(432, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">DSH Runtime</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Managed JSON-RPC Process</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
|
||||
</g>
|
||||
<g transform="translate(620, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Memory System</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Short-term + Extraction</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Consolidation + Dreaming</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Host Governance</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval · Credentials</text>
|
||||
</g>
|
||||
<g transform="translate(808, 222)">
|
||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
|
||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool Plane</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Skills · MCP</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · Built-in</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 3: Core Services ===== -->
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@ -70,40 +70,40 @@
|
||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||
</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="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)">
|
||||
<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="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Runtime Contract</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · 能力</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">生命周期 · 用量 · 投影</text>
|
||||
</g>
|
||||
<g transform="translate(244, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">团队 · 工作流 · 触发器</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">任务板派发 + 并行(2.0.0+)</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step mode · 6 pattern</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">StateGraph · ReAct</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goal · Team</text>
|
||||
</g>
|
||||
<g transform="translate(432, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">DSH Runtime</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">受管理 JSON-RPC 进程</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
|
||||
</g>
|
||||
<g transform="translate(620, 222)">
|
||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
|
||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">宿主治理</text>
|
||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</text>
|
||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">审批 · 凭证隔离</text>
|
||||
</g>
|
||||
<g transform="translate(808, 222)">
|
||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器(1.3.0)</text>
|
||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具平面</text>
|
||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">技能 · MCP</text>
|
||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · 内置工具</text>
|
||||
</g>
|
||||
|
||||
<!-- ===== Layer 3: Core Services ===== -->
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mateclaw-desktop",
|
||||
"version": "2.2.0-SNAPSHOT",
|
||||
"version": "2.3.0-SNAPSHOT",
|
||||
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
|
||||
"author": "MateClaw Team",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@ -11,7 +11,7 @@ import java.util.List;
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public interface PluginMemoryProvider {
|
||||
public interface PluginMemoryProvider extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
|
||||
@ -114,4 +114,9 @@ public interface PluginMemoryProvider {
|
||||
*/
|
||||
default void onSessionEnd(Long agentId, String conversationId) {
|
||||
}
|
||||
|
||||
/** Release provider-owned resources when the plugin is unloaded. */
|
||||
@Override
|
||||
default void close() {
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ package vip.mate.plugin.mem0;
|
||||
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
|
||||
* @param maxResults cap on memories returned per recall
|
||||
* @param timeoutMs HTTP timeout for both recall and sync
|
||||
* @param syncQueueCapacity maximum number of turns waiting for asynchronous sync
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
record Mem0Config(
|
||||
@ -21,10 +22,18 @@ record Mem0Config(
|
||||
boolean searchEnabled,
|
||||
boolean syncEnabled,
|
||||
int maxResults,
|
||||
int timeoutMs
|
||||
int timeoutMs,
|
||||
int syncQueueCapacity
|
||||
) {
|
||||
static final int DEFAULT_MAX_RESULTS = 5;
|
||||
static final int DEFAULT_TIMEOUT_MS = 3000;
|
||||
static final int DEFAULT_SYNC_QUEUE_CAPACITY = 256;
|
||||
|
||||
Mem0Config(String baseUrl, String apiKey, boolean searchEnabled, boolean syncEnabled,
|
||||
int maxResults, int timeoutMs) {
|
||||
this(baseUrl, apiKey, searchEnabled, syncEnabled, maxResults, timeoutMs,
|
||||
DEFAULT_SYNC_QUEUE_CAPACITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this provider should participate at all.
|
||||
|
||||
@ -3,9 +3,8 @@ package vip.mate.plugin.mem0;
|
||||
/**
|
||||
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
|
||||
* <p>
|
||||
* Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade
|
||||
* gracefully (empty recall / dropped sync) without affecting the agent's
|
||||
* response path.
|
||||
* Sync failures are caught by {@link Mem0Provider}; recall failures propagate
|
||||
* to the platform provider boundary for timeout/circuit-breaker accounting.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
|
||||
@ -39,6 +39,7 @@ public class Mem0Plugin implements MateClawPlugin {
|
||||
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
|
||||
private static final String CONFIG_MAX_RESULTS = "maxResults";
|
||||
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
|
||||
private static final String CONFIG_SYNC_QUEUE_CAPACITY = "syncQueueCapacity";
|
||||
|
||||
private Logger log;
|
||||
|
||||
@ -54,11 +55,16 @@ public class Mem0Plugin implements MateClawPlugin {
|
||||
|
||||
Mem0Client client = new Mem0Client(config);
|
||||
Mem0Provider provider = new Mem0Provider(config, client, log);
|
||||
context.registerMemoryProvider(provider);
|
||||
try {
|
||||
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(),
|
||||
config.maxResults(), config.timeoutMs());
|
||||
config.maxResults(), config.timeoutMs(), config.syncQueueCapacity());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -78,6 +84,7 @@ public class Mem0Plugin implements MateClawPlugin {
|
||||
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
|
||||
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
|
||||
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
|
||||
Integer syncQueueCapacity = ctx.getConfig(CONFIG_SYNC_QUEUE_CAPACITY, Integer.class);
|
||||
|
||||
return new Mem0Config(
|
||||
baseUrl,
|
||||
@ -85,7 +92,9 @@ public class Mem0Plugin implements MateClawPlugin {
|
||||
searchEnabled == null ? true : searchEnabled,
|
||||
syncEnabled == null ? true : syncEnabled,
|
||||
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
|
||||
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs
|
||||
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs,
|
||||
syncQueueCapacity == null ? Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY
|
||||
: Math.max(1, syncQueueCapacity)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -4,9 +4,11 @@ import org.slf4j.Logger;
|
||||
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
|
||||
@ -17,13 +19,14 @@ import java.util.concurrent.Executors;
|
||||
* <li>{@code systemPromptBlock} — no-op (returns ""), aligns with SessionSearchProvider</li>
|
||||
* <li>{@code prefetch(agentId, query, ownerKey)} — when {@code searchEnabled}
|
||||
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
|
||||
* and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
|
||||
* or when disabled.</li>
|
||||
* and returns a {@code [Mem0 Recall]} block. Failures propagate to the
|
||||
* platform's timeout/circuit-breaker boundary.</li>
|
||||
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} — when
|
||||
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
|
||||
* pushes the turn to {@code POST /memories/} under {@code user_id =
|
||||
* ownerKey}, the same identifier prefetch recalls by. Failures are
|
||||
* logged and swallowed; never blocks the response path. The 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
|
||||
* would produce memories that owner-scoped recall can never surface.</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
|
||||
* requires {@code user_id}.
|
||||
*
|
||||
* <p>Asynchronous sync: a single-thread daemon executor is used
|
||||
* so that bursts of turns don't pile up on the platform's request thread.
|
||||
* <p>Asynchronous sync: a single-thread daemon executor with a bounded queue
|
||||
* prevents an unavailable Mem0 service from growing heap usage without limit.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -46,21 +49,19 @@ class Mem0Provider implements PluginMemoryProvider {
|
||||
private final Mem0Config config;
|
||||
private final Mem0Client client;
|
||||
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) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.log = log;
|
||||
// Single-thread executor is enough — syncTurn calls are sequential per
|
||||
// agent and not latency-sensitive; the platform's request thread must
|
||||
// not be blocked. A bounded single-thread queue keeps memory footprint
|
||||
// predictable even under burst load.
|
||||
this.async = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "mem0-sync");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
this.async = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(Math.max(1, config.syncQueueCapacity())), r -> {
|
||||
Thread t = new Thread(r, "mem0-sync");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}, new ThreadPoolExecutor.AbortPolicy());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -104,20 +105,12 @@ class Mem0Provider implements PluginMemoryProvider {
|
||||
if (userQuery == null || userQuery.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
List<String> memories = client.searchMemories(
|
||||
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
|
||||
if (memories.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return formatRecallBlock(memories);
|
||||
} catch (Exception e) {
|
||||
// Fault isolation: log and return empty so the platform falls back
|
||||
// to the other (local) providers without affecting the response.
|
||||
log.warn("[Mem0] prefetch failed for agent={} owner={}: {}",
|
||||
agentId, ownerKey, e.getMessage());
|
||||
List<String> memories = client.searchMemories(
|
||||
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
|
||||
if (memories.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return formatRecallBlock(memories);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -143,15 +136,52 @@ class Mem0Provider implements PluginMemoryProvider {
|
||||
&& (assistantReply == null || assistantReply.isBlank())) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
|
||||
conversationId, userMessage, assistantReply);
|
||||
} catch (Exception e) {
|
||||
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
|
||||
agentId, ownerKey, e.getMessage());
|
||||
try {
|
||||
async.execute(() -> {
|
||||
try {
|
||||
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
|
||||
conversationId, userMessage, assistantReply);
|
||||
} catch (Exception e) {
|
||||
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
|
||||
agentId, ownerKey, e.getMessage());
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException e) {
|
||||
long dropped = droppedSyncCount.incrementAndGet();
|
||||
log.warn("[Mem0] sync queue full or provider closed; dropped turn for agent={} owner={} (totalDropped={})",
|
||||
agentId, ownerKey, dropped);
|
||||
}
|
||||
}
|
||||
|
||||
int queuedSyncCount() {
|
||||
return async.getQueue().size();
|
||||
}
|
||||
|
||||
long droppedSyncCount() {
|
||||
return droppedSyncCount.get();
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return async.isShutdown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
async.shutdown();
|
||||
List<Runnable> dropped = List.of();
|
||||
try {
|
||||
long drainMs = Math.min(1000L, Math.max(100L, config.timeoutMs()));
|
||||
if (!async.awaitTermination(drainMs, TimeUnit.MILLISECONDS)) {
|
||||
dropped = async.shutdownNow();
|
||||
}
|
||||
}, async);
|
||||
} 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
|
||||
|
||||
@ -43,6 +43,12 @@
|
||||
"required": false,
|
||||
"secret": false,
|
||||
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
|
||||
},
|
||||
"syncQueueCapacity": {
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"secret": false,
|
||||
"description": "Maximum pending asynchronous sync turns. New writes are dropped when full. Default 256."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,4 +35,10 @@ class Mem0ConfigTest {
|
||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
||||
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyConstructorUsesBoundedQueueDefault() {
|
||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
||||
assertThat(c.syncQueueCapacity()).isEqualTo(Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY);
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ class Mem0PluginTest {
|
||||
PluginContext ctx = new StubContext(config, registered) {
|
||||
@Override
|
||||
public void registerMemoryProvider(PluginMemoryProvider provider) {
|
||||
registered.set(provider);
|
||||
throw new PluginException("Only one external memory provider allowed");
|
||||
}
|
||||
};
|
||||
@ -98,6 +99,7 @@ class Mem0PluginTest {
|
||||
assertThatThrownBy(() -> plugin.onLoad(ctx))
|
||||
.isInstanceOf(PluginException.class)
|
||||
.hasMessageContaining("Only one");
|
||||
assertThat(((Mem0Provider) registered.get()).isClosed()).isTrue();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -14,8 +14,11 @@ import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class Mem0ProviderTest {
|
||||
|
||||
@ -43,6 +46,7 @@ class Mem0ProviderTest {
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (provider != null) provider.close();
|
||||
if (server != null) server.stop(0);
|
||||
}
|
||||
|
||||
@ -129,16 +133,15 @@ class Mem0ProviderTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void threeArgPrefetch_returnsEmptyOnServerError() {
|
||||
// Replace handler to fail; the provider should swallow and return "".
|
||||
void threeArgPrefetch_propagatesServerErrorToPlatformCircuitBreaker() {
|
||||
server.removeContext("/");
|
||||
server.createContext("/", ex -> {
|
||||
ex.sendResponseHeaders(500, 0);
|
||||
ex.close();
|
||||
});
|
||||
|
||||
String result = provider.prefetch(1L, "q", "user:42");
|
||||
assertThat(result).isEmpty();
|
||||
assertThatThrownBy(() -> provider.prefetch(1L, "q", "user:42"))
|
||||
.isInstanceOf(Mem0Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -215,5 +218,42 @@ class Mem0ProviderTest {
|
||||
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
||||
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
|
||||
assertThat(searchCount.get()).isZero();
|
||||
p.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncQueueIsBoundedAndCloseReleasesExecutor() throws Exception {
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
Mem0Config cfg = new Mem0Config("http://localhost:8080", null,
|
||||
false, true, 3, 3000, 1);
|
||||
Mem0Client blockingClient = new Mem0Client(cfg) {
|
||||
@Override
|
||||
void addMemories(String userId, String agentId, String conversationId,
|
||||
String userMessage, String assistantReply) {
|
||||
writes.incrementAndGet();
|
||||
firstStarted.countDown();
|
||||
try {
|
||||
releaseFirst.await(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
};
|
||||
Mem0Provider bounded = new Mem0Provider(cfg, blockingClient, LoggerFactory.getLogger("test"));
|
||||
try {
|
||||
bounded.syncTurn(1L, "one", "u", "a", "user:1");
|
||||
assertThat(firstStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
bounded.syncTurn(1L, "two", "u", "a", "user:1");
|
||||
bounded.syncTurn(1L, "three", "u", "a", "user:1");
|
||||
|
||||
assertThat(bounded.queuedSyncCount()).isEqualTo(1);
|
||||
assertThat(bounded.droppedSyncCount()).isEqualTo(1);
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
bounded.close();
|
||||
}
|
||||
assertThat(bounded.isClosed()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +60,9 @@ public class AcpEndpointEntity {
|
||||
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
|
||||
private Long stdioBufferLimitBytes;
|
||||
|
||||
/** Max wait for session/prompt, in seconds. Defaults to 300, capped at 3600. */
|
||||
private Integer promptTimeoutSeconds;
|
||||
|
||||
/** UNKNOWN / OK / ERROR — last test result. */
|
||||
private String lastStatus;
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -47,11 +46,6 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class AcpDelegationService {
|
||||
|
||||
/** Hard ceiling on a single ACP delegation. Long enough for a
|
||||
* multi-turn coding session, short enough that a hung agent can't
|
||||
* permanently block an LLM tool call. */
|
||||
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
|
||||
|
||||
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||
|
||||
@ -89,6 +83,7 @@ public class AcpDelegationService {
|
||||
List<String> args = endpointService.parseArgs(endpoint);
|
||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
|
||||
long promptTimeoutMillis = resolvePromptTimeoutMillis(endpoint);
|
||||
// Always resolve cwd to a real directory: Zed's ACP Zod schema
|
||||
// marks cwd as a required string and rejects {@code undefined}
|
||||
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
|
||||
@ -124,7 +119,7 @@ public class AcpDelegationService {
|
||||
ObjectNode promptParams = objectMapper.createObjectNode();
|
||||
promptParams.put("sessionId", sessionId);
|
||||
promptParams.set("prompt", buildPromptArray(userPrompt));
|
||||
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
|
||||
autoClose.sendRequest("session/prompt", promptParams, promptTimeoutMillis);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
|
||||
@ -144,6 +139,12 @@ public class AcpDelegationService {
|
||||
return accumulator.toString().trim();
|
||||
}
|
||||
|
||||
static long resolvePromptTimeoutMillis(AcpEndpointEntity endpoint) {
|
||||
int seconds = AcpEndpointService.normalizePromptTimeoutSeconds(
|
||||
endpoint != null ? endpoint.getPromptTimeoutSeconds() : null);
|
||||
return seconds * 1000L;
|
||||
}
|
||||
|
||||
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
|
||||
boolean trusted, String endpointName) {
|
||||
// Notifications carry session/update messages; agent_message_chunk
|
||||
|
||||
@ -36,6 +36,9 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class AcpEndpointService {
|
||||
|
||||
public static final int DEFAULT_PROMPT_TIMEOUT_SECONDS = 300;
|
||||
public static final int MAX_PROMPT_TIMEOUT_SECONDS = 3600;
|
||||
|
||||
private final AcpEndpointMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
@ -91,6 +94,7 @@ public class AcpEndpointService {
|
||||
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
||||
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
||||
}
|
||||
input.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(input.getPromptTimeoutSeconds()));
|
||||
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
||||
mapper.insert(input);
|
||||
log.info("Created ACP endpoint: {}", input.getName());
|
||||
@ -118,6 +122,9 @@ public class AcpEndpointService {
|
||||
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
||||
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
||||
}
|
||||
if (patch.getPromptTimeoutSeconds() != null) {
|
||||
existing.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(patch.getPromptTimeoutSeconds()));
|
||||
}
|
||||
mapper.updateById(existing);
|
||||
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
||||
return existing;
|
||||
@ -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) {
|
||||
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||
try {
|
||||
|
||||
@ -18,6 +18,7 @@ import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.graph.StateGraphReActAgent;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
|
||||
import vip.mate.agent.graph.edge.ObservationDispatcher;
|
||||
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
||||
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
||||
@ -103,6 +104,13 @@ public class AgentGraphBuilder {
|
||||
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
||||
private boolean loadSkillToolEnabled;
|
||||
|
||||
private ExecutionEvidenceRecorder executionEvidenceRecorder;
|
||||
|
||||
@Autowired
|
||||
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
|
||||
this.executionEvidenceRecorder = recorder;
|
||||
}
|
||||
|
||||
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
|
||||
@org.springframework.beans.factory.annotation.Value(
|
||||
"${mate.agent.markdown-normalize-enabled:true}")
|
||||
@ -675,6 +683,7 @@ public class AgentGraphBuilder {
|
||||
executor.setSkillRuntimeService(skillRuntimeService);
|
||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||
executor.setProgressContext(progressContext);
|
||||
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
|
||||
// Optional: route child-agent denied-tool audit events through
|
||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||
if (auditEventService != null) {
|
||||
@ -998,6 +1007,7 @@ public class AgentGraphBuilder {
|
||||
executor.setSkillRuntimeService(skillRuntimeService);
|
||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||
executor.setProgressContext(progressContext);
|
||||
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
|
||||
// Optional: route child-agent denied-tool audit events through
|
||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||
if (auditEventService != null) {
|
||||
@ -1069,6 +1079,7 @@ public class AgentGraphBuilder {
|
||||
// Summarizing
|
||||
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
|
||||
// 终止控制
|
||||
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
|
||||
|
||||
@ -27,8 +27,9 @@ import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
@ -70,6 +71,9 @@ public class AgentService {
|
||||
@Autowired(required = false)
|
||||
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
|
||||
|
||||
@Autowired
|
||||
private vip.mate.agent.runtime.ConversationTurnGate turnGate = new vip.mate.agent.runtime.ConversationTurnGate();
|
||||
|
||||
/**
|
||||
* Optional — clears leftover auto-recorded ledger entries when a new
|
||||
* user turn starts. Field-injected so existing test constructors of
|
||||
@ -78,6 +82,13 @@ public class AgentService {
|
||||
@Autowired(required = false)
|
||||
private ProgressLedgerService progressLedgerService;
|
||||
|
||||
/** Runtime SPI coordinator. Native agents remain the default. */
|
||||
@Autowired(required = false)
|
||||
private vip.mate.agent.runtime.contract.AgentRuntimeCoordinator runtimeCoordinator;
|
||||
|
||||
@Autowired(required = false)
|
||||
private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService;
|
||||
|
||||
/**
|
||||
* Runtime Agent instance cache. Keyed first by agentId, then by a model
|
||||
* key, so a conversation that pins a non-default model gets its own graph
|
||||
@ -132,6 +143,16 @@ public class AgentService {
|
||||
if (agent.getAgentType() == null) {
|
||||
agent.setAgentType("react");
|
||||
}
|
||||
if (!StringUtils.hasText(agent.getRuntimeType())) {
|
||||
agent.setRuntimeType("native");
|
||||
} else {
|
||||
agent.setRuntimeType(agent.getRuntimeType().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (!"native".equals(agent.getRuntimeType()) && !"dsh".equals(agent.getRuntimeType())) {
|
||||
throw new MateClawException("err.agent.runtime_unsupported", 400,
|
||||
"Unsupported runtime provider: " + agent.getRuntimeType());
|
||||
}
|
||||
validateDshConfiguration(agent);
|
||||
requireUniqueName(agent, null);
|
||||
agentMapper.insert(agent);
|
||||
publishLifecycle(agent, "spawned");
|
||||
@ -156,6 +177,9 @@ public class AgentService {
|
||||
}
|
||||
requireUniqueName(agent, agent.getId());
|
||||
}
|
||||
if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) {
|
||||
validateDshConfiguration(agent);
|
||||
}
|
||||
agentMapper.updateById(agent);
|
||||
agentInstances.remove(agent.getId());
|
||||
if (prior != null && prior.getEnabled() != null
|
||||
@ -275,6 +299,8 @@ public class AgentService {
|
||||
* work already done before the pause.
|
||||
*/
|
||||
private void clearAutoRecordedForNewTurn(String conversationId) {
|
||||
// Autonomous segments resume the same objective; retain authoritative tool progress.
|
||||
if (vip.mate.agent.context.GoalContinuationContext.active()) return;
|
||||
if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
@ -298,7 +324,11 @@ public class AgentService {
|
||||
*/
|
||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
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);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
@ -335,7 +365,13 @@ public class AgentService {
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
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);
|
||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||
@ -372,7 +408,20 @@ public class AgentService {
|
||||
String requesterId, String thinkingLevel,
|
||||
ChatOrigin origin) {
|
||||
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);
|
||||
|
||||
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
||||
@ -419,7 +468,7 @@ public class AgentService {
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||
clearAutoRecordedForNewTurn(conversationId);
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
trackMemoryRecalls(agentId, goal, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
@ -446,7 +495,7 @@ public class AgentService {
|
||||
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
trackMemoryRecalls(agentId, userMessage, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
@ -474,23 +523,7 @@ public class AgentService {
|
||||
* {@code _usage_final} event for token and model attribution.
|
||||
*/
|
||||
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
final int[] usage = {0, 0};
|
||||
final String[] modelInfo = {null, null};
|
||||
stream.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
} else if (delta.content() != null) {
|
||||
content.append(delta.content());
|
||||
}
|
||||
}).blockLast(Duration.ofMinutes(10));
|
||||
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
return ChatResultCollector.collect(stream);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -510,7 +543,7 @@ public class AgentService {
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
trackMemoryRecalls(agentId, userMessage, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
@ -618,6 +651,13 @@ public class AgentService {
|
||||
*/
|
||||
private String withLifecycleSync(Long agentId, String message, String conversationId,
|
||||
java.util.function.BiFunction<String, String, String> invoke) {
|
||||
try (var permit = acquireTurn(conversationId)) {
|
||||
return invokeWithLifecycleSync(agentId,message,conversationId,invoke);
|
||||
}
|
||||
}
|
||||
|
||||
private String invokeWithLifecycleSync(Long agentId, String message, String conversationId,
|
||||
java.util.function.BiFunction<String, String, String> invoke) {
|
||||
safeRegister(conversationId, agentId);
|
||||
try {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
@ -646,11 +686,26 @@ public class AgentService {
|
||||
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
|
||||
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
||||
Function<T, String> contentExtractor) {
|
||||
return Flux.using(() -> acquireTurn(conversationId),
|
||||
permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor),
|
||||
vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
|
||||
}
|
||||
|
||||
private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) {
|
||||
var permit = turnGate.tryAcquire(conversationId);
|
||||
if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running");
|
||||
return permit;
|
||||
}
|
||||
|
||||
private <T> Flux<T> invokeWithLifecycleFlux(Long agentId, String message, String conversationId,
|
||||
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
||||
Function<T, String> contentExtractor) {
|
||||
boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active();
|
||||
safeRegister(conversationId, agentId);
|
||||
try {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return invoke.apply(message, conversationId)
|
||||
.doFinally(s -> safeUnregister(conversationId));
|
||||
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
|
||||
}
|
||||
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
||||
@ -666,11 +721,11 @@ public class AgentService {
|
||||
})
|
||||
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
|
||||
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
|
||||
.doFinally(s -> safeUnregister(conversationId));
|
||||
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
|
||||
} catch (Exception e) {
|
||||
// If invoke.apply() throws before the Flux is constructed, the
|
||||
// doFinally above never runs — clean up here.
|
||||
safeUnregister(conversationId);
|
||||
safeUnregister(conversationId, goalContinuation);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@ -684,9 +739,48 @@ public class AgentService {
|
||||
|
||||
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
|
||||
private void safeUnregister(String conversationId) {
|
||||
safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active());
|
||||
}
|
||||
|
||||
private void safeUnregister(String conversationId, boolean goalContinuation) {
|
||||
if (runningConversationRegistry != null) {
|
||||
runningConversationRegistry.unregister(conversationId);
|
||||
}
|
||||
if (events != null && !goalContinuation) events.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished(conversationId));
|
||||
}
|
||||
|
||||
private boolean isDshAgent(Long agentId) {
|
||||
if (runtimeCoordinator == null || agentId == null) return false;
|
||||
AgentEntity entity = getAgent(agentId);
|
||||
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
|
||||
}
|
||||
|
||||
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
|
||||
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
|
||||
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
|
||||
: null;
|
||||
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
|
||||
}
|
||||
|
||||
private void validateDshConfiguration(AgentEntity agent) {
|
||||
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
|
||||
if (dshRuntimeService == null) {
|
||||
throw new MateClawException("err.agent.runtime_unavailable", 503,
|
||||
"DSH runtime provider is unavailable");
|
||||
}
|
||||
try {
|
||||
dshRuntimeService.validateAgentConfiguration(agent);
|
||||
} catch (IllegalArgumentException error) {
|
||||
throw new MateClawException("err.agent.runtime_invalid", 400, error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Path dshWorkingDirectory(AgentEntity agent) {
|
||||
String configured = System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"));
|
||||
if (agent.getWorkspaceBasePath() != null && !agent.getWorkspaceBasePath().isBlank()) {
|
||||
configured = agent.getWorkspaceBasePath().trim();
|
||||
}
|
||||
return Path.of(configured).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -866,10 +960,15 @@ public class AgentService {
|
||||
* post-approval replays).
|
||||
*/
|
||||
public record ChatResult(String content, int promptTokens, int completionTokens,
|
||||
String runtimeModel, String runtimeProvider) {
|
||||
String runtimeModel, String runtimeProvider, String finishReason) {
|
||||
|
||||
public ChatResult(String content, int promptTokens, int completionTokens,
|
||||
String runtimeModel, String runtimeProvider) {
|
||||
this(content, promptTokens, completionTokens, runtimeModel, runtimeProvider, null);
|
||||
}
|
||||
|
||||
public static ChatResult contentOnly(String content) {
|
||||
return new ChatResult(content != null ? content : "", 0, 0, null, null);
|
||||
return new ChatResult(content != null ? content : "", 0, 0, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import org.springframework.util.MimeType;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.agent.context.GoalContinuationContext;
|
||||
import vip.mate.approval.ApprovalPlaceholderUtil;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.routing.MediaCaptionService;
|
||||
@ -1264,6 +1265,12 @@ public abstract class BaseAgent {
|
||||
* the primary model can't already handle.
|
||||
*/
|
||||
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
|
||||
// Autonomous segments have no new persisted user row. Reconstructing
|
||||
// from the last user would replace the continuation/recovery instruction.
|
||||
// History is still loaded normally; queued user turns retain attachment routing.
|
||||
if (GoalContinuationContext.explicitPrompt()) {
|
||||
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
|
||||
}
|
||||
// Scheduled-job run (issue #142): the task text is the explicit
|
||||
// userMessageText argument. Never reconstruct it from the conversation
|
||||
// — a shared cron conversation under concurrent runs has no reliable
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
@ -730,6 +730,7 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
"addGoalCriterion",
|
||||
"completeGoal",
|
||||
"getGoalStatus",
|
||||
"waitForGoalInput",
|
||||
// Conversation-scoped progress ledger — same rationale as the
|
||||
// goal primitives above. Long multi-step research / drafting
|
||||
// tasks need it on every business agent, not just the planner,
|
||||
@ -768,6 +769,7 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
"read_file",
|
||||
"send_file",
|
||||
"write_file",
|
||||
"append_file",
|
||||
"edit_file",
|
||||
"execute_shell_command",
|
||||
// Inline code execution — an agent-wide capability alongside shell.
|
||||
|
||||
@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable value object that travels alongside an agent invocation describing
|
||||
@ -76,9 +77,22 @@ public record ChatOrigin(
|
||||
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||
*/
|
||||
@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,
|
||||
@Nullable String requesterId, @Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath, @Nullable Long channelId,
|
||||
@ -149,27 +163,27 @@ public record ChatOrigin(
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||
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()}). */
|
||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
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) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
|
||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
||||
}
|
||||
|
||||
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId);
|
||||
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 ----------------
|
||||
|
||||
@ -1410,6 +1410,17 @@ public class ConversationWindowManager {
|
||||
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
|
||||
if (response != null && response.getResult() != null
|
||||
&& response.getResult().getOutput() != null) {
|
||||
String finishReason = response.getResult().getMetadata() != null
|
||||
? response.getResult().getMetadata().getFinishReason() : null;
|
||||
if ("length".equalsIgnoreCase(finishReason)) {
|
||||
// A non-empty response can still be structurally incomplete
|
||||
// when the provider exhausts max tokens. Persisting it would
|
||||
// poison every later iterative summary.
|
||||
log.warn("[ConversationWindow] LLM 摘要因 token 上限被截断,丢弃结果, conv={}",
|
||||
conversationId);
|
||||
setSummaryCooldown(conversationId);
|
||||
return null;
|
||||
}
|
||||
String summary = response.getResult().getOutput().getText();
|
||||
if (summary != null && !summary.isBlank()) {
|
||||
// 成功:保存摘要供下次迭代更新,清除冷却
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
@ -554,6 +555,7 @@ public class NodeStreamingChatHelper {
|
||||
// ("credit balance is too low") use these phrases in 402-class responses.
|
||||
// Chinese provider patterns (Zhipu 1113, DashScope, general) — same hard
|
||||
// failure semantics: retrying the same provider won't refill the balance.
|
||||
String lowerMsg = msg.toLowerCase(Locale.ROOT);
|
||||
if (msg.contains("402") || msg.contains("insufficient_quota")
|
||||
|| msg.contains("credit balance is too low")
|
||||
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|
||||
@ -562,7 +564,13 @@ public class NodeStreamingChatHelper {
|
||||
|| msg.contains("余额不足") || msg.contains("请充值")
|
||||
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|
||||
|| 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;
|
||||
}
|
||||
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
||||
@ -2509,11 +2517,41 @@ public class NodeStreamingChatHelper {
|
||||
acc.id,
|
||||
acc.type != null ? acc.type : "function",
|
||||
acc.name,
|
||||
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
|
||||
toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a streamed tool call for local execution.
|
||||
*
|
||||
* <p>Blank arguments are a common zero-argument representation and remain
|
||||
* normalized to an empty object. Invalid non-blank JSON, however, must be
|
||||
* preserved until {@code ToolExecutionExecutor} sees it; replacing it with
|
||||
* {@code {}} loses the distinction between a truncated stream and a real
|
||||
* empty call and can execute the wrong operation. The outgoing-history
|
||||
* normalization path still calls {@link #sanitizeToolCallArguments} before
|
||||
* a later provider request.</p>
|
||||
*/
|
||||
private static String toolCallArgumentsForExecution(String toolName, String arguments) {
|
||||
if (arguments == null || arguments.isBlank()) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
TOOL_ARG_JSON_MAPPER.readTree(arguments);
|
||||
return arguments;
|
||||
} catch (Exception e) {
|
||||
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
|
||||
+ "(len={}, head={}); preserving the payload for safe executor rejection. "
|
||||
+ "Parse error: {}",
|
||||
toolName,
|
||||
arguments.length(),
|
||||
arguments.substring(0, Math.min(80, arguments.length())),
|
||||
e.getMessage());
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure {@code function.arguments} is always a well-formed JSON string.
|
||||
* <p>
|
||||
|
||||
@ -310,10 +310,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
boolean longFormAccumulation = !output.state()
|
||||
.value(LONG_FORM_DRAFT, "").isEmpty();
|
||||
String resolvedFinalAnswer = isFinalAnswerTurn
|
||||
? extractFinalAnswer(output) : "";
|
||||
if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation,
|
||||
streamed, resolvedFinalAnswer)) {
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
@ -503,10 +510,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
boolean longFormAccumulation = !output.state()
|
||||
.value(LONG_FORM_DRAFT, "").isEmpty();
|
||||
String resolvedFinalAnswer = isFinalAnswerTurn
|
||||
? extractFinalAnswer(output) : "";
|
||||
if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation,
|
||||
streamed, resolvedFinalAnswer)) {
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||
@ -633,6 +647,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
inputs.put(TOOL_CALL_COUNT, 0);
|
||||
inputs.put(ERROR_COUNT, 0);
|
||||
inputs.put(SHOULD_SUMMARIZE, false);
|
||||
inputs.put(LONG_FORM_DRAFT, "");
|
||||
inputs.put(LIMIT_EXCEEDED, false);
|
||||
inputs.put(CONTENT_STREAMED, false);
|
||||
inputs.put(THINKING_STREAMED, false);
|
||||
@ -774,6 +789,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
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) {
|
||||
if (output == null || output.state() == null) {
|
||||
return false;
|
||||
|
||||
@ -15,6 +15,7 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
|
||||
import vip.mate.agent.context.StructuredTruncator;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
@ -23,6 +24,7 @@ import vip.mate.approval.grant.AutoApproveResult;
|
||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.tool.ToolInputValidationException;
|
||||
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
import vip.mate.tool.guard.ToolGuardResult;
|
||||
@ -84,7 +86,7 @@ public class ToolExecutionExecutor {
|
||||
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
|
||||
|
||||
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
||||
"browser_use", "BrowserUseTool", "write_file", "edit_file"
|
||||
"browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"
|
||||
);
|
||||
|
||||
/**
|
||||
@ -465,7 +467,32 @@ public class ToolExecutionExecutor {
|
||||
boolean isReplay, String requesterId,
|
||||
String workspaceBasePath,
|
||||
ChatOrigin origin) {
|
||||
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
|
||||
workspaceBasePath, origin, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred graph overload. {@code loadedSkills} is the conversation/run
|
||||
* state captured before this batch, allowing the shared executor to reject
|
||||
* both cross-iteration and same-batch duplicate {@code load_skill} calls
|
||||
* before parallel execution starts.
|
||||
*/
|
||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||
String conversationId, String agentId,
|
||||
boolean isReplay, String requesterId,
|
||||
String workspaceBasePath,
|
||||
ChatOrigin origin,
|
||||
Set<String> loadedSkills) {
|
||||
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
if (!isReplay && safeOrigin.executionAttribution() != null) {
|
||||
safeOrigin = safeOrigin.withApprovalId(null);
|
||||
}
|
||||
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
|
||||
safeOrigin = safeOrigin.withConversationId(conversationId);
|
||||
}
|
||||
if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) {
|
||||
safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), workspaceBasePath);
|
||||
}
|
||||
// Reset per-turn audit dedupe state. A retried denied tool inside the
|
||||
// same turn writes a single audit row; the set is repopulated by the
|
||||
// denial branch below.
|
||||
@ -511,6 +538,15 @@ public class ToolExecutionExecutor {
|
||||
// ═══ Phase 1: 顺序 Guard + 分段 ═══
|
||||
List<PreparedToolCall> preparedCalls = new ArrayList<>();
|
||||
ApprovalBarrier barrier = null;
|
||||
Set<String> seenSkillLoads = new LinkedHashSet<>();
|
||||
if (loadedSkills != null) {
|
||||
loadedSkills.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(name -> !name.isEmpty())
|
||||
.map(name -> name.toLowerCase(Locale.ROOT))
|
||||
.forEach(seenSkillLoads::add);
|
||||
}
|
||||
|
||||
for (int i = 0; i < effectiveCalls.size(); i++) {
|
||||
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
||||
@ -582,7 +618,7 @@ public class ToolExecutionExecutor {
|
||||
} catch (Exception jsonEx) {
|
||||
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
||||
toolName, arguments.length(), jsonEx.getMessage());
|
||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
||||
String truncationError = incompleteToolArgumentsError(toolName);
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
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 模式跳过)
|
||||
if (!isReplay) {
|
||||
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
|
||||
@ -649,7 +705,7 @@ public class ToolExecutionExecutor {
|
||||
// 4. 分类: concurrencySafe
|
||||
boolean safe = isConcurrencySafe(toolName);
|
||||
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 填充
|
||||
allResponses.add(null);
|
||||
}
|
||||
@ -678,6 +734,34 @@ public class ToolExecutionExecutor {
|
||||
rawEvidenceRef.get());
|
||||
}
|
||||
|
||||
private static String incompleteToolArgumentsError(String toolName) {
|
||||
var error = OBJECT_MAPPER.createObjectNode();
|
||||
error.put("error", true);
|
||||
error.put("code", "TOOL_ARGUMENTS_INCOMPLETE");
|
||||
error.put("recoverable", true);
|
||||
error.put("toolName", toolName == null ? "" : toolName);
|
||||
error.put("message", "Tool arguments were incomplete or invalid JSON; the tool was not executed.");
|
||||
error.put("hint", "Retry with a smaller payload. For file updates, prefer edit_file or append_file instead of rewriting the whole file.");
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
private static String requestedSkillName(String arguments) {
|
||||
if (arguments == null || arguments.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var node = OBJECT_MAPPER.readTree(arguments);
|
||||
var value = node.get("skillName");
|
||||
if (value == null || value.isNull() || value.asText().isBlank()) {
|
||||
value = node.get("name");
|
||||
}
|
||||
return value == null || value.isNull() || value.asText().isBlank()
|
||||
? null : value.asText().trim();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
|
||||
* after a user approves a previously-blocked invocation).
|
||||
@ -711,6 +795,15 @@ public class ToolExecutionExecutor {
|
||||
List<GraphEventPublisher.GraphEvent> events,
|
||||
String conversationId, String workspaceBasePath,
|
||||
List<DirectToolOutput> directOutputs) {
|
||||
return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||
directOutputs, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public ToolResponseMessage.ToolResponse executePreApproved(
|
||||
AssistantMessage.ToolCall toolCall, String storedArguments,
|
||||
List<GraphEventPublisher.GraphEvent> events,
|
||||
String conversationId, String workspaceBasePath,
|
||||
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
|
||||
String toolName = resolveToolName(toolCall.name());
|
||||
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
||||
|
||||
@ -746,10 +839,11 @@ public class ToolExecutionExecutor {
|
||||
// Origin is method-local (see thread-safety note on execute());
|
||||
// the legacy ThreadLocal that used to carry it across executePreApproved
|
||||
// calls was a cross-conversation footgun and has been removed.
|
||||
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||
.withConversationId(conversationId)
|
||||
.withWorkspace(null, workspaceBasePath);
|
||||
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
|
||||
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
|
||||
.withConversationId(conversationId);
|
||||
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
||||
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
|
||||
UUID.randomUUID().toString(), toolCall.id());
|
||||
throwIfStopRequested(conversationId);
|
||||
int rawLen = result != null ? result.length() : 0;
|
||||
|
||||
@ -767,6 +861,13 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
events.add(GraphEventPublisher.toolDirectResult(
|
||||
toolCall.id(), toolName, fullResult));
|
||||
// A direct result replaces the tool card's body, but the
|
||||
// started event still needs a terminal pair so live clients
|
||||
// do not leave the card spinning forever. The placeholder is
|
||||
// deliberately used here: the full result remains confined to
|
||||
// tool_direct_result / DIRECT_TOOL_OUTPUTS.
|
||||
events.add(GraphEventPublisher.toolComplete(
|
||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||
}
|
||||
@ -789,9 +890,12 @@ public class ToolExecutionExecutor {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
||||
String safeError = isReturnDirect(callback)
|
||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||
: "Tool execution failed: " + e.getMessage();
|
||||
String validationError = safeInputValidationMessage(e);
|
||||
String safeError = validationError != null
|
||||
? validationError
|
||||
: isReturnDirect(callback)
|
||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||
: "Tool execution failed: " + e.getMessage();
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
||||
} finally {
|
||||
@ -987,7 +1091,7 @@ public class ToolExecutionExecutor {
|
||||
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);
|
||||
} finally {
|
||||
if (progressToken != null) {
|
||||
@ -1019,8 +1123,14 @@ public class ToolExecutionExecutor {
|
||||
if (streamTracker != null) {
|
||||
streamTracker.broadcastObject(pc.conversationId,
|
||||
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
|
||||
streamTracker.broadcastObject(pc.conversationId,
|
||||
GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName,
|
||||
DIRECT_TOOL_PLACEHOLDER, true).data());
|
||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||
}
|
||||
events.add(GraphEventPublisher.toolComplete(
|
||||
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
||||
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
||||
// for OpenAI-compatible providers, while withholding the data from
|
||||
// any subsequent LLM round (the graph won't take a next round —
|
||||
@ -1080,9 +1190,12 @@ public class ToolExecutionExecutor {
|
||||
// or other sensitive substrings that should not enter LLM context.
|
||||
// Emit a generic placeholder instead. Full error still goes to logs
|
||||
// for operator diagnosis.
|
||||
String reportedError = isReturnDirect(pc.callback)
|
||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||
: normalizeToolExecutionError(e);
|
||||
String validationError = safeInputValidationMessage(e);
|
||||
String reportedError = validationError != null
|
||||
? validationError
|
||||
: isReturnDirect(pc.callback)
|
||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||
: normalizeToolExecutionError(e);
|
||||
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
||||
if (streamTracker != null) {
|
||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||
@ -1215,6 +1328,10 @@ public class ToolExecutionExecutor {
|
||||
return GuardDecision.allowed();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny an approval-required tool when the run is non-interactive (no human can
|
||||
* approve), returning an actionable message so the agent falls back to a
|
||||
@ -1304,6 +1421,17 @@ public class ToolExecutionExecutor {
|
||||
return "Tool execution failed: " + message;
|
||||
}
|
||||
|
||||
private String safeInputValidationMessage(Throwable error) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
if (current instanceof ToolInputValidationException validation) {
|
||||
return "Tool input validation failed: " + validation.getMessage();
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #46 — when a tool callback miss happens, check whether the
|
||||
* unrecognized name actually matches an active skill. If it does, return
|
||||
@ -1658,6 +1786,18 @@ public class ToolExecutionExecutor {
|
||||
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(
|
||||
@ -1671,6 +1811,7 @@ public class ToolExecutionExecutor {
|
||||
String requesterId,
|
||||
String workspaceBasePath,
|
||||
ChatOrigin origin,
|
||||
String invocationKey,
|
||||
/**
|
||||
* Shared reference (one per execute() invocation) where each
|
||||
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
|
||||
|
||||
@ -144,7 +144,8 @@ public class ActionNode implements NodeAction {
|
||||
|
||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||
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()
|
||||
.responses(result.responses())
|
||||
@ -406,7 +407,7 @@ public class ActionNode implements NodeAction {
|
||||
return names;
|
||||
}
|
||||
|
||||
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
|
||||
public static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
|
||||
if (toolCalls == null || toolCalls.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@ -12,12 +12,16 @@ import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.goal.model.GoalResponse;
|
||||
import vip.mate.goal.service.GoalEvaluationService;
|
||||
import vip.mate.goal.service.GoalFollowupService;
|
||||
import vip.mate.goal.service.GoalService;
|
||||
import vip.mate.goal.service.GraphFlavor;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@ -184,6 +188,12 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(refreshed.getPersistentExecution())
|
||||
&& refreshed.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) {
|
||||
return MateClawStateAccessor.output().goalEvaluatedThisRun(true)
|
||||
.events(List.of(skippedEvent(refreshed.getId(), "goal_no_longer_active"))).build();
|
||||
}
|
||||
|
||||
// Decision branches. Each terminal write is wrapped so a DB hiccup
|
||||
// (e.g. optimistic-lock conflict exceeding retries, memory sync
|
||||
// failure on completion) does not propagate into the chat graph
|
||||
@ -199,7 +209,7 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||
"goalId", String.valueOf(completed.getId()),
|
||||
"score", result.score(),
|
||||
"goal", goalService.toResponse(completed)))))
|
||||
"goal", stateSafeGoal(goalService.toResponse(completed))))))
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -216,7 +226,7 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
|
||||
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
|
||||
"reason", reason,
|
||||
"goal", goalService.toResponse(exhausted)))))
|
||||
"goal", stateSafeGoal(goalService.toResponse(exhausted))))))
|
||||
.build();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
@ -229,6 +239,21 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
// A persistent goal yields a finite segment. Its durable supervisor owns
|
||||
// the next turn, cooldown and recovery; never consume graph recursion here.
|
||||
if (Boolean.TRUE.equals(refreshed.getPersistentExecution())) {
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_evaluated", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score(),
|
||||
"decision", result.decision(),
|
||||
"gap", result.gap() == null ? "" : result.gap(),
|
||||
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
|
||||
.build();
|
||||
}
|
||||
|
||||
int followupCountThisRun = accessor.goalFollowupCount();
|
||||
int hardContinuationCount = accessor.goalHardContinuationCount();
|
||||
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
|
||||
@ -289,7 +314,7 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"prompt", followup.get(),
|
||||
"goal", goalService.toResponse(refreshed)))));
|
||||
"goal", stateSafeGoal(goalService.toResponse(refreshed))))));
|
||||
|
||||
if (flavor == GraphFlavor.REACT) {
|
||||
// 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()),
|
||||
"score", result.score(),
|
||||
"gap", result.gap() == null ? "" : result.gap(),
|
||||
"goal", goalService.toResponse(refreshed)))))
|
||||
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph state may be checkpointed and restored through a generic map
|
||||
* serializer. Keep event payloads limited to JSON primitives, maps and
|
||||
* lists so a restored checklist cannot contain raw maps inside a typed
|
||||
* {@link GoalResponse} bean and fail during SSE serialization.
|
||||
*/
|
||||
private static Map<String, Object> stateSafeGoal(GoalResponse goal) {
|
||||
if (goal == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("id", stringId(goal.getId()));
|
||||
snapshot.put("conversationId", goal.getConversationId());
|
||||
snapshot.put("agentId", stringId(goal.getAgentId()));
|
||||
snapshot.put("workspaceId", stringId(goal.getWorkspaceId()));
|
||||
snapshot.put("createdBy", goal.getCreatedBy());
|
||||
snapshot.put("title", goal.getTitle());
|
||||
snapshot.put("description", goal.getDescription());
|
||||
snapshot.put("exitCriteria", goal.getExitCriteria());
|
||||
snapshot.put("successCheckPrompt", goal.getSuccessCheckPrompt());
|
||||
snapshot.put("status", goal.getStatus() == null ? null : goal.getStatus().getValue());
|
||||
snapshot.put("persistentExecution", goal.getPersistentExecution());
|
||||
snapshot.put("turnBudget", goal.getTurnBudget());
|
||||
snapshot.put("turnsUsed", goal.getTurnsUsed());
|
||||
snapshot.put("llmCallBudget", goal.getLlmCallBudget());
|
||||
snapshot.put("agentLlmCallsUsed", goal.getAgentLlmCallsUsed());
|
||||
snapshot.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed());
|
||||
snapshot.put("totalLlmCallsUsed", goal.getTotalLlmCallsUsed());
|
||||
snapshot.put("progressSummary", goal.getProgressSummary());
|
||||
snapshot.put("completionScore", goal.getCompletionScore());
|
||||
snapshot.put("lastEvaluationAt", stringTime(goal.getLastEvaluationAt()));
|
||||
snapshot.put("autoFollowupEnabled", goal.getAutoFollowupEnabled());
|
||||
snapshot.put("followupCooldownSeconds", goal.getFollowupCooldownSeconds());
|
||||
snapshot.put("lastFollowupAt", stringTime(goal.getLastFollowupAt()));
|
||||
snapshot.put("version", goal.getVersion());
|
||||
snapshot.put("createTime", stringTime(goal.getCreateTime()));
|
||||
snapshot.put("updateTime", stringTime(goal.getUpdateTime()));
|
||||
|
||||
List<Map<String, Object>> criteria = new ArrayList<>();
|
||||
if (goal.getCriteria() != null) {
|
||||
goal.getCriteria().forEach(criterion -> {
|
||||
if (criterion == null) return;
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", criterion.id() == null ? "" : criterion.id());
|
||||
item.put("text", criterion.text() == null ? "" : criterion.text());
|
||||
item.put("passed", criterion.passed());
|
||||
item.put("evidence", criterion.evidence() == null ? "" : criterion.evidence());
|
||||
criteria.add(Collections.unmodifiableMap(item));
|
||||
});
|
||||
}
|
||||
snapshot.put("criteria", List.copyOf(criteria));
|
||||
return Collections.unmodifiableMap(snapshot);
|
||||
}
|
||||
|
||||
private static String stringId(Long value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private static String stringTime(java.time.LocalDateTime value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active goal for this run: prefer the turn-start
|
||||
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation
|
||||
|
||||
@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction {
|
||||
* determined statically, and a false reminder is worse than none.
|
||||
*/
|
||||
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
|
||||
java.util.Set.of("write_file", "edit_file");
|
||||
java.util.Set.of("write_file", "append_file", "edit_file");
|
||||
|
||||
private static final String VERIFICATION_REMINDER =
|
||||
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
|
||||
|
||||
@ -37,6 +37,8 @@ import vip.mate.team.service.TeamContextBuilder;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||
|
||||
@ -137,6 +139,17 @@ public class ReasoningNode implements NodeAction {
|
||||
*/
|
||||
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
|
||||
|
||||
private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000;
|
||||
private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile(
|
||||
"(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)");
|
||||
private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile(
|
||||
"(一万|1万|十千)\\s*(字|字符|中文字|汉字)");
|
||||
private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile(
|
||||
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
|
||||
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
|
||||
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile",
|
||||
"write_file", "append_file", "local_write_file", "edit_file", "local_edit_file");
|
||||
|
||||
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
||||
private static final String EMPTY_COMPLETION_NUDGE =
|
||||
"上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:"
|
||||
@ -235,6 +248,100 @@ public class ReasoningNode implements NodeAction {
|
||||
return false;
|
||||
}
|
||||
|
||||
static OptionalInt requestedLongFormChars(String userMessage) {
|
||||
if (userMessage == null || userMessage.isBlank()) {
|
||||
return OptionalInt.empty();
|
||||
}
|
||||
Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage);
|
||||
if (tenThousand.find()) {
|
||||
return OptionalInt.of(10_000);
|
||||
}
|
||||
Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage);
|
||||
int best = 0;
|
||||
while (matcher.find()) {
|
||||
String rawNumber = matcher.group(1).replace(",", "").replace(",", "");
|
||||
double value;
|
||||
try {
|
||||
value = Double.parseDouble(rawNumber);
|
||||
} catch (NumberFormatException ignored) {
|
||||
continue;
|
||||
}
|
||||
String unit = matcher.group(2);
|
||||
if ("万".equals(unit)) {
|
||||
value *= 10_000;
|
||||
} else if ("千".equals(unit) || "k".equals(unit) || "K".equals(unit)) {
|
||||
value *= 1_000;
|
||||
}
|
||||
best = Math.max(best, (int) Math.round(value));
|
||||
}
|
||||
return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty();
|
||||
}
|
||||
|
||||
static List<ToolCallback> filterLongFormArtifactTools(String userMessage,
|
||||
List<ToolCallback> callbacks) {
|
||||
String currentRequest = currentUserRequest(userMessage);
|
||||
if (callbacks == null || callbacks.isEmpty()
|
||||
|| requestedLongFormChars(currentRequest).isEmpty()
|
||||
|| EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) {
|
||||
return callbacks;
|
||||
}
|
||||
return callbacks.stream()
|
||||
.filter(callback -> {
|
||||
String name = callback.getToolDefinition().name();
|
||||
return ARTIFACT_DELIVERY_TOOL_PREFIXES.stream().noneMatch(name::startsWith);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
static boolean hasDisallowedLongFormArtifactCall(String userMessage,
|
||||
List<AssistantMessage.ToolCall> toolCalls) {
|
||||
String currentRequest = currentUserRequest(userMessage);
|
||||
if (toolCalls == null || toolCalls.isEmpty()
|
||||
|| requestedLongFormChars(currentRequest).isEmpty()
|
||||
|| EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) {
|
||||
return false;
|
||||
}
|
||||
return toolCalls.stream().anyMatch(call -> ARTIFACT_DELIVERY_TOOL_PREFIXES.stream()
|
||||
.anyMatch(prefix -> call.name().startsWith(prefix)));
|
||||
}
|
||||
|
||||
private static String currentUserRequest(String userMessage) {
|
||||
if (userMessage == null) {
|
||||
return "";
|
||||
}
|
||||
int memoryEnd = userMessage.lastIndexOf("</memory-context>");
|
||||
return memoryEnd >= 0
|
||||
? userMessage.substring(memoryEnd + "</memory-context>".length()).trim()
|
||||
: userMessage;
|
||||
}
|
||||
|
||||
private static String appendLongFormChunk(String draft, String currentContent) {
|
||||
return (draft != null ? draft : "") + (currentContent != null ? currentContent : "");
|
||||
}
|
||||
|
||||
private static boolean shouldContinueLongForm(String userMessage, String longFormDraft,
|
||||
String currentContent, int iteration, int maxIterations) {
|
||||
OptionalInt requested = requestedLongFormChars(userMessage);
|
||||
if (requested.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (maxIterations > 0 && iteration + 1 >= maxIterations) {
|
||||
return false;
|
||||
}
|
||||
return appendLongFormChunk(longFormDraft, currentContent).length() < requested.getAsInt();
|
||||
}
|
||||
|
||||
private static UserMessage longFormContinuationPrompt(String userMessage, String longFormDraft,
|
||||
String currentContent) {
|
||||
int written = appendLongFormChunk(longFormDraft, currentContent).length();
|
||||
int requested = requestedLongFormChars(userMessage).orElse(0);
|
||||
return new UserMessage("""
|
||||
[Runtime long-form continuation]
|
||||
用户明确要求长篇输出,目标约 %d 字;目前累计约 %d 字,尚未达到目标。
|
||||
请从上一段结尾自然继续写,不要重写开头,不要总结,不要说明原因,直接续写正文。
|
||||
""".formatted(requested, written));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-use enforcement clause appended to every ReasoningNode
|
||||
* system prompt. Treats narration ("I will now …") as a protocol violation
|
||||
@ -857,6 +964,7 @@ public class ReasoningNode implements NodeAction {
|
||||
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
|
||||
.activeCallbacks()
|
||||
: toolCallbacks;
|
||||
activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks);
|
||||
|
||||
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
|
||||
|
||||
@ -1136,12 +1244,62 @@ public class ReasoningNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
|
||||
// Compatibility safety net for providers/adapters that return the
|
||||
// runtime's reserved error placeholder as an HTTP-successful content
|
||||
// response. Without this guard the long-form completion gate treats
|
||||
// the placeholder as a short draft and can repeat it until the graph's
|
||||
// iteration cap. Cron and other synchronous callers consume the
|
||||
// resulting structured ERROR_FALLBACK; they do not need to infer from
|
||||
// user-facing text.
|
||||
if (isRuntimeErrorPlaceholder(result.text())) {
|
||||
String errorText = result.text();
|
||||
log.error("[ReasoningNode] Runtime error placeholder returned as normal content; failing turn");
|
||||
return reasonOutput()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer(errorText)
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.finishReason(FinishReason.ERROR_FALLBACK)
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.build();
|
||||
}
|
||||
|
||||
if (result.partial()) {
|
||||
int partialChars = result.text() != null ? result.text().length() : 0;
|
||||
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
|
||||
}
|
||||
|
||||
if (result.hasToolCalls()) {
|
||||
if (hasDisallowedLongFormArtifactCall(accessor.userMessage(), result.toolCalls())) {
|
||||
log.warn("[ReasoningNode] Rejecting artifact tool call for plain long-form response: {}",
|
||||
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
||||
UserMessage continuation = new UserMessage("""
|
||||
[Runtime long-form delivery gate]
|
||||
The user requested the long-form text directly in chat and did not request a file,
|
||||
document, attachment, export, or download. Do not call rendering or file-writing tools.
|
||||
Continue writing the requested text directly in the response.
|
||||
""");
|
||||
return reasonOutput()
|
||||
.continueReasoning(true)
|
||||
.iterationCount(accessor.iterationCount() + 1)
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.toolCalls(List.of())
|
||||
.finalAnswer("")
|
||||
.clearFinishReason()
|
||||
.messages(List.of((Message) continuation))
|
||||
.currentPhase("reasoning")
|
||||
.streamedContent("")
|
||||
.streamedThinking(result.thinking())
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
||||
.build();
|
||||
}
|
||||
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
|
||||
result.toolCalls().size(),
|
||||
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
||||
@ -1219,12 +1377,43 @@ public class ReasoningNode implements NodeAction {
|
||||
.build();
|
||||
}
|
||||
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
||||
if (shouldContinueLongForm(accessor.userMessage(), accessor.longFormDraft(), content,
|
||||
accessor.iterationCount(), accessor.maxIterations())) {
|
||||
String accumulatedDraft = appendLongFormChunk(accessor.longFormDraft(), content);
|
||||
int written = accumulatedDraft.length();
|
||||
int requested = requestedLongFormChars(accessor.userMessage()).orElse(0);
|
||||
log.info("[ReasoningNode] Long-form answer below requested length ({} / {} chars), continuing",
|
||||
written, requested);
|
||||
return reasonOutput()
|
||||
.continueReasoning(true)
|
||||
.iterationCount(accessor.iterationCount() + 1)
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
.finalAnswer("")
|
||||
.longFormDraft(accumulatedDraft)
|
||||
.clearFinishReason()
|
||||
.messages(List.of((Message) result.assistantMessage(),
|
||||
longFormContinuationPrompt(accessor.userMessage(), accessor.longFormDraft(), content)))
|
||||
.currentPhase("reasoning")
|
||||
.streamedContent(content != null ? content : "")
|
||||
.streamedThinking(result.thinking())
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
||||
.build();
|
||||
}
|
||||
pushPhase(conversationId, "drafting_answer", Map.of(
|
||||
"iteration", accessor.iterationCount(),
|
||||
"answerChars", content != null ? content.length() : 0
|
||||
));
|
||||
boolean longFormRequest = requestedLongFormChars(accessor.userMessage()).isPresent();
|
||||
String accumulatedContent = longFormRequest
|
||||
? appendLongFormChunk(accessor.longFormDraft(), content)
|
||||
: (content != null ? content : "");
|
||||
String answerWithSources = accessor.sourceEvidenceLedger()
|
||||
.appendWikiSourceTable(content != null ? content : "");
|
||||
.appendWikiSourceTable(accumulatedContent);
|
||||
SourceEvidenceLedger.Validation validation =
|
||||
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
||||
boolean evidenceInsufficient = !validation.valid();
|
||||
@ -1251,9 +1440,9 @@ public class ReasoningNode implements NodeAction {
|
||||
.finalThinking(result.thinking())
|
||||
.messages(List.of((Message) result.assistantMessage()))
|
||||
.currentPhase("reasoning")
|
||||
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
||||
.streamedContent(evidenceInsufficient ? accumulatedContent : "")
|
||||
.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())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.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) {
|
||||
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||
+ String.join(", ", unsupportedReferences)
|
||||
|
||||
@ -623,7 +623,9 @@ public class PlanGenerationNode implements NodeAction {
|
||||
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
|
||||
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
|
||||
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。"));
|
||||
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n"
|
||||
+ "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。"
|
||||
+ "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。"));
|
||||
} else {
|
||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||
if (!delegatable.isEmpty()) {
|
||||
|
||||
@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.node.ActionNode;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
@ -35,6 +36,7 @@ import vip.mate.tool.builtin.DelegationContext;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@ -195,6 +197,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
||||
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
||||
Set<String> loadedSkills = new LinkedHashSet<>(accessor.loadedSkills());
|
||||
|
||||
if (stepIndex >= steps.size()) {
|
||||
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
||||
@ -202,6 +205,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentStepResult("步骤索引越界")
|
||||
.completedResults(formatStepResult(stepIndex, "步骤索引越界"))
|
||||
.currentStepIndex(stepIndex + 1)
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -358,13 +362,14 @@ public class StepExecutionNode implements NodeAction {
|
||||
// (instead of leaking into the next LLM round).
|
||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||
stepDirectOutputs);
|
||||
stepDirectOutputs, chatOrigin);
|
||||
toolResponses.add(response);
|
||||
preApprovedPayload = ""; // 只消费一次
|
||||
} else {
|
||||
// 非预批准工具走正常执行器
|
||||
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());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
@ -379,20 +384,28 @@ public class StepExecutionNode implements NodeAction {
|
||||
}
|
||||
} else {
|
||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||
toolResponses.addAll(execResult.responses());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||
}
|
||||
if (execResult.awaitingApproval()) {
|
||||
approvalTriggered = true;
|
||||
approvalToolName = execResult.barrierToolName() != null
|
||||
? execResult.barrierToolName() : "unknown";
|
||||
if (!allToolCalls.isEmpty()) {
|
||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
|
||||
chatOrigin, loadedSkills);
|
||||
toolResponses.addAll(execResult.responses());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||
}
|
||||
if (execResult.awaitingApproval()) {
|
||||
approvalTriggered = true;
|
||||
approvalToolName = execResult.barrierToolName() != null
|
||||
? execResult.barrierToolName() : "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.responses(toolResponses)
|
||||
@ -450,6 +463,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentPhase("awaiting_approval")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!stepThinking.isEmpty())
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
@ -486,6 +500,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
|
||||
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
|
||||
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
@ -536,6 +551,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentStepTitle("")
|
||||
.currentStepResult("")
|
||||
.contentStreamed(false)
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
@ -594,6 +610,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentStepTitle("")
|
||||
.currentStepResult("")
|
||||
.contentStreamed(false)
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
@ -610,6 +627,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
// FINAL_SUMMARY is the single persistence/broadcast channel.
|
||||
.finalSummary(shortError)
|
||||
.contentStreamed(false)
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
@ -654,6 +672,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentPhase("step_completed")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!stepThinking.isEmpty())
|
||||
.loadedSkills(Set.copyOf(loadedSkills))
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
@ -803,10 +822,9 @@ public class StepExecutionNode implements NodeAction {
|
||||
""";
|
||||
messages.add(new SystemMessage(enhancedSystemPrompt));
|
||||
// Runtime skill catalog (rendered here instead of baked into the system
|
||||
// prompt). The Plan path never pins per-run loads, so render with an
|
||||
// empty loaded set — this reproduces the pre-disclosure DB ordering.
|
||||
// prompt), ranked with skills already loaded during this graph run.
|
||||
if (skillCatalogRenderer != null) {
|
||||
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of());
|
||||
String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
|
||||
if (skillCatalog != null && !skillCatalog.isBlank()) {
|
||||
messages.add(new SystemMessage(skillCatalog));
|
||||
}
|
||||
|
||||
@ -140,6 +140,11 @@ public final class PlanStateAccessor {
|
||||
return state.value(WORKING_CONTEXT, "");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Set<String> loadedSkills() {
|
||||
return state.<Set<String>>value(MateClawStateKeys.LOADED_SKILLS).orElse(Set.of());
|
||||
}
|
||||
|
||||
// ===== 输出构建器 =====
|
||||
|
||||
public static OutputBuilder output() {
|
||||
@ -251,6 +256,10 @@ public final class PlanStateAccessor {
|
||||
return put(MateClawStateKeys.PENDING_EVENTS, events);
|
||||
}
|
||||
|
||||
public OutputBuilder loadedSkills(Set<String> names) {
|
||||
return put(MateClawStateKeys.LOADED_SKILLS, names);
|
||||
}
|
||||
|
||||
// ---- 阶段标记(写入共享键 MateClawStateKeys.CURRENT_PHASE)----
|
||||
public OutputBuilder currentPhase(String phase) {
|
||||
return put(MateClawStateKeys.CURRENT_PHASE, phase);
|
||||
|
||||
@ -119,6 +119,10 @@ public final class MateClawStateAccessor {
|
||||
return state.value(FINAL_ANSWER_DRAFT, "");
|
||||
}
|
||||
|
||||
public String longFormDraft() {
|
||||
return state.value(LONG_FORM_DRAFT, "");
|
||||
}
|
||||
|
||||
public boolean limitExceeded() {
|
||||
return state.value(LIMIT_EXCEEDED, false);
|
||||
}
|
||||
@ -453,6 +457,10 @@ public final class MateClawStateAccessor {
|
||||
return put(FINAL_ANSWER_DRAFT, draft);
|
||||
}
|
||||
|
||||
public OutputBuilder longFormDraft(String draft) {
|
||||
return put(LONG_FORM_DRAFT, draft);
|
||||
}
|
||||
|
||||
// ---- 终止 ----
|
||||
public OutputBuilder finalAnswer(String answer) {
|
||||
return put(FINAL_ANSWER, answer);
|
||||
|
||||
@ -64,6 +64,8 @@ public final class MateClawStateKeys {
|
||||
|
||||
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
|
||||
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft";
|
||||
/** Accumulated visible body for an explicit long-form generation request. */
|
||||
public static final String LONG_FORM_DRAFT = "long_form_draft";
|
||||
|
||||
/** 是否需要进入 summarizing 阶段 */
|
||||
public static final String SHOULD_SUMMARIZE = "should_summarize";
|
||||
|
||||
@ -26,6 +26,14 @@ public class AgentEntity {
|
||||
/** Agent 类型:react / plan_execute */
|
||||
private String agentType;
|
||||
|
||||
/** Runtime provider type: native / dsh / other registered providers. */
|
||||
@TableField(value = "runtime_type")
|
||||
private String runtimeType;
|
||||
|
||||
/** Runtime-specific JSON configuration. Null means provider defaults. */
|
||||
@TableField(value = "runtime_config", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String runtimeConfig;
|
||||
|
||||
/** 系统提示词 */
|
||||
@TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String systemPrompt;
|
||||
|
||||
@ -10,6 +10,7 @@ import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@ -116,18 +117,26 @@ public class AgentRuntimeAggregator {
|
||||
) {}
|
||||
|
||||
public RuntimeSnapshot snapshot() {
|
||||
return snapshot(null);
|
||||
}
|
||||
|
||||
public RuntimeSnapshot snapshot(Long workspaceId) {
|
||||
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
||||
Set<Long> agentIds = rawRuns.stream()
|
||||
.map(RunSnapshot::agentId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
for (var rec : subagentRegistry.allActive()) {
|
||||
Collection<SubagentRegistry.SubagentRecord> rawSubagents = subagentRegistry.allActive();
|
||||
for (var rec : rawSubagents) {
|
||||
if (rec.agentId() != null) agentIds.add(rec.agentId());
|
||||
}
|
||||
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
|
||||
|
||||
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();
|
||||
if (parent != null) {
|
||||
subagentCountByParent.merge(parent, 1L, Long::sum);
|
||||
@ -141,6 +150,7 @@ public class AgentRuntimeAggregator {
|
||||
int runningCount = 0;
|
||||
for (RunSnapshot s : rawRuns) {
|
||||
if (s.done()) continue;
|
||||
if (!belongsToWorkspace(s.agentId(), agentInfo, workspaceId)) continue;
|
||||
runningCount++;
|
||||
String stuckReason = computeStuckReason(s);
|
||||
boolean orphan = s.subscriberCount() == 0;
|
||||
@ -181,7 +191,8 @@ public class AgentRuntimeAggregator {
|
||||
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 -> {
|
||||
long now = System.currentTimeMillis();
|
||||
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());
|
||||
}
|
||||
|
||||
public boolean runBelongsToWorkspace(String conversationId, Long workspaceId) {
|
||||
if (conversationId == null || workspaceId == null) {
|
||||
return false;
|
||||
}
|
||||
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
||||
for (RunSnapshot run : rawRuns) {
|
||||
if (run.done() || !conversationId.equals(run.conversationId())) {
|
||||
continue;
|
||||
}
|
||||
AgentEntity agent = resolveAgent(run.agentId());
|
||||
return agent != null && workspaceId.equals(agent.getWorkspaceId());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean subagentBelongsToWorkspace(String subagentId, Long workspaceId) {
|
||||
if (subagentId == null || workspaceId == null) {
|
||||
return false;
|
||||
}
|
||||
return subagentRegistry.get(subagentId)
|
||||
.map(rec -> {
|
||||
AgentEntity agent = resolveAgent(rec.agentId());
|
||||
return agent != null && workspaceId.equals(agent.getWorkspaceId());
|
||||
})
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when the run looks healthy. The returned tag is a stable
|
||||
* machine-readable code (not a translated label) so the frontend can
|
||||
@ -244,4 +282,25 @@ public class AgentRuntimeAggregator {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private AgentEntity resolveAgent(Long id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return agentService.getAgent(id);
|
||||
} catch (Exception e) {
|
||||
log.debug("agent lookup failed for id={}: {}", id, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean belongsToWorkspace(Long agentId, Map<Long, AgentEntity> agentInfo,
|
||||
Long workspaceId) {
|
||||
if (workspaceId == null) {
|
||||
return true;
|
||||
}
|
||||
AgentEntity agent = agentId == null ? null : agentInfo.get(agentId);
|
||||
return agent != null && workspaceId.equals(agent.getWorkspaceId());
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,13 +19,13 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
import vip.mate.agent.runtime.dsh.DshRuntimeService;
|
||||
|
||||
/**
|
||||
* Admin-only live runtime surface: the 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
|
||||
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
|
||||
* owner-scoped — this controller is intentionally cross-tenant for the
|
||||
* operator role.
|
||||
* owner-scoped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Agent Runtime (Live)")
|
||||
@ -40,21 +40,35 @@ public class AgentRuntimeController {
|
||||
private final AuditEventService auditEventService;
|
||||
private final ConversationService conversationService;
|
||||
private final I18nService i18nService;
|
||||
private final DshRuntimeService dshRuntimeService;
|
||||
|
||||
@Operation(summary = "Snapshot of every in-flight agent turn")
|
||||
@GetMapping("/snapshot")
|
||||
@RequireGlobalAdmin
|
||||
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
|
||||
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication 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")
|
||||
@PostMapping("/runs/{conversationId}/stop")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
requireRunInWorkspace(conversationId, workspaceId);
|
||||
boolean ok = streamTracker.requestStop(conversationId);
|
||||
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
|
||||
return R.ok(Map.of("stopped", ok));
|
||||
@ -64,8 +78,10 @@ public class AgentRuntimeController {
|
||||
@PostMapping("/runs/{conversationId}/recycle")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
requireRunInWorkspace(conversationId, workspaceId);
|
||||
boolean ok = streamTracker.forceRecycle(conversationId);
|
||||
if (ok) {
|
||||
finalizeRecycledConversation(conversationId);
|
||||
@ -78,8 +94,10 @@ public class AgentRuntimeController {
|
||||
@PostMapping("/subagents/{subagentId}/interrupt")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
requireAdmin(auth);
|
||||
requireSubagentInWorkspace(subagentId, workspaceId);
|
||||
boolean ok = subagentRegistry.interrupt(subagentId);
|
||||
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
|
||||
return R.ok(Map.of("interrupted", ok));
|
||||
@ -93,9 +111,12 @@ public class AgentRuntimeController {
|
||||
@Operation(summary = "Recycle every run currently flagged as stuck")
|
||||
@PostMapping("/sweep")
|
||||
@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);
|
||||
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
|
||||
requireWorkspace(workspaceId);
|
||||
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(workspaceId);
|
||||
List<String> ids = snap.runs().stream()
|
||||
.filter(r -> r.stuckReason() != null)
|
||||
.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,
|
||||
String resourceId, Map<String, Object> detail) {
|
||||
try {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package vip.mate.agent.runtime.contract;
|
||||
|
||||
public record RuntimeCapabilities(
|
||||
boolean supportsCancellation,
|
||||
boolean supportsApprovals,
|
||||
boolean supportsSubagents,
|
||||
boolean supportsContextUsage
|
||||
) {}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
@ -0,0 +1,11 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
public interface DshProcessHandle {
|
||||
boolean isAlive();
|
||||
|
||||
void destroy();
|
||||
|
||||
void destroyForcibly();
|
||||
|
||||
boolean awaitExit(long millis);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
public enum DshToolDecision {
|
||||
ALLOW,
|
||||
APPROVAL,
|
||||
DENY
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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()); }
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -412,6 +412,24 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
"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 +
|
||||
* 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) {
|
||||
return ResolveOutcome.alreadyResolved(null);
|
||||
}
|
||||
return performResolveOnSnapshot(target, null, "CONSUMED", MetadataDecision.APPROVED,
|
||||
return performResolveOnSnapshot(target, null, "APPROVED", "CONSUMED", MetadataDecision.APPROVED,
|
||||
"consumed", /* removeFromMap */ true);
|
||||
}
|
||||
|
||||
@ -447,7 +465,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
|
||||
for (PendingApproval target : targets) {
|
||||
try {
|
||||
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "DENIED",
|
||||
ResolveOutcome outcome = performResolveOnSnapshot(target, userId, "PENDING", "DENIED",
|
||||
MetadataDecision.DENIED, "denied", /* removeFromMap */ true);
|
||||
if (outcome.dbSynced()) outcomes.add(outcome);
|
||||
} catch (Exception e) {
|
||||
@ -475,7 +493,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
if (targets.isEmpty()) return List.of();
|
||||
List<ResolveOutcome> outcomes = new java.util.ArrayList<>(targets.size());
|
||||
for (PendingApproval target : targets) {
|
||||
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "SUPERSEDED",
|
||||
ResolveOutcome outcome = performResolveOnSnapshot(target, null, "PENDING", "SUPERSEDED",
|
||||
MetadataDecision.DENIED, "superseded", /* removeFromMap */ true);
|
||||
if (outcome.dbSynced()) outcomes.add(outcome);
|
||||
}
|
||||
@ -644,12 +662,13 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
pendingId, snapshot != null, snapshot != null ? snapshot.getStatus() : "n/a");
|
||||
return ResolveOutcome.alreadyResolved(pendingId);
|
||||
}
|
||||
return performResolveOnSnapshot(snapshot, userId, dbStatus, metaDecision,
|
||||
return performResolveOnSnapshot(snapshot, userId, "PENDING", dbStatus, metaDecision,
|
||||
snapshotStatus, removeFromMap);
|
||||
}
|
||||
|
||||
private ResolveOutcome performResolveOnSnapshot(PendingApproval snapshot, String userId,
|
||||
String dbStatus, MetadataDecision metaDecision,
|
||||
String expectedDbStatus, String dbStatus,
|
||||
MetadataDecision metaDecision,
|
||||
String snapshotStatus, boolean removeFromMap) {
|
||||
// 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
|
||||
@ -658,7 +677,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
try {
|
||||
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||
.eq(ToolApprovalEntity::getPendingId, snapshot.getPendingId())
|
||||
.eq(ToolApprovalEntity::getStatus, "PENDING")
|
||||
.eq(ToolApprovalEntity::getStatus, expectedDbStatus)
|
||||
.set(ToolApprovalEntity::getStatus, dbStatus)
|
||||
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now());
|
||||
if (userId != null) {
|
||||
@ -672,8 +691,8 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
throw e;
|
||||
}
|
||||
if (rows == 0) {
|
||||
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in PENDING (concurrent resolve)",
|
||||
snapshot.getPendingId());
|
||||
log.info("[ApprovalWorkflow] resolve no-op for {}: DB row not in {} (concurrent resolve)",
|
||||
snapshot.getPendingId(), expectedDbStatus);
|
||||
return ResolveOutcome.alreadyResolved(snapshot.getPendingId());
|
||||
}
|
||||
|
||||
@ -809,6 +828,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
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) {
|
||||
return approvalService.findPendingByConversation(conversationId);
|
||||
}
|
||||
|
||||
@ -1469,6 +1469,7 @@ public class ChannelMessageRouter {
|
||||
replayOrigin = chatOriginFactory.from(
|
||||
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
||||
}
|
||||
replayOrigin = replayOrigin.withApprovalId(consumed.getPendingId());
|
||||
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
|
||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||
String reply = replayResult.content();
|
||||
|
||||
@ -19,6 +19,7 @@ import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.runtime.ConversationTurnGate;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.MetadataDecision;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
@ -43,6 +44,8 @@ import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Web 渠道聊天接口
|
||||
@ -66,6 +69,10 @@ public class ChatController {
|
||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
|
||||
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
|
||||
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
|
||||
// (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread
|
||||
@ -200,13 +207,29 @@ public class ChatController {
|
||||
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 ----
|
||||
String normalizedMsg = requestMessage.trim().toLowerCase();
|
||||
boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg);
|
||||
boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg);
|
||||
|
||||
if (isApprovalCommand || isDenyCommand) {
|
||||
PendingApproval pending = approvalService.findPendingByConversation(conversationId);
|
||||
PendingApproval pending = findRequestedPendingApproval(
|
||||
conversationId, request.getPendingApprovalId());
|
||||
if (pending == null) {
|
||||
try {
|
||||
sendEvent(emitter, "error", Map.of("message", "当前没有待审批的工具调用"));
|
||||
@ -249,6 +272,7 @@ public class ChatController {
|
||||
final String decision = isApprovalCommand ? "approved" : "denied";
|
||||
|
||||
streamTracker.register(conversationId);
|
||||
setupPermit.close();
|
||||
Long approvalAgentId = parseLongOrNull(pending.getAgentId());
|
||||
streamTracker.bindRunMeta(conversationId, approvalAgentId, username);
|
||||
registerEmitterCallbacks(emitter, conversationId);
|
||||
@ -279,8 +303,8 @@ public class ChatController {
|
||||
conversationService.getMessageCount(conversationId)));
|
||||
// deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息
|
||||
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (denyCr.allDone() && denyCr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username, requestBaseUrl);
|
||||
if (denyCr.allDone() && shouldDrainQueuedInput(conversationId, "completed")) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||
}
|
||||
@ -293,8 +317,8 @@ public class ChatController {
|
||||
broadcastEvent(conversationId, "done", Map.of("status", "completed"));
|
||||
// 审批记录被另一个请求消费,但用户可能在等待期间排了消息
|
||||
ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username, requestBaseUrl);
|
||||
if (consumedNullCr.allDone() && shouldDrainQueuedInput(conversationId, "completed")) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||
}
|
||||
@ -322,7 +346,7 @@ public class ChatController {
|
||||
}
|
||||
// Carry the request-thread base URL so any file a replayed
|
||||
// tool generates gets an absolute download link.
|
||||
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl);
|
||||
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl).withApprovalId(finalConsumed.getPendingId());
|
||||
Disposable disposable = agentService.chatWithReplayStream(
|
||||
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
||||
.doOnNext(delta -> {
|
||||
@ -398,8 +422,8 @@ public class ChatController {
|
||||
} finally {
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (cr.allDone()) {
|
||||
if (cr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||
if (shouldDrainQueuedInput(conversationId, persistStatus)) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||
@ -499,8 +523,8 @@ public class ChatController {
|
||||
streamTracker.clearInterruptState(conversationId);
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (cr.allDone()) {
|
||||
if (cr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||
if (shouldDrainQueuedInput(conversationId, errStatus)) {
|
||||
startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, approvalEmitterDone);
|
||||
@ -555,6 +579,7 @@ public class ChatController {
|
||||
|
||||
// ---- 正常请求:注册流状态并附着首个订阅者 ----
|
||||
streamTracker.register(conversationId);
|
||||
setupPermit.close();
|
||||
streamTracker.bindRunMeta(conversationId, agentId, username);
|
||||
registerEmitterCallbacks(emitter, conversationId);
|
||||
streamTracker.attach(conversationId, emitter);
|
||||
@ -743,7 +768,7 @@ public class ChatController {
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (cr.allDone()) {
|
||||
// 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
|
||||
// the running turn and then sent a new message via the
|
||||
// enqueue path (not the interrupt-with-followup path) —
|
||||
@ -754,8 +779,8 @@ public class ChatController {
|
||||
// run it" condition; align with them. If the user
|
||||
// genuinely doesn't want continuation, no message would
|
||||
// have been in messageQueue to begin with.
|
||||
if (cr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||
if (shouldDrainQueuedInput(conversationId, persistStatus)) {
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
// 延迟关闭 emitter,确保最后的事件都已发送
|
||||
@ -847,9 +872,9 @@ public class ChatController {
|
||||
streamTracker.clearInterruptState(conversationId);
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (cr.allDone()) {
|
||||
if (cr.queuedInput() != null) {
|
||||
if (shouldDrainQueuedInput(conversationId, status)) {
|
||||
// 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug)
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, emitterDone);
|
||||
@ -957,7 +982,7 @@ public class ChatController {
|
||||
streamTracker.clearInterruptState(conversationId);
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
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()) {
|
||||
// RFC follow-up (2026-04-27): the previous guard
|
||||
// cr.queuedInput()!=null && !(isUserStop && !isInterruptFollowup)
|
||||
@ -971,8 +996,8 @@ public class ChatController {
|
||||
// follow-up. Whoever puts a message in messageQueue means it
|
||||
// — just run it. Aligns with doOnComplete and the 4 other
|
||||
// queue-launch sites in this controller.
|
||||
if (cr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl);
|
||||
if (shouldDrainQueuedInput(conversationId, status)) {
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, emitterDone);
|
||||
@ -1005,6 +1030,7 @@ public class ChatController {
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1086,17 +1112,24 @@ public class ChatController {
|
||||
// 判断当前阶段(仅用于 reason 字段,行为对所有阶段一致:仅入队)
|
||||
boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null;
|
||||
|
||||
// 仅入队、不 dispose。延迟持久化到 startQueuedMessage(让 Asst-N 先在 doOnComplete 落库,
|
||||
// 否则 listMessages ORDER BY create_time ASC 会把 Q(N+1) 排到 Asst-N 前面)
|
||||
boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, false, contentParts);
|
||||
// Commit the payload before publishing acceptance. The stream tracker is
|
||||
// only a wake signal; the database row remains authoritative on restart.
|
||||
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={}",
|
||||
conversationId, username, streamTracker.getQueueSize(conversationId), isAwaitingApproval);
|
||||
conversationId, username, inputQueue.countQueued(conversationId), isAwaitingApproval);
|
||||
|
||||
return R.ok(Map.of(
|
||||
"interrupted", false,
|
||||
"queued", queued,
|
||||
"queueSize", streamTracker.getQueueSize(conversationId),
|
||||
"reason", isAwaitingApproval ? "awaiting_approval" : "queued"
|
||||
"queueItemId", stored.id().toString(),
|
||||
"queueSize", inputQueue.countQueued(conversationId),
|
||||
"reason", queued ? (isAwaitingApproval ? "awaiting_approval" : "queued") : "no_active_stream"
|
||||
));
|
||||
}
|
||||
|
||||
@ -1123,6 +1156,10 @@ public class ChatController {
|
||||
if (username == null) {
|
||||
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);
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
request.getConversationId(), "user", request.getMessage(), request.getContentParts());
|
||||
@ -1134,7 +1171,8 @@ public class ChatController {
|
||||
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId,
|
||||
request.getEndUserId()).withOriginMessageId(
|
||||
savedUser == null ? null : savedUser.getId());
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
|
||||
AgentService.ChatResult result = turnGate.withPermit(permit, () ->
|
||||
agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin));
|
||||
String response = result.content();
|
||||
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
||||
result.promptTokens(), result.completionTokens(),
|
||||
@ -1142,6 +1180,7 @@ public class ChatController {
|
||||
completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web",
|
||||
memoryOwnerResolver.resolve(webOrigin));
|
||||
return R.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "上传聊天附件")
|
||||
@ -1362,6 +1401,8 @@ public class ChatController {
|
||||
private String message;
|
||||
private String conversationId = "default";
|
||||
private List<MessageContentPart> contentParts;
|
||||
/** Exact approval selected by the UI; absent for legacy FIFO clients. */
|
||||
private String pendingApprovalId;
|
||||
/** true 表示断线重连,不发送新消息,只附着到已有的流 */
|
||||
private Boolean reconnect;
|
||||
/**
|
||||
@ -1396,21 +1437,30 @@ public class ChatController {
|
||||
private Boolean regenerate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动启动排队消息(interrupt-with-followup 或自然完成后的续跑逻辑)。
|
||||
* 接受由 {@link ChatStreamTracker#completeAndConsumeIfLast} 预先消费的 QueuedInput 快照。
|
||||
* 快照已脱离 RunState 生命周期,不受后续 complete/register 影响。
|
||||
* 支持链式续跑:queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用。
|
||||
*/
|
||||
/** Claims and starts the next durable input after the current stream finishes. */
|
||||
private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone,
|
||||
ChatStreamTracker.QueuedInput preConsumedInput, String requesterId,
|
||||
String baseUrl) {
|
||||
String requesterId, String baseUrl) {
|
||||
String queueClaimId = UUID.randomUUID().toString();
|
||||
ConversationInputQueueStore.QueuedInput preConsumedInput = inputQueue
|
||||
.claimNext(conversationId, queueClaimId, LocalDateTime.now())
|
||||
.orElse(null);
|
||||
if (preConsumedInput == null) {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, emitterDone);
|
||||
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)。
|
||||
// 改为持久化用户消息 + 通知前端"稍后重试",避免连锁 429 浪费配额。
|
||||
String lastMessage = conversationService.getLastMessage(conversationId);
|
||||
@ -1418,11 +1468,14 @@ public class ChatController {
|
||||
|| lastMessage.contains("429") || lastMessage.contains("速率限制"))) {
|
||||
log.warn("Skipping queued message after rate limit error: conversationId={}, lastMessage={}",
|
||||
conversationId, lastMessage.substring(0, Math.min(50, lastMessage.length())));
|
||||
// 持久化用户消息不丢失
|
||||
if (preConsumedInput.message() != null && !preConsumedInput.message().isBlank()
|
||||
&& !preConsumedInput.persisted()) {
|
||||
conversationService.saveMessage(conversationId, "user", preConsumedInput.message());
|
||||
if (preConsumedInput.persistedMessageId() == null) {
|
||||
MessageEntity saved = conversationService.saveMessage(conversationId, "user",
|
||||
preConsumedInput.message(), preConsumedInput.contentParts(), "queued");
|
||||
if (saved != null) {
|
||||
inputQueue.bindMessage(preConsumedInput.id(), queueClaimId, saved.getId(), LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
inputQueue.consume(preConsumedInput.id(), queueClaimId, LocalDateTime.now());
|
||||
broadcastEvent(conversationId, "warning", Map.of(
|
||||
"message", "上一轮请求触发了频率限制,排队消息已保存,请稍后重新发送"));
|
||||
broadcastEvent(conversationId, "done", Map.of("status", "rate_limited"));
|
||||
@ -1432,18 +1485,26 @@ public class ChatController {
|
||||
}
|
||||
|
||||
String queuedMessage = preConsumedInput.message();
|
||||
Long agentId = preConsumedInput.agentId() != null ? preConsumedInput.agentId() : 1L;
|
||||
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 已提前持久化则跳过)。
|
||||
// 这里持久化是为了确保 user 消息在 assistant 消息(doOnError/doOnCancel 已写入)之后落库,
|
||||
// 让 listMessages ORDER BY create_time ASC 后顺序正确:Q1 → Asst1 → Q2 → Asst2。
|
||||
Long queuedOriginMessageId = null;
|
||||
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
|
||||
Long queuedOriginMessageId = preConsumedInput.persistedMessageId();
|
||||
if (queuedOriginMessageId == null) {
|
||||
MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage,
|
||||
preConsumedInput.contentParts(), "queued");
|
||||
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 事件
|
||||
@ -1530,9 +1591,9 @@ public class ChatController {
|
||||
} finally {
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (cr.allDone()) {
|
||||
if (cr.queuedInput() != null) {
|
||||
if (shouldDrainQueuedInput(conversationId, persistStatus)) {
|
||||
// 链式续跑:queued stream 期间又排了新消息
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl);
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, requesterId, baseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
sseExecutor.execute(() -> {
|
||||
@ -1576,8 +1637,8 @@ public class ChatController {
|
||||
}
|
||||
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
|
||||
if (cr.allDone()) {
|
||||
if (cr.queuedInput() != null) {
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl);
|
||||
if (shouldDrainQueuedInput(conversationId, "failed")) {
|
||||
startQueuedMessage(conversationId, emitter, emitterDone, requesterId, baseUrl);
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, emitterDone);
|
||||
@ -1590,6 +1651,10 @@ public class ChatController {
|
||||
() -> emergencySaveAccumulator(conversationId, accumulator));
|
||||
}
|
||||
|
||||
private boolean hasQueuedInput(String conversationId) {
|
||||
return inputQueue.countQueued(conversationId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal error path for requests rejected before a stream is registered:
|
||||
* emit an {@code error} + terminal {@code done} pair and complete the
|
||||
@ -1660,6 +1725,31 @@ public class ChatController {
|
||||
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) {
|
||||
return savedAssistant != null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -88,6 +89,18 @@ public class ChatStreamTracker {
|
||||
@Value("${mateclaw.stream.iteration-events: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
|
||||
* because pre-token gaps strand the UI on a blank "正在生成中" placeholder
|
||||
@ -127,6 +140,11 @@ public class ChatStreamTracker {
|
||||
this.iterationEventsEnabled = enabled;
|
||||
}
|
||||
|
||||
void setContentBatchingForTesting(long flushMs, int maxChars) {
|
||||
this.contentBatchMs = Math.max(1L, flushMs);
|
||||
this.contentBatchChars = Math.max(1, maxChars);
|
||||
}
|
||||
|
||||
public boolean isIterationEventsEnabled() {
|
||||
return iterationEventsEnabled;
|
||||
}
|
||||
@ -190,8 +208,8 @@ public class ChatStreamTracker {
|
||||
/** 等待原因(审批等待时有值) */
|
||||
volatile String waitingReason;
|
||||
|
||||
/** 排队的用户消息队列(支持多条排队消息,按序消费) */
|
||||
final java.util.Queue<QueuedInput> messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
/** Wake signal only; queued input payloads live in the database. */
|
||||
final AtomicBoolean queuedInputPending = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* Emergency save callback registered by the SSE chain owner (ChatController).
|
||||
@ -219,6 +237,11 @@ public class ChatStreamTracker {
|
||||
/** 已广播的 pending approval ID 集合(用于幂等去重) */
|
||||
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 检测和清理) */
|
||||
final long createdAt = System.currentTimeMillis();
|
||||
|
||||
@ -500,18 +523,7 @@ public class ChatStreamTracker {
|
||||
}
|
||||
if (current.done) {
|
||||
stopHeartbeat(current);
|
||||
RunState nextState = 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;
|
||||
return new RunState(id);
|
||||
}
|
||||
// Registration is a fresh lifecycle entrance. Refresh every
|
||||
// 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) {
|
||||
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;
|
||||
// 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) {
|
||||
if (handle == null) return;
|
||||
if (handle == null || disposable == null) return;
|
||||
RunState state = handle.state;
|
||||
boolean disposeImmediately;
|
||||
synchronized (state.lock) {
|
||||
if (!isCurrent(state)) return;
|
||||
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 表示确实停止了正在运行的流。
|
||||
*/
|
||||
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;
|
||||
String conversationId = state.conversationId;
|
||||
|
||||
final boolean firstRequest;
|
||||
final Disposable d;
|
||||
@ -711,14 +771,109 @@ public class ChatStreamTracker {
|
||||
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.
|
||||
* <p>
|
||||
* Two event categories survive {@code state.done=true}:
|
||||
* Lifecycle event categories survive {@code state.done=true}:
|
||||
* <ul>
|
||||
* <li>{@code "done"} — the lifecycle marker itself. If a client missed
|
||||
* this on a broken pipe and reconnects within the 5-minute retention
|
||||
* 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
|
||||
* {@code AsyncTaskService} (image/video/music generation). These
|
||||
* 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) {
|
||||
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;
|
||||
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);
|
||||
List<SseEmitter> targets;
|
||||
long eventId = 0L;
|
||||
@ -751,10 +918,10 @@ public class ChatStreamTracker {
|
||||
if (!isHeartbeat) {
|
||||
state.lastEventAt = System.currentTimeMillis();
|
||||
}
|
||||
if (!isDone && !isAsyncTask && !isHeartbeat && state.done) {
|
||||
if (!isDone && !isPostTurnEvent && !isHeartbeat && state.done) {
|
||||
return;
|
||||
}
|
||||
if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) {
|
||||
if ((isDone || isPostTurnEvent) || (!isHeartbeat && !skipBuffer)) {
|
||||
eventId = EVENT_IDS.nextId();
|
||||
state.buffer.add(new SseEvent(eventId, eventName, jsonData));
|
||||
if (state.buffer.size() > MAX_BUFFER_SIZE) {
|
||||
@ -762,7 +929,7 @@ public class ChatStreamTracker {
|
||||
}
|
||||
}
|
||||
targets = new ArrayList<>(state.subscribers);
|
||||
forwardRelays = !isDone && !isAsyncTask && !isHeartbeat;
|
||||
forwardRelays = !isDone && !isPostTurnEvent && !isHeartbeat;
|
||||
}
|
||||
|
||||
List<SseEmitter> dead = new ArrayList<>();
|
||||
@ -814,9 +981,22 @@ public class ChatStreamTracker {
|
||||
*/
|
||||
public void broadcast(String conversationId, String eventName, String jsonData, boolean skipBuffer) {
|
||||
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 isAsyncTask = eventName != null && eventName.startsWith("async_task_");
|
||||
boolean isPostTurnEvent = "goal_continuation".equals(eventName)
|
||||
|| (eventName != null && eventName.startsWith("async_task_"));
|
||||
boolean isHeartbeat = "heartbeat".equals(eventName);
|
||||
|
||||
// Stamp last activity for stuck detection. Heartbeats are excluded
|
||||
@ -826,7 +1006,7 @@ public class ChatStreamTracker {
|
||||
state.lastEventAt = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
if (isDone || isAsyncTask) {
|
||||
if (isDone || isPostTurnEvent) {
|
||||
if (state == null) return;
|
||||
synchronized (state.lock) {
|
||||
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
|
||||
// events, not lifecycle markers.
|
||||
return;
|
||||
@ -1244,10 +1424,8 @@ public class ChatStreamTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成结果:包含是否全部完成、排队消息快照
|
||||
*/
|
||||
public record CompletionResult(boolean allDone, QueuedInput queuedInput) {}
|
||||
/** Completion result for the current in-memory stream generation. */
|
||||
public record CompletionResult(boolean allDone) {}
|
||||
|
||||
/**
|
||||
* 标记一个 Flux 完成。仅在所有 Flux 都完成时才真正移除 RunState。
|
||||
@ -1273,6 +1451,10 @@ public class ChatStreamTracker {
|
||||
|
||||
private boolean complete(RunState state) {
|
||||
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;
|
||||
synchronized (state.lock) {
|
||||
if (!isCurrent(state)) {
|
||||
@ -1313,22 +1495,20 @@ public class ChatStreamTracker {
|
||||
public CompletionResult completeAndConsumeIfLast(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null) {
|
||||
return new CompletionResult(true, null);
|
||||
return new CompletionResult(true);
|
||||
}
|
||||
QueuedInput consumed = null;
|
||||
flushPendingContent(state);
|
||||
ScheduledFuture<?> oldHeartbeat;
|
||||
synchronized (state.lock) {
|
||||
if (!isCurrent(state)) {
|
||||
return new CompletionResult(false, null);
|
||||
return new CompletionResult(false);
|
||||
}
|
||||
state.activeFluxCount = Math.max(0, state.activeFluxCount - 1);
|
||||
if (state.activeFluxCount > 0) {
|
||||
log.debug("Stream partially completed: {} (remaining flux={}, queuePreserved={})",
|
||||
conversationId, state.activeFluxCount, !state.messageQueue.isEmpty());
|
||||
return new CompletionResult(false, null);
|
||||
log.debug("Stream partially completed: {} (remaining flux={}, queuedInputPending={})",
|
||||
conversationId, state.activeFluxCount, state.queuedInputPending.get());
|
||||
return new CompletionResult(false);
|
||||
}
|
||||
// 最后一个 Flux:在同一个锁内消费排队消息(取队首)
|
||||
consumed = state.messageQueue.poll();
|
||||
state.done = true;
|
||||
state.cancellationHooks.clear();
|
||||
state.termination.complete(null);
|
||||
@ -1340,9 +1520,9 @@ public class ChatStreamTracker {
|
||||
if (oldHeartbeat != null) {
|
||||
oldHeartbeat.cancel(false);
|
||||
}
|
||||
log.debug("Stream fully completed: {} (hasQueuedSnapshot={}, kept in map for {}ms reconnect window)",
|
||||
conversationId, consumed != null, DONE_RETENTION_MS);
|
||||
return new CompletionResult(true, consumed);
|
||||
log.debug("Stream fully completed: {} (queuedInputPending={}, kept in map for {}ms reconnect window)",
|
||||
conversationId, state.queuedInputPending.get(), DONE_RETENTION_MS);
|
||||
return new CompletionResult(true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1445,7 +1625,7 @@ public class ChatStreamTracker {
|
||||
"currentPhase", safe(state.currentPhase),
|
||||
"waitingReason", safe(state.waitingReason),
|
||||
"runningToolName", safe(state.runningToolName),
|
||||
"queueLength", state.messageQueue.size(),
|
||||
"queueLength", state.queuedInputPending.get() ? 1 : 0,
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
@ -1590,8 +1770,7 @@ public class ChatStreamTracker {
|
||||
synchronized (state.lock) {
|
||||
Disposable d = state.disposable;
|
||||
canInterrupt = d != null && !d.isDisposed();
|
||||
// 无论是否可中断,都入队(支持多条排队消息)
|
||||
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted, contentParts));
|
||||
state.queuedInputPending.set(true);
|
||||
if (canInterrupt) {
|
||||
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
|
||||
state.stopRequested.set(true);
|
||||
@ -1658,7 +1837,7 @@ public class ChatStreamTracker {
|
||||
if (state == null || state.done) {
|
||||
return false;
|
||||
}
|
||||
state.messageQueue.offer(new QueuedInput(message, agentId, persisted, contentParts));
|
||||
state.queuedInputPending.set(true);
|
||||
// broadcast 在锁外
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(Map.of(
|
||||
@ -1688,9 +1867,7 @@ public class ChatStreamTracker {
|
||||
* 从队列头部取出一条消息。
|
||||
*/
|
||||
public QueuedInput consumeQueuedInput(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null) return null;
|
||||
return state.messageQueue.poll();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1734,7 +1911,7 @@ public class ChatStreamTracker {
|
||||
*/
|
||||
public boolean hasQueuedMessage(String 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) {
|
||||
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 =====
|
||||
@ -2172,7 +2362,7 @@ public class ChatStreamTracker {
|
||||
int queue;
|
||||
synchronized (s.lock) {
|
||||
subs = s.subscribers.size();
|
||||
queue = s.messageQueue.size();
|
||||
queue = s.queuedInputPending.get() ? 1 : 0;
|
||||
}
|
||||
out.add(new RunSnapshot(
|
||||
s.conversationId,
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -1362,6 +1362,8 @@ public class WebChatController {
|
||||
conversationId, actor, wsId, null).withSender(null, "api", null);
|
||||
}
|
||||
|
||||
replayOrigin = replayOrigin.withApprovalId(snapshot.getPendingId());
|
||||
|
||||
// Neutral replay prompt (aligned with IM + web channels — naming a
|
||||
// tool here can mislead the LLM on fallthrough).
|
||||
String replayPrompt = "继续执行已批准的工具调用。";
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.common.result;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 统一响应结果封装
|
||||
@ -24,12 +25,18 @@ public class R<T> implements Serializable {
|
||||
private T data;
|
||||
|
||||
/** 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) {
|
||||
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() {
|
||||
|
||||
@ -100,15 +100,13 @@ public class SecurityConfig {
|
||||
// KB Open API: authenticated by KbOpenApiAuthFilter (API key),
|
||||
// not JWT — must be permitAll so the filter is the sole gatekeeper (R1).
|
||||
"/api/v1/open/kb/**",
|
||||
"/api/a2a/card",
|
||||
"/.well-known/agent-card.json",
|
||||
"/api/v1/talk/ws",
|
||||
// Desktop local-tool tunnel — the handshake interceptor
|
||||
// authenticates the ?token= query param itself, so the
|
||||
// upgrade request is opened to the filter chain like talk/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/**"
|
||||
"/api/v1/desktop/ws"
|
||||
).permitAll();
|
||||
// Swagger UI / OpenAPI document — explicit rule rather than the
|
||||
// permitAll() fallthrough. Public for local dev, admin-only in
|
||||
|
||||
@ -34,6 +34,7 @@ public class ToolTimeoutProperties {
|
||||
"web_fetch", "web",
|
||||
"url_fetch", "web",
|
||||
"write_file", "file",
|
||||
"append_file", "file",
|
||||
"edit_file", "file",
|
||||
"read_file", "file"
|
||||
);
|
||||
|
||||
@ -96,36 +96,44 @@ public abstract class AbstractCronResultDelivery implements CronResultDelivery {
|
||||
// ---------- SQL state-machine helpers ----------
|
||||
|
||||
/**
|
||||
* Atomic SQL CAS: transition delivery_status from {@code NONE} or
|
||||
* {@code PENDING} → {@code PENDING}. Returns true iff this instance won
|
||||
* the race. NONE-eligibility lets fresh runs claim without a separate
|
||||
* "first-time" branch; PENDING-eligibility covers the rare same-instance
|
||||
* retry inside the listener.
|
||||
* Atomic SQL CAS: transition delivery_status from {@code NONE} (or legacy
|
||||
* {@code NULL}) to {@code PENDING}. An already-pending row is owned by the
|
||||
* worker that claimed it and must never be claimable again.
|
||||
*
|
||||
* <p>SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy
|
||||
* 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.
|
||||
*/
|
||||
private boolean claimRun(CronJobRunEntity run) {
|
||||
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus)
|
||||
.or().in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING"))
|
||||
.or().eq(CronJobRunEntity::getDeliveryStatus, "NONE"))
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
|
||||
}
|
||||
|
||||
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::getDeliveryStatus, "PENDING")
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
|
||||
.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) {
|
||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
int updated = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
|
||||
.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||