Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd7ccaead | ||
|
|
c6f6f10fd0 | ||
|
|
c8bf4e0f89 | ||
|
|
db6caea824 | ||
|
|
84375da3c5 | ||
|
|
68c010ecf9 | ||
|
|
493910bf5a | ||
|
|
da8005a8cb | ||
|
|
1d64194e15 | ||
|
|
f47cf8c6be | ||
|
|
d994be3d04 | ||
|
|
9ada305b8a |
4
.gitignore
vendored
@ -78,12 +78,8 @@ pom.xml.versionsBackup
|
|||||||
# mateclaw static build output (do not commit)
|
# mateclaw static build output (do not commit)
|
||||||
mateclaw-server/src/main/resources/static/
|
mateclaw-server/src/main/resources/static/
|
||||||
|
|
||||||
# Maven must not materialize an unresolved property as a literal directory.
|
|
||||||
**/${project.build.directory}/
|
|
||||||
|
|
||||||
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
||||||
mateclaw-server/data/
|
mateclaw-server/data/
|
||||||
.sessions/
|
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
# VitePress build output and cache (do not commit)
|
# VitePress build output and cache (do not commit)
|
||||||
|
|||||||
79
README.md
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<p align="center"><b>Your second brain</b></p>
|
<p align="center"><b>Your second brain</b></p>
|
||||||
|
|
||||||
<p align="center"><sub><b>Pluggable Agent Runtime · Native + DSH · Spring Boot inside</b></sub></p>
|
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
|
||||||
|
|
||||||
[](https://github.com/mateaix/mateclaw)
|
[](https://github.com/mateaix/mateclaw)
|
||||||
[](https://claw.mate.vip/docs)
|
[](https://claw.mate.vip/docs)
|
||||||
@ -30,19 +30,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Latest stable: v2.2.0 — a pluggable, recoverable Agent Runtime.** Digital employees can now run on MateClaw's native StateGraph engine or the managed DeepSeek Harness (DSH) runtime while keeping one conversation, policy, tool, persistence, and observability plane. Persistent Goals survive bounded turns and backend restarts, and A2A connects governed employees across systems. Read the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
|
> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
|
||||||
>
|
>
|
||||||
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
|
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
|
||||||
>
|
>
|
||||||
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native StateGraph runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over authenticated JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
|
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
|
||||||
|
|
||||||
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
|
||||||
|
|
||||||
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model is unavailable, the next healthy provider retries the current request.
|
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -52,7 +48,7 @@ Most AI tools die when their vendor has a bad day. Most forget you the moment th
|
|||||||
|
|
||||||
Primary key expired. Vendor returns 401. Network blip. Quota drained.
|
Primary key expired. Vendor returns 401. Network blip. Quota drained.
|
||||||
|
|
||||||
Other tools hand you a red error card. MateClaw tries the next healthy provider in configured order — including built-in and OpenAI-compatible options such as DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, and MLX — and attempts to recover the current request. It returns an error only when the available chain is exhausted. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
||||||
|
|
||||||
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
|
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
|
||||||
|
|
||||||
@ -60,7 +56,7 @@ You don't write a retry script. You drag providers into priority order in **Sett
|
|||||||
|
|
||||||
Upload a PDF, a batch of markdown, a scraped page — raw material in.
|
Upload a PDF, a batch of markdown, a scraped page — raw material in.
|
||||||
|
|
||||||
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and preserves traceable citations for generated content. Open the citation drawer to inspect the corresponding source chunk and verify page or answer references.
|
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
|
||||||
|
|
||||||
This is the difference between a warehouse and a library.
|
This is the difference between a warehouse and a library.
|
||||||
|
|
||||||
@ -83,20 +79,10 @@ Same brain. Same memory. Same tools. Different doors.
|
|||||||
## What's in the box
|
## What's in the box
|
||||||
|
|
||||||
### Digital employees, not chatbots
|
### Digital employees, not chatbots
|
||||||
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a runtime, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). Employee identity and governance stay stable even when the execution engine changes.
|
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
|
||||||
|
|
||||||
### Agent Runtime: native or DSH (2.2.0+)
|
### Agent Teams (2.0.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)
|
One lead, a crew of employees, one **shared task board**. Tell the lead a goal and it breaks the goal into tasks on the board (`blockedBy` declares dependencies); the dispatch engine hands tasks to members in parallel, prerequisite results hand off to downstream tasks automatically, and settled results are announced back to the lead for synthesis. Execution leases + heartbeats eliminate double execution, **cancel actually interrupts** a running member session, and sensitive tasks park at `in_review` for a human. Deliverables (docx / pptx / xlsx / pdf) register on tasks for download, timelines record everything, and you can jump into any member's child conversation to watch it execute word by word. A Plan-Execute lead hands its **whole plan over to the board** — a lead that can plan turns planning into orchestration.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### Knowledge & memory
|
### Knowledge & memory
|
||||||
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
|
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
|
||||||
@ -104,7 +90,7 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
|
|||||||
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
|
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
|
||||||
|
|
||||||
### Skills · MCP · ACP — three ways to extend capability
|
### Skills · MCP · ACP — three ways to extend capability
|
||||||
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md**. In 2.1, reflection and cross-session recurring-request mining can produce reusable improvements; routine promotion, constrained auto-binding, curator handover/governance, origin policy, snapshots, and restore points keep evolution observable, workspace-scoped, and reversible. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** before install
|
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
|
||||||
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
|
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
|
||||||
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
|
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
|
||||||
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
|
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
|
||||||
@ -115,7 +101,7 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
|
|||||||
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
|
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
|
||||||
|
|
||||||
### You see what every employee is doing
|
### You see what every employee is doing
|
||||||
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, which runtime provider owns the turn, what step it is on, how many tokens it uses, and one-click force-recycle when stuck. Native and DSH events enter the same thinking / tool / answer projection; completion, failure, usage, and cancellation retain consistent lifecycle semantics. Per-event SSE IDs make reconnects safe, and Team Runs group member work under one live execution.
|
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
|
||||||
|
|
||||||
### Multimodal creation
|
### Multimodal creation
|
||||||
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
|
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
|
||||||
@ -124,15 +110,15 @@ Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class,
|
|||||||
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
|
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
|
||||||
|
|
||||||
### Enterprise-ready
|
### Enterprise-ready
|
||||||
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema. One JAR to ship. H2 for development; the public Docker stack defaults to PostgreSQL 16, the MySQL profile remains supported, and the Kingbase driver is opt-in.
|
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI is becoming infrastructure
|
## AI is becoming infrastructure
|
||||||
|
|
||||||
Model providers rate-limit, networks fail, keys expire, and services become temporarily unavailable. Betting every AI capability on one provider turns an upstream incident into your own outage.
|
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
|
||||||
|
|
||||||
Once AI enters production, the stable layer should not be tied to one model supplier or one Agent loop. MateClaw absorbs model uncertainty through provider priorities, health tracking, cooldown, and failover, then places native and external execution engines behind one governed Agent Runtime contract.
|
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
|
||||||
|
|
||||||
**MateClaw is that layer — built the Spring Boot way.**
|
**MateClaw is that layer — built the Spring Boot way.**
|
||||||
|
|
||||||
@ -152,7 +138,7 @@ Once AI enters production, the stable layer should not be tied to one model supp
|
|||||||
|
|
||||||
**OpenClaw and Hermes Agent are excellent personal AI platforms** — pick either if you're running one user on one laptop, building your own agent from CLI, and treating everything as config files to hand-tune. Both have bigger communities than MateClaw today.
|
**OpenClaw and Hermes Agent are excellent personal AI platforms** — pick either if you're running one user on one laptop, building your own agent from CLI, and treating everything as config files to hand-tune. Both have bigger communities than MateClaw today.
|
||||||
|
|
||||||
**MateClaw is the version built for teams.** Digital employees, models, and tools sit behind permissions and workspace boundaries. Approval flows can pause risky actions for review, and key operations enter the audit trail. The Admin Runtime Console centralizes active employee and provider state with force-recycle for stuck runs. Spring Boot inside — a natural fit for Java shops already running production services.
|
**MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
|
||||||
|
|
||||||
Same "whole widget" philosophy. Different center of gravity.
|
Same "whole widget" philosophy. Different center of gravity.
|
||||||
|
|
||||||
@ -167,7 +153,7 @@ mvn spring-boot:run # http://localhost:18088
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
cd mateclaw-ui
|
cd mateclaw-ui
|
||||||
npm install && npm run dev # http://localhost:5173
|
pnpm install && pnpm dev # http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Login: `admin` / `admin123`
|
Login: `admin` / `admin123`
|
||||||
@ -204,14 +190,11 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
|
|||||||
|
|
||||||
```
|
```
|
||||||
mateclaw/
|
mateclaw/
|
||||||
├── mateclaw-server/ Spring Boot 3.5 backend (Agent Runtime contract, native StateGraph + DSH)
|
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
|
||||||
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
||||||
├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized)
|
|
||||||
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
||||||
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
||||||
├── mateclaw-plugin-sample/ Reference plugin implementation
|
├── mateclaw-plugin-sample/ Reference plugin implementation
|
||||||
├── mateclaw-plugin-mem0/ Optional Mem0 memory-provider plugin
|
|
||||||
├── mateclaw-plugin-search-sample/ Search Provider SPI example
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
@ -223,10 +206,10 @@ Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/
|
|||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||||
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
|
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution · Team task board (2.0.0+) |
|
||||||
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
|
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
|
||||||
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
|
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
|
||||||
| Database | H2 (dev) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
|
| Database | H2 (dev) · MySQL 8.0+ (prod) |
|
||||||
| Auth | Spring Security + JWT |
|
| Auth | Spring Security + JWT |
|
||||||
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||||
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
||||||
@ -240,27 +223,7 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
**v2.2.0 (shipped 2026-08-29)** — from one built-in reasoning loop to **a pluggable and recoverable Agent Runtime**:
|
**v2.0.0 (shipped 2026-07-26)** — from "one person who gets things done" to "a team that collaborates": **Agent Teams** become a standing roster around a shared task board:
|
||||||
|
|
||||||
- **Runtime contract** — provider registry, session factory, capability validation, normalized event stream, lifecycle, usage, and UI projection decouple employees from execution engines
|
|
||||||
- **DeepSeek Harness runtime** — managed installation and configuration, authenticated JSON-RPC process bridge, Cordis composition, cancellable streaming, isolated child environment, and host-governed tool dispatch
|
|
||||||
- **Durable long work** — bounded Goal segments, persisted continuation and input queues, attempts, cooldown, retry, leases, restart recovery, and explicit pause / resume semantics
|
|
||||||
- **Agent interoperability** — inbound and outbound A2A with Agent Cards, JSON-RPC / SSE tasks, authentication, idempotency, and guarded network boundaries
|
|
||||||
- **Runtime hardening** — tighter workspace ownership, reliable Team Run recovery and deliverable gates, plus consistent long-form output and input handling across approval, stop, and recovery
|
|
||||||
|
|
||||||
Full story in the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
|
|
||||||
|
|
||||||
**v2.1.0 (shipped 2026-08-15)** — from “a board full of tasks” to **one governable team run**:
|
|
||||||
|
|
||||||
- **Unified Team Runs** — one `runId` links request, task DAG, worker conversations, events, final synthesis, and deliverables; Chat delivers outcomes, Agents observes live work, Teams governs history
|
|
||||||
- **Closed skill evolution** — reflection + recurring-request mining + promotion + constrained auto-binding + curator governance + snapshots/restore, conservative by default and isolated per workspace
|
|
||||||
- **Replayable execution** — live `<think>` extraction, every reasoning iteration in emission order with real duration, superseded narration, and linear trajectory export
|
|
||||||
- **Capabilities reach operations** — proactive IM push, targeted Cron delivery, model-specific context windows, progressive tool disclosure, and tool-backed action completion
|
|
||||||
- **Reliability pass** — hardened browser refs/navigation/waits, WebChat/SSE cleanup and upstream idle timeout, Feishu progress, Qwen3-ASR HTTP, batch session deletion, date-partitioned files, and safe 64-bit ids
|
|
||||||
|
|
||||||
Full story in the [v2.1.0 release notes](https://claw.mate.vip/docs/en/releases/2.1.0).
|
|
||||||
|
|
||||||
**v2.0.0 (shipped 2026-07-31)** — from "one person who gets things done" to "a team that collaborates": **Agent Teams** become a standing roster around a shared task board:
|
|
||||||
|
|
||||||
- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation
|
- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation
|
||||||
- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale
|
- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale
|
||||||
@ -311,7 +274,7 @@ Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/
|
|||||||
git clone https://github.com/mateaix/mateclaw.git
|
git clone https://github.com/mateaix/mateclaw.git
|
||||||
cd mateclaw
|
cd mateclaw
|
||||||
cd mateclaw-server && mvn clean compile
|
cd mateclaw-server && mvn clean compile
|
||||||
cd ../mateclaw-ui && npm install && npm run dev
|
cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
81
README_zh.md
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<p align="center"><b>你的超级大脑</b></p>
|
<p align="center"><b>你的超级大脑</b></p>
|
||||||
|
|
||||||
<p align="center"><sub><b>可插拔 Agent Runtime · Native + DSH · Spring Boot 内核</b></sub></p>
|
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
|
||||||
|
|
||||||
[](https://github.com/mateaix/mateclaw)
|
[](https://github.com/mateaix/mateclaw)
|
||||||
[](https://claw.mate.vip/docs)
|
[](https://claw.mate.vip/docs)
|
||||||
@ -30,19 +30,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **最新稳定版: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)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
||||||
>
|
>
|
||||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
|
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
|
||||||
>
|
>
|
||||||
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run,也可以通过认证 JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
|
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
|
||||||
|
|
||||||
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
||||||
|
|
||||||
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型不可用时,系统会按优先级改由下一家健康供应商重新完成当前请求。
|
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -52,7 +48,7 @@
|
|||||||
|
|
||||||
Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
||||||
|
|
||||||
别的工具丢你一张红色错误卡。MateClaw 会按配置顺序尝试下一家健康供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等内置或 OpenAI 兼容供应商——尽可能恢复当前请求;仅当可用链路全部失败时才返回错误。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
|
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX,共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
|
||||||
|
|
||||||
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
|
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
|
||||||
|
|
||||||
@ -60,7 +56,7 @@ Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
|||||||
|
|
||||||
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
|
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
|
||||||
|
|
||||||
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,生成内容保留可追踪引用。点开引用抽屉,就能看到对应的原始 chunk;页面与回答中的引用可以回到来源核对。
|
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题,得到的页面是从对应片段拼出来的——带可核对的出处。
|
||||||
|
|
||||||
这是**仓库**和**图书馆**的区别。
|
这是**仓库**和**图书馆**的区别。
|
||||||
|
|
||||||
@ -83,20 +79,10 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
|
|||||||
## 盒子里有什么
|
## 盒子里有什么
|
||||||
|
|
||||||
### 数字员工,不是聊天机器人
|
### 数字员工,不是聊天机器人
|
||||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
|
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
|
||||||
|
|
||||||
### Agent Runtime:Native 或 DSH(2.2.0+)
|
### 团队协作(2.0.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)
|
一个 Lead 带一群员工,围着一块**共享任务板**干活。你对 Lead 说一句目标,它拆成任务上板(`blockedBy` 声明依赖);派发引擎把任务并行分给成员,前置结果自动传给下游,完成结果自动通报回 Lead 汇总。执行租约 + 心跳杜绝双重执行,**取消即中断**正在跑的成员会话,敏感任务停在 `in_review` 等人批。交付物(docx / pptx / xlsx / pdf)登记到任务可下载,任务时间线记录全程,还能跳进任意成员的子会话看它逐字执行。Plan-Execute 型 Lead 的计划**整体移交任务板**——会规划的 Lead,规划能力就是编排能力。
|
||||||
|
|
||||||
### 持久长任务:检查点、重启、继续(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 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
|
|
||||||
|
|
||||||
### 知识与记忆
|
### 知识与记忆
|
||||||
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**(1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
|
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**(1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
|
||||||
@ -104,7 +90,7 @@ Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段
|
|||||||
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
|
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
|
||||||
|
|
||||||
### 技能 · MCP · ACP — 三种"接外部能力"的方式
|
### 技能 · MCP · ACP — 三种"接外部能力"的方式
|
||||||
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md**。2.1 可通过对话反思与跨会话重复请求挖掘形成可复用改进,并以候选晋升、受约束自动绑定、curator 治理、来源策略、快照和恢复点保证过程可观察、按工作空间隔离且可回滚;所有自动能力均由独立开关控制。另有 8 个起步模板、5 步创作向导和安装前 **Pre-flight 检查**
|
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
|
||||||
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**(1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
|
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**(1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
|
||||||
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
|
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
|
||||||
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
|
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
|
||||||
@ -115,24 +101,24 @@ Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段
|
|||||||
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合,reverse-citation 绑定到源 chunk,JSON 输出 + 可选 JSON Schema,每个模板独立选模型
|
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合,reverse-citation 绑定到源 chunk,JSON 输出 + 可选 JSON Schema,每个模板独立选模型
|
||||||
|
|
||||||
### 你看得见每位员工正在干什么
|
### 你看得见每位员工正在干什么
|
||||||
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、当前回合由哪个 runtime provider 承载、跑到哪一步、占多少 token,卡住可一键回收。Native 与 DSH 事件进入同一套思考 / 工具 / 回答投影,完成、失败、用量和取消保持一致的生命周期语义。SSE 每事件 ID 支持安全重连,Team Run 将成员工作聚合到同一次运行下。
|
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示(思考 / 工具 / 回答),SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
|
||||||
|
|
||||||
### 多模态创作
|
### 多模态创作
|
||||||
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
||||||
|
|
||||||
### 内容工作室(1.8.0+)
|
### 内容工作室(1.8.0+)
|
||||||
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)**文章以内联样式 HTML 进入草稿箱,正文图自动上传到微信;**小红书**笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化围绕一个**可度量的 AI 痕迹评分**运行;每次交付都经过合规扫描,并记入按选题指纹去重的**内容日历**。
|
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)** 文章以内联样式 HTML 躺进你的草稿箱,正文图自动上传进微信;**小红书** 笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化对着一个**可度量的 AI 痕迹评分**跑;每次交付都被合规扫描并记进一个按选题指纹去重的**内容日历**。
|
||||||
|
|
||||||
### 企业就绪
|
### 企业就绪
|
||||||
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**避免多实例重复执行。完整审计事件流。Flyway 管理数据库 schema。一个 JAR 交付。开发环境可用 H2;公开 Docker 栈默认使用 PostgreSQL 16,同时保留 MySQL profile,Kingbase 驱动为按需启用。
|
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema,升级时自愈。一个 JAR 交付。生产用 MySQL,开发用 H2,代码零改动。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI 正在变成基础设施
|
## AI 正在变成基础设施
|
||||||
|
|
||||||
模型供应商会限流,网络会抖动,Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障。
|
2026 年 3 月 2 日,Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次,**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
|
||||||
|
|
||||||
当 AI 进入生产环境,稳定的一层既不应绑定一家模型供应商,也不应绑定一套 Agent loop。MateClaw 用供应商优先级、健康追踪、冷却与故障转移吸收模型侧不确定性,再把 Native 与外部执行引擎收进同一份受治理的 Agent Runtime contract。
|
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
|
||||||
|
|
||||||
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
||||||
|
|
||||||
@ -152,7 +138,7 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**We
|
|||||||
|
|
||||||
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优,选它们没问题。两家的社区规模今天都大于 MateClaw。
|
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优,选它们没问题。两家的社区规模今天都大于 MateClaw。
|
||||||
|
|
||||||
**MateClaw 是那个给团队用的版本。** 数字员工、模型与工具都纳入权限和工作空间边界。危险动作可暂停等待审批,关键操作进入审计事件流。Admin 运行时控制台集中展示正在执行的员工与供应商状态,卡住时可回收。底座是 Spring Boot,适合并入已有 Java 服务体系。
|
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
|
||||||
|
|
||||||
**同一套"完整一整套"哲学,不同的重心。**
|
**同一套"完整一整套"哲学,不同的重心。**
|
||||||
|
|
||||||
@ -167,7 +153,7 @@ mvn spring-boot:run # http://localhost:18088
|
|||||||
|
|
||||||
# 前端
|
# 前端
|
||||||
cd mateclaw-ui
|
cd mateclaw-ui
|
||||||
npm install && npm run dev # http://localhost:5173
|
pnpm install && pnpm dev # http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
默认登录:`admin` / `admin123`
|
默认登录:`admin` / `admin123`
|
||||||
@ -204,14 +190,11 @@ docker compose up -d # http://localhost:18080
|
|||||||
|
|
||||||
```
|
```
|
||||||
mateclaw/
|
mateclaw/
|
||||||
├── mateclaw-server/ Spring Boot 3.5 后端(Agent Runtime contract · Native StateGraph + DSH)
|
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时)
|
||||||
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
||||||
├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
|
|
||||||
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
||||||
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
||||||
├── mateclaw-plugin-sample/ 参考插件实现
|
├── mateclaw-plugin-sample/ 参考插件实现
|
||||||
├── mateclaw-plugin-mem0/ 可选 Mem0 记忆 Provider 插件
|
|
||||||
├── mateclaw-plugin-search-sample/ 搜索 Provider SPI 示例
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
@ -223,10 +206,10 @@ mateclaw/
|
|||||||
| 层次 | 技术 |
|
| 层次 | 技术 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
||||||
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph(ReAct + Plan-Execute)· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
|
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 · 团队任务板(2.0.0+)|
|
||||||
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
|
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
|
||||||
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
||||||
| 数据库 | H2(开发)· PostgreSQL 16(Docker 默认)· MySQL 8.0+(支持)· Kingbase(按需驱动)|
|
| 数据库 | H2(开发)· MySQL 8.0+(生产)|
|
||||||
| 认证 | Spring Security + JWT |
|
| 认证 | Spring Security + JWT |
|
||||||
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
||||||
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
||||||
@ -240,27 +223,7 @@ mateclaw/
|
|||||||
|
|
||||||
## 路线图
|
## 路线图
|
||||||
|
|
||||||
**v2.2.0(2026-08-29 发布)** —— 从一套内置推理循环走向**可插拔、可恢复的 Agent Runtime**:
|
**v2.0.0(2026-07-26 发布)** — 从"一个能干活的人"到"一支能协作的队伍":**Agent 团队**成为常设编制,围着一块共享任务板干活:
|
||||||
|
|
||||||
- **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 管理历史与治理
|
|
||||||
- **Skill 自进化闭环** —— 对话反思、重复请求挖掘、候选晋升、受约束自动绑定、curator 治理、快照与恢复;默认保守、显式控制并按工作空间隔离
|
|
||||||
- **可回放执行** —— 实时提取内联 `<think>`,每轮推理按发生顺序展示实际耗时,保留被后续工具调用替代的阶段旁白,并可导出线性 trajectory
|
|
||||||
- **能力进入日常运营** —— 主动 IM 推送、Cron 定向投递、模型级上下文窗口、渐进式工具披露,以及基于实际工具调用结果的行动完成检查
|
|
||||||
- **可靠性加固** —— 浏览器 ref / 导航 / 等待、WebChat 与 SSE 清理及上游空闲超时、飞书进度、Qwen3-ASR HTTP、会话批量删除、文件按日分区和 64 位 ID 精度保护
|
|
||||||
|
|
||||||
完整内容见 [v2.1.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.1.0)。
|
|
||||||
|
|
||||||
**v2.0.0(2026-07-31 发布)** —— 从“一个能干活的人”到“一支能协作的队伍”:**Agent 团队**成为常设编制,围绕共享任务板工作:
|
|
||||||
|
|
||||||
- **Agent 团队与共享任务板** — 团队 / 角色(lead · member · reviewer)、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead;Teams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
|
- **Agent 团队与共享任务板** — 团队 / 角色(lead · member · reviewer)、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead;Teams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
|
||||||
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
|
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
|
||||||
@ -311,7 +274,7 @@ mateclaw/
|
|||||||
git clone https://github.com/mateaix/mateclaw.git
|
git clone https://github.com/mateaix/mateclaw.git
|
||||||
cd mateclaw
|
cd mateclaw
|
||||||
cd mateclaw-server && mvn clean compile
|
cd mateclaw-server && mvn clean compile
|
||||||
cd ../mateclaw-ui && npm install && npm run dev
|
cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
159
UPGRADING.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Upgrading MateClaw
|
||||||
|
|
||||||
|
## 1.0.x → 1.1.0
|
||||||
|
|
||||||
|
**TL;DR** — Most users have nothing to do. Restart with 1.1.0, Flyway's built-in repair heals known checksum drift, Ollama auto-discovery rewrites the bad `:latest` defaults, and everything else self-converges. Docker Compose deployments need a one-time `.env` update.
|
||||||
|
|
||||||
|
See `docs/en/releases/1.1.0.md` for the feature changelog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For everyone
|
||||||
|
|
||||||
|
### ⚠️ What happens automatically (no action)
|
||||||
|
|
||||||
|
- **Flyway migration self-heal** — 1.1.0 rewrote all MySQL migrations V2–V14 to replace unsupported `ADD COLUMN IF NOT EXISTS` syntax (Gitee #IIYHLJ). `FlywayRepairConfig` runs `flyway.repair()` on every boot, so the new checksums auto-accept and migration resumes from wherever your schema is.
|
||||||
|
- **Ollama default model** — if your 1.0.x run auto-picked a model tag Ollama no longer has (commonly `deepseek-r1:latest`), on 1.1.0 restart `OllamaAutoDiscoveryRunner` detects the broken default and re-picks a tag-capable model (e.g. `deepseek-r1:7b`, `qwen3:latest`), preferring one that supports function calling.
|
||||||
|
- **Stale `mate_model_config` rows** — idempotent seed data reconciles on each startup.
|
||||||
|
|
||||||
|
### 📋 Recommended pre-upgrade steps
|
||||||
|
|
||||||
|
1. Back up your database — `mateclaw` schema on MySQL, or `data/mateclaw.mv.db` on H2.
|
||||||
|
2. Back up `data/` directory (skill workspaces, uploaded files, memory files).
|
||||||
|
3. Note your current default model in Settings → Models in case you want to switch back.
|
||||||
|
|
||||||
|
### 🚀 Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
cd mateclaw-server
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
# then restart your service per your deployment method
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for Desktop app users: just update to 1.1.0 via the in-app updater or re-download.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Docker Compose deployments
|
||||||
|
|
||||||
|
**One-time migration step required** — 1.1.0 refuses to start with default hardcoded passwords.
|
||||||
|
|
||||||
|
### 1. Copy-paste merge the new `.env.example` keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env .env.backup
|
||||||
|
# open .env.example — it has new required keys:
|
||||||
|
# DB_PASSWORD= (was default 'mateclaw123', now MUST be overridden)
|
||||||
|
# DB_ROOT_PASSWORD= (new, required for MySQL root)
|
||||||
|
# JWT_SECRET= (new, strongly recommended)
|
||||||
|
# MATECLAW_CORS_ALLOWED_ORIGINS= (new, strongly recommended for prod)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set strong values in your `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
# STRONG passwords — at least 16 chars, mixed case + digits + symbols
|
||||||
|
DB_PASSWORD=<your-strong-db-user-password>
|
||||||
|
DB_ROOT_PASSWORD=<different-strong-root-password>
|
||||||
|
|
||||||
|
# 32+ char random string — generate with: openssl rand -base64 48
|
||||||
|
JWT_SECRET=<your-jwt-secret>
|
||||||
|
|
||||||
|
# Production CORS allowlist — comma-separated, no wildcards
|
||||||
|
MATECLAW_CORS_ALLOWED_ORIGINS=https://mateclaw.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
If any of `DB_PASSWORD` / `DB_ROOT_PASSWORD` / `DASHSCOPE_API_KEY` is missing, `docker compose up` will fail fast with a clear error — this is intentional.
|
||||||
|
|
||||||
|
### 3. Existing MySQL volume compatibility
|
||||||
|
|
||||||
|
If you already ran 1.0.x with the old default password (`mateclaw123`), **your existing MySQL volume still has the old root password inside**. You have two options:
|
||||||
|
|
||||||
|
**Option A — keep existing password** (fastest, least secure):
|
||||||
|
Set `DB_ROOT_PASSWORD=mateclaw123` and `DB_PASSWORD=mateclaw123` in `.env` to match. Upgrade works. Then rotate after upgrade using `ALTER USER ... IDENTIFIED BY ...` inside the MySQL container.
|
||||||
|
|
||||||
|
**Option B — fresh volume with new password** (cleanest, loses DB if not backed up):
|
||||||
|
```bash
|
||||||
|
docker compose down -v # ⚠️ deletes mysql_data volume; back up first
|
||||||
|
# edit .env with new strong password
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
Then re-import your backup if you kept one.
|
||||||
|
|
||||||
|
### 4. Restart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f mateclaw-server # watch for "Flyway Successfully applied N migrations"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected log lines during boot:
|
||||||
|
- `Flyway Successfully applied N migrations to schema mateclaw`
|
||||||
|
- `Ollama: auto-activated default model '<actual-tag>'` (if you use Ollama — should NOT say `:latest` any more)
|
||||||
|
- `[Security] Using default JWT secret!` → means you forgot to set `JWT_SECRET` — fix and restart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For local dev / H2 deployments
|
||||||
|
|
||||||
|
No action required. `mvn spring-boot:run` picks up the latest migrations on next start, Flyway repair handles checksum drift, H2 file at `data/mateclaw.mv.db` is preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known migration quirks
|
||||||
|
|
||||||
|
### 1. If you manually fiddled with `flyway_schema_history`
|
||||||
|
|
||||||
|
In 1.0.x some users hit Flyway version collisions (V8/V9 and V9/V10) which 1.1.0 fixes by renumbering. If you manually deleted rows from `flyway_schema_history` you may see `Validate failed` on 1.1.0 startup — run:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- MySQL
|
||||||
|
DELETE FROM flyway_schema_history WHERE success = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart. `FlywayRepairConfig` will rebuild history from current schema state.
|
||||||
|
|
||||||
|
### 2. If your Ollama models are all in the no-tools family
|
||||||
|
|
||||||
|
After upgrade, agents that require tool calling will log a warning on first invocation:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ollama: auto-activated default model '...' but its family does not support tool calling
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix — pull a tool-capable model, or switch default in Settings → Models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama pull qwen3
|
||||||
|
# or
|
||||||
|
ollama pull llama3.1:8b
|
||||||
|
# or
|
||||||
|
ollama pull mistral-nemo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. If you had custom tools using `extract_document_text` / wiki tools
|
||||||
|
|
||||||
|
Wiki chunk schema changed (new `embedding` + `embedding_model` columns on `mate_wiki_chunk`). Your existing wiki pages work unchanged; only semantic search is new and requires an embedding model to be configured in Settings → Models (a default DashScope embedding is seeded).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rolling back to 1.0.x
|
||||||
|
|
||||||
|
Not recommended (some new tables / columns don't exist in 1.0.x), but possible if you backed up the DB before upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout v1.0.418
|
||||||
|
# restore DB backup
|
||||||
|
docker compose up -d # or mvn spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to keep the new data but downgrade the app, you're in unsupported territory — open a Gitee issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting help
|
||||||
|
|
||||||
|
- **Logs first**: `mateclaw-server/logs/mateclaw.log` + `mateclaw-error.log` have everything. Flyway decisions are at INFO level in main log.
|
||||||
|
- **Doctor tab**: in-app Settings → Doctor runs basic health checks
|
||||||
|
- **Gitee**: https://gitee.com/matevip_admin/mateclaw/issues — include your upgrade path (1.0.?? → 1.1.0), profile (H2 / MySQL), and the last 100 lines of startup log
|
||||||
@ -32,10 +32,9 @@
|
|||||||
<!-- ===== Center: Agent Core ===== -->
|
<!-- ===== Center: Agent Core ===== -->
|
||||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||||
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">Digital Employee</text>
|
<text x="480" y="268" text-anchor="middle" font-size="16" 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="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
|
||||||
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
|
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||||
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">One policy + event plane</text>
|
|
||||||
|
|
||||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.8 KiB |
@ -33,10 +33,9 @@
|
|||||||
<!-- ===== Center: Agent Core ===== -->
|
<!-- ===== Center: Agent Core ===== -->
|
||||||
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
|
||||||
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
||||||
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">数字员工</text>
|
<text x="480" y="268" text-anchor="middle" font-size="16" 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="288" text-anchor="middle" font-size="11" 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="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
|
||||||
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">同一策略与事件平面</text>
|
|
||||||
|
|
||||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
||||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.0 KiB |
@ -67,40 +67,40 @@
|
|||||||
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 2: Agent Runtime ===== -->
|
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · NORMALIZED EVENTS & GOVERNANCE</text>
|
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
|
||||||
|
|
||||||
<g transform="translate(56, 222)">
|
<g transform="translate(56, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Runtime Contract</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · Capability</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">Lifecycle · Usage · Projection</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(244, 222)">
|
<g transform="translate(244, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Native Runtime</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Team · Workflow · Trigger</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">StateGraph · ReAct</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">Plan-Execute · Goals · Teams</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step modes · 6 patterns</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(432, 222)">
|
<g transform="translate(432, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">DSH Runtime</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Managed JSON-RPC Process</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">DeepSeek Harness · Cordis</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(620, 222)">
|
<g transform="translate(620, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Host Governance</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Memory System</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</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">Approval · Credentials</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Consolidation + Dreaming</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(808, 222)">
|
<g transform="translate(808, 222)">
|
||||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool Plane</text>
|
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Skills · MCP</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">ACP · Built-in</text>
|
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 3: Core Services ===== -->
|
<!-- ===== 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>
|
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 2: Agent Runtime ===== -->
|
<!-- ===== Layer 2: Agent Engine ===== -->
|
||||||
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
||||||
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
|
||||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · 统一事件与治理</text>
|
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
|
||||||
|
|
||||||
<g transform="translate(56, 222)">
|
<g transform="translate(56, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Runtime Contract</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · 能力</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">生命周期 · 用量 · 投影</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(244, 222)">
|
<g transform="translate(244, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Native Runtime</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">StateGraph · ReAct</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">Plan-Execute · Goal · Team</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">7 step mode · 6 pattern</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(432, 222)">
|
<g transform="translate(432, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">DSH Runtime</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">受管理 JSON-RPC 进程</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">DeepSeek Harness · Cordis</text>
|
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(620, 222)">
|
<g transform="translate(620, 222)">
|
||||||
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">宿主治理</text>
|
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</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="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
|
||||||
</g>
|
</g>
|
||||||
<g transform="translate(808, 222)">
|
<g transform="translate(808, 222)">
|
||||||
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
|
||||||
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具平面</text>
|
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
|
||||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">技能 · MCP</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">ACP · 内置工具</text>
|
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器(1.3.0)</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== Layer 3: Core Services ===== -->
|
<!-- ===== Layer 3: Core Services ===== -->
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@ -1,117 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- 修复:知识库原始材料重复入库
|
|
||||||
-- 适用:MySQL 8.0+(使用 JSON 函数处理 source_raw_ids)
|
|
||||||
-- 说明:同一 (kb_id, source_path) 可能因文件内容变更
|
|
||||||
-- 被多次 INSERT 而形成多行。本脚本保留最新行,
|
|
||||||
-- 并级联清理其关联的 chunk、citation、page。
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
-- STEP 0:预览(只读,不改数据,先跑这一步确认影响范围)
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- 0-A:查看所有重复组(按 kb_id + source_path 分组,count > 1)
|
|
||||||
SELECT
|
|
||||||
kb_id,
|
|
||||||
source_path,
|
|
||||||
COUNT(*) AS duplicate_count,
|
|
||||||
MAX(id) AS keep_id,
|
|
||||||
GROUP_CONCAT(id ORDER BY id DESC) AS all_ids
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
HAVING COUNT(*) > 1;
|
|
||||||
|
|
||||||
-- 0-B:查看待删除的具体行(排除每组最新的那一行)
|
|
||||||
SELECT
|
|
||||||
r.id, r.kb_id, r.source_path,
|
|
||||||
r.content_hash, r.processing_status, r.create_time
|
|
||||||
FROM mate_wiki_raw_material r
|
|
||||||
WHERE r.source_path IS NOT NULL
|
|
||||||
AND r.id NOT IN (
|
|
||||||
SELECT MAX(id)
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
)
|
|
||||||
ORDER BY r.kb_id, r.source_path, r.id;
|
|
||||||
|
|
||||||
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
-- STEP 1:开事务,执行清理(确认 STEP 0 结果后再运行)
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
START TRANSACTION;
|
|
||||||
|
|
||||||
-- 1-A:把待删除的 raw id 暂存到临时表,后续步骤复用
|
|
||||||
CREATE TEMPORARY TABLE IF NOT EXISTS _stale_raw_ids AS
|
|
||||||
SELECT id AS raw_id, kb_id
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
AND id NOT IN (
|
|
||||||
SELECT MAX(id)
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1-B:删除这些 raw 产生的 citation(通过 chunk_id 关联)
|
|
||||||
DELETE c
|
|
||||||
FROM mate_wiki_page_citation c
|
|
||||||
INNER JOIN mate_wiki_chunk ch ON c.chunk_id = ch.id
|
|
||||||
INNER JOIN _stale_raw_ids s ON ch.raw_id = s.raw_id;
|
|
||||||
|
|
||||||
-- 1-C:删除 chunk
|
|
||||||
DELETE ch
|
|
||||||
FROM mate_wiki_chunk ch
|
|
||||||
INNER JOIN _stale_raw_ids s ON ch.raw_id = s.raw_id;
|
|
||||||
|
|
||||||
-- 1-D:删除仅由该 raw 派生的 page(source_raw_ids 数组长度为 1)
|
|
||||||
-- 使用 JSON_CONTAINS 判断 page 是否引用了待删 raw
|
|
||||||
DELETE p
|
|
||||||
FROM mate_wiki_page p
|
|
||||||
WHERE JSON_LENGTH(p.source_raw_ids) = 1
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM _stale_raw_ids s
|
|
||||||
WHERE JSON_CONTAINS(p.source_raw_ids, CAST(s.raw_id AS CHAR))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1-E:对多来源 page,将待删 raw 从 source_raw_ids 中移除
|
|
||||||
-- 通过 JSON_TABLE 把数组展开再重组,排除掉 stale raw id
|
|
||||||
UPDATE mate_wiki_page p
|
|
||||||
SET p.source_raw_ids = (
|
|
||||||
SELECT JSON_ARRAYAGG(jt.v)
|
|
||||||
FROM JSON_TABLE(p.source_raw_ids, '$[*]' COLUMNS (v BIGINT PATH '$')) jt
|
|
||||||
WHERE jt.v NOT IN (SELECT raw_id FROM _stale_raw_ids)
|
|
||||||
)
|
|
||||||
WHERE JSON_LENGTH(p.source_raw_ids) > 1
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM _stale_raw_ids s
|
|
||||||
WHERE JSON_CONTAINS(p.source_raw_ids, CAST(s.raw_id AS CHAR))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1-F:删除 stale raw 行
|
|
||||||
DELETE r
|
|
||||||
FROM mate_wiki_raw_material r
|
|
||||||
INNER JOIN _stale_raw_ids s ON r.id = s.raw_id;
|
|
||||||
|
|
||||||
-- 1-G:确认结果
|
|
||||||
SELECT
|
|
||||||
'stale raws deleted' AS action,
|
|
||||||
ROW_COUNT() AS affected_rows;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'remaining duplicates' AS check_item,
|
|
||||||
COUNT(*) AS count
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
HAVING COUNT(*) > 1;
|
|
||||||
|
|
||||||
-- 确认无误后提交;如有问题改为 ROLLBACK
|
|
||||||
COMMIT;
|
|
||||||
-- ROLLBACK;
|
|
||||||
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS _stale_raw_ids;
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
# 插件化搜索 Provider + 搜索设置页重构 设计文档
|
|
||||||
|
|
||||||
日期:2026-07-03
|
|
||||||
状态:待评审
|
|
||||||
相关:`vip.mate.tool.search`(现有搜索 provider 链)、`mateclaw-plugin-api`(插件 SDK)、`/settings/system` 搜索设置区块
|
|
||||||
|
|
||||||
## 1. 背景与问题
|
|
||||||
|
|
||||||
### 1.1 自定义搜索 provider 没有插件化路径
|
|
||||||
|
|
||||||
当前 `SearchProviderRegistry` 通过 Spring 构造器注入 `List<SearchProvider>` 收集 provider,只认同一 `ApplicationContext` 里的 bean。要新增一个搜索源,唯一办法是**在 `vip.mate.tool.search` 源码树里加 `@Component` 类并重新编译部署整个 server**。
|
|
||||||
|
|
||||||
而项目已有一套真正的运行时插件系统(`mateclaw-plugin-api` + `PluginManager`):独立 jar 丢进 `~/.mateclaw/plugins/` 或工作区 `plugins/`,`URLClassLoader` 隔离加载,支持运行时 enable/disable,配置走 manifest 声明的 schema(`mateclaw-plugin.json` 的 `config` 字段)+ `plugin` 表 `config_json` 持久化 + `PUT /api/v1/plugins/{name}/config` 接口。但 `PluginType` 只有 `TOOL / PROVIDER(LLM) / CHANNEL / MEMORY` 四类,**没有 SEARCH**,`PluginContext` 也没有对应注册方法。
|
|
||||||
|
|
||||||
LLM provider 已有"内置 `@Component` 链 + 插件注册表"双轨并存的先例(`ModelProviderService.pluginChatModels`),搜索 provider 缺的就是同构的第二轨。
|
|
||||||
|
|
||||||
### 1.2 搜索设置 UI 平铺、下拉菜单硬编码
|
|
||||||
|
|
||||||
`/settings/system` 的搜索区块把 4 个 provider 的开关/key/url 共 9 个配置项拍平在一个列表里;主 provider 下拉菜单是写死的两个 `<option>`(serper/tavily),`searxng`/`duckduckgo` 无法显式选中,只能靠后端自动探测兜底;管理员也无法看到"当前实际生效的是哪个 provider"。
|
|
||||||
|
|
||||||
### 1.3 插件配置表单缺失(前端)
|
|
||||||
|
|
||||||
后端 `PluginInfo` 已返回 `configSchema`(来自 manifest)和脱敏后的 `currentConfig`,`updateConfig()` 已有 schema 白名单 + required 校验,前端 `pluginApi.updateConfig` 客户端也已存在——但 `Plugins.vue` 没有任何配置编辑 UI,这条链路在前端是死代码。所有类型的插件目前都无法在界面上配置。
|
|
||||||
|
|
||||||
## 2. 目标 / 非目标
|
|
||||||
|
|
||||||
**目标**
|
|
||||||
1. 第三方以独立 jar 形式提供搜索 provider:实现 SDK 接口 + manifest 声明,丢进 plugins 目录即用,**mateclaw-server 源码零改动**。
|
|
||||||
2. 搜索设置页:主 provider 选择动态化(含插件 provider 与"自动选择")、按 provider 分组折叠、显示当前实际生效的 provider。
|
|
||||||
3. 补上 schema 驱动的插件配置表单(服务所有插件类型,不只 search)。
|
|
||||||
|
|
||||||
**非目标**
|
|
||||||
- 不改内置 4 个 provider 的配置存储方式(继续走 `SystemSettingsDTO` / `mate_system_setting`)。
|
|
||||||
- 不删除、不重命名 `GET/PUT /api/v1/settings` 现有字段(无破坏性改动)。
|
|
||||||
- 不做搜索结果聚合/多 provider 并发查询。
|
|
||||||
|
|
||||||
## 3. 设计
|
|
||||||
|
|
||||||
### 3.1 SDK 侧(`mateclaw-plugin-api`)
|
|
||||||
|
|
||||||
新增 `vip.mate.plugin.api.search` 包,接口**不依赖任何 server 类**(jar 隔离加载下的硬约束;对比核心 `SearchProvider` 依赖 `SystemSettingsDTO`,SDK 版必须自包含):
|
|
||||||
|
|
||||||
```java
|
|
||||||
public interface PluginSearchProvider {
|
|
||||||
String id(); // 全局唯一,如 "my-search"
|
|
||||||
String label(); // 显示名
|
|
||||||
default boolean requiresCredential() { return true; }
|
|
||||||
default int autoDetectOrder() { return 500; } // 默认排在内置 provider(50~400)之后
|
|
||||||
boolean isAvailable(); // 插件自查:如 context.getConfig 拿 key 判空
|
|
||||||
List<PluginSearchResult> search(PluginSearchQuery query);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record PluginSearchQuery(String query, String freshness, String language, Integer count) {}
|
|
||||||
public record PluginSearchResult(String title, String url, String snippet, String source, String date) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `PluginType` 增加 `SEARCH`。
|
|
||||||
- `PluginContext` 增加 `void registerSearchProvider(PluginSearchProvider provider);`。
|
|
||||||
(接口新增方法对已编译的存量插件无影响——它们不调用即可。)
|
|
||||||
- 插件的配置(API key 等)**不进搜索设置页**,走插件系统自己的机制:manifest `config` 声明 schema,运行时 `context.getConfig(key, type)` 读取。职责天然分离:搜索设置页只管"选谁",插件页管"配它"。
|
|
||||||
|
|
||||||
### 3.2 Server 桥接侧
|
|
||||||
|
|
||||||
**`bridge/PluginSearchBridge.java`**(模式照抄 `PluginChannelBridge`):把 `PluginSearchProvider` 适配成核心 `SearchProvider`:
|
|
||||||
- `search(SearchQuery, SystemSettingsDTO)` → 转调插件 `search(PluginSearchQuery)`,忽略 DTO;
|
|
||||||
- 结果转核心 `SearchResult`,`providerId` 填插件 provider id;
|
|
||||||
- `isAvailable(SystemSettingsDTO)` → 委托插件无参 `isAvailable()`;
|
|
||||||
- 插件抛出的异常原样上抛(`WebSearchService.tryProvider()` 已有 catch-and-fallback 语义)。
|
|
||||||
|
|
||||||
**`SearchProviderRegistry` 可变化**:从"构造时定死的 immutable list"改为两层合并视图:
|
|
||||||
- 基底:Spring 注入的内置 provider(不变);
|
|
||||||
- 插件区:`ConcurrentHashMap<String, SearchProvider>`,新增 `registerPluginProvider(SearchProvider)` / `unregisterPluginProvider(String id)`;
|
|
||||||
- `allSorted()` / `getById()` / `resolve()` 全部查合并视图,排序仍按 `autoDetectOrder`;
|
|
||||||
- **id 冲突拒绝注册**(插件 id 与内置或已注册插件 id 重复时抛 `PluginException`,不允许顶掉 serper 等内置项)。
|
|
||||||
|
|
||||||
**生命周期**(与现有四类完全对称):
|
|
||||||
- `PluginContextImpl.registerSearchProvider()` → 包 bridge 后调 registry 注册,记录到 `LoadedPlugin`;
|
|
||||||
- `disablePlugin()` 与加载失败 rollback 路径各加一个 `searchProviderRegistry.unregisterPluginProvider(...)`(best-effort,同现有风格);
|
|
||||||
- 插件被 disable 后,若它正是 `searchProvider` 显式指定项,`resolve()` 因 `getById()` 查不到而自动落入 auto-detect 分支——行为安全,无需额外处理。
|
|
||||||
|
|
||||||
### 3.3 动态 provider catalog 接口
|
|
||||||
|
|
||||||
`GET /api/v1/settings/search-providers`(`SystemSettingController`,`@RequireWorkspaceRole("admin")`),只读:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{ "id": "serper", "label": "Serper (Google)", "builtin": true, "requiresCredential": true, "available": false },
|
|
||||||
{ "id": "my-search","label": "My Search", "builtin": false, "requiresCredential": true, "available": true,
|
|
||||||
"pluginName": "my-search-plugin" }
|
|
||||||
],
|
|
||||||
"resolved": { "id": "my-search", "source": "configured" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 数据源:`SearchProviderRegistry.allSorted()`(合并视图,插件 provider 自动出现)+ `resolve(config)`(暴露"当前实际生效"与原因:`configured` / `auto-detect` / `keyless-fallback`)。
|
|
||||||
- `pluginName` 供前端渲染"去插件页配置"跳转。
|
|
||||||
- 不含任何敏感值。
|
|
||||||
|
|
||||||
### 3.4 搜索设置页重构(`views/Settings/System/index.vue`)
|
|
||||||
|
|
||||||
- **主 provider 选择**:选项从 catalog 接口动态渲染,新增首项"自动选择(推荐)"——对应 `searchProvider=""`(后端 `resolve()` 对空值本就走 auto-detect,无需引入 `"auto"` 特殊值)。下方常驻一行状态提示:`✓ 当前实际生效: Xxx(原因)`。
|
|
||||||
- **分组折叠卡片**:每个 provider 一张可折叠卡片,标题行 = 名称 + 徽标(已配置/未配置/生效中),默认只展开"当前生效"的那张。
|
|
||||||
- 内置 provider:卡片内是现有的 key/url 输入框(字段与保存逻辑不变,仍走 `PUT /api/v1/settings`);
|
|
||||||
- 插件 provider:卡片内不放表单,显示"该 Provider 由插件 {pluginName} 提供,请在插件页配置" + 跳转链接。
|
|
||||||
- 现有保存语义不变(API key 仅在用户输入新值时提交)。
|
|
||||||
|
|
||||||
### 3.5 插件配置表单(`views/Plugins.vue`,纯前端)
|
|
||||||
|
|
||||||
插件卡片增加"配置"入口(有 `configSchema` 时显示),弹出 schema 驱动的通用表单:
|
|
||||||
- 按 `configSchema` 渲染字段:`secret=true` → password 输入框(placeholder 显示脱敏值,留空表示不修改);其余按 `type` 渲染 text/number/boolean;`required` 标星并做前端必填校验(后端已有兜底校验);`description` 作为字段提示。
|
|
||||||
- 提交走已存在的 `pluginApi.updateConfig`;保存后刷新列表。
|
|
||||||
- 该表单对所有 `PluginType` 通用,非 search 专属。
|
|
||||||
- 注意:manifest `ConfigField.type` 是自由字符串,前端对未知 type 一律降级为 text 输入。
|
|
||||||
|
|
||||||
### 3.6 参考实现(`mateclaw-plugin-sample`)
|
|
||||||
|
|
||||||
sample 模块增加一个最小 `PluginSearchProvider` 实现(如包装一个可配 baseUrl+apiKey 的通用 HTTP 搜索 API),manifest 声明 `type: "search"` + config schema——同时充当文档示例与集成测试素材。
|
|
||||||
|
|
||||||
## 4. 交付拆分(遵循上游单一关注点规范)
|
|
||||||
|
|
||||||
- **上游 issue 先行**:动手前在 mateaix/mateclaw 提 issue 说明设计(本文档摘要),获认可后实施。
|
|
||||||
- **PR-1(后端 + SDK)**:`PluginType.SEARCH` + SDK 接口/record + `PluginSearchBridge` + registry 可变化 + `PluginContextImpl`/`PluginManager` 生命周期 + sample 参考实现 + 单测。
|
|
||||||
- **PR-2(接口 + 前端)**:catalog 接口 + 搜索设置页分组折叠重构 + Plugins.vue schema 配置表单。PR-2 不依赖 PR-1 合并(catalog 对纯内置 provider 同样成立),但先后合并时插件 provider 自动出现在下拉中。
|
|
||||||
|
|
||||||
## 5. 测试
|
|
||||||
|
|
||||||
**PR-1**
|
|
||||||
- registry:注册/反注册/合并排序/`resolve()` 三分支含插件项/id 冲突拒绝。
|
|
||||||
- bridge:`SearchQuery`↔`PluginSearchQuery`、`SearchResult` 转换、异常透传。
|
|
||||||
- 生命周期:disable 后 registry 查不到该 id;显式指定的插件 provider 被 disable 后 resolve 落回 auto-detect。
|
|
||||||
- sample 插件 jar 端到端:打包 → 放插件目录 → 启动加载 → `getAllToolCallbacks` 路径外单独验证 `web_search` 走插件 provider。
|
|
||||||
|
|
||||||
**PR-2**
|
|
||||||
- catalog 接口:内置/插件混合列表、resolved 三种 source、无敏感值泄露。
|
|
||||||
- 前端:下拉动态渲染、"自动选择"存空串、折叠展开状态、secret 字段留空不覆盖。
|
|
||||||
|
|
||||||
## 6. 兼容性与风险
|
|
||||||
|
|
||||||
- 存量插件:`PluginType` 加枚举值 + `PluginContext` 加方法,均为增量,不影响已编译插件。
|
|
||||||
- `GET/PUT /api/v1/settings` 字段不动,旧前端/脚本不受影响。
|
|
||||||
- `SearchProviderRegistry` 由不可变转可变:并发读多写少,`ConcurrentHashMap` + 每次读时合并排序(provider 总数 <10,无性能顾虑)。
|
|
||||||
- 插件 provider 质量不可控:`WebSearchService` 现有 15s 超时属于各 provider 自身实现,插件侧超时由插件自负;catch-and-fallback 链保证坏插件不拖垮搜索功能(最多浪费一次尝试)。
|
|
||||||
- 安全:插件 jar 本身即任意代码执行(现有插件系统的既定信任模型,本设计不扩大攻击面);catalog 接口仅 admin 可见。
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mateclaw-desktop",
|
"name": "mateclaw-desktop",
|
||||||
"version": "2.3.0-SNAPSHOT",
|
"version": "2.0.0",
|
||||||
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
|
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
|
||||||
"author": "MateClaw Team",
|
"author": "MateClaw Team",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
public interface PluginMemoryProvider extends AutoCloseable {
|
public interface PluginMemoryProvider {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
|
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
|
||||||
@ -114,9 +114,4 @@ public interface PluginMemoryProvider extends AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
default void onSessionEnd(Long agentId, String conversationId) {
|
default void onSessionEnd(Long agentId, String conversationId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release provider-owned resources when the plugin is unloaded. */
|
|
||||||
@Override
|
|
||||||
default void close() {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ package vip.mate.plugin.mem0;
|
|||||||
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
|
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
|
||||||
* @param maxResults cap on memories returned per recall
|
* @param maxResults cap on memories returned per recall
|
||||||
* @param timeoutMs HTTP timeout for both recall and sync
|
* @param timeoutMs HTTP timeout for both recall and sync
|
||||||
* @param syncQueueCapacity maximum number of turns waiting for asynchronous sync
|
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
record Mem0Config(
|
record Mem0Config(
|
||||||
@ -22,18 +21,10 @@ record Mem0Config(
|
|||||||
boolean searchEnabled,
|
boolean searchEnabled,
|
||||||
boolean syncEnabled,
|
boolean syncEnabled,
|
||||||
int maxResults,
|
int maxResults,
|
||||||
int timeoutMs,
|
int timeoutMs
|
||||||
int syncQueueCapacity
|
|
||||||
) {
|
) {
|
||||||
static final int DEFAULT_MAX_RESULTS = 5;
|
static final int DEFAULT_MAX_RESULTS = 5;
|
||||||
static final int DEFAULT_TIMEOUT_MS = 3000;
|
static final int DEFAULT_TIMEOUT_MS = 3000;
|
||||||
static final int DEFAULT_SYNC_QUEUE_CAPACITY = 256;
|
|
||||||
|
|
||||||
Mem0Config(String baseUrl, String apiKey, boolean searchEnabled, boolean syncEnabled,
|
|
||||||
int maxResults, int timeoutMs) {
|
|
||||||
this(baseUrl, apiKey, searchEnabled, syncEnabled, maxResults, timeoutMs,
|
|
||||||
DEFAULT_SYNC_QUEUE_CAPACITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this provider should participate at all.
|
* Whether this provider should participate at all.
|
||||||
|
|||||||
@ -3,8 +3,9 @@ package vip.mate.plugin.mem0;
|
|||||||
/**
|
/**
|
||||||
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
|
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
|
||||||
* <p>
|
* <p>
|
||||||
* Sync failures are caught by {@link Mem0Provider}; recall failures propagate
|
* Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade
|
||||||
* to the platform provider boundary for timeout/circuit-breaker accounting.
|
* gracefully (empty recall / dropped sync) without affecting the agent's
|
||||||
|
* response path.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -39,7 +39,6 @@ public class Mem0Plugin implements MateClawPlugin {
|
|||||||
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
|
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
|
||||||
private static final String CONFIG_MAX_RESULTS = "maxResults";
|
private static final String CONFIG_MAX_RESULTS = "maxResults";
|
||||||
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
|
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
|
||||||
private static final String CONFIG_SYNC_QUEUE_CAPACITY = "syncQueueCapacity";
|
|
||||||
|
|
||||||
private Logger log;
|
private Logger log;
|
||||||
|
|
||||||
@ -55,16 +54,11 @@ public class Mem0Plugin implements MateClawPlugin {
|
|||||||
|
|
||||||
Mem0Client client = new Mem0Client(config);
|
Mem0Client client = new Mem0Client(config);
|
||||||
Mem0Provider provider = new Mem0Provider(config, client, log);
|
Mem0Provider provider = new Mem0Provider(config, client, log);
|
||||||
try {
|
context.registerMemoryProvider(provider);
|
||||||
context.registerMemoryProvider(provider);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
provider.close();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}, syncQueueCapacity={}",
|
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}",
|
||||||
maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(),
|
maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(),
|
||||||
config.maxResults(), config.timeoutMs(), config.syncQueueCapacity());
|
config.maxResults(), config.timeoutMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -84,7 +78,6 @@ public class Mem0Plugin implements MateClawPlugin {
|
|||||||
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
|
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
|
||||||
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
|
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
|
||||||
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
|
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
|
||||||
Integer syncQueueCapacity = ctx.getConfig(CONFIG_SYNC_QUEUE_CAPACITY, Integer.class);
|
|
||||||
|
|
||||||
return new Mem0Config(
|
return new Mem0Config(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@ -92,9 +85,7 @@ public class Mem0Plugin implements MateClawPlugin {
|
|||||||
searchEnabled == null ? true : searchEnabled,
|
searchEnabled == null ? true : searchEnabled,
|
||||||
syncEnabled == null ? true : syncEnabled,
|
syncEnabled == null ? true : syncEnabled,
|
||||||
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
|
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
|
||||||
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs,
|
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs
|
||||||
syncQueueCapacity == null ? Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY
|
|
||||||
: Math.max(1, syncQueueCapacity)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,9 @@ import org.slf4j.Logger;
|
|||||||
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
|
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
|
||||||
@ -19,14 +17,13 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
* <li>{@code systemPromptBlock} — no-op (returns ""), aligns with SessionSearchProvider</li>
|
* <li>{@code systemPromptBlock} — no-op (returns ""), aligns with SessionSearchProvider</li>
|
||||||
* <li>{@code prefetch(agentId, query, ownerKey)} — when {@code searchEnabled}
|
* <li>{@code prefetch(agentId, query, ownerKey)} — when {@code searchEnabled}
|
||||||
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
|
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
|
||||||
* and returns a {@code [Mem0 Recall]} block. Failures propagate to the
|
* and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
|
||||||
* platform's timeout/circuit-breaker boundary.</li>
|
* or when disabled.</li>
|
||||||
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} — when
|
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} — when
|
||||||
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
|
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
|
||||||
* pushes the turn to {@code POST /memories/} under {@code user_id =
|
* pushes the turn to {@code POST /memories/} under {@code user_id =
|
||||||
* ownerKey}, the same identifier prefetch recalls by. Failures are
|
* ownerKey}, the same identifier prefetch recalls by. Failures are
|
||||||
* logged and swallowed; never blocks the response path. The bounded
|
* logged and swallowed; never blocks the response path. The four-arg
|
||||||
* queue drops new writes when saturated. The four-arg
|
|
||||||
* variant (no ownerKey) skips — writing under any other identifier
|
* variant (no ownerKey) skips — writing under any other identifier
|
||||||
* would produce memories that owner-scoped recall can never surface.</li>
|
* would produce memories that owner-scoped recall can never surface.</li>
|
||||||
* <li>{@code getToolBeans} — empty (no agent-facing tools in v1)</li>
|
* <li>{@code getToolBeans} — empty (no agent-facing tools in v1)</li>
|
||||||
@ -37,8 +34,8 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
* When {@code ownerKey} is null/blank, both recall and sync are skipped — Mem0
|
* When {@code ownerKey} is null/blank, both recall and sync are skipped — Mem0
|
||||||
* requires {@code user_id}.
|
* requires {@code user_id}.
|
||||||
*
|
*
|
||||||
* <p>Asynchronous sync: a single-thread daemon executor with a bounded queue
|
* <p>Asynchronous sync: a single-thread daemon executor is used
|
||||||
* prevents an unavailable Mem0 service from growing heap usage without limit.
|
* so that bursts of turns don't pile up on the platform's request thread.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -49,19 +46,21 @@ class Mem0Provider implements PluginMemoryProvider {
|
|||||||
private final Mem0Config config;
|
private final Mem0Config config;
|
||||||
private final Mem0Client client;
|
private final Mem0Client client;
|
||||||
private final Logger log;
|
private final Logger log;
|
||||||
private final ThreadPoolExecutor async;
|
private final Executor async;
|
||||||
private final AtomicLong droppedSyncCount = new AtomicLong();
|
|
||||||
|
|
||||||
Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
|
Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.log = log;
|
this.log = log;
|
||||||
this.async = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
|
// Single-thread executor is enough — syncTurn calls are sequential per
|
||||||
new ArrayBlockingQueue<>(Math.max(1, config.syncQueueCapacity())), r -> {
|
// agent and not latency-sensitive; the platform's request thread must
|
||||||
Thread t = new Thread(r, "mem0-sync");
|
// not be blocked. A bounded single-thread queue keeps memory footprint
|
||||||
t.setDaemon(true);
|
// predictable even under burst load.
|
||||||
return t;
|
this.async = Executors.newSingleThreadExecutor(r -> {
|
||||||
}, new ThreadPoolExecutor.AbortPolicy());
|
Thread t = new Thread(r, "mem0-sync");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -105,12 +104,20 @@ class Mem0Provider implements PluginMemoryProvider {
|
|||||||
if (userQuery == null || userQuery.isBlank()) {
|
if (userQuery == null || userQuery.isBlank()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
List<String> memories = client.searchMemories(
|
try {
|
||||||
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
|
List<String> memories = client.searchMemories(
|
||||||
if (memories.isEmpty()) {
|
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
|
||||||
|
if (memories.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return formatRecallBlock(memories);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fault isolation: log and return empty so the platform falls back
|
||||||
|
// to the other (local) providers without affecting the response.
|
||||||
|
log.warn("[Mem0] prefetch failed for agent={} owner={}: {}",
|
||||||
|
agentId, ownerKey, e.getMessage());
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return formatRecallBlock(memories);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -136,52 +143,15 @@ class Mem0Provider implements PluginMemoryProvider {
|
|||||||
&& (assistantReply == null || assistantReply.isBlank())) {
|
&& (assistantReply == null || assistantReply.isBlank())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
CompletableFuture.runAsync(() -> {
|
||||||
async.execute(() -> {
|
try {
|
||||||
try {
|
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
|
||||||
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
|
conversationId, userMessage, assistantReply);
|
||||||
conversationId, userMessage, assistantReply);
|
} catch (Exception e) {
|
||||||
} catch (Exception e) {
|
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
|
||||||
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
|
agentId, ownerKey, e.getMessage());
|
||||||
agentId, ownerKey, e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (RejectedExecutionException e) {
|
|
||||||
long dropped = droppedSyncCount.incrementAndGet();
|
|
||||||
log.warn("[Mem0] sync queue full or provider closed; dropped turn for agent={} owner={} (totalDropped={})",
|
|
||||||
agentId, ownerKey, dropped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int queuedSyncCount() {
|
|
||||||
return async.getQueue().size();
|
|
||||||
}
|
|
||||||
|
|
||||||
long droppedSyncCount() {
|
|
||||||
return droppedSyncCount.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isClosed() {
|
|
||||||
return async.isShutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
async.shutdown();
|
|
||||||
List<Runnable> dropped = List.of();
|
|
||||||
try {
|
|
||||||
long drainMs = Math.min(1000L, Math.max(100L, config.timeoutMs()));
|
|
||||||
if (!async.awaitTermination(drainMs, TimeUnit.MILLISECONDS)) {
|
|
||||||
dropped = async.shutdownNow();
|
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
}, async);
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
dropped = async.shutdownNow();
|
|
||||||
}
|
|
||||||
if (!dropped.isEmpty()) {
|
|
||||||
droppedSyncCount.addAndGet(dropped.size());
|
|
||||||
log.warn("[Mem0] provider closed with {} queued sync turn(s) discarded", dropped.size());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -43,12 +43,6 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"secret": false,
|
"secret": false,
|
||||||
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
|
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
|
||||||
},
|
|
||||||
"syncQueueCapacity": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": false,
|
|
||||||
"secret": false,
|
|
||||||
"description": "Maximum pending asynchronous sync turns. New writes are dropped when full. Default 256."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,10 +35,4 @@ class Mem0ConfigTest {
|
|||||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
||||||
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
|
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void legacyConstructorUsesBoundedQueueDefault() {
|
|
||||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
|
||||||
assertThat(c.syncQueueCapacity()).isEqualTo(Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,6 @@ class Mem0PluginTest {
|
|||||||
PluginContext ctx = new StubContext(config, registered) {
|
PluginContext ctx = new StubContext(config, registered) {
|
||||||
@Override
|
@Override
|
||||||
public void registerMemoryProvider(PluginMemoryProvider provider) {
|
public void registerMemoryProvider(PluginMemoryProvider provider) {
|
||||||
registered.set(provider);
|
|
||||||
throw new PluginException("Only one external memory provider allowed");
|
throw new PluginException("Only one external memory provider allowed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -99,7 +98,6 @@ class Mem0PluginTest {
|
|||||||
assertThatThrownBy(() -> plugin.onLoad(ctx))
|
assertThatThrownBy(() -> plugin.onLoad(ctx))
|
||||||
.isInstanceOf(PluginException.class)
|
.isInstanceOf(PluginException.class)
|
||||||
.hasMessageContaining("Only one");
|
.hasMessageContaining("Only one");
|
||||||
assertThat(((Mem0Provider) registered.get()).isClosed()).isTrue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -14,11 +14,8 @@ import java.net.InetSocketAddress;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class Mem0ProviderTest {
|
class Mem0ProviderTest {
|
||||||
|
|
||||||
@ -46,7 +43,6 @@ class Mem0ProviderTest {
|
|||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() {
|
void tearDown() {
|
||||||
if (provider != null) provider.close();
|
|
||||||
if (server != null) server.stop(0);
|
if (server != null) server.stop(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,15 +129,16 @@ class Mem0ProviderTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void threeArgPrefetch_propagatesServerErrorToPlatformCircuitBreaker() {
|
void threeArgPrefetch_returnsEmptyOnServerError() {
|
||||||
|
// Replace handler to fail; the provider should swallow and return "".
|
||||||
server.removeContext("/");
|
server.removeContext("/");
|
||||||
server.createContext("/", ex -> {
|
server.createContext("/", ex -> {
|
||||||
ex.sendResponseHeaders(500, 0);
|
ex.sendResponseHeaders(500, 0);
|
||||||
ex.close();
|
ex.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
assertThatThrownBy(() -> provider.prefetch(1L, "q", "user:42"))
|
String result = provider.prefetch(1L, "q", "user:42");
|
||||||
.isInstanceOf(Mem0Exception.class);
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -218,42 +215,5 @@ class Mem0ProviderTest {
|
|||||||
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
||||||
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
|
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
|
||||||
assertThat(searchCount.get()).isZero();
|
assertThat(searchCount.get()).isZero();
|
||||||
p.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncQueueIsBoundedAndCloseReleasesExecutor() throws Exception {
|
|
||||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
|
||||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
|
||||||
AtomicInteger writes = new AtomicInteger();
|
|
||||||
Mem0Config cfg = new Mem0Config("http://localhost:8080", null,
|
|
||||||
false, true, 3, 3000, 1);
|
|
||||||
Mem0Client blockingClient = new Mem0Client(cfg) {
|
|
||||||
@Override
|
|
||||||
void addMemories(String userId, String agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply) {
|
|
||||||
writes.incrementAndGet();
|
|
||||||
firstStarted.countDown();
|
|
||||||
try {
|
|
||||||
releaseFirst.await(2, TimeUnit.SECONDS);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Mem0Provider bounded = new Mem0Provider(cfg, blockingClient, LoggerFactory.getLogger("test"));
|
|
||||||
try {
|
|
||||||
bounded.syncTurn(1L, "one", "u", "a", "user:1");
|
|
||||||
assertThat(firstStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
|
||||||
bounded.syncTurn(1L, "two", "u", "a", "user:1");
|
|
||||||
bounded.syncTurn(1L, "three", "u", "a", "user:1");
|
|
||||||
|
|
||||||
assertThat(bounded.queuedSyncCount()).isEqualTo(1);
|
|
||||||
assertThat(bounded.droppedSyncCount()).isEqualTo(1);
|
|
||||||
} finally {
|
|
||||||
releaseFirst.countDown();
|
|
||||||
bounded.close();
|
|
||||||
}
|
|
||||||
assertThat(bounded.isClosed()).isTrue();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,7 @@ RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
|
|||||||
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
|
||||||
# tag with the matching driver, so mismatched versions cause the java driver to
|
# tag with the matching driver, so mismatched versions cause the java driver to
|
||||||
# re-download browsers at runtime (defeating the whole point of this image).
|
# re-download browsers at runtime (defeating the whole point of this image).
|
||||||
FROM mcr.microsoft.com/playwright:v1.62.0-noble
|
FROM mcr.microsoft.com/playwright:v1.59.0-noble
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
||||||
|
|||||||
@ -60,9 +60,6 @@ public class AcpEndpointEntity {
|
|||||||
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
|
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
|
||||||
private Long stdioBufferLimitBytes;
|
private Long stdioBufferLimitBytes;
|
||||||
|
|
||||||
/** Max wait for session/prompt, in seconds. Defaults to 300, capped at 3600. */
|
|
||||||
private Integer promptTimeoutSeconds;
|
|
||||||
|
|
||||||
/** UNKNOWN / OK / ERROR — last test result. */
|
/** UNKNOWN / OK / ERROR — last test result. */
|
||||||
private String lastStatus;
|
private String lastStatus;
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import vip.mate.acp.model.AcpEndpointEntity;
|
|||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -46,6 +47,11 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AcpDelegationService {
|
public class AcpDelegationService {
|
||||||
|
|
||||||
|
/** Hard ceiling on a single ACP delegation. Long enough for a
|
||||||
|
* multi-turn coding session, short enough that a hung agent can't
|
||||||
|
* permanently block an LLM tool call. */
|
||||||
|
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
|
||||||
|
|
||||||
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||||
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||||
|
|
||||||
@ -83,7 +89,6 @@ public class AcpDelegationService {
|
|||||||
List<String> args = endpointService.parseArgs(endpoint);
|
List<String> args = endpointService.parseArgs(endpoint);
|
||||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||||
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
|
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
|
||||||
long promptTimeoutMillis = resolvePromptTimeoutMillis(endpoint);
|
|
||||||
// Always resolve cwd to a real directory: Zed's ACP Zod schema
|
// Always resolve cwd to a real directory: Zed's ACP Zod schema
|
||||||
// marks cwd as a required string and rejects {@code undefined}
|
// marks cwd as a required string and rejects {@code undefined}
|
||||||
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
|
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
|
||||||
@ -119,7 +124,7 @@ public class AcpDelegationService {
|
|||||||
ObjectNode promptParams = objectMapper.createObjectNode();
|
ObjectNode promptParams = objectMapper.createObjectNode();
|
||||||
promptParams.put("sessionId", sessionId);
|
promptParams.put("sessionId", sessionId);
|
||||||
promptParams.set("prompt", buildPromptArray(userPrompt));
|
promptParams.set("prompt", buildPromptArray(userPrompt));
|
||||||
autoClose.sendRequest("session/prompt", promptParams, promptTimeoutMillis);
|
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
|
||||||
} catch (IOException | InterruptedException e) {
|
} catch (IOException | InterruptedException e) {
|
||||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||||
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
|
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
|
||||||
@ -139,12 +144,6 @@ public class AcpDelegationService {
|
|||||||
return accumulator.toString().trim();
|
return accumulator.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
static long resolvePromptTimeoutMillis(AcpEndpointEntity endpoint) {
|
|
||||||
int seconds = AcpEndpointService.normalizePromptTimeoutSeconds(
|
|
||||||
endpoint != null ? endpoint.getPromptTimeoutSeconds() : null);
|
|
||||||
return seconds * 1000L;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
|
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
|
||||||
boolean trusted, String endpointName) {
|
boolean trusted, String endpointName) {
|
||||||
// Notifications carry session/update messages; agent_message_chunk
|
// Notifications carry session/update messages; agent_message_chunk
|
||||||
|
|||||||
@ -36,9 +36,6 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AcpEndpointService {
|
public class AcpEndpointService {
|
||||||
|
|
||||||
public static final int DEFAULT_PROMPT_TIMEOUT_SECONDS = 300;
|
|
||||||
public static final int MAX_PROMPT_TIMEOUT_SECONDS = 3600;
|
|
||||||
|
|
||||||
private final AcpEndpointMapper mapper;
|
private final AcpEndpointMapper mapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
@ -94,7 +91,6 @@ public class AcpEndpointService {
|
|||||||
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
||||||
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
||||||
}
|
}
|
||||||
input.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(input.getPromptTimeoutSeconds()));
|
|
||||||
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
||||||
mapper.insert(input);
|
mapper.insert(input);
|
||||||
log.info("Created ACP endpoint: {}", input.getName());
|
log.info("Created ACP endpoint: {}", input.getName());
|
||||||
@ -122,9 +118,6 @@ public class AcpEndpointService {
|
|||||||
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
||||||
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
||||||
}
|
}
|
||||||
if (patch.getPromptTimeoutSeconds() != null) {
|
|
||||||
existing.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(patch.getPromptTimeoutSeconds()));
|
|
||||||
}
|
|
||||||
mapper.updateById(existing);
|
mapper.updateById(existing);
|
||||||
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
||||||
return existing;
|
return existing;
|
||||||
@ -187,13 +180,6 @@ public class AcpEndpointService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int normalizePromptTimeoutSeconds(Integer seconds) {
|
|
||||||
if (seconds == null || seconds <= 0) {
|
|
||||||
return DEFAULT_PROMPT_TIMEOUT_SECONDS;
|
|
||||||
}
|
|
||||||
return Math.min(seconds, MAX_PROMPT_TIMEOUT_SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> parseStringList(String json) {
|
private List<String> parseStringList(String json) {
|
||||||
if (json == null || json.isBlank()) return Collections.emptyList();
|
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -12,13 +12,11 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.client.ChatClient;
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
import org.springframework.retry.support.RetryTemplate;
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.graph.StateGraphReActAgent;
|
import vip.mate.agent.graph.StateGraphReActAgent;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||||
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
|
|
||||||
import vip.mate.agent.graph.edge.ObservationDispatcher;
|
import vip.mate.agent.graph.edge.ObservationDispatcher;
|
||||||
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
||||||
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
||||||
@ -33,13 +31,9 @@ import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
|||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
import vip.mate.agent.binding.service.AgentBindingService;
|
import vip.mate.agent.binding.service.AgentBindingService;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import vip.mate.config.GraphObservationProperties;
|
import vip.mate.config.GraphObservationProperties;
|
||||||
import vip.mate.config.ReasoningRetentionProperties;
|
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.llm.chatmodel.HttpTimeouts;
|
|
||||||
import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder;
|
import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder;
|
||||||
import vip.mate.llm.chatmodel.ProviderGenerateKwargs;
|
|
||||||
import vip.mate.llm.chatmodel.ReasoningEffortResolver;
|
import vip.mate.llm.chatmodel.ReasoningEffortResolver;
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.model.ModelFamily;
|
import vip.mate.llm.model.ModelFamily;
|
||||||
@ -104,13 +98,6 @@ public class AgentGraphBuilder {
|
|||||||
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
||||||
private boolean loadSkillToolEnabled;
|
private boolean loadSkillToolEnabled;
|
||||||
|
|
||||||
private ExecutionEvidenceRecorder executionEvidenceRecorder;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
|
|
||||||
this.executionEvidenceRecorder = recorder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
|
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
|
||||||
@org.springframework.beans.factory.annotation.Value(
|
@org.springframework.beans.factory.annotation.Value(
|
||||||
"${mate.agent.markdown-normalize-enabled:true}")
|
"${mate.agent.markdown-normalize-enabled:true}")
|
||||||
@ -182,24 +169,11 @@ public class AgentGraphBuilder {
|
|||||||
*/
|
*/
|
||||||
private vip.mate.audit.service.AuditEventService auditEventService;
|
private vip.mate.audit.service.AuditEventService auditEventService;
|
||||||
|
|
||||||
@Autowired(required = false)
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
|
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
|
||||||
this.auditEventService = s;
|
this.auditEventService = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reasoning retention policy for ReAct turns. Setter injection so the
|
|
||||||
* {@code @RequiredArgsConstructor} signature stays stable for the unit
|
|
||||||
* constructions across the test suite; null in those, where the agent's own
|
|
||||||
* default (keep every iteration) applies.
|
|
||||||
*/
|
|
||||||
private ReasoningRetentionProperties reasoningRetentionProperties;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
public void setReasoningRetentionProperties(ReasoningRetentionProperties p) {
|
|
||||||
this.reasoningRetentionProperties = p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional per-step delegation dependencies for the Plan-Execute graph.
|
* Optional per-step delegation dependencies for the Plan-Execute graph.
|
||||||
* Setter injection (like {@link #auditEventService}) breaks the
|
* Setter injection (like {@link #auditEventService}) breaks the
|
||||||
@ -412,7 +386,7 @@ public class AgentGraphBuilder {
|
|||||||
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
|
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
|
||||||
builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider);
|
builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider);
|
||||||
} else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider)
|
} else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider)
|
||||||
&& Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(providerKwargs, "enableSearch"))) {
|
&& Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) {
|
||||||
builtinSearchEnabled = true;
|
builtinSearchEnabled = true;
|
||||||
}
|
}
|
||||||
if (builtinSearchEnabled) {
|
if (builtinSearchEnabled) {
|
||||||
@ -464,19 +438,24 @@ public class AgentGraphBuilder {
|
|||||||
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
||||||
entity, boundTools, effectiveMaxInputTokens);
|
entity, boundTools, effectiveMaxInputTokens);
|
||||||
|
|
||||||
// Progressive tool catalog is shared by both agent types. ReAct applies
|
// Extension-tool catalog — only for ReAct. The dynamic tool split runs
|
||||||
// the split in ReasoningNode; Plan-Execute receives a separate advertised
|
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
|
||||||
// set for StepExecutionNode while its executor retains the full scoped
|
// action node to record enable_tool), so baking the catalog there would
|
||||||
// set, so tool_call can recover a deferred tool in the same action round.
|
// describe an enable_tool flow that can never take effect.
|
||||||
|
// Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
|
||||||
|
// would remove it with no enable_tool path to recover it.
|
||||||
|
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
|
||||||
Set<String> autoDemotedTools = Set.of();
|
Set<String> autoDemotedTools = Set.of();
|
||||||
if (prefixBudgetPlan.enabled()) {
|
if (!isPlanExecute) {
|
||||||
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
if (prefixBudgetPlan.enabled()) {
|
||||||
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
||||||
}
|
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
||||||
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
}
|
||||||
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
||||||
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
||||||
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
||||||
|
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
||||||
@ -488,8 +467,7 @@ public class AgentGraphBuilder {
|
|||||||
BaseAgent agent;
|
BaseAgent agent;
|
||||||
boolean toolCallingEnabled;
|
boolean toolCallingEnabled;
|
||||||
if ("plan_execute".equals(entity.getAgentType())) {
|
if ("plan_execute".equals(entity.getAgentType())) {
|
||||||
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(),
|
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
|
||||||
skillCatalogRenderer, autoDemotedTools);
|
|
||||||
toolCallingEnabled = true;
|
toolCallingEnabled = true;
|
||||||
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
||||||
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
||||||
@ -593,12 +571,8 @@ public class AgentGraphBuilder {
|
|||||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
||||||
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
|
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
|
||||||
StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||||||
chatModel, conversationWindowManager, toolSet);
|
chatModel, conversationWindowManager, toolSet);
|
||||||
if (reasoningRetentionProperties != null) {
|
|
||||||
agent.setPersistEveryIterationReasoning(reasoningRetentionProperties.persistsEveryIteration());
|
|
||||||
}
|
|
||||||
return agent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
|
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
|
||||||
@ -613,19 +587,11 @@ public class AgentGraphBuilder {
|
|||||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
||||||
int maxIter, Long agentId,
|
int maxIter, Long agentId,
|
||||||
SkillCatalogRenderer skillCatalogRenderer) {
|
SkillCatalogRenderer skillCatalogRenderer) {
|
||||||
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, agentId,
|
|
||||||
skillCatalogRenderer, Set.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
|
||||||
int maxIter, Long agentId,
|
|
||||||
SkillCatalogRenderer skillCatalogRenderer,
|
|
||||||
Set<String> autoDemotedTools) {
|
|
||||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||||
ChatClient chatClient = ChatClient.create(chatModel);
|
ChatClient chatClient = ChatClient.create(chatModel);
|
||||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
||||||
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
|
runtimeModel, agentId, skillCatalogRenderer);
|
||||||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||||||
chatModel, conversationWindowManager, toolSet);
|
chatModel, conversationWindowManager, toolSet);
|
||||||
}
|
}
|
||||||
@ -649,14 +615,6 @@ public class AgentGraphBuilder {
|
|||||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
||||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
|
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
|
||||||
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
|
|
||||||
primaryModelConfig, agentId, skillCatalogRenderer, Set.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
|
||||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
|
||||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
|
|
||||||
Set<String> autoDemotedTools) {
|
|
||||||
try {
|
try {
|
||||||
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
|
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
|
||||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
|
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
|
||||||
@ -671,8 +629,6 @@ public class AgentGraphBuilder {
|
|||||||
primaryModelConfig.getProvider(),
|
primaryModelConfig.getProvider(),
|
||||||
primaryModelConfig.getModelName(), errorMessage));
|
primaryModelConfig.getModelName(), errorMessage));
|
||||||
}
|
}
|
||||||
streamingHelper.setStreamIdleTimeoutSec(
|
|
||||||
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
|
|
||||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||||
toolSet, toolGuardService, approvalService, streamTracker,
|
toolSet, toolGuardService, approvalService, streamTracker,
|
||||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||||
@ -683,7 +639,6 @@ public class AgentGraphBuilder {
|
|||||||
executor.setSkillRuntimeService(skillRuntimeService);
|
executor.setSkillRuntimeService(skillRuntimeService);
|
||||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||||
executor.setProgressContext(progressContext);
|
executor.setProgressContext(progressContext);
|
||||||
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
|
|
||||||
// Optional: route child-agent denied-tool audit events through
|
// Optional: route child-agent denied-tool audit events through
|
||||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||||
if (auditEventService != null) {
|
if (auditEventService != null) {
|
||||||
@ -693,13 +648,7 @@ public class AgentGraphBuilder {
|
|||||||
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
|
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
|
||||||
// the team task board instead of the serial delegation pipeline.
|
// the team task board instead of the serial delegation pipeline.
|
||||||
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
|
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
|
||||||
List<ToolCallback> advertisedCallbacks = toolDisclosureService
|
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
|
||||||
.split(toolSet, Set.of(), autoDemotedTools).activeCallbacks();
|
|
||||||
AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks(
|
|
||||||
toolSet.toolBeans(), advertisedCallbacks);
|
|
||||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, advertisedToolSet,
|
|
||||||
executor, planningService, streamTracker, reasoningEffort, streamingHelper,
|
|
||||||
conversationWindowManager, skillCatalogRenderer);
|
|
||||||
// Per-step delegation: route a step assigned to a specialist agent
|
// Per-step delegation: route a step assigned to a specialist agent
|
||||||
// through DelegateAgentTool (null when delegation deps aren't wired).
|
// through DelegateAgentTool (null when delegation deps aren't wired).
|
||||||
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
||||||
@ -734,7 +683,6 @@ public class AgentGraphBuilder {
|
|||||||
// Thinking 键
|
// Thinking 键
|
||||||
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE)
|
|
||||||
// 流式防重键
|
// 流式防重键
|
||||||
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
||||||
@ -781,10 +729,6 @@ public class AgentGraphBuilder {
|
|||||||
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
|
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
|
||||||
// StateKeyRegistrationCoverageTest 专门兜这条。
|
// StateKeyRegistrationCoverageTest 专门兜这条。
|
||||||
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
|
|
||||||
// Multimodal sidecar routing decision for the current turn.
|
// Multimodal sidecar routing decision for the current turn.
|
||||||
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
|
||||||
// RFC 48 — persistent goal state keys must be registered in
|
// RFC 48 — persistent goal state keys must be registered in
|
||||||
@ -947,13 +891,6 @@ public class AgentGraphBuilder {
|
|||||||
return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
|
return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
static long resolveStreamIdleTimeoutSeconds(ModelConfigEntity modelConfig) {
|
|
||||||
Integer override = modelConfig != null
|
|
||||||
? modelConfig.getRequestTimeoutSeconds()
|
|
||||||
: null;
|
|
||||||
return HttpTimeouts.resolveStreamIdleTimeout(override).toSeconds();
|
|
||||||
}
|
|
||||||
|
|
||||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||||
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
|
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
|
||||||
}
|
}
|
||||||
@ -995,8 +932,6 @@ public class AgentGraphBuilder {
|
|||||||
primaryModelConfig.getProvider(),
|
primaryModelConfig.getProvider(),
|
||||||
primaryModelConfig.getModelName(), errorMessage));
|
primaryModelConfig.getModelName(), errorMessage));
|
||||||
}
|
}
|
||||||
streamingHelper.setStreamIdleTimeoutSec(
|
|
||||||
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
|
|
||||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||||
toolSet, toolGuardService, approvalService, streamTracker,
|
toolSet, toolGuardService, approvalService, streamTracker,
|
||||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||||
@ -1007,7 +942,6 @@ public class AgentGraphBuilder {
|
|||||||
executor.setSkillRuntimeService(skillRuntimeService);
|
executor.setSkillRuntimeService(skillRuntimeService);
|
||||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||||
executor.setProgressContext(progressContext);
|
executor.setProgressContext(progressContext);
|
||||||
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
|
|
||||||
// Optional: route child-agent denied-tool audit events through
|
// Optional: route child-agent denied-tool audit events through
|
||||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||||
if (auditEventService != null) {
|
if (auditEventService != null) {
|
||||||
@ -1079,7 +1013,6 @@ public class AgentGraphBuilder {
|
|||||||
// Summarizing
|
// Summarizing
|
||||||
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
|
||||||
// 终止控制
|
// 终止控制
|
||||||
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
|
||||||
@ -1136,10 +1069,6 @@ public class AgentGraphBuilder {
|
|||||||
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
|
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
|
||||||
// StateKeyRegistrationCoverageTest 专门兜这条。
|
// StateKeyRegistrationCoverageTest 专门兜这条。
|
||||||
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
|
|
||||||
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
|
|
||||||
// Multimodal sidecar routing decision for the current turn.
|
// Multimodal sidecar routing decision for the current turn.
|
||||||
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
|
||||||
// RFC 48 — persistent goal state keys must be registered in
|
// RFC 48 — persistent goal state keys must be registered in
|
||||||
@ -1192,8 +1121,7 @@ public class AgentGraphBuilder {
|
|||||||
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
|
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
|
||||||
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
|
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
|
||||||
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
|
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
|
||||||
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
|
Map.of(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
|
||||||
MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
|
|
||||||
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
|
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
|
||||||
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE,
|
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE,
|
||||||
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE))
|
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE))
|
||||||
@ -1754,7 +1682,7 @@ public class AgentGraphBuilder {
|
|||||||
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
|
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
|
||||||
- Tools from DIFFERENT servers have DIFFERENT serverId prefixes, even if they have the same raw name (e.g. `search` on server A vs server B) — they are DIFFERENT tools and are NOT interchangeable.
|
- Tools from DIFFERENT servers have DIFFERENT serverId prefixes, even if they have the same raw name (e.g. `search` on server A vs server B) — they are DIFFERENT tools and are NOT interchangeable.
|
||||||
- Each MCP tool's description starts with `[MCP server: <name>]` so you can identify the source server by its human-readable name.
|
- Each MCP tool's description starts with `[MCP server: <name>]` so you can identify the source server by its human-readable name.
|
||||||
- MCP tools are listed in the Extension Tools catalog by default. Call `tool_call(toolName="<exact-name>", arguments={...})` to execute one in the same action round; use `tool_search` first only when the exact name is unknown.
|
- MCP tools are listed in the Extension Tools catalog by default. Use `enable_tool(toolName="<exact-name>")` to activate the one you need before calling it.
|
||||||
- Always call tools by the EXACT name shown in the tool list. Do NOT reconstruct a tool name by swapping the slug into a serverId you remember from a previous successful call — that produces a non-existent tool name and the call will fail.
|
- Always call tools by the EXACT name shown in the tool list. Do NOT reconstruct a tool name by swapping the slug into a serverId you remember from a previous successful call — that produces a non-existent tool name and the call will fail.
|
||||||
- If a tool call returns "Tool not found" with candidate suggestions, pick the correct one from the candidates verbatim.
|
- If a tool call returns "Tool not found" with candidate suggestions, pick the correct one from the candidates verbatim.
|
||||||
|
|
||||||
@ -1788,7 +1716,7 @@ public class AgentGraphBuilder {
|
|||||||
|
|
||||||
## ProgressLedger Discipline (mandatory)
|
## ProgressLedger Discipline (mandatory)
|
||||||
The `## 当前任务进度` block injected near the top of every turn is the **authoritative record** of what you have done and what remains. Treat it as ground truth, not as a scratchpad you may ignore.
|
The `## 当前任务进度` block injected near the top of every turn is the **authoritative record** of what you have done and what remains. Treat it as ground truth, not as a scratchpad you may ignore.
|
||||||
- **On starting any multi-step task** (≥3 tool calls expected), call `progress_update` in parallel batches of at most 16 calls to register every pending step BEFORE doing the work. Split larger ledgers across turns so the executor cap never drops entries.
|
- **On starting any multi-step task** (≥3 tool calls expected), call `progress_update` in a parallel tool_calls batch to register every pending step BEFORE doing the work. Do not wait until "later" — context compression can trim earlier turns and you will lose track.
|
||||||
- **After each completed sub-step**, immediately call `progress_update` to flip its status to `done`. "Immediately" means in the same tool_calls batch that returns the result, not after the next reasoning turn.
|
- **After each completed sub-step**, immediately call `progress_update` to flip its status to `done`. "Immediately" means in the same tool_calls batch that returns the result, not after the next reasoning turn.
|
||||||
- **Never re-execute a step the ledger shows as `done`** unless you can articulate why the prior result is stale.
|
- **Never re-execute a step the ledger shows as `done`** unless you can articulate why the prior result is stale.
|
||||||
- **🔒 固定约束 entries** (pinned from skill manifests) are non-negotiable. They survive context compression for a reason — re-read them every turn and make sure your planned action still satisfies them.
|
- **🔒 固定约束 entries** (pinned from skill manifests) are non-negotiable. They survive context compression for a reason — re-read them every turn and make sure your planned action still satisfies them.
|
||||||
|
|||||||
@ -27,9 +27,8 @@ import vip.mate.workspace.conversation.model.ConversationEntity;
|
|||||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
@ -71,9 +70,6 @@ public class AgentService {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
|
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private vip.mate.agent.runtime.ConversationTurnGate turnGate = new vip.mate.agent.runtime.ConversationTurnGate();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional — clears leftover auto-recorded ledger entries when a new
|
* Optional — clears leftover auto-recorded ledger entries when a new
|
||||||
* user turn starts. Field-injected so existing test constructors of
|
* user turn starts. Field-injected so existing test constructors of
|
||||||
@ -82,13 +78,6 @@ public class AgentService {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private ProgressLedgerService progressLedgerService;
|
private ProgressLedgerService progressLedgerService;
|
||||||
|
|
||||||
/** Runtime SPI coordinator. Native agents remain the default. */
|
|
||||||
@Autowired(required = false)
|
|
||||||
private vip.mate.agent.runtime.contract.AgentRuntimeCoordinator runtimeCoordinator;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime Agent instance cache. Keyed first by agentId, then by a model
|
* Runtime Agent instance cache. Keyed first by agentId, then by a model
|
||||||
* key, so a conversation that pins a non-default model gets its own graph
|
* key, so a conversation that pins a non-default model gets its own graph
|
||||||
@ -143,16 +132,6 @@ public class AgentService {
|
|||||||
if (agent.getAgentType() == null) {
|
if (agent.getAgentType() == null) {
|
||||||
agent.setAgentType("react");
|
agent.setAgentType("react");
|
||||||
}
|
}
|
||||||
if (!StringUtils.hasText(agent.getRuntimeType())) {
|
|
||||||
agent.setRuntimeType("native");
|
|
||||||
} else {
|
|
||||||
agent.setRuntimeType(agent.getRuntimeType().trim().toLowerCase(Locale.ROOT));
|
|
||||||
}
|
|
||||||
if (!"native".equals(agent.getRuntimeType()) && !"dsh".equals(agent.getRuntimeType())) {
|
|
||||||
throw new MateClawException("err.agent.runtime_unsupported", 400,
|
|
||||||
"Unsupported runtime provider: " + agent.getRuntimeType());
|
|
||||||
}
|
|
||||||
validateDshConfiguration(agent);
|
|
||||||
requireUniqueName(agent, null);
|
requireUniqueName(agent, null);
|
||||||
agentMapper.insert(agent);
|
agentMapper.insert(agent);
|
||||||
publishLifecycle(agent, "spawned");
|
publishLifecycle(agent, "spawned");
|
||||||
@ -177,9 +156,6 @@ public class AgentService {
|
|||||||
}
|
}
|
||||||
requireUniqueName(agent, agent.getId());
|
requireUniqueName(agent, agent.getId());
|
||||||
}
|
}
|
||||||
if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) {
|
|
||||||
validateDshConfiguration(agent);
|
|
||||||
}
|
|
||||||
agentMapper.updateById(agent);
|
agentMapper.updateById(agent);
|
||||||
agentInstances.remove(agent.getId());
|
agentInstances.remove(agent.getId());
|
||||||
if (prior != null && prior.getEnabled() != null
|
if (prior != null && prior.getEnabled() != null
|
||||||
@ -260,13 +236,15 @@ public class AgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate the cached agent instance only for shared workspace files that
|
* Invalidate the cached agent instance whenever one of its workspace files
|
||||||
* are baked into the system prompt. Owner-scoped PERSONAL memory rows are
|
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured
|
||||||
* injected per turn, so updating them must not force a cold agent rebuild.
|
* memory) is baked into the cached instance at build time, so memory edits made
|
||||||
|
* via tools, consolidation, or cleanup would otherwise stay invisible until an
|
||||||
|
* agent config change or restart. Rebuilding on the next turn picks them up.
|
||||||
*/
|
*/
|
||||||
@org.springframework.context.event.EventListener
|
@org.springframework.context.event.EventListener
|
||||||
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
|
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
|
||||||
if (event.agentId() != null && event.affectsSystemPrompt()) {
|
if (event.agentId() != null) {
|
||||||
agentInstances.remove(event.agentId());
|
agentInstances.remove(event.agentId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -299,8 +277,6 @@ public class AgentService {
|
|||||||
* work already done before the pause.
|
* work already done before the pause.
|
||||||
*/
|
*/
|
||||||
private void clearAutoRecordedForNewTurn(String conversationId) {
|
private void clearAutoRecordedForNewTurn(String conversationId) {
|
||||||
// Autonomous segments resume the same objective; retain authoritative tool progress.
|
|
||||||
if (vip.mate.agent.context.GoalContinuationContext.active()) return;
|
|
||||||
if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
|
if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -324,11 +300,7 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
if (isDshAgent(agentId)) {
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
|
|
||||||
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
|
|
||||||
}
|
|
||||||
trackMemoryRecalls(agentId, message, origin);
|
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -365,13 +337,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
if (isDshAgent(agentId)) {
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
return chatStructuredStream(agentId, message, conversationId, "", null,
|
|
||||||
origin != null ? origin : ChatOrigin.EMPTY)
|
|
||||||
.filter(delta -> delta.content() != null)
|
|
||||||
.map(StreamDelta::content);
|
|
||||||
}
|
|
||||||
trackMemoryRecalls(agentId, message, origin);
|
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||||
@ -408,20 +374,7 @@ public class AgentService {
|
|||||||
String requesterId, String thinkingLevel,
|
String requesterId, String thinkingLevel,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
trackMemoryRecalls(agentId, message, origin);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
if (isDshAgent(agentId)) {
|
|
||||||
AgentEntity dshAgent = getAgent(agentId);
|
|
||||||
return withLifecycleFlux(agentId, message, conversationId,
|
|
||||||
(msg, convId) -> Flux.using(
|
|
||||||
() -> runtimeCoordinator.start(dshAgent, convId, convId,
|
|
||||||
dshAgent.getModelName(), dshWorkingDirectory(dshAgent),
|
|
||||||
dshWorkingDirectory(dshAgent)),
|
|
||||||
connection -> vip.mate.agent.runtime.RuntimeEventStreamAdapter.adapt(
|
|
||||||
connection.prompt(msg)),
|
|
||||||
connection -> connection.close()),
|
|
||||||
StreamDelta::content)
|
|
||||||
.doFinally(signal -> ThinkingLevelHolder.clear());
|
|
||||||
}
|
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
|
|
||||||
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
||||||
@ -468,7 +421,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
trackMemoryRecalls(agentId, goal, origin);
|
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -495,7 +448,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, ChatOrigin origin) {
|
String toolCallPayload, ChatOrigin origin) {
|
||||||
trackMemoryRecalls(agentId, userMessage, origin);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -523,7 +476,23 @@ public class AgentService {
|
|||||||
* {@code _usage_final} event for token and model attribution.
|
* {@code _usage_final} event for token and model attribution.
|
||||||
*/
|
*/
|
||||||
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
|
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
|
||||||
return ChatResultCollector.collect(stream);
|
StringBuilder content = new StringBuilder();
|
||||||
|
final int[] usage = {0, 0};
|
||||||
|
final String[] modelInfo = {null, null};
|
||||||
|
stream.doOnNext(delta -> {
|
||||||
|
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||||
|
Map<String, Object> data = delta.eventData();
|
||||||
|
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||||
|
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||||
|
Object model = data.get("runtimeModelName");
|
||||||
|
Object provider = data.get("runtimeProviderId");
|
||||||
|
if (model != null) modelInfo[0] = model.toString();
|
||||||
|
if (provider != null) modelInfo[1] = provider.toString();
|
||||||
|
} else if (delta.content() != null) {
|
||||||
|
content.append(delta.content());
|
||||||
|
}
|
||||||
|
}).blockLast(Duration.ofMinutes(10));
|
||||||
|
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -543,7 +512,7 @@ public class AgentService {
|
|||||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, String requesterId,
|
String toolCallPayload, String requesterId,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
trackMemoryRecalls(agentId, userMessage, origin);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
return Flux.defer(() -> {
|
return Flux.defer(() -> {
|
||||||
@ -651,13 +620,6 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
private String withLifecycleSync(Long agentId, String message, String conversationId,
|
private String withLifecycleSync(Long agentId, String message, String conversationId,
|
||||||
java.util.function.BiFunction<String, String, String> invoke) {
|
java.util.function.BiFunction<String, String, String> invoke) {
|
||||||
try (var permit = acquireTurn(conversationId)) {
|
|
||||||
return invokeWithLifecycleSync(agentId,message,conversationId,invoke);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String invokeWithLifecycleSync(Long agentId, String message, String conversationId,
|
|
||||||
java.util.function.BiFunction<String, String, String> invoke) {
|
|
||||||
safeRegister(conversationId, agentId);
|
safeRegister(conversationId, agentId);
|
||||||
try {
|
try {
|
||||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||||
@ -686,26 +648,11 @@ public class AgentService {
|
|||||||
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
|
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
|
||||||
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
||||||
Function<T, String> contentExtractor) {
|
Function<T, String> contentExtractor) {
|
||||||
return Flux.using(() -> acquireTurn(conversationId),
|
|
||||||
permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor),
|
|
||||||
vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
|
|
||||||
}
|
|
||||||
|
|
||||||
private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) {
|
|
||||||
var permit = turnGate.tryAcquire(conversationId);
|
|
||||||
if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running");
|
|
||||||
return permit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T> Flux<T> invokeWithLifecycleFlux(Long agentId, String message, String conversationId,
|
|
||||||
java.util.function.BiFunction<String, String, Flux<T>> invoke,
|
|
||||||
Function<T, String> contentExtractor) {
|
|
||||||
boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active();
|
|
||||||
safeRegister(conversationId, agentId);
|
safeRegister(conversationId, agentId);
|
||||||
try {
|
try {
|
||||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||||
return invoke.apply(message, conversationId)
|
return invoke.apply(message, conversationId)
|
||||||
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
|
.doFinally(s -> safeUnregister(conversationId));
|
||||||
}
|
}
|
||||||
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
||||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
||||||
@ -721,11 +668,11 @@ public class AgentService {
|
|||||||
})
|
})
|
||||||
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
|
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
|
||||||
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
|
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
|
||||||
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
|
.doFinally(s -> safeUnregister(conversationId));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// If invoke.apply() throws before the Flux is constructed, the
|
// If invoke.apply() throws before the Flux is constructed, the
|
||||||
// doFinally above never runs — clean up here.
|
// doFinally above never runs — clean up here.
|
||||||
safeUnregister(conversationId, goalContinuation);
|
safeUnregister(conversationId);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -739,48 +686,9 @@ public class AgentService {
|
|||||||
|
|
||||||
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
|
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
|
||||||
private void safeUnregister(String conversationId) {
|
private void safeUnregister(String conversationId) {
|
||||||
safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void safeUnregister(String conversationId, boolean goalContinuation) {
|
|
||||||
if (runningConversationRegistry != null) {
|
if (runningConversationRegistry != null) {
|
||||||
runningConversationRegistry.unregister(conversationId);
|
runningConversationRegistry.unregister(conversationId);
|
||||||
}
|
}
|
||||||
if (events != null && !goalContinuation) events.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished(conversationId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isDshAgent(Long agentId) {
|
|
||||||
if (runtimeCoordinator == null || agentId == null) return false;
|
|
||||||
AgentEntity entity = getAgent(agentId);
|
|
||||||
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
|
|
||||||
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
|
|
||||||
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
|
|
||||||
: null;
|
|
||||||
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateDshConfiguration(AgentEntity agent) {
|
|
||||||
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
|
|
||||||
if (dshRuntimeService == null) {
|
|
||||||
throw new MateClawException("err.agent.runtime_unavailable", 503,
|
|
||||||
"DSH runtime provider is unavailable");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
dshRuntimeService.validateAgentConfiguration(agent);
|
|
||||||
} catch (IllegalArgumentException error) {
|
|
||||||
throw new MateClawException("err.agent.runtime_invalid", 400, error.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path dshWorkingDirectory(AgentEntity agent) {
|
|
||||||
String configured = System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"));
|
|
||||||
if (agent.getWorkspaceBasePath() != null && !agent.getWorkspaceBasePath().isBlank()) {
|
|
||||||
configured = agent.getWorkspaceBasePath().trim();
|
|
||||||
}
|
|
||||||
return Path.of(configured).toAbsolutePath().normalize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -856,42 +764,22 @@ public class AgentService {
|
|||||||
// ==================== StreamDelta ====================
|
// ==================== StreamDelta ====================
|
||||||
|
|
||||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
|
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
|
||||||
boolean persistenceOnly, boolean segmentOnly, ContentKind kind) {
|
boolean persistenceOnly, boolean segmentOnly) {
|
||||||
|
|
||||||
// 兼容构造器(广播+持久化)
|
// 兼容构造器(广播+持久化)
|
||||||
public StreamDelta(String content, String thinking) {
|
public StreamDelta(String content, String thinking) {
|
||||||
this(content, thinking, null, null, false, false, null);
|
this(content, thinking, null, null, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
|
// 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
|
||||||
public StreamDelta(String content, String thinking, String eventType,
|
public StreamDelta(String content, String thinking, String eventType,
|
||||||
Map<String, Object> eventData, boolean persistenceOnly) {
|
Map<String, Object> eventData, boolean persistenceOnly) {
|
||||||
this(content, thinking, eventType, eventData, persistenceOnly, false, null);
|
this(content, thinking, eventType, eventData, persistenceOnly, false);
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容构造器:kind 出现之前的 6 参 canonical 形态
|
|
||||||
public StreamDelta(String content, String thinking, String eventType,
|
|
||||||
Map<String, Object> eventData, boolean persistenceOnly, boolean segmentOnly) {
|
|
||||||
this(content, thinking, eventType, eventData, persistenceOnly, segmentOnly, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
|
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
|
||||||
public static StreamDelta persistOnly(String content, String thinking) {
|
public static StreamDelta persistOnly(String content, String thinking) {
|
||||||
return new StreamDelta(content, thinking, null, null, true, false, null);
|
return new StreamDelta(content, thinking, null, null, true, false);
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link #persistOnly(String, String)} 带内容语义标注的变体。 */
|
|
||||||
public static StreamDelta persistOnly(String content, String thinking, ContentKind kind) {
|
|
||||||
return new StreamDelta(content, thinking, null, null, true, false, kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Final-answer content of the terminal turn. {@code alreadyStreamed}
|
|
||||||
* decides broadcast suppression exactly like the persistOnly/plain
|
|
||||||
* split at the emission sites did before the kind tag existed.
|
|
||||||
*/
|
|
||||||
public static StreamDelta finalAnswer(String content, boolean alreadyStreamed) {
|
|
||||||
return new StreamDelta(content, null, null, null, alreadyStreamed, false, ContentKind.FINAL_ANSWER);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -915,20 +803,15 @@ public class AgentService {
|
|||||||
* persisted content field via this flavor.
|
* persisted content field via this flavor.
|
||||||
*/
|
*/
|
||||||
public static StreamDelta segmentOnly(String content, String thinking) {
|
public static StreamDelta segmentOnly(String content, String thinking) {
|
||||||
return new StreamDelta(content, thinking, null, null, true, true, null);
|
return new StreamDelta(content, thinking, null, null, true, true);
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link #segmentOnly(String, String)} 带内容语义标注的变体。 */
|
|
||||||
public static StreamDelta segmentOnly(String content, String thinking, ContentKind kind) {
|
|
||||||
return new StreamDelta(content, thinking, null, null, true, true, kind);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StreamDelta empty() {
|
public static StreamDelta empty() {
|
||||||
return new StreamDelta(null, null, null, null, false, false, null);
|
return new StreamDelta(null, null, null, null, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StreamDelta event(String type, Map<String, Object> data) {
|
public static StreamDelta event(String type, Map<String, Object> data) {
|
||||||
return new StreamDelta(null, null, type, data, false, false, null);
|
return new StreamDelta(null, null, type, data, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEvent() {
|
public boolean isEvent() {
|
||||||
@ -960,15 +843,10 @@ public class AgentService {
|
|||||||
* post-approval replays).
|
* post-approval replays).
|
||||||
*/
|
*/
|
||||||
public record ChatResult(String content, int promptTokens, int completionTokens,
|
public record ChatResult(String content, int promptTokens, int completionTokens,
|
||||||
String runtimeModel, String runtimeProvider, String finishReason) {
|
String runtimeModel, String runtimeProvider) {
|
||||||
|
|
||||||
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) {
|
public static ChatResult contentOnly(String content) {
|
||||||
return new ChatResult(content != null ? content : "", 0, 0, null, null, null);
|
return new ChatResult(content != null ? content : "", 0, 0, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,14 +179,6 @@ public class AgentToolSet {
|
|||||||
return callbackByName;
|
return callbackByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Every runtime identifier this set can resolve: function names plus any
|
|
||||||
* Spring bean / Java class aliases captured when the set was built.
|
|
||||||
*/
|
|
||||||
public Set<String> allNames() {
|
|
||||||
return aliasIndex.keySet();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取原始的 @Tool Bean 列表
|
* 获取原始的 @Tool Bean 列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import org.springframework.util.MimeType;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.context.ChatOriginHolder;
|
import vip.mate.agent.context.ChatOriginHolder;
|
||||||
import vip.mate.agent.context.GoalContinuationContext;
|
|
||||||
import vip.mate.approval.ApprovalPlaceholderUtil;
|
import vip.mate.approval.ApprovalPlaceholderUtil;
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.routing.MediaCaptionService;
|
import vip.mate.llm.routing.MediaCaptionService;
|
||||||
@ -22,7 +21,6 @@ import vip.mate.llm.routing.model.MultimodalRoutingDecision;
|
|||||||
import vip.mate.llm.service.ModelCapabilityService;
|
import vip.mate.llm.service.ModelCapabilityService;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
import vip.mate.workspace.conversation.MessageMetadataJson;
|
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||||
|
|
||||||
@ -813,15 +811,8 @@ public abstract class BaseAgent {
|
|||||||
if (msg == null) return List.of();
|
if (msg == null) return List.of();
|
||||||
String metadata = msg.getMetadata();
|
String metadata = msg.getMetadata();
|
||||||
if (metadata == null || metadata.isEmpty()) return List.of();
|
if (metadata == null || metadata.isEmpty()) return List.of();
|
||||||
// Guard on the bare key, not on `"directToolNames"`: the escaped form
|
if (!metadata.contains("\"directToolNames\"")) return List.of();
|
||||||
// reads \"directToolNames\", where the quotes are no longer adjacent to
|
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
|
||||||
// the name, so a quoted guard exits early on every H2-backed row and the
|
|
||||||
// badge silently disappears. Bare-key matching holds for both forms and
|
|
||||||
// keeps the common case (no such key) allocation-free; the exact match
|
|
||||||
// then runs against normalized JSON.
|
|
||||||
if (!metadata.contains("directToolNames")) return List.of();
|
|
||||||
java.util.regex.Matcher arrayMatcher =
|
|
||||||
DIRECT_TOOL_NAMES_ARRAY.matcher(MessageMetadataJson.normalize(metadata));
|
|
||||||
if (!arrayMatcher.find()) return List.of();
|
if (!arrayMatcher.find()) return List.of();
|
||||||
String inner = arrayMatcher.group(1);
|
String inner = arrayMatcher.group(1);
|
||||||
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
||||||
@ -896,13 +887,7 @@ public abstract class BaseAgent {
|
|||||||
}
|
}
|
||||||
return switch (message.getRole()) {
|
return switch (message.getRole()) {
|
||||||
case "assistant" -> new AssistantMessage(renderedContent);
|
case "assistant" -> new AssistantMessage(renderedContent);
|
||||||
case "system" -> isCompressionSummary(message)
|
case "system" -> new SystemMessage(renderedContent);
|
||||||
// Compression boundaries are persisted as system rows so
|
|
||||||
// the loader can find the latest boundary cheaply. They
|
|
||||||
// are still model-generated history context, not durable
|
|
||||||
// instructions, so replay them at user priority.
|
|
||||||
? new UserMessage(renderedContent)
|
|
||||||
: new SystemMessage(renderedContent);
|
|
||||||
// History user messages: text only. Re-injecting Media on every replay
|
// History user messages: text only. Re-injecting Media on every replay
|
||||||
// accumulates attachments across turns — many providers cap at 1 video
|
// accumulates attachments across turns — many providers cap at 1 video
|
||||||
// per request (e.g. Zhipu GLM-5V returns code 1210). The current turn
|
// per request (e.g. Zhipu GLM-5V returns code 1210). The current turn
|
||||||
@ -1265,12 +1250,6 @@ public abstract class BaseAgent {
|
|||||||
* the primary model can't already handle.
|
* the primary model can't already handle.
|
||||||
*/
|
*/
|
||||||
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
|
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
|
||||||
// Autonomous segments have no new persisted user row. Reconstructing
|
|
||||||
// from the last user would replace the continuation/recovery instruction.
|
|
||||||
// History is still loaded normally; queued user turns retain attachment routing.
|
|
||||||
if (GoalContinuationContext.explicitPrompt()) {
|
|
||||||
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
|
|
||||||
}
|
|
||||||
// Scheduled-job run (issue #142): the task text is the explicit
|
// Scheduled-job run (issue #142): the task text is the explicit
|
||||||
// userMessageText argument. Never reconstruct it from the conversation
|
// userMessageText argument. Never reconstruct it from the conversation
|
||||||
// — a shared cron conversation under concurrent runs has no reliable
|
// — a shared cron conversation under concurrent runs has no reliable
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
package vip.mate.agent;
|
|
||||||
|
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** Collapses a structured agent stream without discarding terminal metadata. */
|
|
||||||
final class ChatResultCollector {
|
|
||||||
|
|
||||||
private ChatResultCollector() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static AgentService.ChatResult collect(Flux<AgentService.StreamDelta> stream) {
|
|
||||||
StringBuilder content = new StringBuilder();
|
|
||||||
final int[] usage = {0, 0};
|
|
||||||
final String[] modelInfo = {null, null};
|
|
||||||
final String[] finishReason = {null};
|
|
||||||
stream.doOnNext(delta -> {
|
|
||||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
|
||||||
Map<String, Object> data = delta.eventData() != null ? delta.eventData() : Map.of();
|
|
||||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
|
||||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
|
||||||
Object model = data.get("runtimeModelName");
|
|
||||||
Object provider = data.get("runtimeProviderId");
|
|
||||||
if (model != null) modelInfo[0] = model.toString();
|
|
||||||
if (provider != null) modelInfo[1] = provider.toString();
|
|
||||||
} else if (delta.isEvent() && "finish_reason".equals(delta.eventType())) {
|
|
||||||
Map<String, Object> data = delta.eventData();
|
|
||||||
Object reason = data != null ? data.get("reason") : null;
|
|
||||||
if (reason != null) finishReason[0] = reason.toString();
|
|
||||||
} else if (delta.content() != null) {
|
|
||||||
content.append(delta.content());
|
|
||||||
}
|
|
||||||
}).blockLast(Duration.ofMinutes(10));
|
|
||||||
return new AgentService.ChatResult(content.toString(), usage[0], usage[1],
|
|
||||||
modelInfo[0], modelInfo[1], finishReason[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package vip.mate.agent;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic category of a content-bearing stream delta, assigned once at the
|
|
||||||
* producer (the agent graph) where the classification inputs — whether the
|
|
||||||
* completion carried tool calls and whether any tool observation preceded the
|
|
||||||
* text this turn — are definitively known.
|
|
||||||
*
|
|
||||||
* <p>Downstream consumers (web segment persistence, IM channel adapters, the
|
|
||||||
* SSE client) MUST read this tag instead of re-deriving the category from
|
|
||||||
* stream structure. Deltas from producers that predate this tag carry
|
|
||||||
* {@code null}; consumers fall back to their legacy structural handling in
|
|
||||||
* that case.
|
|
||||||
*/
|
|
||||||
public enum ContentKind {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text emitted in a completion that also carries tool calls, before any
|
|
||||||
* tool observation this turn. Not grounded in this turn's results — it may
|
|
||||||
* be process narration or a fully fabricated "rehearsal" of the outcome.
|
|
||||||
* Provisional: replaced by the next content of the same turn if one
|
|
||||||
* arrives, kept only when the turn produces no later content at all.
|
|
||||||
*/
|
|
||||||
PRE_TOOL_NARRATION,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intermediate narration emitted after at least one tool observation this
|
|
||||||
* turn (even when the same completion issues further tool calls). Grounded
|
|
||||||
* in real results; never replaced.
|
|
||||||
*/
|
|
||||||
GROUNDED_NARRATION,
|
|
||||||
|
|
||||||
/** Final-answer text of the terminal turn. */
|
|
||||||
FINAL_ANSWER;
|
|
||||||
|
|
||||||
/** Stable lower-case token used in persisted segment metadata and SSE payloads. */
|
|
||||||
public String wireName() {
|
|
||||||
return name().toLowerCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
package vip.mate.agent.binding;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import vip.mate.agent.binding.service.AgentBindingService;
|
|
||||||
import vip.mate.skill.event.SkillAuthoredEvent;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a self-authored skill reachable from the catalog of the agent that
|
|
||||||
* authored it.
|
|
||||||
*
|
|
||||||
* <h2>Why this exists</h2>
|
|
||||||
* An agent's visible skill catalog is filtered by
|
|
||||||
* {@link AgentBindingService#getBoundSkillIds(Long)}. That method has a
|
|
||||||
* three-state contract:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code null} — no binding rows: the agent inherits every globally
|
|
||||||
* enabled skill, so a newly created skill is visible automatically.</li>
|
|
||||||
* <li>{@code Set.of()} — the agent is explicitly scoped to zero skills
|
|
||||||
* (opt-out flag, or every binding row disabled).</li>
|
|
||||||
* <li>non-empty — an explicit allowlist; anything not in it is invisible.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* Without this listener, an agent in the third state can author a skill,
|
|
||||||
* persist it, and then never see it again — the catalog renderer filters the
|
|
||||||
* new row straight out. Self-improvement writes into a hole: the skill exists
|
|
||||||
* in the registry but the agent that learned it cannot reach it on the next
|
|
||||||
* turn.
|
|
||||||
*
|
|
||||||
* <h2>Binding policy</h2>
|
|
||||||
* Bind only when the agent is already in explicit-allowlist mode
|
|
||||||
* (non-null, non-empty). The other two states are deliberately left alone:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code null} — writing a row here would flip the agent from "inherit
|
|
||||||
* everything" into allowlist mode containing exactly one skill, which
|
|
||||||
* would silently revoke every other skill it had. Strictly worse than
|
|
||||||
* doing nothing.</li>
|
|
||||||
* <li>{@code Set.of()} — the operator asked for an agent with no skills.
|
|
||||||
* Binding would also clear the {@code skills_disabled} flag as a side
|
|
||||||
* effect of {@link AgentBindingService#bindSkill}, overriding an
|
|
||||||
* explicit human decision from a background code path.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AgentSkillAutoBindListener {
|
|
||||||
|
|
||||||
private final AgentBindingService agentBindingService;
|
|
||||||
|
|
||||||
@EventListener
|
|
||||||
public void onSkillAuthored(SkillAuthoredEvent event) {
|
|
||||||
if (event == null || event.agentId() == null || event.skillId() == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Set<Long> bound;
|
|
||||||
try {
|
|
||||||
bound = agentBindingService.getBoundSkillIds(event.agentId());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[SkillAutoBind] Could not resolve bindings for agent={}: {}",
|
|
||||||
event.agentId(), e.getMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// null = inherits every enabled skill; empty = explicitly scoped to
|
|
||||||
// none. Neither state should be rewritten by a background author.
|
|
||||||
if (bound == null || bound.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (bound.contains(event.skillId())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
agentBindingService.bindSkill(event.agentId(), event.skillId());
|
|
||||||
log.info("[SkillAutoBind] Bound self-authored skill '{}' (id={}) to agent={}",
|
|
||||||
event.skillName(), event.skillId(), event.agentId());
|
|
||||||
} catch (Exception e) {
|
|
||||||
// A cross-workspace skill, a deleted agent, or a concurrent unbind
|
|
||||||
// all land here. The skill itself is already persisted and remains
|
|
||||||
// usable through the global catalog, so this stays a warning.
|
|
||||||
log.warn("[SkillAutoBind] Failed to bind skill '{}' (id={}) to agent={}: {}",
|
|
||||||
event.skillName(), event.skillId(), event.agentId(), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -254,11 +254,7 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
* silently undoes a user's explicit skill picks.
|
* silently undoes a user's explicit skill picks.
|
||||||
*/
|
*/
|
||||||
public Set<Long> skillIdsBoundToEnabledAgents() {
|
public Set<Long> skillIdsBoundToEnabledAgents() {
|
||||||
return skillIdsBoundToEnabledAgents(null);
|
Set<Long> enabledAgentIds = enabledAgentIds();
|
||||||
}
|
|
||||||
|
|
||||||
public Set<Long> skillIdsBoundToEnabledAgents(Long workspaceId) {
|
|
||||||
Set<Long> enabledAgentIds = enabledAgentIds(workspaceId);
|
|
||||||
if (enabledAgentIds.isEmpty()) {
|
if (enabledAgentIds.isEmpty()) {
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
@ -277,11 +273,7 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
* archival candidates regardless of bindings.
|
* archival candidates regardless of bindings.
|
||||||
*/
|
*/
|
||||||
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
|
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
|
||||||
return blockedByBindingCandidates(now, null);
|
Set<Long> enabledAgentIds = enabledAgentIds();
|
||||||
}
|
|
||||||
|
|
||||||
public List<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now, Long workspaceId) {
|
|
||||||
Set<Long> enabledAgentIds = enabledAgentIds(workspaceId);
|
|
||||||
if (enabledAgentIds.isEmpty()) {
|
if (enabledAgentIds.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@ -298,9 +290,6 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
List<BlockedByBindingRow> rows = new ArrayList<>();
|
List<BlockedByBindingRow> rows = new ArrayList<>();
|
||||||
for (SkillEntity skill : skillMapper.selectBatchIds(bySkill.keySet())) {
|
for (SkillEntity skill : skillMapper.selectBatchIds(bySkill.keySet())) {
|
||||||
if (workspaceId != null && !workspaceId.equals(skill.getWorkspaceId())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(skill.getBuiltin()) || Boolean.TRUE.equals(skill.getPinned())) {
|
if (Boolean.TRUE.equals(skill.getBuiltin()) || Boolean.TRUE.equals(skill.getPinned())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -345,17 +334,9 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
|
|
||||||
/** Ids of every currently-enabled agent. */
|
/** Ids of every currently-enabled agent. */
|
||||||
private Set<Long> enabledAgentIds() {
|
private Set<Long> enabledAgentIds() {
|
||||||
return enabledAgentIds(null);
|
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
||||||
}
|
.eq(AgentEntity::getEnabled, true)
|
||||||
|
.select(AgentEntity::getId))
|
||||||
private Set<Long> enabledAgentIds(Long workspaceId) {
|
|
||||||
LambdaQueryWrapper<AgentEntity> query = new LambdaQueryWrapper<AgentEntity>()
|
|
||||||
.eq(AgentEntity::getEnabled, true);
|
|
||||||
if (workspaceId != null) {
|
|
||||||
query.eq(AgentEntity::getWorkspaceId, workspaceId);
|
|
||||||
}
|
|
||||||
query.select(AgentEntity::getId);
|
|
||||||
return agentMapper.selectList(query)
|
|
||||||
.stream()
|
.stream()
|
||||||
.map(AgentEntity::getId)
|
.map(AgentEntity::getId)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
@ -691,12 +672,6 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
// extension-tier tool for the rest of the conversation. Must be
|
// extension-tier tool for the rest of the conversation. Must be
|
||||||
// agent-wide so the model can always surface hidden tools.
|
// agent-wide so the model can always surface hidden tools.
|
||||||
"enable_tool",
|
"enable_tool",
|
||||||
// Stable Hermes-style bridges. They must survive every explicit
|
|
||||||
// allowlist because they are the only way to discover, inspect
|
|
||||||
// and invoke schemas that were deferred by the hard budget.
|
|
||||||
"tool_search",
|
|
||||||
"tool_describe",
|
|
||||||
"tool_call",
|
|
||||||
// Skill discovery / dispatch — skills are docs, not callables;
|
// Skill discovery / dispatch — skills are docs, not callables;
|
||||||
// these helpers let the LLM read SKILL.md / run scripts.
|
// these helpers let the LLM read SKILL.md / run scripts.
|
||||||
"load_skill",
|
"load_skill",
|
||||||
@ -730,7 +705,6 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
"addGoalCriterion",
|
"addGoalCriterion",
|
||||||
"completeGoal",
|
"completeGoal",
|
||||||
"getGoalStatus",
|
"getGoalStatus",
|
||||||
"waitForGoalInput",
|
|
||||||
// Conversation-scoped progress ledger — same rationale as the
|
// Conversation-scoped progress ledger — same rationale as the
|
||||||
// goal primitives above. Long multi-step research / drafting
|
// goal primitives above. Long multi-step research / drafting
|
||||||
// tasks need it on every business agent, not just the planner,
|
// tasks need it on every business agent, not just the planner,
|
||||||
@ -769,7 +743,6 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
"read_file",
|
"read_file",
|
||||||
"send_file",
|
"send_file",
|
||||||
"write_file",
|
"write_file",
|
||||||
"append_file",
|
|
||||||
"edit_file",
|
"edit_file",
|
||||||
"execute_shell_command",
|
"execute_shell_command",
|
||||||
// Inline code execution — an agent-wide capability alongside shell.
|
// Inline code execution — an agent-wide capability alongside shell.
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import org.springframework.ai.chat.model.ToolContext;
|
|||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable value object that travels alongside an agent invocation describing
|
* Immutable value object that travels alongside an agent invocation describing
|
||||||
@ -76,42 +75,15 @@ public record ChatOrigin(
|
|||||||
* forwarding uses this to tell "MateClaw authenticated this user" apart
|
* forwarding uses this to tell "MateClaw authenticated this user" apart
|
||||||
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||||
*/
|
*/
|
||||||
@Nullable Long requesterUserId,
|
@Nullable Long requesterUserId
|
||||||
@Nullable Long originMessageId,
|
|
||||||
@Nullable 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,
|
|
||||||
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
|
|
||||||
@Nullable String senderName, @Nullable String channelType,
|
|
||||||
@Nullable String chatId, @Nullable String baseUrl,
|
|
||||||
@Nullable Long requesterUserId) {
|
|
||||||
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
|
|
||||||
channelId, channelTarget, cronOrigin, senderName, channelType,
|
|
||||||
chatId, baseUrl, requesterUserId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||||
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||||
|
|
||||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
public static final ChatOrigin EMPTY =
|
public static final ChatOrigin EMPTY =
|
||||||
new ChatOrigin(null, null, "", null, null, null, null, false,
|
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
|
||||||
null, null, null, null, null, null);
|
|
||||||
|
|
||||||
// ---------------- Factories per entry point ----------------
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
@ -145,7 +117,7 @@ public record ChatOrigin(
|
|||||||
return new ChatOrigin(null, conversationId,
|
return new ChatOrigin(null, conversationId,
|
||||||
requesterId != null ? requesterId : "",
|
requesterId != null ? requesterId : "",
|
||||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
||||||
requesterUserId, null);
|
requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ChatOrigin cron(@Nullable String conversationId,
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
@ -154,8 +126,7 @@ public record ChatOrigin(
|
|||||||
@Nullable Long channelId,
|
@Nullable Long channelId,
|
||||||
@Nullable ChannelTarget target) {
|
@Nullable ChannelTarget target) {
|
||||||
return new ChatOrigin(null, conversationId, "system",
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
workspaceId, workspaceBasePath, channelId, target, true,
|
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
|
||||||
null, null, null, null, null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Wither-style updates ----------------
|
// ---------------- Wither-style updates ----------------
|
||||||
@ -163,27 +134,27 @@ public record ChatOrigin(
|
|||||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
@Nullable String newWorkspaceBasePath) {
|
@Nullable String newWorkspaceBasePath) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, Objects.equals(conversationId, newConversationId) ? executionAttribution : null);
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId, executionAttribution);
|
senderName, channelType, chatId, newBaseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -197,26 +168,7 @@ public record ChatOrigin(
|
|||||||
@Nullable String newChatId) {
|
@Nullable String newChatId) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
|
||||||
}
|
|
||||||
|
|
||||||
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
|
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
|
||||||
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId, executionAttribution);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatOrigin withApprovalId(String pendingId) {
|
|
||||||
ExecutionAttribution attribution = executionAttribution == null
|
|
||||||
? new ExecutionAttribution(null, null, null, pendingId, null)
|
|
||||||
: executionAttribution.withApproval(pendingId);
|
|
||||||
return withExecutionAttribution(attribution);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatOrigin withExecutionAttribution(ExecutionAttribution attribution) {
|
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
|
|
||||||
channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl,
|
|
||||||
requesterUserId, originMessageId, attribution);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Spring AI ToolContext interop ----------------
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|||||||
@ -1410,17 +1410,6 @@ public class ConversationWindowManager {
|
|||||||
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
|
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
|
||||||
if (response != null && response.getResult() != null
|
if (response != null && response.getResult() != null
|
||||||
&& response.getResult().getOutput() != null) {
|
&& response.getResult().getOutput() != null) {
|
||||||
String finishReason = response.getResult().getMetadata() != null
|
|
||||||
? response.getResult().getMetadata().getFinishReason() : null;
|
|
||||||
if ("length".equalsIgnoreCase(finishReason)) {
|
|
||||||
// A non-empty response can still be structurally incomplete
|
|
||||||
// when the provider exhausts max tokens. Persisting it would
|
|
||||||
// poison every later iterative summary.
|
|
||||||
log.warn("[ConversationWindow] LLM 摘要因 token 上限被截断,丢弃结果, conv={}",
|
|
||||||
conversationId);
|
|
||||||
setSummaryCooldown(conversationId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String summary = response.getResult().getOutput().getText();
|
String summary = response.getResult().getOutput().getText();
|
||||||
if (summary != null && !summary.isBlank()) {
|
if (summary != null && !summary.isBlank()) {
|
||||||
// 成功:保存摘要供下次迭代更新,清除冷却
|
// 成功:保存摘要供下次迭代更新,清除冷却
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
package vip.mate.agent.context;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
|
|
||||||
/** Server-issued execution linkage. This record is never a tool argument or an HTTP request body. */
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
public record ExecutionAttribution(Long goalId, String goalAttemptId, Long cronRunId,
|
|
||||||
String approvalId, String ownerFence) {
|
|
||||||
public ExecutionAttribution withApproval(String pendingId) {
|
|
||||||
return new ExecutionAttribution(goalId, goalAttemptId, cronRunId, pendingId, ownerFence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package vip.mate.agent.context;
|
|
||||||
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
/** Subscription-time marker; callers capture it before asynchronous lifecycle callbacks. */
|
|
||||||
public final class GoalContinuationContext {
|
|
||||||
private static final ThreadLocal<Boolean> EXPLICIT_PROMPT = new ThreadLocal<>();
|
|
||||||
private GoalContinuationContext() {}
|
|
||||||
public static boolean active() { return EXPLICIT_PROMPT.get() != null; }
|
|
||||||
public static boolean explicitPrompt() { return Boolean.TRUE.equals(EXPLICIT_PROMPT.get()); }
|
|
||||||
public static <T> T call(Supplier<T> action) { return call(true, action); }
|
|
||||||
|
|
||||||
/** Queued user input keeps normal attachment reconstruction within the same worker. */
|
|
||||||
public static <T> T call(boolean explicitPrompt, Supplier<T> action) {
|
|
||||||
Boolean previous=EXPLICIT_PROMPT.get();
|
|
||||||
EXPLICIT_PROMPT.set(explicitPrompt);
|
|
||||||
try { return action.get(); }
|
|
||||||
finally { if(previous==null) EXPLICIT_PROMPT.remove(); else EXPLICIT_PROMPT.set(previous); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -87,8 +87,7 @@ public class PrefixBudgetPlanner {
|
|||||||
(int) (injectionBudget * shares.getSkill() / sum),
|
(int) (injectionBudget * shares.getSkill() / sum),
|
||||||
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
||||||
(int) (injectionBudget * shares.getLedger() / sum),
|
(int) (injectionBudget * shares.getLedger() / sum),
|
||||||
Math.min((int) (effectiveMax * properties.getToolSchemaRatio()),
|
(int) (effectiveMax * properties.getToolSchemaRatio()));
|
||||||
Math.max(1, properties.getToolSchemaMaxTokens())));
|
|
||||||
|
|
||||||
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
|
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
|
||||||
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
|
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
|
||||||
|
|||||||
@ -27,9 +27,6 @@ import vip.mate.common.result.R;
|
|||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
import vip.mate.workspace.core.service.WorkspaceService;
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -50,7 +47,6 @@ import java.util.concurrent.Executors;
|
|||||||
public class AgentController {
|
public class AgentController {
|
||||||
|
|
||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final ConversationService conversationService;
|
|
||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final WorkspaceService workspaceService;
|
private final WorkspaceService workspaceService;
|
||||||
@ -215,13 +211,12 @@ public class AgentController {
|
|||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
verifyAgentEnabled(agent);
|
verifyAgentEnabled(agent);
|
||||||
ChatOrigin origin = persistOrigin(agent, id, message, conversationId, workspaceId);
|
|
||||||
|
|
||||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||||
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
||||||
sseExecutor.execute(() -> {
|
sseExecutor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
agentService.chatStream(id, message, conversationId, origin)
|
agentService.chatStream(id, message, conversationId)
|
||||||
.doOnNext(chunk -> {
|
.doOnNext(chunk -> {
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("message").data(chunk));
|
emitter.send(SseEmitter.event().name("message").data(chunk));
|
||||||
@ -256,9 +251,7 @@ public class AgentController {
|
|||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
verifyAgentEnabled(agent);
|
verifyAgentEnabled(agent);
|
||||||
ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
|
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
|
||||||
request.getConversationId(), workspaceId);
|
|
||||||
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId(), origin));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "执行复杂任务(Plan-Execute)")
|
@Operation(summary = "执行复杂任务(Plan-Execute)")
|
||||||
@ -271,21 +264,7 @@ public class AgentController {
|
|||||||
AgentEntity agent = agentService.getAgent(id);
|
AgentEntity agent = agentService.getAgent(id);
|
||||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||||
verifyAgentEnabled(agent);
|
verifyAgentEnabled(agent);
|
||||||
ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
|
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
|
||||||
request.getConversationId(), workspaceId);
|
|
||||||
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId(), origin));
|
|
||||||
}
|
|
||||||
|
|
||||||
private ChatOrigin persistOrigin(AgentEntity agent, Long agentId, String message,
|
|
||||||
String conversationId, Long requestedWorkspaceId) {
|
|
||||||
Long resolvedWorkspaceId = agent != null && agent.getWorkspaceId() != null
|
|
||||||
? agent.getWorkspaceId()
|
|
||||||
: requestedWorkspaceId != null ? requestedWorkspaceId : 1L;
|
|
||||||
MessageEntity savedUser = conversationService.saveMessage(
|
|
||||||
conversationId, "user", message);
|
|
||||||
return ChatOrigin.web(conversationId, "anonymous", resolvedWorkspaceId, null)
|
|
||||||
.withAgent(agentId)
|
|
||||||
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取Agent运行状态")
|
@Operation(summary = "获取Agent运行状态")
|
||||||
|
|||||||
@ -14,25 +14,20 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
|
|||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
|
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
|
||||||
import vip.mate.llm.chatmodel.ReasoningContentCache;
|
import vip.mate.llm.chatmodel.ReasoningContentCache;
|
||||||
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
|
||||||
|
|
||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
@ -96,26 +91,6 @@ public class NodeStreamingChatHelper {
|
|||||||
*/
|
*/
|
||||||
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
|
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
|
||||||
|
|
||||||
/**
|
|
||||||
* Inter-frame idle timeout (seconds) applied to every streaming LLM call.
|
|
||||||
* The JDK HttpClient request timeout (which {@code setReadTimeout} maps to)
|
|
||||||
* only protects up to the response headers; once they arrive the clock
|
|
||||||
* stops, so a provider that returns 200 + a first SSE frame then goes
|
|
||||||
* silent hangs the body Flux forever — no exception, so health tracking /
|
|
||||||
* failover never engage (issue #585). A reactor {@code .timeout()} on the
|
|
||||||
* delta Flux fills that gap: total silence for this long propagates a
|
|
||||||
* {@code TimeoutException} down the existing error path (classifyError
|
|
||||||
* buckets it as a retryable SERVER_ERROR).
|
|
||||||
* <p>
|
|
||||||
* Defaults to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}
|
|
||||||
* (180s). {@code 0} or negative disables it (for tests / opt-out).
|
|
||||||
* Production wiring sets it from {@code ModelConfigEntity.requestTimeoutSeconds}
|
|
||||||
* so a single per-model knob governs both the connect-level read timeout
|
|
||||||
* and the body-level idle timeout.
|
|
||||||
*/
|
|
||||||
private long streamIdleTimeoutSec =
|
|
||||||
vip.mate.llm.chatmodel.HttpTimeouts.DEFAULT_STREAM_IDLE_TIMEOUT.toSeconds();
|
|
||||||
|
|
||||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
||||||
this(streamTracker, List.of(), null, null, null, null);
|
this(streamTracker, List.of(), null, null, null, null);
|
||||||
}
|
}
|
||||||
@ -409,7 +384,6 @@ public class NodeStreamingChatHelper {
|
|||||||
// retry (e.g., proxy timeout returns HTTP 200 with empty body). Keep
|
// retry (e.g., proxy timeout returns HTTP 200 with empty body). Keep
|
||||||
// the cap low — if it truly takes 4+ attempts, the provider is sick.
|
// the cap low — if it truly takes 4+ attempts, the provider is sick.
|
||||||
static final int MAX_RETRIES_EMPTY_RESPONSE = 3;
|
static final int MAX_RETRIES_EMPTY_RESPONSE = 3;
|
||||||
static final long EMPTY_RESPONSE_BACKOFF_MS = 250;
|
|
||||||
// UNKNOWN: conservative retry cap. Defensive: retry what we can't
|
// UNKNOWN: conservative retry cap. Defensive: retry what we can't
|
||||||
// classify, but with a smaller budget than SERVER_ERROR (5 vs 10) to
|
// classify, but with a smaller budget than SERVER_ERROR (5 vs 10) to
|
||||||
// avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the
|
// avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the
|
||||||
@ -470,17 +444,6 @@ public class NodeStreamingChatHelper {
|
|||||||
this.maxTotalDurationMs = maxTotalDurationMs;
|
this.maxTotalDurationMs = maxTotalDurationMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Override the streaming inter-frame idle timeout (seconds). Wired from
|
|
||||||
* {@code ModelConfigEntity.requestTimeoutSeconds} by AgentGraphBuilder so a
|
|
||||||
* single per-model knob governs both the connect-level read timeout and
|
|
||||||
* the body-level idle timeout. {@code 0} or negative disables the idle
|
|
||||||
* timeout (used by tests / opt-out). See {@link #streamIdleTimeoutSec}.
|
|
||||||
*/
|
|
||||||
public void setStreamIdleTimeoutSec(long seconds) {
|
|
||||||
this.streamIdleTimeoutSec = seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
|
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -555,7 +518,6 @@ public class NodeStreamingChatHelper {
|
|||||||
// ("credit balance is too low") use these phrases in 402-class responses.
|
// ("credit balance is too low") use these phrases in 402-class responses.
|
||||||
// Chinese provider patterns (Zhipu 1113, DashScope, general) — same hard
|
// Chinese provider patterns (Zhipu 1113, DashScope, general) — same hard
|
||||||
// failure semantics: retrying the same provider won't refill the balance.
|
// failure semantics: retrying the same provider won't refill the balance.
|
||||||
String lowerMsg = msg.toLowerCase(Locale.ROOT);
|
|
||||||
if (msg.contains("402") || msg.contains("insufficient_quota")
|
if (msg.contains("402") || msg.contains("insufficient_quota")
|
||||||
|| msg.contains("credit balance is too low")
|
|| msg.contains("credit balance is too low")
|
||||||
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|
||||||
@ -564,13 +526,7 @@ public class NodeStreamingChatHelper {
|
|||||||
|| msg.contains("余额不足") || msg.contains("请充值")
|
|| msg.contains("余额不足") || msg.contains("请充值")
|
||||||
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|
|| msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
|
||||||
|| msg.contains("AccountBalanceNotEnough")
|
|| msg.contains("AccountBalanceNotEnough")
|
||||||
|| msg.contains("balance not enough")
|
|| msg.contains("balance not enough")) {
|
||||||
|| lowerMsg.contains("invalidsubscription")
|
|
||||||
|| lowerMsg.contains("subscription has expired")
|
|
||||||
|| lowerMsg.contains("arrearage")
|
|
||||||
|| lowerMsg.contains("account is in good standing")
|
|
||||||
|| lowerMsg.contains("insufficient_balance")
|
|
||||||
|| lowerMsg.contains("insufficient balance")) {
|
|
||||||
return ErrorType.BILLING;
|
return ErrorType.BILLING;
|
||||||
}
|
}
|
||||||
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
||||||
@ -897,7 +853,6 @@ public class NodeStreamingChatHelper {
|
|||||||
if (errType == ErrorType.EMPTY_RESPONSE && attempt < errType.retryBudget()) {
|
if (errType == ErrorType.EMPTY_RESPONSE && attempt < errType.retryBudget()) {
|
||||||
log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
|
log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
|
||||||
phase, attempt + 1, errType.retryBudget() + 1);
|
phase, attempt + 1, errType.retryBudget() + 1);
|
||||||
retryType.set(ErrorType.EMPTY_RESPONSE);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Generic routing — driven entirely by the ErrorType policy
|
// Generic routing — driven entirely by the ErrorType policy
|
||||||
@ -1118,7 +1073,6 @@ public class NodeStreamingChatHelper {
|
|||||||
AtomicReference<Long> retryHintRef) {
|
AtomicReference<Long> retryHintRef) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
boolean overloaded = retryTypeRef.get() == ErrorType.OVERLOADED;
|
boolean overloaded = retryTypeRef.get() == ErrorType.OVERLOADED;
|
||||||
boolean emptyResponse = retryTypeRef.get() == ErrorType.EMPTY_RESPONSE;
|
|
||||||
Long hintedMs = retryHintRef.get();
|
Long hintedMs = retryHintRef.get();
|
||||||
long delay;
|
long delay;
|
||||||
if (hintedMs != null && hintedMs > 0) {
|
if (hintedMs != null && hintedMs > 0) {
|
||||||
@ -1129,8 +1083,6 @@ public class NodeStreamingChatHelper {
|
|||||||
// in lockstep at the stated instant.
|
// in lockstep at the stated instant.
|
||||||
delay = Math.min(hintedMs, HINTED_BACKOFF_CAP_MS)
|
delay = Math.min(hintedMs, HINTED_BACKOFF_CAP_MS)
|
||||||
+ ThreadLocalRandom.current().nextLong(0, 1_000);
|
+ ThreadLocalRandom.current().nextLong(0, 1_000);
|
||||||
} else if (emptyResponse) {
|
|
||||||
delay = EMPTY_RESPONSE_BACKOFF_MS;
|
|
||||||
} else if (overloaded) {
|
} else if (overloaded) {
|
||||||
// Saturated provider: recovery periods run tens of seconds, so
|
// Saturated provider: recovery periods run tens of seconds, so
|
||||||
// the generic 3s-based exponential would burn attempts before
|
// the generic 3s-based exponential would burn attempts before
|
||||||
@ -1148,7 +1100,7 @@ public class NodeStreamingChatHelper {
|
|||||||
log.warn("[{}] Retry attempt {}/{} after {}ms (prev type={}) for conversation {}",
|
log.warn("[{}] Retry attempt {}/{} after {}ms (prev type={}) for conversation {}",
|
||||||
phase, attempt, MAX_RETRIES, delay, retryTypeRef.get(), conversationId);
|
phase, attempt, MAX_RETRIES, delay, retryTypeRef.get(), conversationId);
|
||||||
// 广播给前端:用户可见的重试倒计时
|
// 广播给前端:用户可见的重试倒计时
|
||||||
if (broadcast && !emptyResponse) {
|
if (broadcast) {
|
||||||
String cause = overloaded ? "模型服务繁忙" : "请求频率受限";
|
String cause = overloaded ? "模型服务繁忙" : "请求频率受限";
|
||||||
broadcastDelta(conversationId, "warning",
|
broadcastDelta(conversationId, "warning",
|
||||||
buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
|
buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
|
||||||
@ -1230,63 +1182,9 @@ public class NodeStreamingChatHelper {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline <think> tag extraction: models without structured reasoning
|
|
||||||
// stream their reasoning inside <think>...</think> in the content
|
|
||||||
// channel. Split those spans off live so the stream the user watches
|
|
||||||
// matches what persistence later stores (raw tags used to leak into
|
|
||||||
// content_delta and only disappear after a reload).
|
|
||||||
ThinkTagStreamExtractor thinkExtractor = new ThinkTagStreamExtractor();
|
|
||||||
|
|
||||||
// Shared handling for a thinking delta, regardless of origin
|
|
||||||
// (structured reasoningContent metadata or inline-tag extraction).
|
|
||||||
Consumer<String> onThinkingDelta = thinkingDelta -> {
|
|
||||||
// First-token signaling fires for thinking too — UI
|
|
||||||
// shows "thinking" activity before any content streams.
|
|
||||||
if (broadcast && streamTracker != null
|
|
||||||
&& firstTokenSignaled.compareAndSet(false, true)) {
|
|
||||||
streamTracker.markFirstTokenReceived(conversationId);
|
|
||||||
}
|
|
||||||
// First thinking delta opens the thinking phase. We
|
|
||||||
// emit the start lazily (on first delta) rather than
|
|
||||||
// before subscription so models that never produce
|
|
||||||
// thinking don't ghost-pair an empty segment.
|
|
||||||
if (broadcast && thinkingAccum.length() == 0
|
|
||||||
&& thinkingStartEmitted.compareAndSet(false, true)) {
|
|
||||||
streamTracker.broadcastObject(conversationId, "thinking_start", Map.of(
|
|
||||||
"phase", phase != null ? phase : "",
|
|
||||||
"timestamp", System.currentTimeMillis()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
thinkingAccum.append(thinkingDelta);
|
|
||||||
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
|
||||||
boolean suppressThinking = "off".equalsIgnoreCase(ThinkingLevelHolder.get());
|
|
||||||
if (broadcast && !suppressThinking) {
|
|
||||||
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
|
||||||
// Issue #585: inter-frame idle timeout on the streaming body Flux.
|
Disposable subscription = chatModel.stream(prompt)
|
||||||
// The JDK HttpClient request timeout (which setReadTimeout maps to)
|
|
||||||
// only protects up to the response headers; once they arrive the
|
|
||||||
// clock stops, so a provider that returns 200 + a first frame then
|
|
||||||
// goes silent hangs the body forever. This reactor timeout measures
|
|
||||||
// the gap between successive stream elements, so total silence for
|
|
||||||
// streamIdleTimeoutSec propagates an error down the existing path.
|
|
||||||
// The fallback Flux carries a descriptive message so classifyError's
|
|
||||||
// "timeout" pattern matches it (vanilla TimeoutException.getMessage()
|
|
||||||
// is null) and the health tracker / failover chain engage.
|
|
||||||
Flux<ChatResponse> streamWithIdleGuard =
|
|
||||||
streamIdleTimeoutSec > 0
|
|
||||||
? chatModel.stream(prompt).timeout(
|
|
||||||
Duration.ofSeconds(streamIdleTimeoutSec),
|
|
||||||
Flux.error(new TimeoutException(
|
|
||||||
"LLM stream idle timeout after " + streamIdleTimeoutSec
|
|
||||||
+ "s with no delta — provider half-open or stalled")))
|
|
||||||
: chatModel.stream(prompt);
|
|
||||||
|
|
||||||
Disposable subscription = streamWithIdleGuard
|
|
||||||
.doOnNext(chatResponse -> {
|
.doOnNext(chatResponse -> {
|
||||||
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
|
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -1300,29 +1198,8 @@ public class NodeStreamingChatHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 拆分本 chunk 的通道:出现结构化 reasoningContent 即关闭
|
// 1. 提取 content delta
|
||||||
// 内联标签提取(此类模型不会再用 <think> 包裹思考,正文里的
|
String contentDelta = msg.getText();
|
||||||
// 字面标签是真实内容)。
|
|
||||||
String nativeThinking = extractReasoningContent(msg);
|
|
||||||
if (nativeThinking != null && !nativeThinking.isEmpty()) {
|
|
||||||
thinkExtractor.disable();
|
|
||||||
}
|
|
||||||
String rawContent = msg.getText();
|
|
||||||
String contentDelta = rawContent;
|
|
||||||
String tagThinking = null;
|
|
||||||
if (rawContent != null && !rawContent.isEmpty()) {
|
|
||||||
var split = thinkExtractor.feed(rawContent);
|
|
||||||
contentDelta = split.content();
|
|
||||||
tagThinking = split.thinking();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 标签提取的 thinking 先处理:形如 "…</think>answer" 的
|
|
||||||
// chunk 里思考先于正文出现。
|
|
||||||
if (tagThinking != null && !tagThinking.isEmpty()) {
|
|
||||||
onThinkingDelta.accept(tagThinking);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. content delta(已剥离 <think> 内文本)
|
|
||||||
if (contentDelta != null && !contentDelta.isEmpty()) {
|
if (contentDelta != null && !contentDelta.isEmpty()) {
|
||||||
// First content delta closes the thinking phase if one
|
// First content delta closes the thinking phase if one
|
||||||
// was open, and arms first-token heartbeat relaxation.
|
// was open, and arms first-token heartbeat relaxation.
|
||||||
@ -1343,19 +1220,43 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 结构化 thinking delta. Do not cancel the stream for
|
// 2. 提取 thinking delta. Do not cancel the stream for
|
||||||
// repeated thinking phrases: some models emit repetitive
|
// repeated thinking phrases: some models emit repetitive
|
||||||
// internal planning while still making valid tool progress.
|
// internal planning while still making valid tool progress.
|
||||||
if (nativeThinking != null && !nativeThinking.isEmpty()) {
|
String thinkingDelta = extractReasoningContent(msg);
|
||||||
onThinkingDelta.accept(nativeThinking);
|
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
|
||||||
|
// First-token signaling fires for thinking too — UI
|
||||||
|
// shows "thinking" activity before any content streams.
|
||||||
|
if (broadcast && streamTracker != null
|
||||||
|
&& firstTokenSignaled.compareAndSet(false, true)) {
|
||||||
|
streamTracker.markFirstTokenReceived(conversationId);
|
||||||
|
}
|
||||||
|
// First thinking delta opens the thinking phase. We
|
||||||
|
// emit the start lazily (on first delta) rather than
|
||||||
|
// before subscription so models that never produce
|
||||||
|
// thinking don't ghost-pair an empty segment.
|
||||||
|
if (broadcast && thinkingAccum.length() == 0
|
||||||
|
&& thinkingStartEmitted.compareAndSet(false, true)) {
|
||||||
|
streamTracker.broadcastObject(conversationId, "thinking_start", Map.of(
|
||||||
|
"phase", phase != null ? phase : "",
|
||||||
|
"timestamp", System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
thinkingAccum.append(thinkingDelta);
|
||||||
|
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
||||||
|
boolean suppressThinking = "off".equalsIgnoreCase(
|
||||||
|
vip.mate.llm.chatmodel.ThinkingLevelHolder.get());
|
||||||
|
if (broadcast && !suppressThinking) {
|
||||||
|
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 累积 tool calls(处理分片)
|
// 3. 累积 tool calls(处理分片)
|
||||||
if (msg.hasToolCalls()) {
|
if (msg.hasToolCalls()) {
|
||||||
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
|
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Thinking-only no-progress guard. MUST run after both
|
// 4. Thinking-only no-progress guard. MUST run after both
|
||||||
// content delta and tool call accumulation, otherwise a
|
// content delta and tool call accumulation, otherwise a
|
||||||
// chunk that carries thinking AND a tool_call together
|
// chunk that carries thinking AND a tool_call together
|
||||||
// (some Anthropic / DeepSeek-thinking responses do this)
|
// (some Anthropic / DeepSeek-thinking responses do this)
|
||||||
@ -1380,7 +1281,7 @@ public class NodeStreamingChatHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Content-repetition guard. Some reasoning-mode models
|
// 5. Content-repetition guard. Some reasoning-mode models
|
||||||
// (qwen3.6, deepseek-r1) get stuck in a "Wait, I should X
|
// (qwen3.6, deepseek-r1) get stuck in a "Wait, I should X
|
||||||
// → 写答案 → Wait, I should Y → 写同一份答案 → ..." loop
|
// → 写答案 → Wait, I should Y → 写同一份答案 → ..." loop
|
||||||
// and emit the same final-answer paragraph dozens of times
|
// and emit the same final-answer paragraph dozens of times
|
||||||
@ -1410,7 +1311,7 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
// 4. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
||||||
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
||||||
var usage = chatResponse.getMetadata().getUsage();
|
var usage = chatResponse.getMetadata().getUsage();
|
||||||
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
||||||
@ -1474,7 +1375,6 @@ public class NodeStreamingChatHelper {
|
|||||||
"returning stopped partial result: conversationId={}",
|
"returning stopped partial result: conversationId={}",
|
||||||
phase, contentAccum.length(), thinkingAccum.length(),
|
phase, contentAccum.length(), thinkingAccum.length(),
|
||||||
toolCallAccumulators.size(), conversationId);
|
toolCallAccumulators.size(), conversationId);
|
||||||
drainThinkExtractor(thinkExtractor, contentAccum, thinkingAccum);
|
|
||||||
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||||
promptTokens.get(), completionTokens.get(),
|
promptTokens.get(), completionTokens.get(),
|
||||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||||
@ -1496,11 +1396,6 @@ public class NodeStreamingChatHelper {
|
|||||||
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream is over (complete, error, or disposed by a guard) — drain the
|
|
||||||
// extractor's held-back tail so the accumulators are complete before
|
|
||||||
// any assembly or emptiness check below.
|
|
||||||
drainThinkExtractor(thinkExtractor, contentAccum, thinkingAccum);
|
|
||||||
|
|
||||||
Throwable error = errorRef.get();
|
Throwable error = errorRef.get();
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
boolean hasAccumulatedContent = !contentAccum.isEmpty() || !toolCallAccumulators.isEmpty();
|
boolean hasAccumulatedContent = !contentAccum.isEmpty() || !toolCallAccumulators.isEmpty();
|
||||||
@ -1601,10 +1496,7 @@ public class NodeStreamingChatHelper {
|
|||||||
&& thinkingAccum.length() == 0
|
&& thinkingAccum.length() == 0
|
||||||
&& toolCallAccumulators.isEmpty()) {
|
&& toolCallAccumulators.isEmpty()) {
|
||||||
log.warn("[{}] LLM returned empty response (no content, no thinking, no tool calls) — marking as EMPTY_RESPONSE for fallback", phase);
|
log.warn("[{}] LLM returned empty response (no content, no thinking, no tool calls) — marking as EMPTY_RESPONSE for fallback", phase);
|
||||||
// The outer policy owns retry/failover. Keep transient empty
|
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
|
||||||
// attempts out of the user-visible error stream, and leave text
|
|
||||||
// blank so callers can apply a deterministic final fallback.
|
|
||||||
return buildEmptyResponseResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
|
String truncationReason = truncatedByThinkingCap ? "thinking_only_no_content"
|
||||||
@ -1930,12 +1822,6 @@ public class NodeStreamingChatHelper {
|
|||||||
List.of(), false, 0, 0, false, errorMsg, errorType);
|
List.of(), false, 0, 0, false, errorMsg, errorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private StreamResult buildEmptyResponseResult() {
|
|
||||||
return new StreamResult("", "", new AssistantMessage(""),
|
|
||||||
List.of(), false, 0, 0, false,
|
|
||||||
"LLM 返回空响应", ErrorType.EMPTY_RESPONSE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建 error 事件的 JSON payload */
|
/** 构建 error 事件的 JSON payload */
|
||||||
private static String buildErrorEventJson(String message, String conversationId, ErrorType errorType) {
|
private static String buildErrorEventJson(String message, String conversationId, ErrorType errorType) {
|
||||||
StringBuilder sb = new StringBuilder("{");
|
StringBuilder sb = new StringBuilder("{");
|
||||||
@ -2517,41 +2403,11 @@ public class NodeStreamingChatHelper {
|
|||||||
acc.id,
|
acc.id,
|
||||||
acc.type != null ? acc.type : "function",
|
acc.type != null ? acc.type : "function",
|
||||||
acc.name,
|
acc.name,
|
||||||
toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
|
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Finalize a streamed tool call for local execution.
|
|
||||||
*
|
|
||||||
* <p>Blank arguments are a common zero-argument representation and remain
|
|
||||||
* normalized to an empty object. Invalid non-blank JSON, however, must be
|
|
||||||
* preserved until {@code ToolExecutionExecutor} sees it; replacing it with
|
|
||||||
* {@code {}} loses the distinction between a truncated stream and a real
|
|
||||||
* empty call and can execute the wrong operation. The outgoing-history
|
|
||||||
* normalization path still calls {@link #sanitizeToolCallArguments} before
|
|
||||||
* a later provider request.</p>
|
|
||||||
*/
|
|
||||||
private static String toolCallArgumentsForExecution(String toolName, String arguments) {
|
|
||||||
if (arguments == null || arguments.isBlank()) {
|
|
||||||
return "{}";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
TOOL_ARG_JSON_MAPPER.readTree(arguments);
|
|
||||||
return arguments;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
|
|
||||||
+ "(len={}, head={}); preserving the payload for safe executor rejection. "
|
|
||||||
+ "Parse error: {}",
|
|
||||||
toolName,
|
|
||||||
arguments.length(),
|
|
||||||
arguments.substring(0, Math.min(80, arguments.length())),
|
|
||||||
e.getMessage());
|
|
||||||
return arguments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure {@code function.arguments} is always a well-formed JSON string.
|
* Ensure {@code function.arguments} is always a well-formed JSON string.
|
||||||
* <p>
|
* <p>
|
||||||
@ -2599,19 +2455,6 @@ public class NodeStreamingChatHelper {
|
|||||||
|
|
||||||
// ==================== <think> 标签 fallback 解析 ====================
|
// ==================== <think> 标签 fallback 解析 ====================
|
||||||
|
|
||||||
/** Flush the streaming extractor's held-back tail into the accumulators. */
|
|
||||||
private static void drainThinkExtractor(ThinkTagStreamExtractor extractor,
|
|
||||||
StringBuilder contentAccum,
|
|
||||||
StringBuilder thinkingAccum) {
|
|
||||||
var rest = extractor.flush();
|
|
||||||
if (!rest.content().isEmpty()) {
|
|
||||||
contentAccum.append(rest.content());
|
|
||||||
}
|
|
||||||
if (!rest.thinking().isEmpty()) {
|
|
||||||
thinkingAccum.append(rest.thinking());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record ThinkExtracted(String thinking, String content) {}
|
private record ThinkExtracted(String thinking, String content) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import reactor.core.publisher.Mono;
|
|||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.AgentState;
|
import vip.mate.agent.AgentState;
|
||||||
import vip.mate.agent.BaseAgent;
|
import vip.mate.agent.BaseAgent;
|
||||||
import vip.mate.agent.ContentKind;
|
|
||||||
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
|
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.StructuredStreamCapable;
|
import vip.mate.agent.StructuredStreamCapable;
|
||||||
@ -63,20 +62,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
*/
|
*/
|
||||||
private final vip.mate.agent.AgentToolSet toolSet;
|
private final vip.mate.agent.AgentToolSet toolSet;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether every iteration's reasoning is persisted, or only the terminal
|
|
||||||
* one. Set from {@code mate.agent.reasoning.retention}; defaults to keeping
|
|
||||||
* everything so a turn stays replayable without operator opt-in. A setter
|
|
||||||
* rather than a constructor argument — the agent is built per request in a
|
|
||||||
* builder that already threads a dozen collaborators, and this is a single
|
|
||||||
* boolean with a safe default.
|
|
||||||
*/
|
|
||||||
private boolean persistEveryIterationReasoning = true;
|
|
||||||
|
|
||||||
public void setPersistEveryIterationReasoning(boolean persistEveryIterationReasoning) {
|
|
||||||
this.persistEveryIterationReasoning = persistEveryIterationReasoning;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
|
||||||
CompiledGraph compiledGraph,
|
CompiledGraph compiledGraph,
|
||||||
org.springframework.ai.chat.model.ChatModel chatModel,
|
org.springframework.ai.chat.model.ChatModel chatModel,
|
||||||
@ -222,7 +207,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
|
|
||||||
// Silent-termination guard (mirrors chatStructuredStream)
|
// Silent-termination guard (mirrors chatStructuredStream)
|
||||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||||
@ -244,48 +228,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
// Thinking is emitted BEFORE any content delta of the same
|
|
||||||
// batch. The reasoning that produced an answer precedes the
|
|
||||||
// answer, and the accumulator builds its segment timeline in
|
|
||||||
// delta arrival order — emitting thinking last appended a
|
|
||||||
// thinking segment after the content segment, which readers
|
|
||||||
// then had to reorder. FINAL_THINKING and FINAL_ANSWER are
|
|
||||||
// written by the same node output, so ordering them here is
|
|
||||||
// enough to make the persisted timeline match reality.
|
|
||||||
//
|
|
||||||
// Every iteration's reasoning is persisted, not just the
|
|
||||||
// terminal one. A tool-calling iteration parks its reasoning
|
|
||||||
// in STREAMED_THINKING (REPLACE, one value per node), which
|
|
||||||
// the live channel already broadcast — persistOnly carries it
|
|
||||||
// into the accumulator without a second broadcast. Without
|
|
||||||
// this, a turn that ran N tool rounds kept only the last
|
|
||||||
// round's thinking, so the persisted turn read as a bare
|
|
||||||
// conclusion and the reasoning that justified each tool call
|
|
||||||
// survived nowhere.
|
|
||||||
//
|
|
||||||
// The cursor tracks STREAMED_THINKING and nothing else. A
|
|
||||||
// shared cursor would let the stale value re-qualify: the key
|
|
||||||
// keeps its last write for the rest of the run, so once an
|
|
||||||
// unrelated emission moved a shared cursor past it, the same
|
|
||||||
// span was emitted a second time — after the final answer,
|
|
||||||
// since the later nodes run after the answer was streamed.
|
|
||||||
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
|
|
||||||
if (persistEveryIterationReasoning
|
|
||||||
&& !iterationThinking.isEmpty()
|
|
||||||
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
|
|
||||||
lastEmittedIterationThinking.set(iterationThinking);
|
|
||||||
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
|
|
||||||
}
|
|
||||||
|
|
||||||
String thinking = extractFinalThinking(output);
|
|
||||||
if (thinking != null && !thinking.isEmpty()
|
|
||||||
&& !thinking.equals(lastEmittedIterationThinking.get())
|
|
||||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
|
||||||
deltas.add(thinkingAlreadyStreamed
|
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
|
||||||
: new AgentService.StreamDelta(null, thinking));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
||||||
// SummarizingNode output) into segments only — final-answer
|
// SummarizingNode output) into segments only — final-answer
|
||||||
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
||||||
@ -309,26 +251,24 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
lastEmittedStreamedContent.set(streamed);
|
lastEmittedStreamedContent.set(streamed);
|
||||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
deltas.add(streamedContentDelta(isFinalAnswerTurn, 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)) {
|
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
|
deltas.add(contentAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(answer, null)
|
||||||
|
: new AgentService.StreamDelta(answer, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
String thinking = extractFinalThinking(output);
|
||||||
|
if (thinking != null && !thinking.isEmpty()
|
||||||
|
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||||
|
deltas.add(thinkingAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
|
: new AgentService.StreamDelta(null, thinking));
|
||||||
|
}
|
||||||
|
|
||||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||||
@ -426,9 +366,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
// 同 STREAMED_CONTENT:STREAMED_THINKING 也是 REPLACE,用独立游标
|
|
||||||
// 跟踪已持久化的每轮 thinking,避免后续节点的 NodeOutput 重复发送。
|
|
||||||
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
|
|
||||||
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||||
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
@ -465,31 +402,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(THINKING_STREAMED, false);
|
.value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
// 2a. Thinking first — see the ordering note in
|
// 2a. Route per-iteration narrative into the segments timeline
|
||||||
// chatStructuredStream. The accumulator builds its
|
|
||||||
// segment timeline in delta arrival order, so the
|
|
||||||
// reasoning must be emitted ahead of the answer it
|
|
||||||
// produced. Every iteration's reasoning is persisted —
|
|
||||||
// see the note in chatStructuredStream for why the
|
|
||||||
// terminal one alone is not enough.
|
|
||||||
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
|
|
||||||
if (persistEveryIterationReasoning
|
|
||||||
&& !iterationThinking.isEmpty()
|
|
||||||
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
|
|
||||||
lastEmittedIterationThinking.set(iterationThinking);
|
|
||||||
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
|
|
||||||
}
|
|
||||||
|
|
||||||
String thinking = extractFinalThinking(output);
|
|
||||||
if (thinking != null && !thinking.isEmpty()
|
|
||||||
&& !thinking.equals(lastEmittedIterationThinking.get())
|
|
||||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
|
||||||
deltas.add(thinkingAlreadyStreamed
|
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
|
||||||
: new AgentService.StreamDelta(null, thinking));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. Route per-iteration narrative into the segments timeline
|
|
||||||
// so the segmented UI view still shows "我来…" preludes
|
// so the segmented UI view still shows "我来…" preludes
|
||||||
// between tool cards, but keep the top-level content
|
// between tool cards, but keep the top-level content
|
||||||
// field (= persisted mate_message.content) reserved for
|
// field (= persisted mate_message.content) reserved for
|
||||||
@ -509,27 +422,26 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
lastEmittedStreamedContent.set(streamed);
|
lastEmittedStreamedContent.set(streamed);
|
||||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
deltas.add(streamedContentDelta(isFinalAnswerTurn, 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)) {
|
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
|
deltas.add(contentAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(answer, null)
|
||||||
|
: new AgentService.StreamDelta(answer, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String thinking = extractFinalThinking(output);
|
||||||
|
if (thinking != null && !thinking.isEmpty()
|
||||||
|
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||||
|
deltas.add(thinkingAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
|
: new AgentService.StreamDelta(null, thinking));
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 更新最新累计 token usage
|
// 3. 更新最新累计 token usage
|
||||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||||
@ -647,7 +559,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
inputs.put(TOOL_CALL_COUNT, 0);
|
inputs.put(TOOL_CALL_COUNT, 0);
|
||||||
inputs.put(ERROR_COUNT, 0);
|
inputs.put(ERROR_COUNT, 0);
|
||||||
inputs.put(SHOULD_SUMMARIZE, false);
|
inputs.put(SHOULD_SUMMARIZE, false);
|
||||||
inputs.put(LONG_FORM_DRAFT, "");
|
|
||||||
inputs.put(LIMIT_EXCEEDED, false);
|
inputs.put(LIMIT_EXCEEDED, false);
|
||||||
inputs.put(CONTENT_STREAMED, false);
|
inputs.put(CONTENT_STREAMED, false);
|
||||||
inputs.put(THINKING_STREAMED, false);
|
inputs.put(THINKING_STREAMED, false);
|
||||||
@ -730,74 +641,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
* renderers (copy / TTS / history reload) showing the full text.</li>
|
* renderers (copy / TTS / history reload) showing the full text.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Beyond flavor, this is the single assignment point for the delta's
|
|
||||||
* {@link ContentKind}: the graph is the only layer that definitively knows
|
|
||||||
* whether the completion carried tool calls ({@code NEEDS_TOOL_CALL}) and
|
|
||||||
* whether any tool observation preceded the text this turn
|
|
||||||
* ({@code TOOL_CALL_COUNT} — ObservationNode adds each round's observed
|
|
||||||
* results to it, so 0 means "no observation yet"). Downstream consumers
|
|
||||||
* read the tag instead of re-deriving it from stream structure.
|
|
||||||
*
|
|
||||||
* <p>The observation signal MUST be the observation counter, not
|
|
||||||
* {@code CURRENT_ITERATION}: the latter is an iteration <em>budget</em>
|
|
||||||
* counter that ObservationNode refunds for progressive-disclosure rounds
|
|
||||||
* (load_skill / enable_tool) and GoalEvaluationNode resets to 0 on a hard
|
|
||||||
* continuation. Either path leaves the budget at 0 after real observations
|
|
||||||
* already landed, which tagged grounded narration as provisional and made
|
|
||||||
* renderers collapse it.
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>terminal turn → {@code FINAL_ANSWER};</li>
|
|
||||||
* <li>completion carries tool calls and no tool observation happened yet
|
|
||||||
* this turn → {@code PRE_TOOL_NARRATION} (provisional, may be
|
|
||||||
* replaced by the turn's next content);</li>
|
|
||||||
* <li>otherwise → {@code GROUNDED_NARRATION} (follows an observation, or
|
|
||||||
* closed its completion without tool calls — never replaced).</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Package-private so the unit test can pin the decision without standing
|
* <p>Package-private so the unit test can pin the decision without standing
|
||||||
* up a full StateGraph fixture. Returning {@code null} for blank input is the
|
* up a full StateGraph fixture. Returning {@code null} for blank input is the
|
||||||
* caller's responsibility — this helper just decides flavor for non-blank
|
* caller's responsibility — this helper just decides flavor for non-blank
|
||||||
* content.
|
* content.
|
||||||
*/
|
*/
|
||||||
/**
|
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
|
||||||
* Append a content-bearing delta plus, when it carries a producer-assigned
|
return isFinalAnswerTurn
|
||||||
* kind, a {@code segment_kind} broadcast event tagging the just-emitted
|
? AgentService.StreamDelta.persistOnly(streamed, null)
|
||||||
* content span. The kind cannot ride on the live {@code content_delta}
|
: AgentService.StreamDelta.segmentOnly(streamed, null);
|
||||||
* broadcasts — text streams before the producer knows whether the
|
|
||||||
* completion carries tool calls — so it is delivered as a follow-up event
|
|
||||||
* once the completion resolves, letting the client tag its running content
|
|
||||||
* segment and collapse a provisional narration the moment later content
|
|
||||||
* arrives, without waiting for the persisted-metadata round-trip.
|
|
||||||
*/
|
|
||||||
static void addWithKindEvent(List<AgentService.StreamDelta> deltas, AgentService.StreamDelta delta) {
|
|
||||||
deltas.add(delta);
|
|
||||||
if (delta.kind() != null) {
|
|
||||||
deltas.add(AgentService.StreamDelta.event("segment_kind",
|
|
||||||
Map.of("kind", delta.kind().wireName())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, boolean carriesToolCalls,
|
|
||||||
int observationCount, String streamed) {
|
|
||||||
if (isFinalAnswerTurn) {
|
|
||||||
return AgentService.StreamDelta.persistOnly(streamed, null, ContentKind.FINAL_ANSWER);
|
|
||||||
}
|
|
||||||
ContentKind kind = carriesToolCalls && observationCount == 0
|
|
||||||
? ContentKind.PRE_TOOL_NARRATION
|
|
||||||
: ContentKind.GROUNDED_NARRATION;
|
|
||||||
return AgentService.StreamDelta.segmentOnly(streamed, null, kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean shouldEmitStreamedContent(boolean isFinalAnswerTurn,
|
|
||||||
boolean longFormAccumulation,
|
|
||||||
String streamed,
|
|
||||||
String finalAnswer) {
|
|
||||||
if (longFormAccumulation) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !isFinalAnswerTurn || finalAnswer == null || streamed == null
|
|
||||||
|| !finalAnswer.contains(streamed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasFinalAnswer(NodeOutput output) {
|
private boolean hasFinalAnswer(NodeOutput output) {
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
package vip.mate.agent.graph;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Incremental extractor that routes inline {@code <think>...</think>} spans
|
|
||||||
* out of a streamed content channel and into a thinking channel, chunk by
|
|
||||||
* chunk. Models without structured reasoning support emit their reasoning
|
|
||||||
* inline in the content stream; without live extraction the raw tags reach
|
|
||||||
* the user during streaming and only disappear after the persisted (cleaned)
|
|
||||||
* message is reloaded.
|
|
||||||
* <p>
|
|
||||||
* A tag may be split across chunk boundaries ({@code "abc<thi"} +
|
|
||||||
* {@code "nk>xyz"}). The extractor holds back a chunk tail that is a proper
|
|
||||||
* prefix of the next expected tag (at most {@code </think>.length() - 1}
|
|
||||||
* characters) and re-examines it with the following chunk, so the hold-back
|
|
||||||
* buffer is O(1). Call {@link #flush()} once the stream ends to drain that
|
|
||||||
* tail: in text mode it is returned as content, inside an unclosed
|
|
||||||
* {@code <think>} it is returned as thinking — matching the post-stream
|
|
||||||
* fallback parser's semantics for unterminated tags.
|
|
||||||
* <p>
|
|
||||||
* Not thread-safe. One instance per streamed LLM call; Reactor serializes
|
|
||||||
* {@code doOnNext} so no synchronization is needed.
|
|
||||||
*/
|
|
||||||
final class ThinkTagStreamExtractor {
|
|
||||||
|
|
||||||
/** Split result of one {@link #feed} / {@link #flush} call; fields are never null. */
|
|
||||||
record Extracted(String content, String thinking) {
|
|
||||||
static final Extracted EMPTY = new Extracted("", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final String OPEN_TAG = "<think>";
|
|
||||||
private static final String CLOSE_TAG = "</think>";
|
|
||||||
|
|
||||||
/** Carry-over between chunks: a chunk tail that may still become a tag. */
|
|
||||||
private final StringBuilder pending = new StringBuilder();
|
|
||||||
private boolean insideThink;
|
|
||||||
private boolean disabled;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn extraction off for the rest of the stream. Called when structured
|
|
||||||
* reasoning content shows up — such a model never tag-wraps its thinking,
|
|
||||||
* so any literal tag text in the answer is real content. Thinking already
|
|
||||||
* extracted stays extracted; a held-back tail is returned as content on
|
|
||||||
* the next {@link #feed} / {@link #flush}.
|
|
||||||
*/
|
|
||||||
void disable() {
|
|
||||||
disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split one content chunk into its content and thinking parts. */
|
|
||||||
Extracted feed(String chunk) {
|
|
||||||
if (chunk == null || chunk.isEmpty()) {
|
|
||||||
return Extracted.EMPTY;
|
|
||||||
}
|
|
||||||
if (disabled) {
|
|
||||||
if (pending.isEmpty()) {
|
|
||||||
return new Extracted(chunk, "");
|
|
||||||
}
|
|
||||||
String held = pending.toString();
|
|
||||||
pending.setLength(0);
|
|
||||||
return new Extracted(held + chunk, "");
|
|
||||||
}
|
|
||||||
pending.append(chunk);
|
|
||||||
String buf = pending.toString();
|
|
||||||
pending.setLength(0);
|
|
||||||
|
|
||||||
StringBuilder content = new StringBuilder();
|
|
||||||
StringBuilder thinking = new StringBuilder();
|
|
||||||
int i = 0;
|
|
||||||
while (i < buf.length()) {
|
|
||||||
String tag = insideThink ? CLOSE_TAG : OPEN_TAG;
|
|
||||||
StringBuilder out = insideThink ? thinking : content;
|
|
||||||
int idx = buf.indexOf(tag, i);
|
|
||||||
if (idx >= 0) {
|
|
||||||
out.append(buf, i, idx);
|
|
||||||
i = idx + tag.length();
|
|
||||||
insideThink = !insideThink;
|
|
||||||
} else {
|
|
||||||
int hold = holdbackStart(buf, i, tag);
|
|
||||||
out.append(buf, i, hold);
|
|
||||||
pending.append(buf, hold, buf.length());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Extracted(content.toString(), thinking.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drain the held-back tail once the stream is over. Inside an unclosed
|
|
||||||
* {@code <think>} the remainder counts as thinking, otherwise as content.
|
|
||||||
*/
|
|
||||||
Extracted flush() {
|
|
||||||
if (pending.isEmpty()) {
|
|
||||||
return Extracted.EMPTY;
|
|
||||||
}
|
|
||||||
String rest = pending.toString();
|
|
||||||
pending.setLength(0);
|
|
||||||
return insideThink ? new Extracted("", rest) : new Extracted(rest, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Smallest index {@code s >= from} such that {@code buf[s..)} is a
|
|
||||||
* non-empty proper prefix of {@code tag}; {@code buf.length()} when the
|
|
||||||
* tail cannot start a tag. Only the last {@code tag.length() - 1} chars
|
|
||||||
* can qualify — a full tag would have been found by {@code indexOf}.
|
|
||||||
*/
|
|
||||||
private static int holdbackStart(String buf, int from, String tag) {
|
|
||||||
int len = buf.length();
|
|
||||||
int earliest = Math.max(from, len - tag.length() + 1);
|
|
||||||
for (int s = earliest; s < len; s++) {
|
|
||||||
if (isProperPrefixOfTag(buf, s, tag)) {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isProperPrefixOfTag(String buf, int start, String tag) {
|
|
||||||
int n = buf.length() - start;
|
|
||||||
if (n <= 0 || n >= tag.length()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (int k = 0; k < n; k++) {
|
|
||||||
if (buf.charAt(start + k) != tag.charAt(k)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -41,11 +41,6 @@ public class ReasoningDispatcher implements EdgeAction {
|
|||||||
return LIMIT_EXCEEDED_NODE;
|
return LIMIT_EXCEEDED_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessor.continueReasoning()) {
|
|
||||||
log.info("[ReasoningDispatcher] Completion gate requested another reasoning pass");
|
|
||||||
return REASONING_NODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 可直接回答 → finalAnswerNode
|
// 2. 可直接回答 → finalAnswerNode
|
||||||
// 覆盖以下场景:
|
// 覆盖以下场景:
|
||||||
// - LLM 正常产出最终回答 (needsToolCall=false, finalAnswer 非空)
|
// - LLM 正常产出最终回答 (needsToolCall=false, finalAnswer 非空)
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
|||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
|
||||||
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
||||||
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
||||||
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
||||||
@ -15,7 +14,6 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
|
|||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
|
|
||||||
import vip.mate.agent.context.StructuredTruncator;
|
import vip.mate.agent.context.StructuredTruncator;
|
||||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||||
@ -24,7 +22,6 @@ import vip.mate.approval.grant.AutoApproveResult;
|
|||||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.tool.ToolInputValidationException;
|
|
||||||
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
||||||
import vip.mate.tool.guard.ToolGuard;
|
import vip.mate.tool.guard.ToolGuard;
|
||||||
import vip.mate.tool.guard.ToolGuardResult;
|
import vip.mate.tool.guard.ToolGuardResult;
|
||||||
@ -86,7 +83,7 @@ public class ToolExecutionExecutor {
|
|||||||
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
|
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
|
||||||
|
|
||||||
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
||||||
"browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"
|
"browser_use", "BrowserUseTool", "write_file", "edit_file"
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -467,32 +464,7 @@ public class ToolExecutionExecutor {
|
|||||||
boolean isReplay, String requesterId,
|
boolean isReplay, String requesterId,
|
||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
|
|
||||||
workspaceBasePath, origin, Set.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preferred graph overload. {@code loadedSkills} is the conversation/run
|
|
||||||
* state captured before this batch, allowing the shared executor to reject
|
|
||||||
* both cross-iteration and same-batch duplicate {@code load_skill} calls
|
|
||||||
* before parallel execution starts.
|
|
||||||
*/
|
|
||||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
|
||||||
String conversationId, String agentId,
|
|
||||||
boolean isReplay, String requesterId,
|
|
||||||
String workspaceBasePath,
|
|
||||||
ChatOrigin origin,
|
|
||||||
Set<String> loadedSkills) {
|
|
||||||
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
if (!isReplay && safeOrigin.executionAttribution() != null) {
|
|
||||||
safeOrigin = safeOrigin.withApprovalId(null);
|
|
||||||
}
|
|
||||||
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
|
|
||||||
safeOrigin = safeOrigin.withConversationId(conversationId);
|
|
||||||
}
|
|
||||||
if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) {
|
|
||||||
safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), workspaceBasePath);
|
|
||||||
}
|
|
||||||
// Reset per-turn audit dedupe state. A retried denied tool inside the
|
// Reset per-turn audit dedupe state. A retried denied tool inside the
|
||||||
// same turn writes a single audit row; the set is repopulated by the
|
// same turn writes a single audit row; the set is repopulated by the
|
||||||
// denial branch below.
|
// denial branch below.
|
||||||
@ -538,42 +510,9 @@ public class ToolExecutionExecutor {
|
|||||||
// ═══ Phase 1: 顺序 Guard + 分段 ═══
|
// ═══ Phase 1: 顺序 Guard + 分段 ═══
|
||||||
List<PreparedToolCall> preparedCalls = new ArrayList<>();
|
List<PreparedToolCall> preparedCalls = new ArrayList<>();
|
||||||
ApprovalBarrier barrier = null;
|
ApprovalBarrier barrier = null;
|
||||||
Set<String> seenSkillLoads = new LinkedHashSet<>();
|
|
||||||
if (loadedSkills != null) {
|
|
||||||
loadedSkills.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(name -> !name.isEmpty())
|
|
||||||
.map(name -> name.toLowerCase(Locale.ROOT))
|
|
||||||
.forEach(seenSkillLoads::add);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < effectiveCalls.size(); i++) {
|
for (int i = 0; i < effectiveCalls.size(); i++) {
|
||||||
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
||||||
// Keep the provider-facing response name paired with the function
|
|
||||||
// name emitted by the model. The execution name may be rewritten
|
|
||||||
// below (tool_call -> real target), but Gemini pairs a
|
|
||||||
// functionResponse by name rather than OpenAI's call_id alone.
|
|
||||||
String responseName = toolCall.name();
|
|
||||||
// Hermes-style deferred tool proxy: unwrap tool_call before any
|
|
||||||
// policy decision so guard, approval, audit, concurrency and UI
|
|
||||||
// all operate on the real tool. The executor's callback map is
|
|
||||||
// already scoped to this agent, making it the final authority for
|
|
||||||
// whether the requested target may be invoked.
|
|
||||||
if (ProgressiveToolBridgeTool.CALL.equals(resolveToolName(toolCall.name()))) {
|
|
||||||
BridgeUnwrap unwrap = unwrapBridgeCall(toolCall);
|
|
||||||
if (unwrap.error() != null) {
|
|
||||||
events.add(GraphEventPublisher.toolStart(
|
|
||||||
toolCall.id(), ProgressiveToolBridgeTool.CALL, toolCall.arguments()));
|
|
||||||
events.add(GraphEventPublisher.toolComplete(
|
|
||||||
toolCall.id(), ProgressiveToolBridgeTool.CALL, unwrap.error(), false));
|
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
|
||||||
toolCall.id(), responseName, unwrap.error()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
toolCall = unwrap.toolCall();
|
|
||||||
log.info("[ToolExecutor] Progressive bridge unwrapped tool_call -> {}", toolCall.name());
|
|
||||||
}
|
|
||||||
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
|
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
|
||||||
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
|
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
|
||||||
// bypass guard rules keyed on the canonical name.
|
// bypass guard rules keyed on the canonical name.
|
||||||
@ -606,7 +545,7 @@ public class ToolExecutionExecutor {
|
|||||||
}
|
}
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||||
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, msg));
|
toolCall.id(), toolName, msg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -618,30 +557,10 @@ public class ToolExecutionExecutor {
|
|||||||
} catch (Exception jsonEx) {
|
} catch (Exception jsonEx) {
|
||||||
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
||||||
toolName, arguments.length(), jsonEx.getMessage());
|
toolName, arguments.length(), jsonEx.getMessage());
|
||||||
String truncationError = incompleteToolArgumentsError(toolName);
|
String truncationError = normalizeToolExecutionError(jsonEx);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, truncationError));
|
toolCall.id(), toolName, truncationError));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// load_skill is retrieval-only and concurrency-safe, so identical
|
|
||||||
// calls in one model response would otherwise race through the
|
|
||||||
// parallel phase and read/record the same skill twice. Keep this in
|
|
||||||
// the shared executor so both ActionNode and plan execution receive
|
|
||||||
// identical protection while preserving one response per call id.
|
|
||||||
if ("load_skill".equals(toolName)) {
|
|
||||||
String requestedSkill = requestedSkillName(arguments);
|
|
||||||
if (requestedSkill != null
|
|
||||||
&& !seenSkillLoads.add(requestedSkill.toLowerCase(Locale.ROOT))) {
|
|
||||||
String message = "Skill '" + requestedSkill + "' was already loaded earlier in this run. "
|
|
||||||
+ "Reuse the SKILL.md content already present in the conversation; "
|
|
||||||
+ "do not call load_skill for this skill again.";
|
|
||||||
log.debug("[ToolExecutor] Skipping duplicate load_skill({})", requestedSkill);
|
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, message, true));
|
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
|
||||||
toolCall.id(), responseName, message));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -653,13 +572,13 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
if (decision.blocked) {
|
if (decision.blocked) {
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, decision.response));
|
toolCall.id(), toolName, decision.response));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (decision.needsApproval) {
|
if (decision.needsApproval) {
|
||||||
// Barrier: 当前工具创建审批,后续工具不执行
|
// Barrier: 当前工具创建审批,后续工具不执行
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, decision.response));
|
toolCall.id(), toolName, decision.response));
|
||||||
// 标记后续工具为等待审批
|
// 标记后续工具为等待审批
|
||||||
for (int j = i + 1; j < effectiveCalls.size(); j++) {
|
for (int j = i + 1; j < effectiveCalls.size(); j++) {
|
||||||
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
|
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
|
||||||
@ -678,7 +597,7 @@ public class ToolExecutionExecutor {
|
|||||||
if (toolName.startsWith("$")) {
|
if (toolName.startsWith("$")) {
|
||||||
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
|
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, "Provider builtin tool executed server-side"));
|
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
@ -691,21 +610,21 @@ public class ToolExecutionExecutor {
|
|||||||
events.add(GraphEventPublisher.toolComplete(
|
events.add(GraphEventPublisher.toolComplete(
|
||||||
toolCall.id(), toolName, redirect.response(), true));
|
toolCall.id(), toolName, redirect.response(), true));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, redirect.response()));
|
toolCall.id(), toolName, redirect.response()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
|
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
|
||||||
log.warn("[ToolExecutor] {}", msg);
|
log.warn("[ToolExecutor] {}", msg);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, msg));
|
toolCall.id(), toolName, msg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 分类: concurrencySafe
|
// 4. 分类: concurrencySafe
|
||||||
boolean safe = isConcurrencySafe(toolName);
|
boolean safe = isConcurrencySafe(toolName);
|
||||||
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
|
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||||
conversationId, requesterId, workspaceBasePath, safeOrigin, UUID.randomUUID().toString(), rawEvidenceRef));
|
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
|
||||||
// 占位,Phase 2 填充
|
// 占位,Phase 2 填充
|
||||||
allResponses.add(null);
|
allResponses.add(null);
|
||||||
}
|
}
|
||||||
@ -734,34 +653,6 @@ public class ToolExecutionExecutor {
|
|||||||
rawEvidenceRef.get());
|
rawEvidenceRef.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String incompleteToolArgumentsError(String toolName) {
|
|
||||||
var error = OBJECT_MAPPER.createObjectNode();
|
|
||||||
error.put("error", true);
|
|
||||||
error.put("code", "TOOL_ARGUMENTS_INCOMPLETE");
|
|
||||||
error.put("recoverable", true);
|
|
||||||
error.put("toolName", toolName == null ? "" : toolName);
|
|
||||||
error.put("message", "Tool arguments were incomplete or invalid JSON; the tool was not executed.");
|
|
||||||
error.put("hint", "Retry with a smaller payload. For file updates, prefer edit_file or append_file instead of rewriting the whole file.");
|
|
||||||
return error.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String requestedSkillName(String arguments) {
|
|
||||||
if (arguments == null || arguments.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var node = OBJECT_MAPPER.readTree(arguments);
|
|
||||||
var value = node.get("skillName");
|
|
||||||
if (value == null || value.isNull() || value.asText().isBlank()) {
|
|
||||||
value = node.get("name");
|
|
||||||
}
|
|
||||||
return value == null || value.isNull() || value.asText().isBlank()
|
|
||||||
? null : value.asText().trim();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
|
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
|
||||||
* after a user approves a previously-blocked invocation).
|
* after a user approves a previously-blocked invocation).
|
||||||
@ -795,15 +686,6 @@ public class ToolExecutionExecutor {
|
|||||||
List<GraphEventPublisher.GraphEvent> events,
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
String conversationId, String workspaceBasePath,
|
String conversationId, String workspaceBasePath,
|
||||||
List<DirectToolOutput> directOutputs) {
|
List<DirectToolOutput> directOutputs) {
|
||||||
return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
|
||||||
directOutputs, ChatOrigin.EMPTY);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ToolResponseMessage.ToolResponse executePreApproved(
|
|
||||||
AssistantMessage.ToolCall toolCall, String storedArguments,
|
|
||||||
List<GraphEventPublisher.GraphEvent> events,
|
|
||||||
String conversationId, String workspaceBasePath,
|
|
||||||
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
|
|
||||||
String toolName = resolveToolName(toolCall.name());
|
String toolName = resolveToolName(toolCall.name());
|
||||||
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
||||||
|
|
||||||
@ -827,24 +709,17 @@ public class ToolExecutionExecutor {
|
|||||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
|
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
Thread executionThread = Thread.currentThread();
|
|
||||||
Runnable removeCancellationHook = streamTracker != null
|
|
||||||
? streamTracker.registerCancellationHook(conversationId, executionThread::interrupt)
|
|
||||||
: () -> { };
|
|
||||||
try {
|
try {
|
||||||
throwIfStopRequested(conversationId);
|
|
||||||
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
||||||
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
||||||
// still observe the originating ChatOrigin (channel/workspace).
|
// still observe the originating ChatOrigin (channel/workspace).
|
||||||
// Origin is method-local (see thread-safety note on execute());
|
// Origin is method-local (see thread-safety note on execute());
|
||||||
// the legacy ThreadLocal that used to carry it across executePreApproved
|
// the legacy ThreadLocal that used to carry it across executePreApproved
|
||||||
// calls was a cross-conversation footgun and has been removed.
|
// calls was a cross-conversation footgun and has been removed.
|
||||||
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
|
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||||
.withConversationId(conversationId);
|
.withConversationId(conversationId)
|
||||||
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
.withWorkspace(null, workspaceBasePath);
|
||||||
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
|
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||||
UUID.randomUUID().toString(), toolCall.id());
|
|
||||||
throwIfStopRequested(conversationId);
|
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
|
||||||
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||||
@ -861,13 +736,6 @@ public class ToolExecutionExecutor {
|
|||||||
}
|
}
|
||||||
events.add(GraphEventPublisher.toolDirectResult(
|
events.add(GraphEventPublisher.toolDirectResult(
|
||||||
toolCall.id(), toolName, fullResult));
|
toolCall.id(), toolName, fullResult));
|
||||||
// A direct result replaces the tool card's body, but the
|
|
||||||
// started event still needs a terminal pair so live clients
|
|
||||||
// do not leave the card spinning forever. The placeholder is
|
|
||||||
// deliberately used here: the full result remains confined to
|
|
||||||
// tool_direct_result / DIRECT_TOOL_OUTPUTS.
|
|
||||||
events.add(GraphEventPublisher.toolComplete(
|
|
||||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
}
|
}
|
||||||
@ -886,23 +754,13 @@ public class ToolExecutionExecutor {
|
|||||||
// leaving the broadcast tool-result panel unchanged.
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||||
} catch (CancellationException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
||||||
String validationError = safeInputValidationMessage(e);
|
String safeError = isReturnDirect(callback)
|
||||||
String safeError = validationError != null
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
? validationError
|
: "Tool execution failed: " + e.getMessage();
|
||||||
: isReturnDirect(callback)
|
|
||||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
|
||||||
: "Tool execution failed: " + e.getMessage();
|
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
||||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
||||||
} finally {
|
|
||||||
removeCancellationHook.run();
|
|
||||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
|
||||||
Thread.interrupted();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -943,7 +801,6 @@ public class ToolExecutionExecutor {
|
|||||||
List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls);
|
List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls);
|
||||||
|
|
||||||
for (List<PreparedToolCall> batch : batches) {
|
for (List<PreparedToolCall> batch : batches) {
|
||||||
throwIfStopRequested(preparedCalls.isEmpty() ? null : preparedCalls.get(0).conversationId);
|
|
||||||
if (batch.size() == 1) {
|
if (batch.size() == 1) {
|
||||||
// 单个工具(safe 或 unsafe),直接执行
|
// 单个工具(safe 或 unsafe),直接执行
|
||||||
PreparedToolCall pc = batch.get(0);
|
PreparedToolCall pc = batch.get(0);
|
||||||
@ -1011,31 +868,18 @@ public class ToolExecutionExecutor {
|
|||||||
// 等待所有并行工具完成,按原始顺序填入结果
|
// 等待所有并行工具完成,按原始顺序填入结果
|
||||||
for (var entry : futures.entrySet()) {
|
for (var entry : futures.entrySet()) {
|
||||||
try {
|
try {
|
||||||
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
|
|
||||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
|
||||||
futures.values().forEach(future -> future.cancel(true));
|
|
||||||
throw new CancellationException("Stream stopped by user during tool execution");
|
|
||||||
}
|
|
||||||
// 按工具名查找配置的超时时间
|
// 按工具名查找配置的超时时间
|
||||||
PreparedToolCall matchedPc = batch.stream()
|
PreparedToolCall matchedPc = batch.stream()
|
||||||
.filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
|
.filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
|
||||||
long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null);
|
long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null);
|
||||||
ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS);
|
ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS);
|
||||||
allResponses.set(entry.getKey(), response);
|
allResponses.set(entry.getKey(), response);
|
||||||
} catch (CancellationException e) {
|
|
||||||
futures.values().forEach(future -> future.cancel(true));
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
|
|
||||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
|
||||||
futures.values().forEach(future -> future.cancel(true));
|
|
||||||
throw new CancellationException("Stream stopped by user during tool execution");
|
|
||||||
}
|
|
||||||
// 超时或异常 — 填入错误响应
|
// 超时或异常 — 填入错误响应
|
||||||
PreparedToolCall pc = batch.stream()
|
PreparedToolCall pc = batch.stream()
|
||||||
.filter(p -> p.resultIndex == entry.getKey())
|
.filter(p -> p.resultIndex == entry.getKey())
|
||||||
.findFirst().orElse(null);
|
.findFirst().orElse(null);
|
||||||
String toolName = pc != null ? pc.responseName : "unknown";
|
String toolName = pc != null ? pc.toolCall.name() : "unknown";
|
||||||
String toolId = pc != null ? pc.toolCall.id() : "";
|
String toolId = pc != null ? pc.toolCall.id() : "";
|
||||||
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
|
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
|
||||||
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
|
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
|
||||||
@ -1051,12 +895,7 @@ public class ToolExecutionExecutor {
|
|||||||
List<GraphEventPublisher.GraphEvent> events,
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
List<DirectToolOutput> directOutputs) {
|
List<DirectToolOutput> directOutputs) {
|
||||||
String toolName = pc.toolCall.name();
|
String toolName = pc.toolCall.name();
|
||||||
Thread executionThread = Thread.currentThread();
|
|
||||||
Runnable removeCancellationHook = streamTracker != null
|
|
||||||
? streamTracker.registerCancellationHook(pc.conversationId, executionThread::interrupt)
|
|
||||||
: () -> { };
|
|
||||||
try {
|
try {
|
||||||
throwIfStopRequested(pc.conversationId);
|
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
|
||||||
@ -1078,7 +917,7 @@ public class ToolExecutionExecutor {
|
|||||||
runtimeOrigin = runtimeOrigin
|
runtimeOrigin = runtimeOrigin
|
||||||
.withConversationId(pc.conversationId)
|
.withConversationId(pc.conversationId)
|
||||||
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||||
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
|
ToolContext toolContext = runtimeOrigin.toToolContext();
|
||||||
|
|
||||||
// MCP progress: generate progressToken and inject into ToolContext
|
// MCP progress: generate progressToken and inject into ToolContext
|
||||||
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
||||||
@ -1091,8 +930,7 @@ public class ToolExecutionExecutor {
|
|||||||
toolContext = new ToolContext(ctxMap);
|
toolContext = new ToolContext(ctxMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id());
|
result = pc.callback.call(pc.arguments, toolContext);
|
||||||
throwIfStopRequested(pc.conversationId);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (progressToken != null) {
|
if (progressToken != null) {
|
||||||
progressContext.remove(progressToken);
|
progressContext.remove(progressToken);
|
||||||
@ -1123,20 +961,14 @@ public class ToolExecutionExecutor {
|
|||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId,
|
streamTracker.broadcastObject(pc.conversationId,
|
||||||
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
|
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
|
||||||
streamTracker.broadcastObject(pc.conversationId,
|
|
||||||
GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
|
||||||
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName,
|
|
||||||
DIRECT_TOOL_PLACEHOLDER, true).data());
|
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
events.add(GraphEventPublisher.toolComplete(
|
|
||||||
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
|
||||||
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
||||||
// for OpenAI-compatible providers, while withholding the data from
|
// for OpenAI-compatible providers, while withholding the data from
|
||||||
// any subsequent LLM round (the graph won't take a next round —
|
// any subsequent LLM round (the graph won't take a next round —
|
||||||
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), pc.responseName, DIRECT_TOOL_PLACEHOLDER);
|
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture SourceEvidenceLedger from the RAW result, before truncate/
|
// Capture SourceEvidenceLedger from the RAW result, before truncate/
|
||||||
@ -1176,13 +1008,7 @@ public class ToolExecutionExecutor {
|
|||||||
// Append the card-rendering directive to the LLM-facing response only,
|
// Append the card-rendering directive to the LLM-facing response only,
|
||||||
// leaving the broadcast tool-result panel unchanged.
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), pc.responseName,
|
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||||
withProductCardDirective(toolName, result != null ? result : ""));
|
|
||||||
} catch (CancellationException e) {
|
|
||||||
if (streamTracker != null) {
|
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
||||||
// RFC-052: for returnDirect tools, even the error message is
|
// RFC-052: for returnDirect tools, even the error message is
|
||||||
@ -1190,12 +1016,9 @@ public class ToolExecutionExecutor {
|
|||||||
// or other sensitive substrings that should not enter LLM context.
|
// or other sensitive substrings that should not enter LLM context.
|
||||||
// Emit a generic placeholder instead. Full error still goes to logs
|
// Emit a generic placeholder instead. Full error still goes to logs
|
||||||
// for operator diagnosis.
|
// for operator diagnosis.
|
||||||
String validationError = safeInputValidationMessage(e);
|
String reportedError = isReturnDirect(pc.callback)
|
||||||
String reportedError = validationError != null
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
? validationError
|
: normalizeToolExecutionError(e);
|
||||||
: isReturnDirect(pc.callback)
|
|
||||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
|
||||||
: normalizeToolExecutionError(e);
|
|
||||||
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
@ -1203,21 +1026,7 @@ public class ToolExecutionExecutor {
|
|||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), pc.responseName, reportedError);
|
pc.toolCall.id(), toolName, reportedError);
|
||||||
} finally {
|
|
||||||
removeCancellationHook.run();
|
|
||||||
// Virtual-thread workers are not reused, but single/unsafe calls
|
|
||||||
// can execute on a Reactor worker. Do not leak Stop's interrupt bit
|
|
||||||
// into unrelated work scheduled on that carrier.
|
|
||||||
if (streamTracker != null && streamTracker.isStopRequested(pc.conversationId)) {
|
|
||||||
Thread.interrupted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void throwIfStopRequested(String conversationId) {
|
|
||||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
|
||||||
throw new CancellationException("Stream stopped by user during tool execution");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1328,10 +1137,6 @@ public class ToolExecutionExecutor {
|
|||||||
return GuardDecision.allowed();
|
return GuardDecision.allowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isBlank(String value) {
|
|
||||||
return value == null || value.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deny an approval-required tool when the run is non-interactive (no human can
|
* Deny an approval-required tool when the run is non-interactive (no human can
|
||||||
* approve), returning an actionable message so the agent falls back to a
|
* approve), returning an actionable message so the agent falls back to a
|
||||||
@ -1421,17 +1226,6 @@ public class ToolExecutionExecutor {
|
|||||||
return "Tool execution failed: " + message;
|
return "Tool execution failed: " + message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safeInputValidationMessage(Throwable error) {
|
|
||||||
Throwable current = error;
|
|
||||||
while (current != null) {
|
|
||||||
if (current instanceof ToolInputValidationException validation) {
|
|
||||||
return "Tool input validation failed: " + validation.getMessage();
|
|
||||||
}
|
|
||||||
current = current.getCause();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #46 — when a tool callback miss happens, check whether the
|
* Issue #46 — when a tool callback miss happens, check whether the
|
||||||
* unrecognized name actually matches an active skill. If it does, return
|
* unrecognized name actually matches an active skill. If it does, return
|
||||||
@ -1668,7 +1462,7 @@ public class ToolExecutionExecutor {
|
|||||||
+ "\",\"filePath\":\"SKILL.md\"}";
|
+ "\",\"filePath\":\"SKILL.md\"}";
|
||||||
String skillMd;
|
String skillMd;
|
||||||
try {
|
try {
|
||||||
ToolContext ctx = toolContextWithScopedCatalog(origin != null ? origin : ChatOrigin.EMPTY);
|
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
||||||
skillMd = readSkillFile.call(redirectArgs, ctx);
|
skillMd = readSkillFile.call(redirectArgs, ctx);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
|
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
|
||||||
@ -1693,116 +1487,10 @@ public class ToolExecutionExecutor {
|
|||||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and validate a progressive {@code tool_call} envelope. Validation
|
|
||||||
* happens before guard execution and never invokes a callback. In
|
|
||||||
* particular, required-argument probing mirrors Hermes: an incomplete
|
|
||||||
* call returns the target schema immediately instead of spending another
|
|
||||||
* round on a guaranteed callback failure.
|
|
||||||
*/
|
|
||||||
private BridgeUnwrap unwrapBridgeCall(AssistantMessage.ToolCall bridgeCall) {
|
|
||||||
try {
|
|
||||||
var envelope = OBJECT_MAPPER.readTree(bridgeCall.arguments());
|
|
||||||
String requestedName = textField(envelope, "toolName", "name");
|
|
||||||
if (requestedName == null || requestedName.isBlank()) {
|
|
||||||
return BridgeUnwrap.error("Error: tool_call requires an exact toolName.");
|
|
||||||
}
|
|
||||||
String targetName = resolveToolName(requestedName);
|
|
||||||
if (ProgressiveToolBridgeTool.BRIDGE_NAMES.contains(targetName)) {
|
|
||||||
return BridgeUnwrap.error("Error: tool_call cannot invoke a progressive bridge recursively.");
|
|
||||||
}
|
|
||||||
ToolCallback target = toolCallbackMap.get(targetName);
|
|
||||||
if (target == null) {
|
|
||||||
return BridgeUnwrap.error("Error: Tool '" + requestedName
|
|
||||||
+ "' is not available to this agent. Use tool_search for scoped results.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var argsNode = envelope != null && envelope.has("arguments")
|
|
||||||
? envelope.get("arguments")
|
|
||||||
: envelope != null ? envelope.get("args") : null;
|
|
||||||
String targetArguments;
|
|
||||||
if (argsNode == null || argsNode.isNull()) {
|
|
||||||
targetArguments = "{}";
|
|
||||||
} else if (argsNode.isTextual()) {
|
|
||||||
targetArguments = argsNode.asText();
|
|
||||||
// A textual envelope is accepted for weaker models, but it
|
|
||||||
// must itself contain valid JSON before proceeding.
|
|
||||||
OBJECT_MAPPER.readTree(targetArguments);
|
|
||||||
} else {
|
|
||||||
targetArguments = OBJECT_MAPPER.writeValueAsString(argsNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
String missing = missingRequiredArguments(target, targetArguments);
|
|
||||||
if (missing != null) {
|
|
||||||
return BridgeUnwrap.error("Error: Missing required arguments for '" + targetName
|
|
||||||
+ "': " + missing + ". Full input schema: "
|
|
||||||
+ target.getToolDefinition().inputSchema());
|
|
||||||
}
|
|
||||||
return BridgeUnwrap.success(new AssistantMessage.ToolCall(
|
|
||||||
bridgeCall.id(), bridgeCall.type(), targetName, targetArguments));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return BridgeUnwrap.error("Error: invalid tool_call envelope: " + normalizeToolExecutionError(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String textField(com.fasterxml.jackson.databind.JsonNode node, String... names) {
|
|
||||||
if (node == null) return null;
|
|
||||||
for (String name : names) {
|
|
||||||
var value = node.get(name);
|
|
||||||
if (value != null && value.isTextual()) return value.asText();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String missingRequiredArguments(ToolCallback callback, String arguments) {
|
|
||||||
try {
|
|
||||||
var schema = OBJECT_MAPPER.readTree(callback.getToolDefinition().inputSchema());
|
|
||||||
var required = schema.get("required");
|
|
||||||
if (required == null || !required.isArray() || required.isEmpty()) return null;
|
|
||||||
var actual = OBJECT_MAPPER.readTree(arguments);
|
|
||||||
List<String> missing = new ArrayList<>();
|
|
||||||
for (var name : required) {
|
|
||||||
if (actual == null || !actual.has(name.asText()) || actual.get(name.asText()).isNull()) {
|
|
||||||
missing.add(name.asText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing.isEmpty() ? null : String.join(", ", missing);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// Bad third-party schemas should not make an otherwise valid tool
|
|
||||||
// unreachable; the callback remains the source of truth.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Carries the executor's immutable, agent-scoped callback snapshot into
|
|
||||||
* catalog bridge calls. This makes tool_search/tool_describe observe the
|
|
||||||
* exact same authority set that tool_call validates against.
|
|
||||||
*/
|
|
||||||
private ToolContext toolContextWithScopedCatalog(ChatOrigin origin) {
|
|
||||||
ToolContext base = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
|
||||||
Map<String, Object> context = new HashMap<>(base.getContext());
|
|
||||||
context.put(ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY, toolCallbackMap);
|
|
||||||
return new ToolContext(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExecutionEvidenceRecorder executionEvidenceRecorder;
|
|
||||||
|
|
||||||
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
|
|
||||||
this.executionEvidenceRecorder = recorder;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String invokeObserved(ToolCallback callback, String arguments, ToolContext context,
|
|
||||||
String invocationKey, String providerCallId) {
|
|
||||||
return executionEvidenceRecorder == null ? callback.call(arguments, context)
|
|
||||||
: executionEvidenceRecorder.invoke(callback, arguments, context, invocationKey, providerCallId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 内部数据类 ====================
|
// ==================== 内部数据类 ====================
|
||||||
|
|
||||||
private record PreparedToolCall(
|
private record PreparedToolCall(
|
||||||
AssistantMessage.ToolCall toolCall,
|
AssistantMessage.ToolCall toolCall,
|
||||||
String responseName,
|
|
||||||
ToolCallback callback,
|
ToolCallback callback,
|
||||||
String arguments,
|
String arguments,
|
||||||
boolean concurrencySafe,
|
boolean concurrencySafe,
|
||||||
@ -1811,7 +1499,6 @@ public class ToolExecutionExecutor {
|
|||||||
String requesterId,
|
String requesterId,
|
||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
ChatOrigin origin,
|
ChatOrigin origin,
|
||||||
String invocationKey,
|
|
||||||
/**
|
/**
|
||||||
* Shared reference (one per execute() invocation) where each
|
* Shared reference (one per execute() invocation) where each
|
||||||
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
|
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
|
||||||
@ -1833,16 +1520,6 @@ public class ToolExecutionExecutor {
|
|||||||
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
|
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private record BridgeUnwrap(AssistantMessage.ToolCall toolCall, String error) {
|
|
||||||
static BridgeUnwrap success(AssistantMessage.ToolCall call) {
|
|
||||||
return new BridgeUnwrap(call, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static BridgeUnwrap error(String message) {
|
|
||||||
return new BridgeUnwrap(null, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record ApprovalBarrier(String pendingId, String toolName) {}
|
private record ApprovalBarrier(String pendingId, String toolName) {}
|
||||||
|
|
||||||
private static final class GuardDecision {
|
private static final class GuardDecision {
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
package vip.mate.agent.graph.guard;
|
|
||||||
|
|
||||||
import vip.mate.agent.graph.state.ActionExecutionLedger;
|
|
||||||
|
|
||||||
/** Pure completion decision for action-required ReAct turns. */
|
|
||||||
public final class ActionCompletionPolicy {
|
|
||||||
|
|
||||||
public static final int MAX_RETRIES = 1;
|
|
||||||
|
|
||||||
public enum Decision { ALLOW, RETRY, UNVERIFIED, FAILED }
|
|
||||||
|
|
||||||
private ActionCompletionPolicy() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Decision evaluate(boolean actionRequired, int retryCount,
|
|
||||||
ActionExecutionLedger ledger) {
|
|
||||||
if (!actionRequired) return Decision.ALLOW;
|
|
||||||
ActionExecutionLedger evidence = ledger != null ? ledger : ActionExecutionLedger.empty();
|
|
||||||
if (evidence.hasSuccessfulSubstantiveCall()) return Decision.ALLOW;
|
|
||||||
if (evidence.hasSubstantiveAttempt()) return Decision.FAILED;
|
|
||||||
return retryCount < MAX_RETRIES ? Decision.RETRY : Decision.UNVERIFIED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@ import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
|||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||||
import vip.mate.agent.graph.state.ActionExecutionLedger;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
@ -52,10 +51,6 @@ public class ActionNode implements NodeAction {
|
|||||||
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
||||||
private static final String ENABLE_TOOL = "enable_tool";
|
private static final String ENABLE_TOOL = "enable_tool";
|
||||||
|
|
||||||
/** Progressive catalog inspection is setup; tool_call itself is real work. */
|
|
||||||
private static final String TOOL_SEARCH = "tool_search";
|
|
||||||
private static final String TOOL_DESCRIBE = "tool_describe";
|
|
||||||
|
|
||||||
/** Function name of the progress-update tool — skip auto-recording it. */
|
/** Function name of the progress-update tool — skip auto-recording it. */
|
||||||
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
|
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
|
||||||
|
|
||||||
@ -75,7 +70,7 @@ public class ActionNode implements NodeAction {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
|
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
|
||||||
LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
|
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
|
||||||
"listAvailableSkills", "readSkillFile", "runSkillScript",
|
"listAvailableSkills", "readSkillFile", "runSkillScript",
|
||||||
// read-only / status-query tools
|
// read-only / status-query tools
|
||||||
"read_file", "web_search",
|
"read_file", "web_search",
|
||||||
@ -144,8 +139,7 @@ public class ActionNode implements NodeAction {
|
|||||||
|
|
||||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin,
|
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
|
||||||
accessor.loadedSkills());
|
|
||||||
|
|
||||||
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
||||||
.responses(result.responses())
|
.responses(result.responses())
|
||||||
@ -154,14 +148,12 @@ public class ActionNode implements NodeAction {
|
|||||||
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
|
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
|
||||||
? result.rawEvidenceLedger()
|
? result.rawEvidenceLedger()
|
||||||
: SourceEvidenceLedger.empty();
|
: SourceEvidenceLedger.empty();
|
||||||
ActionExecutionLedger actionLedger = ActionExecutionLedger.fromEvents(result.events());
|
|
||||||
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
|
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
|
||||||
.toolResults(result.responses())
|
.toolResults(result.responses())
|
||||||
.messages(List.of((Message) toolResponseMessage))
|
.messages(List.of((Message) toolResponseMessage))
|
||||||
.currentPhase("action")
|
.currentPhase("action")
|
||||||
.events(result.events())
|
.events(result.events())
|
||||||
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger))
|
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
|
||||||
.actionExecutionLedger(accessor.actionExecutionLedger().merge(actionLedger));
|
|
||||||
|
|
||||||
if (result.awaitingApproval()) {
|
if (result.awaitingApproval()) {
|
||||||
output.awaitingApproval(true);
|
output.awaitingApproval(true);
|
||||||
@ -199,10 +191,6 @@ public class ActionNode implements NodeAction {
|
|||||||
// and pin them into the ProgressLedger so they survive context
|
// and pin them into the ProgressLedger so they survive context
|
||||||
// compression and stay visible on every turn.
|
// compression and stay visible on every turn.
|
||||||
pinSkillConstraints(conversationId, requestedSkills);
|
pinSkillConstraints(conversationId, requestedSkills);
|
||||||
if (actionLedger.hasSuccessfulTool(LOAD_SKILL_TOOL)
|
|
||||||
&& loadedSkillsRequireAction(conversationId, requestedSkills)) {
|
|
||||||
output.actionCompletionRequired(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same mechanism for enable_tool
|
// Same mechanism for enable_tool
|
||||||
@ -218,7 +206,7 @@ public class ActionNode implements NodeAction {
|
|||||||
// sees what it already did even if it forgot to call progress_update.
|
// sees what it already did even if it forgot to call progress_update.
|
||||||
// Skips meta-tools (load_skill, enable_tool, progress_update) and
|
// Skips meta-tools (load_skill, enable_tool, progress_update) and
|
||||||
// doesn't overwrite LLM-authored entries.
|
// doesn't overwrite LLM-authored entries.
|
||||||
autoRecordToolCalls(conversationId, result.responses(), actionLedger);
|
autoRecordToolCalls(conversationId, result.responses());
|
||||||
|
|
||||||
return output.build();
|
return output.build();
|
||||||
}
|
}
|
||||||
@ -272,41 +260,6 @@ public class ActionNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean loadedSkillsRequireAction(String conversationId, Set<String> skillNames) {
|
|
||||||
if (skillRuntimeService == null) return false;
|
|
||||||
Long workspaceId = executor.workspaceIdForConversation(conversationId);
|
|
||||||
for (String skillName : skillNames) {
|
|
||||||
try {
|
|
||||||
vip.mate.skill.runtime.model.ResolvedSkill skill =
|
|
||||||
skillRuntimeService.findActiveSkill(skillName, workspaceId);
|
|
||||||
if (resolvedSkillRequiresActionCompletion(skill)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("[ActionNode] Could not inspect action contract for skill '{}': {}",
|
|
||||||
skillName, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean manifestRequiresActionCompletion(vip.mate.skill.manifest.SkillManifest manifest) {
|
|
||||||
if (manifest == null) return false;
|
|
||||||
String type = manifest.getType();
|
|
||||||
if (type != null && Set.of("mcp", "acp", "code").contains(type.toLowerCase(java.util.Locale.ROOT))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (manifest.getAllowedTools() != null && !manifest.getAllowedTools().isEmpty())
|
|
||||||
|| (manifest.getScripts() != null && !manifest.getScripts().isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean resolvedSkillRequiresActionCompletion(
|
|
||||||
vip.mate.skill.runtime.model.ResolvedSkill skill) {
|
|
||||||
if (skill == null) return false;
|
|
||||||
return manifestRequiresActionCompletion(skill.getManifest())
|
|
||||||
|| (skill.getScripts() != null && !skill.getScripts().isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== B5: Auto-record tool calls ====================
|
// ==================== B5: Auto-record tool calls ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -321,12 +274,6 @@ public class ActionNode implements NodeAction {
|
|||||||
*/
|
*/
|
||||||
void autoRecordToolCalls(String conversationId,
|
void autoRecordToolCalls(String conversationId,
|
||||||
List<ToolResponseMessage.ToolResponse> responses) {
|
List<ToolResponseMessage.ToolResponse> responses) {
|
||||||
autoRecordToolCalls(conversationId, responses, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
void autoRecordToolCalls(String conversationId,
|
|
||||||
List<ToolResponseMessage.ToolResponse> responses,
|
|
||||||
ActionExecutionLedger executionLedger) {
|
|
||||||
if (progressLedgerService == null || conversationId == null
|
if (progressLedgerService == null || conversationId == null
|
||||||
|| conversationId.isBlank() || responses == null || responses.isEmpty()) {
|
|| conversationId.isBlank() || responses == null || responses.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -335,12 +282,6 @@ public class ActionNode implements NodeAction {
|
|||||||
// N separate lock+load+save cycles when the LLM calls tools in parallel.
|
// N separate lock+load+save cycles when the LLM calls tools in parallel.
|
||||||
List<vip.mate.agent.progress.ProgressLedgerService.AutoRecordEntry> batch = new java.util.ArrayList<>();
|
List<vip.mate.agent.progress.ProgressLedgerService.AutoRecordEntry> batch = new java.util.ArrayList<>();
|
||||||
for (ToolResponseMessage.ToolResponse resp : responses) {
|
for (ToolResponseMessage.ToolResponse resp : responses) {
|
||||||
if (executionLedger != null) {
|
|
||||||
ActionExecutionLedger.Receipt receipt = executionLedger.receipts().get(resp.id());
|
|
||||||
if (receipt == null || receipt.status() != ActionExecutionLedger.Status.SUCCEEDED) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String toolName = resp.name();
|
String toolName = resp.name();
|
||||||
if (toolName == null || toolName.isBlank() || AUTO_RECORD_SKIP.contains(toolName)) {
|
if (toolName == null || toolName.isBlank() || AUTO_RECORD_SKIP.contains(toolName)) {
|
||||||
continue;
|
continue;
|
||||||
@ -407,7 +348,7 @@ public class ActionNode implements NodeAction {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
|
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
|
||||||
if (toolCalls == null || toolCalls.isEmpty()) {
|
if (toolCalls == null || toolCalls.isEmpty()) {
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,12 +139,7 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
} else if (!existingAnswer.isEmpty()) {
|
} else if (!existingAnswer.isEmpty()) {
|
||||||
// 来自 reasoning 直接回答(或 stopped partial)
|
// 来自 reasoning 直接回答(或 stopped partial)
|
||||||
finalAnswer = existingAnswer;
|
finalAnswer = existingAnswer;
|
||||||
// FINAL_THINKING wins here: every writer of FINAL_ANSWER on this path
|
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
|
||||||
// sets it in the same node output, so it is the reasoning that produced
|
|
||||||
// this very answer. CURRENT_THINKING is REPLACE and was last written by
|
|
||||||
// a tool-calling iteration, so preferring it swapped in an earlier
|
|
||||||
// round's reasoning on any turn that used tools.
|
|
||||||
finalThinking = !existingThinking.isEmpty() ? existingThinking : currentThinking;
|
|
||||||
// 尊重上游已设的 finishReason(如 STOPPED),只有未设时才默认 NORMAL
|
// 尊重上游已设的 finishReason(如 STOPPED),只有未设时才默认 NORMAL
|
||||||
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
|
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
|
||||||
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",
|
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",
|
||||||
|
|||||||
@ -12,16 +12,12 @@ import vip.mate.agent.graph.state.MateClawStateAccessor;
|
|||||||
import vip.mate.goal.config.GoalProperties;
|
import vip.mate.goal.config.GoalProperties;
|
||||||
import vip.mate.goal.model.GoalEntity;
|
import vip.mate.goal.model.GoalEntity;
|
||||||
import vip.mate.goal.model.GoalEvaluationResult;
|
import vip.mate.goal.model.GoalEvaluationResult;
|
||||||
import vip.mate.goal.model.GoalResponse;
|
|
||||||
import vip.mate.goal.service.GoalEvaluationService;
|
import vip.mate.goal.service.GoalEvaluationService;
|
||||||
import vip.mate.goal.service.GoalFollowupService;
|
import vip.mate.goal.service.GoalFollowupService;
|
||||||
import vip.mate.goal.service.GoalService;
|
import vip.mate.goal.service.GoalService;
|
||||||
import vip.mate.goal.service.GraphFlavor;
|
import vip.mate.goal.service.GraphFlavor;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -188,12 +184,6 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean.TRUE.equals(refreshed.getPersistentExecution())
|
|
||||||
&& refreshed.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) {
|
|
||||||
return MateClawStateAccessor.output().goalEvaluatedThisRun(true)
|
|
||||||
.events(List.of(skippedEvent(refreshed.getId(), "goal_no_longer_active"))).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decision branches. Each terminal write is wrapped so a DB hiccup
|
// Decision branches. Each terminal write is wrapped so a DB hiccup
|
||||||
// (e.g. optimistic-lock conflict exceeding retries, memory sync
|
// (e.g. optimistic-lock conflict exceeding retries, memory sync
|
||||||
// failure on completion) does not propagate into the chat graph
|
// failure on completion) does not propagate into the chat graph
|
||||||
@ -209,7 +199,7 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||||
"goalId", String.valueOf(completed.getId()),
|
"goalId", String.valueOf(completed.getId()),
|
||||||
"score", result.score(),
|
"score", result.score(),
|
||||||
"goal", stateSafeGoal(goalService.toResponse(completed))))))
|
"goal", goalService.toResponse(completed)))))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,7 +216,7 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
|
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
|
||||||
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
|
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
|
||||||
"reason", reason,
|
"reason", reason,
|
||||||
"goal", stateSafeGoal(goalService.toResponse(exhausted))))))
|
"goal", goalService.toResponse(exhausted)))))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
@ -239,21 +229,6 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A persistent goal yields a finite segment. Its durable supervisor owns
|
|
||||||
// the next turn, cooldown and recovery; never consume graph recursion here.
|
|
||||||
if (Boolean.TRUE.equals(refreshed.getPersistentExecution())) {
|
|
||||||
return MateClawStateAccessor.output()
|
|
||||||
.goalEvaluationResult(result.toMap())
|
|
||||||
.goalEvaluatedThisRun(true)
|
|
||||||
.events(List.of(goalEvent("goal_evaluated", Map.of(
|
|
||||||
"goalId", String.valueOf(refreshed.getId()),
|
|
||||||
"score", result.score(),
|
|
||||||
"decision", result.decision(),
|
|
||||||
"gap", result.gap() == null ? "" : result.gap(),
|
|
||||||
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
int followupCountThisRun = accessor.goalFollowupCount();
|
int followupCountThisRun = accessor.goalFollowupCount();
|
||||||
int hardContinuationCount = accessor.goalHardContinuationCount();
|
int hardContinuationCount = accessor.goalHardContinuationCount();
|
||||||
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
|
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
|
||||||
@ -314,7 +289,7 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.events(List.of(goalEvent("goal_followup", Map.of(
|
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||||
"goalId", String.valueOf(refreshed.getId()),
|
"goalId", String.valueOf(refreshed.getId()),
|
||||||
"prompt", followup.get(),
|
"prompt", followup.get(),
|
||||||
"goal", stateSafeGoal(goalService.toResponse(refreshed))))));
|
"goal", goalService.toResponse(refreshed)))));
|
||||||
|
|
||||||
if (flavor == GraphFlavor.REACT) {
|
if (flavor == GraphFlavor.REACT) {
|
||||||
// ReAct: append the followup as a fresh user message via the
|
// ReAct: append the followup as a fresh user message via the
|
||||||
@ -368,72 +343,10 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
"goalId", String.valueOf(refreshed.getId()),
|
"goalId", String.valueOf(refreshed.getId()),
|
||||||
"score", result.score(),
|
"score", result.score(),
|
||||||
"gap", result.gap() == null ? "" : result.gap(),
|
"gap", result.gap() == null ? "" : result.gap(),
|
||||||
"goal", stateSafeGoal(goalService.toResponse(refreshed))))))
|
"goal", goalService.toResponse(refreshed)))))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Graph state may be checkpointed and restored through a generic map
|
|
||||||
* serializer. Keep event payloads limited to JSON primitives, maps and
|
|
||||||
* lists so a restored checklist cannot contain raw maps inside a typed
|
|
||||||
* {@link GoalResponse} bean and fail during SSE serialization.
|
|
||||||
*/
|
|
||||||
private static Map<String, Object> stateSafeGoal(GoalResponse goal) {
|
|
||||||
if (goal == null) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
|
||||||
snapshot.put("id", stringId(goal.getId()));
|
|
||||||
snapshot.put("conversationId", goal.getConversationId());
|
|
||||||
snapshot.put("agentId", stringId(goal.getAgentId()));
|
|
||||||
snapshot.put("workspaceId", stringId(goal.getWorkspaceId()));
|
|
||||||
snapshot.put("createdBy", goal.getCreatedBy());
|
|
||||||
snapshot.put("title", goal.getTitle());
|
|
||||||
snapshot.put("description", goal.getDescription());
|
|
||||||
snapshot.put("exitCriteria", goal.getExitCriteria());
|
|
||||||
snapshot.put("successCheckPrompt", goal.getSuccessCheckPrompt());
|
|
||||||
snapshot.put("status", goal.getStatus() == null ? null : goal.getStatus().getValue());
|
|
||||||
snapshot.put("persistentExecution", goal.getPersistentExecution());
|
|
||||||
snapshot.put("turnBudget", goal.getTurnBudget());
|
|
||||||
snapshot.put("turnsUsed", goal.getTurnsUsed());
|
|
||||||
snapshot.put("llmCallBudget", goal.getLlmCallBudget());
|
|
||||||
snapshot.put("agentLlmCallsUsed", goal.getAgentLlmCallsUsed());
|
|
||||||
snapshot.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed());
|
|
||||||
snapshot.put("totalLlmCallsUsed", goal.getTotalLlmCallsUsed());
|
|
||||||
snapshot.put("progressSummary", goal.getProgressSummary());
|
|
||||||
snapshot.put("completionScore", goal.getCompletionScore());
|
|
||||||
snapshot.put("lastEvaluationAt", stringTime(goal.getLastEvaluationAt()));
|
|
||||||
snapshot.put("autoFollowupEnabled", goal.getAutoFollowupEnabled());
|
|
||||||
snapshot.put("followupCooldownSeconds", goal.getFollowupCooldownSeconds());
|
|
||||||
snapshot.put("lastFollowupAt", stringTime(goal.getLastFollowupAt()));
|
|
||||||
snapshot.put("version", goal.getVersion());
|
|
||||||
snapshot.put("createTime", stringTime(goal.getCreateTime()));
|
|
||||||
snapshot.put("updateTime", stringTime(goal.getUpdateTime()));
|
|
||||||
|
|
||||||
List<Map<String, Object>> criteria = new ArrayList<>();
|
|
||||||
if (goal.getCriteria() != null) {
|
|
||||||
goal.getCriteria().forEach(criterion -> {
|
|
||||||
if (criterion == null) return;
|
|
||||||
Map<String, Object> item = new LinkedHashMap<>();
|
|
||||||
item.put("id", criterion.id() == null ? "" : criterion.id());
|
|
||||||
item.put("text", criterion.text() == null ? "" : criterion.text());
|
|
||||||
item.put("passed", criterion.passed());
|
|
||||||
item.put("evidence", criterion.evidence() == null ? "" : criterion.evidence());
|
|
||||||
criteria.add(Collections.unmodifiableMap(item));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
snapshot.put("criteria", List.copyOf(criteria));
|
|
||||||
return Collections.unmodifiableMap(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String stringId(Long value) {
|
|
||||||
return value == null ? null : value.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String stringTime(java.time.LocalDateTime value) {
|
|
||||||
return value == null ? null : value.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active goal for this run: prefer the turn-start
|
* Resolve the active goal for this run: prefer the turn-start
|
||||||
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation
|
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation
|
||||||
|
|||||||
@ -41,7 +41,7 @@ public class ObservationNode implements NodeAction {
|
|||||||
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
||||||
*/
|
*/
|
||||||
private static final java.util.Set<String> DISCLOSURE_TOOLS =
|
private static final java.util.Set<String> DISCLOSURE_TOOLS =
|
||||||
java.util.Set.of("load_skill", "enable_tool", "tool_search", "tool_describe");
|
java.util.Set.of("load_skill", "enable_tool");
|
||||||
|
|
||||||
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
|
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
|
||||||
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
|
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
|
||||||
@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction {
|
|||||||
* determined statically, and a false reminder is worse than none.
|
* determined statically, and a false reminder is worse than none.
|
||||||
*/
|
*/
|
||||||
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
|
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
|
||||||
java.util.Set.of("write_file", "append_file", "edit_file");
|
java.util.Set.of("write_file", "edit_file");
|
||||||
|
|
||||||
private static final String VERIFICATION_REMINDER =
|
private static final String VERIFICATION_REMINDER =
|
||||||
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
|
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
|
||||||
|
|||||||
@ -30,15 +30,12 @@ import vip.mate.agent.graph.state.FinishReason;
|
|||||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||||
import vip.mate.agent.graph.guard.ActionCompletionPolicy;
|
|
||||||
|
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.team.service.TeamContextBuilder;
|
import vip.mate.team.service.TeamContextBuilder;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||||
|
|
||||||
@ -62,7 +59,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
|
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
|
||||||
return MateClawStateAccessor.output().continueReasoning(false);
|
return MateClawStateAccessor.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -139,21 +136,11 @@ public class ReasoningNode implements NodeAction {
|
|||||||
*/
|
*/
|
||||||
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
|
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
|
||||||
|
|
||||||
private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000;
|
|
||||||
private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile(
|
|
||||||
"(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)");
|
|
||||||
private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile(
|
|
||||||
"(一万|1万|十千)\\s*(字|字符|中文字|汉字)");
|
|
||||||
private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile(
|
|
||||||
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
|
|
||||||
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
|
|
||||||
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile",
|
|
||||||
"write_file", "append_file", "local_write_file", "edit_file", "local_edit_file");
|
|
||||||
|
|
||||||
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
||||||
private static final String EMPTY_COMPLETION_NUDGE =
|
private static final String EMPTY_COMPLETION_NUDGE =
|
||||||
"上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:"
|
"Your previous turn was empty. If the task is not yet complete, continue now "
|
||||||
+ "调用工具或写出下一部分。如果所有必要步骤都已完成,请立即输出面向用户的最终答复。";
|
+ "with the next concrete step — call a tool or write the next part. If every "
|
||||||
|
+ "required step is already done, output the final answer to the user now.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continuation nudge for the most common premature-stop pattern: an empty
|
* Continuation nudge for the most common premature-stop pattern: an empty
|
||||||
@ -248,100 +235,6 @@ public class ReasoningNode implements NodeAction {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static OptionalInt requestedLongFormChars(String userMessage) {
|
|
||||||
if (userMessage == null || userMessage.isBlank()) {
|
|
||||||
return OptionalInt.empty();
|
|
||||||
}
|
|
||||||
Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage);
|
|
||||||
if (tenThousand.find()) {
|
|
||||||
return OptionalInt.of(10_000);
|
|
||||||
}
|
|
||||||
Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage);
|
|
||||||
int best = 0;
|
|
||||||
while (matcher.find()) {
|
|
||||||
String rawNumber = matcher.group(1).replace(",", "").replace(",", "");
|
|
||||||
double value;
|
|
||||||
try {
|
|
||||||
value = Double.parseDouble(rawNumber);
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String unit = matcher.group(2);
|
|
||||||
if ("万".equals(unit)) {
|
|
||||||
value *= 10_000;
|
|
||||||
} else if ("千".equals(unit) || "k".equals(unit) || "K".equals(unit)) {
|
|
||||||
value *= 1_000;
|
|
||||||
}
|
|
||||||
best = Math.max(best, (int) Math.round(value));
|
|
||||||
}
|
|
||||||
return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<ToolCallback> filterLongFormArtifactTools(String userMessage,
|
|
||||||
List<ToolCallback> callbacks) {
|
|
||||||
String currentRequest = currentUserRequest(userMessage);
|
|
||||||
if (callbacks == null || callbacks.isEmpty()
|
|
||||||
|| requestedLongFormChars(currentRequest).isEmpty()
|
|
||||||
|| EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) {
|
|
||||||
return callbacks;
|
|
||||||
}
|
|
||||||
return callbacks.stream()
|
|
||||||
.filter(callback -> {
|
|
||||||
String name = callback.getToolDefinition().name();
|
|
||||||
return ARTIFACT_DELIVERY_TOOL_PREFIXES.stream().noneMatch(name::startsWith);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean hasDisallowedLongFormArtifactCall(String userMessage,
|
|
||||||
List<AssistantMessage.ToolCall> toolCalls) {
|
|
||||||
String currentRequest = currentUserRequest(userMessage);
|
|
||||||
if (toolCalls == null || toolCalls.isEmpty()
|
|
||||||
|| requestedLongFormChars(currentRequest).isEmpty()
|
|
||||||
|| EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return toolCalls.stream().anyMatch(call -> ARTIFACT_DELIVERY_TOOL_PREFIXES.stream()
|
|
||||||
.anyMatch(prefix -> call.name().startsWith(prefix)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String currentUserRequest(String userMessage) {
|
|
||||||
if (userMessage == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
int memoryEnd = userMessage.lastIndexOf("</memory-context>");
|
|
||||||
return memoryEnd >= 0
|
|
||||||
? userMessage.substring(memoryEnd + "</memory-context>".length()).trim()
|
|
||||||
: userMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String appendLongFormChunk(String draft, String currentContent) {
|
|
||||||
return (draft != null ? draft : "") + (currentContent != null ? currentContent : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean shouldContinueLongForm(String userMessage, String longFormDraft,
|
|
||||||
String currentContent, int iteration, int maxIterations) {
|
|
||||||
OptionalInt requested = requestedLongFormChars(userMessage);
|
|
||||||
if (requested.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (maxIterations > 0 && iteration + 1 >= maxIterations) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return appendLongFormChunk(longFormDraft, currentContent).length() < requested.getAsInt();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UserMessage longFormContinuationPrompt(String userMessage, String longFormDraft,
|
|
||||||
String currentContent) {
|
|
||||||
int written = appendLongFormChunk(longFormDraft, currentContent).length();
|
|
||||||
int requested = requestedLongFormChars(userMessage).orElse(0);
|
|
||||||
return new UserMessage("""
|
|
||||||
[Runtime long-form continuation]
|
|
||||||
用户明确要求长篇输出,目标约 %d 字;目前累计约 %d 字,尚未达到目标。
|
|
||||||
请从上一段结尾自然继续写,不要重写开头,不要总结,不要说明原因,直接续写正文。
|
|
||||||
""".formatted(requested, written));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool-use enforcement clause appended to every ReasoningNode
|
* Tool-use enforcement clause appended to every ReasoningNode
|
||||||
* system prompt. Treats narration ("I will now …") as a protocol violation
|
* system prompt. Treats narration ("I will now …") as a protocol violation
|
||||||
@ -364,8 +257,8 @@ public class ReasoningNode implements NodeAction {
|
|||||||
+ "\"调研 10 个模型\"、\"逐节起草报告\"、\"批量生成 N 份文档\"、\n"
|
+ "\"调研 10 个模型\"、\"逐节起草报告\"、\"批量生成 N 份文档\"、\n"
|
||||||
+ "\"依次调用 N 个 API\"、\"对每个文件执行同一操作\"等。\n\n"
|
+ "\"依次调用 N 个 API\"、\"对每个文件执行同一操作\"等。\n\n"
|
||||||
+ "**必须做的事**:\n"
|
+ "**必须做的事**:\n"
|
||||||
+ "1. **第一轮回复就用并行 tool_calls 批量注册子目标为 `pending`**\n"
|
+ "1. **第一轮回复就用并行 tool_calls 批量注册全部子目标为 `pending`**\n"
|
||||||
+ " 每批最多 16 个 `progress_update`;超过 16 个时分批注册,避免超过执行器上限。\n"
|
+ " 一条回复里 N 个 `progress_update` 同时发出(不要串行)。\n"
|
||||||
+ " 例:要调研 10 个模型,第一轮就发 10 个 `progress_update(stepKey=\"model_xxx\", status=\"pending\")`。\n"
|
+ " 例:要调研 10 个模型,第一轮就发 10 个 `progress_update(stepKey=\"model_xxx\", status=\"pending\")`。\n"
|
||||||
+ "2. **每开始一个子目标**前发 `progress_update(同 stepKey, status=\"in_progress\")`。\n"
|
+ "2. **每开始一个子目标**前发 `progress_update(同 stepKey, status=\"in_progress\")`。\n"
|
||||||
+ "3. **每完成一个子目标**后立即发 `progress_update(同 stepKey, status=\"done\")`。\n"
|
+ "3. **每完成一个子目标**后立即发 `progress_update(同 stepKey, status=\"done\")`。\n"
|
||||||
@ -380,30 +273,6 @@ public class ReasoningNode implements NodeAction {
|
|||||||
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
|
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
|
||||||
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
|
+ "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n";
|
||||||
|
|
||||||
/**
|
|
||||||
* Staleness guard appended to every ReasoningNode system prompt. Conversation
|
|
||||||
* history can carry earlier rounds of the same status query verbatim (people
|
|
||||||
* count, device state, quotes, timestamps); models pattern-match those rounds
|
|
||||||
* and answer from the stale snapshot before this turn's tools have run —
|
|
||||||
* sometimes while claiming they already re-queried. Static text so the
|
|
||||||
* prompt-cache prefix stays stable; the current time it refers to is injected
|
|
||||||
* per turn by the runtime context block.
|
|
||||||
*/
|
|
||||||
private static final String STALE_CONTEXT_GUARD = "\n\n"
|
|
||||||
+ "## 历史状态数据过期规则(强制)\n\n"
|
|
||||||
+ "- 会话历史中出现的一切状态类数据(在线人数、设备/传感器状态、电量、温度、库存、行情、"
|
|
||||||
+ "查询时间戳等)都只是当时的快照,一律视为已过期,禁止在本轮回答中直接引用或改写后引用。\n"
|
|
||||||
+ "- 状态类问题必须先在本轮调用工具取得最新观察结果,等结果返回后再输出结论;"
|
|
||||||
+ "工具结果返回之前,不得输出含具体数值或结论的正文,只允许一句简短的过程说明(如\"正在查询…\")。\n"
|
|
||||||
+ "- 最终回答中引用的状态数值与查询时间必须来自本轮工具返回,时间基准以运行时上下文注入的当前时间为准。\n"
|
|
||||||
+ "- 注意:\"历史里查过\"不等于\"本轮已查\"。宣称已重新查询但未在本轮实际发出对应 tool_call,视为违规。\n";
|
|
||||||
|
|
||||||
private static final String LANGUAGE_CONSISTENCY_GUARD = "\n\n"
|
|
||||||
+ "## 语言一致性(强制)\n\n"
|
|
||||||
+ "- 用户使用中文时,所有可见思考、过程说明、最终答复都必须使用简体中文。\n"
|
|
||||||
+ "- 不要用英文书写可见思考或推理过程;代码、工具名、参数名、API 字段和专有名词可以保留原文。\n"
|
|
||||||
+ "- 如果工具结果或历史内容是英文,你可以阅读它,但面向用户展示的解释和推理必须翻译/转述为用户语言。\n";
|
|
||||||
|
|
||||||
private static final String GROUNDED_CONTRACT = "\n\n"
|
private static final String GROUNDED_CONTRACT = "\n\n"
|
||||||
+ "## 回答来源约束(强制规则)\n\n"
|
+ "## 回答来源约束(强制规则)\n\n"
|
||||||
+ "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n"
|
+ "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n"
|
||||||
@ -417,8 +286,8 @@ public class ReasoningNode implements NodeAction {
|
|||||||
+ "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n"
|
+ "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n"
|
||||||
+ "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n";
|
+ "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n";
|
||||||
|
|
||||||
static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
|
private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
|
||||||
String prompt = basePrompt + TOOL_USE_ENFORCEMENT + STALE_CONTEXT_GUARD + LANGUAGE_CONSISTENCY_GUARD;
|
String prompt = basePrompt + TOOL_USE_ENFORCEMENT;
|
||||||
return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt;
|
return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -964,7 +833,6 @@ public class ReasoningNode implements NodeAction {
|
|||||||
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
|
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
|
||||||
.activeCallbacks()
|
.activeCallbacks()
|
||||||
: toolCallbacks;
|
: toolCallbacks;
|
||||||
activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks);
|
|
||||||
|
|
||||||
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
|
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
|
||||||
|
|
||||||
@ -1244,62 +1112,12 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compatibility safety net for providers/adapters that return the
|
|
||||||
// runtime's reserved error placeholder as an HTTP-successful content
|
|
||||||
// response. Without this guard the long-form completion gate treats
|
|
||||||
// the placeholder as a short draft and can repeat it until the graph's
|
|
||||||
// iteration cap. Cron and other synchronous callers consume the
|
|
||||||
// resulting structured ERROR_FALLBACK; they do not need to infer from
|
|
||||||
// user-facing text.
|
|
||||||
if (isRuntimeErrorPlaceholder(result.text())) {
|
|
||||||
String errorText = result.text();
|
|
||||||
log.error("[ReasoningNode] Runtime error placeholder returned as normal content; failing turn");
|
|
||||||
return reasonOutput()
|
|
||||||
.needsToolCall(false)
|
|
||||||
.shouldSummarize(false)
|
|
||||||
.finalAnswer(errorText)
|
|
||||||
.llmCallCount(nextLlmCallCount)
|
|
||||||
.finishReason(FinishReason.ERROR_FALLBACK)
|
|
||||||
.contentStreamed(true)
|
|
||||||
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
|
|
||||||
.mergeUsage(state, result)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.partial()) {
|
if (result.partial()) {
|
||||||
int partialChars = result.text() != null ? result.text().length() : 0;
|
int partialChars = result.text() != null ? result.text().length() : 0;
|
||||||
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
|
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.hasToolCalls()) {
|
if (result.hasToolCalls()) {
|
||||||
if (hasDisallowedLongFormArtifactCall(accessor.userMessage(), result.toolCalls())) {
|
|
||||||
log.warn("[ReasoningNode] Rejecting artifact tool call for plain long-form response: {}",
|
|
||||||
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
|
||||||
UserMessage continuation = new UserMessage("""
|
|
||||||
[Runtime long-form delivery gate]
|
|
||||||
The user requested the long-form text directly in chat and did not request a file,
|
|
||||||
document, attachment, export, or download. Do not call rendering or file-writing tools.
|
|
||||||
Continue writing the requested text directly in the response.
|
|
||||||
""");
|
|
||||||
return reasonOutput()
|
|
||||||
.continueReasoning(true)
|
|
||||||
.iterationCount(accessor.iterationCount() + 1)
|
|
||||||
.needsToolCall(false)
|
|
||||||
.shouldSummarize(false)
|
|
||||||
.toolCalls(List.of())
|
|
||||||
.finalAnswer("")
|
|
||||||
.clearFinishReason()
|
|
||||||
.messages(List.of((Message) continuation))
|
|
||||||
.currentPhase("reasoning")
|
|
||||||
.streamedContent("")
|
|
||||||
.streamedThinking(result.thinking())
|
|
||||||
.contentStreamed(true)
|
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.llmCallCount(nextLlmCallCount)
|
|
||||||
.mergeUsage(state, result)
|
|
||||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
|
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
|
||||||
result.toolCalls().size(),
|
result.toolCalls().size(),
|
||||||
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
||||||
@ -1325,95 +1143,13 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
} else {
|
} else {
|
||||||
String content = result.text();
|
String content = result.text();
|
||||||
ActionCompletionPolicy.Decision completionDecision = ActionCompletionPolicy.evaluate(
|
|
||||||
accessor.actionCompletionRequired(), accessor.actionCompletionRetryCount(),
|
|
||||||
accessor.actionExecutionLedger());
|
|
||||||
if (completionDecision == ActionCompletionPolicy.Decision.RETRY) {
|
|
||||||
log.warn("[ReasoningNode] Rejecting text-only action completion; continuing once");
|
|
||||||
UserMessage continuation = new UserMessage("""
|
|
||||||
[Runtime completion gate]
|
|
||||||
This turn requires a real tool-backed action, but no substantive tool call was observed.
|
|
||||||
Continue now by emitting the required tool call. Do not claim success or only describe the call.
|
|
||||||
""");
|
|
||||||
return reasonOutput()
|
|
||||||
.continueReasoning(true)
|
|
||||||
.actionCompletionRetryCount(accessor.actionCompletionRetryCount() + 1)
|
|
||||||
.needsToolCall(false)
|
|
||||||
.shouldSummarize(false)
|
|
||||||
.finalAnswer("")
|
|
||||||
.clearFinishReason()
|
|
||||||
.messages(List.of((Message) result.assistantMessage(), continuation))
|
|
||||||
.currentPhase("reasoning")
|
|
||||||
.streamedContent(content != null ? content : "")
|
|
||||||
.streamedThinking(result.thinking())
|
|
||||||
.contentStreamed(true)
|
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.llmCallCount(nextLlmCallCount)
|
|
||||||
.mergeUsage(state, result)
|
|
||||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
if (completionDecision == ActionCompletionPolicy.Decision.UNVERIFIED
|
|
||||||
|| completionDecision == ActionCompletionPolicy.Decision.FAILED) {
|
|
||||||
boolean failed = completionDecision == ActionCompletionPolicy.Decision.FAILED;
|
|
||||||
String guardedAnswer = failed
|
|
||||||
? "动作工具执行失败,未确认操作成功。请检查工具返回的错误后重试。"
|
|
||||||
: "未观察到实际的动作工具调用,因此没有执行或确认该操作。请重试。";
|
|
||||||
log.warn("[ReasoningNode] Blocking unsupported action completion: {}", completionDecision);
|
|
||||||
return reasonOutput()
|
|
||||||
.needsToolCall(false)
|
|
||||||
.shouldSummarize(false)
|
|
||||||
.finalAnswer(guardedAnswer)
|
|
||||||
.finalThinking(result.thinking())
|
|
||||||
.messages(List.of((Message) result.assistantMessage()))
|
|
||||||
.currentPhase("reasoning")
|
|
||||||
.streamedContent("")
|
|
||||||
.finishReason(failed ? FinishReason.ACTION_FAILED : FinishReason.ACTION_UNVERIFIED)
|
|
||||||
.contentStreamed(false)
|
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.llmCallCount(nextLlmCallCount)
|
|
||||||
.mergeUsage(state, result)
|
|
||||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
||||||
if (shouldContinueLongForm(accessor.userMessage(), accessor.longFormDraft(), content,
|
|
||||||
accessor.iterationCount(), accessor.maxIterations())) {
|
|
||||||
String accumulatedDraft = appendLongFormChunk(accessor.longFormDraft(), content);
|
|
||||||
int written = accumulatedDraft.length();
|
|
||||||
int requested = requestedLongFormChars(accessor.userMessage()).orElse(0);
|
|
||||||
log.info("[ReasoningNode] Long-form answer below requested length ({} / {} chars), continuing",
|
|
||||||
written, requested);
|
|
||||||
return reasonOutput()
|
|
||||||
.continueReasoning(true)
|
|
||||||
.iterationCount(accessor.iterationCount() + 1)
|
|
||||||
.needsToolCall(false)
|
|
||||||
.shouldSummarize(false)
|
|
||||||
.finalAnswer("")
|
|
||||||
.longFormDraft(accumulatedDraft)
|
|
||||||
.clearFinishReason()
|
|
||||||
.messages(List.of((Message) result.assistantMessage(),
|
|
||||||
longFormContinuationPrompt(accessor.userMessage(), accessor.longFormDraft(), content)))
|
|
||||||
.currentPhase("reasoning")
|
|
||||||
.streamedContent(content != null ? content : "")
|
|
||||||
.streamedThinking(result.thinking())
|
|
||||||
.contentStreamed(true)
|
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.llmCallCount(nextLlmCallCount)
|
|
||||||
.mergeUsage(state, result)
|
|
||||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
pushPhase(conversationId, "drafting_answer", Map.of(
|
pushPhase(conversationId, "drafting_answer", Map.of(
|
||||||
"iteration", accessor.iterationCount(),
|
"iteration", accessor.iterationCount(),
|
||||||
"answerChars", content != null ? content.length() : 0
|
"answerChars", content != null ? content.length() : 0
|
||||||
));
|
));
|
||||||
boolean longFormRequest = requestedLongFormChars(accessor.userMessage()).isPresent();
|
|
||||||
String accumulatedContent = longFormRequest
|
|
||||||
? appendLongFormChunk(accessor.longFormDraft(), content)
|
|
||||||
: (content != null ? content : "");
|
|
||||||
String answerWithSources = accessor.sourceEvidenceLedger()
|
String answerWithSources = accessor.sourceEvidenceLedger()
|
||||||
.appendWikiSourceTable(accumulatedContent);
|
.appendWikiSourceTable(content != null ? content : "");
|
||||||
SourceEvidenceLedger.Validation validation =
|
SourceEvidenceLedger.Validation validation =
|
||||||
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
||||||
boolean evidenceInsufficient = !validation.valid();
|
boolean evidenceInsufficient = !validation.valid();
|
||||||
@ -1440,9 +1176,9 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.finalThinking(result.thinking())
|
.finalThinking(result.thinking())
|
||||||
.messages(List.of((Message) result.assistantMessage()))
|
.messages(List.of((Message) result.assistantMessage()))
|
||||||
.currentPhase("reasoning")
|
.currentPhase("reasoning")
|
||||||
.streamedContent(evidenceInsufficient ? accumulatedContent : "")
|
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
||||||
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
||||||
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, accumulatedContent))
|
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : ""))
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.llmCallCount(nextLlmCallCount)
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
@ -1451,10 +1187,6 @@ public class ReasoningNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean isRuntimeErrorPlaceholder(String text) {
|
|
||||||
return text != null && text.stripLeading().startsWith("[错误]");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String evidenceWarning(List<String> unsupportedReferences) {
|
private static String evidenceWarning(List<String> unsupportedReferences) {
|
||||||
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||||
+ String.join(", ", unsupportedReferences)
|
+ String.join(", ", unsupportedReferences)
|
||||||
|
|||||||
@ -152,17 +152,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
// token usage into the turn's _usage_final and to clear the accumulator
|
// token usage into the turn's _usage_final and to clear the accumulator
|
||||||
// on terminal so an errored turn never leaks an entry.
|
// on terminal so an errored turn never leaks an entry.
|
||||||
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
|
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
|
||||||
// Step results are persisted by PlanningService and the plan_step_completed
|
// 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容
|
||||||
// event (metadata.plan.stepResults). They must never be appended to the
|
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||||
// assistant message body: FINAL_SUMMARY is the sole canonical body. Keeping
|
|
||||||
// the two channels separate prevents one-step plans from rendering/persisting
|
|
||||||
// "answeranswer" and keeps live output identical to history replay.
|
|
||||||
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
||||||
// 最终汇总同样需要游标:FINAL_SUMMARY / FINAL_SUMMARY_THINKING 也是 REPLACE,
|
|
||||||
// 一旦写入就会出现在此后每个 NodeOutput 上。
|
|
||||||
AtomicReference<String> lastPersistedSummary = new AtomicReference<>("");
|
|
||||||
AtomicReference<String> lastPersistedSummaryThinking = new AtomicReference<>("");
|
|
||||||
AtomicReference<String> lastPersistedPlanThinking = new AtomicReference<>("");
|
|
||||||
|
|
||||||
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
@ -184,22 +176,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(MateClawStateKeys.THINKING_STREAMED, false);
|
.value(MateClawStateKeys.THINKING_STREAMED, false);
|
||||||
|
|
||||||
// 2·0 规划阶段的推理。它先于计划本身发出,且是整轮唯一必然发生的
|
// 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE,
|
||||||
// 一段推理 —— 步骤被派发到别处执行时,step / summary 两段
|
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
||||||
// 根本不会产生,此前这一轮就一段思考都不落库。
|
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
||||||
output.state().<String>value(PlanStateKeys.PLAN_THINKING)
|
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.filter(s -> !s.equals(lastPersistedPlanThinking.get()))
|
.filter(s -> !s.equals(lastPersistedStepResult.get()))
|
||||||
.ifPresent(planThinking -> {
|
.ifPresent(stepContent -> {
|
||||||
lastPersistedPlanThinking.set(planThinking);
|
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
|
||||||
deltas.add(AgentService.StreamDelta.persistOnly(null, planThinking));
|
lastPersistedStepResult.set(stepContent);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2a. Step reasoning may remain in the diagnostic timeline, but
|
|
||||||
// CURRENT_STEP_RESULT deliberately does not become a content
|
|
||||||
// delta. The result is already durable in the plan record and
|
|
||||||
// plan_step_completed metadata; only FINAL_SUMMARY belongs in
|
|
||||||
// mate_message.content.
|
|
||||||
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
|
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
|
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
|
||||||
@ -208,31 +195,18 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
lastPersistedStepThinking.set(stepThinking);
|
lastPersistedStepThinking.set(stepThinking);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2b. 最终汇总(同样 thinking 先于 content)
|
// 2b. 最终汇总
|
||||||
// 两个 key 都是 REPLACE:值会滞留在后续每个 NodeOutput 里。
|
|
||||||
// 没有游标时每批都会重发一次 —— 汇总正文被反复追加进
|
|
||||||
// mate_message.content,而 thinking 会在正文之后再落一段,
|
|
||||||
// 于是气泡末尾挂出一个孤立的思考框。与 2a 的 step 级
|
|
||||||
// 去重保持同一套写法。
|
|
||||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
|
||||||
.filter(s -> !s.isEmpty())
|
|
||||||
.filter(s -> !s.equals(lastPersistedSummaryThinking.get()))
|
|
||||||
.ifPresent(thinking -> {
|
|
||||||
lastPersistedSummaryThinking.set(thinking);
|
|
||||||
deltas.add(thinkingAlreadyStreamed
|
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
|
||||||
: new AgentService.StreamDelta(null, thinking));
|
|
||||||
});
|
|
||||||
|
|
||||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.filter(s -> !s.equals(lastPersistedSummary.get()))
|
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
|
||||||
.ifPresent(summary -> {
|
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||||
lastPersistedSummary.set(summary);
|
: new AgentService.StreamDelta(summary, null)));
|
||||||
deltas.add(contentAlreadyStreamed
|
|
||||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
||||||
: new AgentService.StreamDelta(summary, null));
|
.filter(s -> !s.isEmpty())
|
||||||
});
|
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
|
: new AgentService.StreamDelta(null, thinking)));
|
||||||
|
|
||||||
// 3. 更新最新累计 token usage
|
// 3. 更新最新累计 token usage
|
||||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||||
|
|||||||
@ -501,7 +501,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
// triage LLM classifies the wake-up text. Mirrors the approval-replay
|
// triage LLM classifies the wake-up text. Mirrors the approval-replay
|
||||||
// pattern: park in the DB, resume from the DB.
|
// pattern: park in the DB, resume from the DB.
|
||||||
if (teamPlanBridge != null) {
|
if (teamPlanBridge != null) {
|
||||||
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId, persistGoal);
|
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId);
|
||||||
if (parked instanceof TeamPlanBridge.Settled settled) {
|
if (parked instanceof TeamPlanBridge.Settled settled) {
|
||||||
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
|
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
|
||||||
settled.planId(), settled.completedResults().size());
|
settled.planId(), settled.completedResults().size());
|
||||||
@ -520,7 +520,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
|
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
|
||||||
log.info("[PlanGeneration] Answering from delegated team state without triage LLM");
|
log.info("[PlanGeneration] Delegated plan still in flight — answering with progress");
|
||||||
if (streamingHelper != null) {
|
if (streamingHelper != null) {
|
||||||
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
|
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
|
||||||
}
|
}
|
||||||
@ -590,26 +590,6 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
|
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
|
||||||
}
|
}
|
||||||
if (leadTeam != null) {
|
if (leadTeam != null) {
|
||||||
List<String> missingNamedMembers = teamPlanBridge.namedAgentsOutsideRoster(
|
|
||||||
leadTeam, persistGoal,
|
|
||||||
listDelegatableAgents(chatOrigin.workspaceId(), agentId));
|
|
||||||
if (!missingNamedMembers.isEmpty()) {
|
|
||||||
String answer = "团队成员校验未通过:当前团队不包含「"
|
|
||||||
+ String.join("、", missingNamedMembers)
|
|
||||||
+ "」。请先将缺失的 Agent 加入团队后重试,或明确允许使用现有成员替代。";
|
|
||||||
if (streamingHelper != null) {
|
|
||||||
streamingHelper.broadcastContent(conversationId, answer);
|
|
||||||
}
|
|
||||||
log.info("[PlanGeneration] Team {} missing explicitly requested agents: {}",
|
|
||||||
leadTeam.getId(), missingNamedMembers);
|
|
||||||
return PlanStateAccessor.output()
|
|
||||||
.needsPlanning(false)
|
|
||||||
.directAnswer(answer)
|
|
||||||
.currentPhase("direct_answer")
|
|
||||||
.contentStreamed(true)
|
|
||||||
.events(events)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
String memberLines = teamPlanBridge.roster(leadTeam).stream()
|
String memberLines = teamPlanBridge.roster(leadTeam).stream()
|
||||||
.map(a -> "- " + a.getName()
|
.map(a -> "- " + a.getName()
|
||||||
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||||
@ -621,11 +601,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
|
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
|
||||||
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。"
|
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。"
|
||||||
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
||||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
|
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
|
||||||
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
|
|
||||||
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n"
|
|
||||||
+ "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。"
|
|
||||||
+ "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。"));
|
|
||||||
} else {
|
} else {
|
||||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||||
if (!delegatable.isEmpty()) {
|
if (!delegatable.isEmpty()) {
|
||||||
@ -688,8 +664,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
|
|
||||||
String llmResponse = result.text();
|
String llmResponse = result.text();
|
||||||
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
|
log.info("[PlanGeneration] Triage completed in {}ms", triageMs);
|
||||||
log.debug("[PlanGeneration] LLM response received ({} chars)",
|
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
|
||||||
llmResponse == null ? 0 : llmResponse.length());
|
|
||||||
|
|
||||||
// D-6: emit triage perf summary
|
// D-6: emit triage perf summary
|
||||||
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
|
events.add(GraphEventPublisher.perfSummary("triage", Map.of(
|
||||||
@ -698,17 +673,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
"completion_tokens", result.completionTokens()
|
"completion_tokens", result.completionTokens()
|
||||||
)));
|
)));
|
||||||
|
|
||||||
TriageResult triage;
|
TriageResult triage = converter.convert(llmResponse);
|
||||||
if (!StringUtils.hasText(llmResponse)) {
|
|
||||||
// An upstream model can occasionally finish without content.
|
|
||||||
// Treat it as a recoverable single-step route, not a parser
|
|
||||||
// exception (and therefore not a false backend ERROR).
|
|
||||||
log.warn("[PlanGeneration] Triage returned empty content; using single-step fallback");
|
|
||||||
triage = new TriageResult(true, null, "single_step",
|
|
||||||
List.of(persistGoal), null, null);
|
|
||||||
} else {
|
|
||||||
triage = converter.convert(llmResponse);
|
|
||||||
}
|
|
||||||
boolean needsPlanning = triage != null && triage.needsPlanning();
|
boolean needsPlanning = triage != null && triage.needsPlanning();
|
||||||
|
|
||||||
if (!needsPlanning) {
|
if (!needsPlanning) {
|
||||||
@ -734,8 +699,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.planValid(true)
|
.planValid(true)
|
||||||
.currentStepIndex(0)
|
.currentStepIndex(0)
|
||||||
.currentPhase("plan_generated")
|
.currentPhase("plan_generated")
|
||||||
.planThinking(result.thinking())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events)
|
||||||
.build();
|
.build();
|
||||||
@ -750,7 +714,6 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.directAnswer(directAnswer)
|
.directAnswer(directAnswer)
|
||||||
.currentPhase("direct_answer")
|
.currentPhase("direct_answer")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.planThinking(result.thinking())
|
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -792,8 +755,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.directAnswer(announcement)
|
.directAnswer(announcement)
|
||||||
.currentPhase("direct_answer")
|
.currentPhase("direct_answer")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.planThinking(result.thinking())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events)
|
||||||
.build();
|
.build();
|
||||||
@ -837,8 +799,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.currentStepIndex(0)
|
.currentStepIndex(0)
|
||||||
.currentPhase("plan_generated")
|
.currentPhase("plan_generated")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.planThinking(result.thinking())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events);
|
.events(events);
|
||||||
if (autoGoal != null) {
|
if (autoGoal != null) {
|
||||||
|
|||||||
@ -85,68 +85,32 @@ public class PlanSummaryNode implements NodeAction {
|
|||||||
chatModel, prompt, conversationId, "plan_summary");
|
chatModel, prompt, conversationId, "plan_summary");
|
||||||
|
|
||||||
String summary = result.text();
|
String summary = result.text();
|
||||||
String thinking = result.thinking() == null ? "" : result.thinking();
|
|
||||||
|
|
||||||
// An interleaved-thinking model can spend its whole turn reasoning and
|
|
||||||
// return empty text. That empty string used to pass straight through:
|
|
||||||
// it became the plan's summary, then the run's terminal answer, and the
|
|
||||||
// goal evaluator skipped on "terminalAnswer empty" — so a plan whose
|
|
||||||
// steps had all succeeded ended with a dangling reasoning block and no
|
|
||||||
// report. The step results are already in hand, so answer from those
|
|
||||||
// rather than hand back nothing.
|
|
||||||
if (summary == null || summary.isBlank()) {
|
|
||||||
log.warn("[PlanSummary] Plan {} produced an empty summary "
|
|
||||||
+ "(thinking={} chars, {} step results); falling back to step results",
|
|
||||||
planId, thinking.length(), completedResults.size());
|
|
||||||
summary = buildFallbackSummary(goal, completedResults, SUMMARY_EMPTY_NOTE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The steps themselves succeeded — only the summary text was missing —
|
|
||||||
// so the plan completes rather than being marked failed.
|
|
||||||
planningService.completePlan(planId, summary);
|
planningService.completePlan(planId, summary);
|
||||||
log.info("[PlanSummary] Plan {} completed with summary: {}",
|
log.info("[PlanSummary] Plan {} completed with summary: {}",
|
||||||
planId, truncate(summary, 100));
|
planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary);
|
||||||
|
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.finalSummary(summary)
|
.finalSummary(summary)
|
||||||
.finalSummaryThinking(thinking)
|
.finalSummaryThinking(result.thinking())
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!thinking.isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e);
|
log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e);
|
||||||
String fallbackSummary = buildFallbackSummary(goal, completedResults, SUMMARY_FAILED_NOTE);
|
String fallbackSummary = buildFallbackSummary(goal, completedResults);
|
||||||
planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100));
|
planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100));
|
||||||
return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary);
|
return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reason line for the summary call throwing. */
|
|
||||||
private static final String SUMMARY_FAILED_NOTE = "LLM 汇总失败,以下为步骤原始结果";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reason line for the summary call returning nothing. Distinct from the
|
* 在 LLM 汇总调用失败时生成本地 fallback 摘要。
|
||||||
* failure note because nothing actually failed — the steps ran, the model
|
|
||||||
* simply produced no text — and telling the user their run failed would be
|
|
||||||
* wrong.
|
|
||||||
*/
|
|
||||||
private static final String SUMMARY_EMPTY_NOTE = "模型未产出汇总正文,以下为步骤原始结果";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在 LLM 汇总不可用时生成本地 fallback 摘要。
|
|
||||||
* 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。
|
* 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。
|
||||||
*
|
|
||||||
* @param note 说明为何回落到步骤原始结果
|
|
||||||
*/
|
*/
|
||||||
private static String buildFallbackSummary(String goal, List<String> completedResults, String note) {
|
private static String buildFallbackSummary(String goal, List<String> completedResults) {
|
||||||
StringBuilder sb = new StringBuilder("目标:").append(goal)
|
StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要(LLM 汇总失败,以下为步骤原始结果):\n");
|
||||||
.append("\n\n执行摘要(").append(note).append("):\n");
|
|
||||||
if (completedResults == null || completedResults.isEmpty()) {
|
|
||||||
sb.append("(没有已完成的步骤结果可供汇总)\n");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
for (String r : completedResults) {
|
for (String r : completedResults) {
|
||||||
sb.append(truncate(r, 300)).append("\n");
|
sb.append(truncate(r, 300)).append("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.node.ActionNode;
|
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
@ -36,7 +35,6 @@ import vip.mate.tool.builtin.DelegationContext;
|
|||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@ -197,7 +195,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
|
||||||
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
|
||||||
Set<String> loadedSkills = new LinkedHashSet<>(accessor.loadedSkills());
|
|
||||||
|
|
||||||
if (stepIndex >= steps.size()) {
|
if (stepIndex >= steps.size()) {
|
||||||
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
||||||
@ -205,7 +202,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.currentStepResult("步骤索引越界")
|
.currentStepResult("步骤索引越界")
|
||||||
.completedResults(formatStepResult(stepIndex, "步骤索引越界"))
|
.completedResults(formatStepResult(stepIndex, "步骤索引越界"))
|
||||||
.currentStepIndex(stepIndex + 1)
|
.currentStepIndex(stepIndex + 1)
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,14 +358,13 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
// (instead of leaking into the next LLM round).
|
// (instead of leaking into the next LLM round).
|
||||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||||
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||||
stepDirectOutputs, chatOrigin);
|
stepDirectOutputs);
|
||||||
toolResponses.add(response);
|
toolResponses.add(response);
|
||||||
preApprovedPayload = ""; // 只消费一次
|
preApprovedPayload = ""; // 只消费一次
|
||||||
} else {
|
} else {
|
||||||
// 非预批准工具走正常执行器
|
// 非预批准工具走正常执行器
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath,
|
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||||
chatOrigin, loadedSkills);
|
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
if (execResult.hasDirectOutputs()) {
|
if (execResult.hasDirectOutputs()) {
|
||||||
@ -384,26 +379,18 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||||
if (!allToolCalls.isEmpty()) {
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
|
toolResponses.addAll(execResult.responses());
|
||||||
chatOrigin, loadedSkills);
|
events.addAll(execResult.events());
|
||||||
toolResponses.addAll(execResult.responses());
|
if (execResult.hasDirectOutputs()) {
|
||||||
events.addAll(execResult.events());
|
stepDirectOutputs.addAll(execResult.directOutputs());
|
||||||
if (execResult.hasDirectOutputs()) {
|
}
|
||||||
stepDirectOutputs.addAll(execResult.directOutputs());
|
if (execResult.awaitingApproval()) {
|
||||||
}
|
approvalTriggered = true;
|
||||||
if (execResult.awaitingApproval()) {
|
approvalToolName = execResult.barrierToolName() != null
|
||||||
approvalTriggered = true;
|
? execResult.barrierToolName() : "unknown";
|
||||||
approvalToolName = execResult.barrierToolName() != null
|
|
||||||
? execResult.barrierToolName() : "unknown";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls);
|
|
||||||
if (!requestedSkills.isEmpty() && loadedSkills.addAll(requestedSkills)) {
|
|
||||||
log.debug("[StepExecution] pinned loaded skills in plan state: {}", requestedSkills);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将工具响应追加到消息
|
// 将工具响应追加到消息
|
||||||
@ -463,7 +450,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.currentPhase("awaiting_approval")
|
.currentPhase("awaiting_approval")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!stepThinking.isEmpty())
|
.thinkingStreamed(!stepThinking.isEmpty())
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -500,7 +486,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
|
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
|
||||||
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
|
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
|
||||||
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
|
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -551,7 +536,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.currentStepTitle("")
|
.currentStepTitle("")
|
||||||
.currentStepResult("")
|
.currentStepResult("")
|
||||||
.contentStreamed(false)
|
.contentStreamed(false)
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -610,7 +594,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.currentStepTitle("")
|
.currentStepTitle("")
|
||||||
.currentStepResult("")
|
.currentStepResult("")
|
||||||
.contentStreamed(false)
|
.contentStreamed(false)
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -622,12 +605,7 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.currentStepResult(shortError)
|
.currentStepResult(shortError)
|
||||||
.currentPhase("plan_aborted")
|
.currentPhase("plan_aborted")
|
||||||
// Terminal failures still need a canonical assistant body.
|
|
||||||
// CURRENT_STEP_RESULT no longer enters mate_message.content;
|
|
||||||
// FINAL_SUMMARY is the single persistence/broadcast channel.
|
|
||||||
.finalSummary(shortError)
|
|
||||||
.contentStreamed(false)
|
.contentStreamed(false)
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -672,7 +650,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.currentPhase("step_completed")
|
.currentPhase("step_completed")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!stepThinking.isEmpty())
|
.thinkingStreamed(!stepThinking.isEmpty())
|
||||||
.loadedSkills(Set.copyOf(loadedSkills))
|
|
||||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -822,9 +799,10 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
""";
|
""";
|
||||||
messages.add(new SystemMessage(enhancedSystemPrompt));
|
messages.add(new SystemMessage(enhancedSystemPrompt));
|
||||||
// Runtime skill catalog (rendered here instead of baked into the system
|
// Runtime skill catalog (rendered here instead of baked into the system
|
||||||
// prompt), ranked with skills already loaded during this graph run.
|
// prompt). The Plan path never pins per-run loads, so render with an
|
||||||
|
// empty loaded set — this reproduces the pre-disclosure DB ordering.
|
||||||
if (skillCatalogRenderer != null) {
|
if (skillCatalogRenderer != null) {
|
||||||
String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
|
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of());
|
||||||
if (skillCatalog != null && !skillCatalog.isBlank()) {
|
if (skillCatalog != null && !skillCatalog.isBlank()) {
|
||||||
messages.add(new SystemMessage(skillCatalog));
|
messages.add(new SystemMessage(skillCatalog));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,10 +94,6 @@ public final class PlanStateAccessor {
|
|||||||
return state.value(FINAL_SUMMARY_THINKING, "");
|
return state.value(FINAL_SUMMARY_THINKING, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String planThinking() {
|
|
||||||
return state.value(PLAN_THINKING, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
public String currentStepThinking() {
|
public String currentStepThinking() {
|
||||||
return state.value(CURRENT_STEP_THINKING, "");
|
return state.value(CURRENT_STEP_THINKING, "");
|
||||||
}
|
}
|
||||||
@ -140,11 +136,6 @@ public final class PlanStateAccessor {
|
|||||||
return state.value(WORKING_CONTEXT, "");
|
return state.value(WORKING_CONTEXT, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public Set<String> loadedSkills() {
|
|
||||||
return state.<Set<String>>value(MateClawStateKeys.LOADED_SKILLS).orElse(Set.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 输出构建器 =====
|
// ===== 输出构建器 =====
|
||||||
|
|
||||||
public static OutputBuilder output() {
|
public static OutputBuilder output() {
|
||||||
@ -234,10 +225,6 @@ public final class PlanStateAccessor {
|
|||||||
return put(FINAL_SUMMARY_THINKING, thinking);
|
return put(FINAL_SUMMARY_THINKING, thinking);
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutputBuilder planThinking(String thinking) {
|
|
||||||
return put(PLAN_THINKING, thinking);
|
|
||||||
}
|
|
||||||
|
|
||||||
public OutputBuilder currentStepThinking(String thinking) {
|
public OutputBuilder currentStepThinking(String thinking) {
|
||||||
return put(CURRENT_STEP_THINKING, thinking);
|
return put(CURRENT_STEP_THINKING, thinking);
|
||||||
}
|
}
|
||||||
@ -256,10 +243,6 @@ public final class PlanStateAccessor {
|
|||||||
return put(MateClawStateKeys.PENDING_EVENTS, events);
|
return put(MateClawStateKeys.PENDING_EVENTS, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutputBuilder loadedSkills(Set<String> names) {
|
|
||||||
return put(MateClawStateKeys.LOADED_SKILLS, names);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 阶段标记(写入共享键 MateClawStateKeys.CURRENT_PHASE)----
|
// ---- 阶段标记(写入共享键 MateClawStateKeys.CURRENT_PHASE)----
|
||||||
public OutputBuilder currentPhase(String phase) {
|
public OutputBuilder currentPhase(String phase) {
|
||||||
return put(MateClawStateKeys.CURRENT_PHASE, phase);
|
return put(MateClawStateKeys.CURRENT_PHASE, phase);
|
||||||
|
|||||||
@ -56,15 +56,6 @@ public final class PlanStateKeys {
|
|||||||
/** 当前步骤的完整 thinking */
|
/** 当前步骤的完整 thinking */
|
||||||
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
|
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
|
||||||
|
|
||||||
/**
|
|
||||||
* 规划阶段的完整 thinking —— 决定整个计划长什么样的那次推理。
|
|
||||||
* <p>
|
|
||||||
* It is the most consequential reasoning of the turn and the only one that
|
|
||||||
* exists when the steps are dispatched elsewhere instead of executed in
|
|
||||||
* this run, which is when the step / summary spans never happen at all.
|
|
||||||
*/
|
|
||||||
public static final String PLAN_THINKING = "plan_thinking";
|
|
||||||
|
|
||||||
// ===== 节点名称 =====
|
// ===== 节点名称 =====
|
||||||
public static final String PLAN_GENERATION_NODE = "plan_generation";
|
public static final String PLAN_GENERATION_NODE = "plan_generation";
|
||||||
public static final String STEP_EXECUTION_NODE = "step_execution";
|
public static final String STEP_EXECUTION_NODE = "step_execution";
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
package vip.mate.agent.graph.state;
|
|
||||||
|
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/** Authoritative per-run tool completion receipts used by the action completion gate. */
|
|
||||||
public final class ActionExecutionLedger {
|
|
||||||
|
|
||||||
private static final int MAX_RESULT_SUMMARY_CHARS = 512;
|
|
||||||
private static final Set<String> NON_SUBSTANTIVE_TOOLS = Set.of(
|
|
||||||
"load_skill", "enable_tool", "tool_search", "tool_describe",
|
|
||||||
"progress_update", "get_progress");
|
|
||||||
|
|
||||||
public enum Status { SUCCEEDED, FAILED }
|
|
||||||
|
|
||||||
public record Receipt(String toolCallId, String toolName, Status status,
|
|
||||||
String resultSummary, long completedAt) {
|
|
||||||
public boolean substantive() {
|
|
||||||
return toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final ActionExecutionLedger EMPTY = new ActionExecutionLedger(Map.of());
|
|
||||||
|
|
||||||
private final Map<String, Receipt> receipts;
|
|
||||||
|
|
||||||
private ActionExecutionLedger(Map<String, Receipt> receipts) {
|
|
||||||
this.receipts = Map.copyOf(receipts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ActionExecutionLedger empty() {
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ActionExecutionLedger fromEvents(List<GraphEventPublisher.GraphEvent> events) {
|
|
||||||
if (events == null || events.isEmpty()) return empty();
|
|
||||||
Map<String, Receipt> receipts = new LinkedHashMap<>();
|
|
||||||
int legacyIndex = 0;
|
|
||||||
for (GraphEventPublisher.GraphEvent event : events) {
|
|
||||||
if (event == null || !GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())) continue;
|
|
||||||
Map<String, Object> data = event.data();
|
|
||||||
String id = String.valueOf(data.getOrDefault("toolCallId", ""));
|
|
||||||
String name = String.valueOf(data.getOrDefault("toolName", ""));
|
|
||||||
if (id.isBlank()) id = "legacy-" + name + "-" + legacyIndex++;
|
|
||||||
boolean success = Boolean.parseBoolean(String.valueOf(data.getOrDefault("success", false)));
|
|
||||||
String result = String.valueOf(data.getOrDefault("result", ""));
|
|
||||||
if (result.length() > MAX_RESULT_SUMMARY_CHARS) {
|
|
||||||
result = result.substring(0, MAX_RESULT_SUMMARY_CHARS) + "...";
|
|
||||||
}
|
|
||||||
receipts.put(id, new Receipt(id, name,
|
|
||||||
success ? Status.SUCCEEDED : Status.FAILED, result, event.timestamp()));
|
|
||||||
}
|
|
||||||
return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Receipt> receipts() {
|
|
||||||
return receipts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasSubstantiveAttempt() {
|
|
||||||
return receipts.values().stream().anyMatch(Receipt::substantive);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasSuccessfulSubstantiveCall() {
|
|
||||||
return receipts.values().stream()
|
|
||||||
.anyMatch(receipt -> receipt.substantive() && receipt.status() == Status.SUCCEEDED);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasSuccessfulTool(String toolName) {
|
|
||||||
return receipts.values().stream().anyMatch(receipt ->
|
|
||||||
receipt.status() == Status.SUCCEEDED && receipt.toolName().equals(toolName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ActionExecutionLedger merge(ActionExecutionLedger other) {
|
|
||||||
if (other == null || other.receipts.isEmpty()) return this;
|
|
||||||
if (receipts.isEmpty()) return other;
|
|
||||||
Map<String, Receipt> merged = new LinkedHashMap<>(receipts);
|
|
||||||
merged.putAll(other.receipts);
|
|
||||||
return new ActionExecutionLedger(merged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -25,12 +25,6 @@ public enum FinishReason {
|
|||||||
/** 最终回答引用了未被工具结果验证的源码事实 */
|
/** 最终回答引用了未被工具结果验证的源码事实 */
|
||||||
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
|
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
|
||||||
|
|
||||||
/** An executable action was required but no substantive tool call was observed. */
|
|
||||||
ACTION_UNVERIFIED("action_unverified"),
|
|
||||||
|
|
||||||
/** Substantive action tools ran, but none completed successfully. */
|
|
||||||
ACTION_FAILED("action_failed"),
|
|
||||||
|
|
||||||
/** 用户主动停止 */
|
/** 用户主动停止 */
|
||||||
STOPPED("stopped"),
|
STOPPED("stopped"),
|
||||||
|
|
||||||
|
|||||||
@ -119,10 +119,6 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(FINAL_ANSWER_DRAFT, "");
|
return state.value(FINAL_ANSWER_DRAFT, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String longFormDraft() {
|
|
||||||
return state.value(LONG_FORM_DRAFT, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean limitExceeded() {
|
public boolean limitExceeded() {
|
||||||
return state.value(LIMIT_EXCEEDED, false);
|
return state.value(LIMIT_EXCEEDED, false);
|
||||||
}
|
}
|
||||||
@ -220,22 +216,6 @@ public final class MateClawStateAccessor {
|
|||||||
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
|
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ActionExecutionLedger actionExecutionLedger() {
|
|
||||||
return state.<ActionExecutionLedger>value(ACTION_EXECUTION_LEDGER).orElse(ActionExecutionLedger.empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean actionCompletionRequired() {
|
|
||||||
return state.value(ACTION_COMPLETION_REQUIRED, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int actionCompletionRetryCount() {
|
|
||||||
return state.value(ACTION_COMPLETION_RETRY_COUNT, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean continueReasoning() {
|
|
||||||
return state.value(CONTINUE_REASONING, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 审批重放 =====
|
// ===== 审批重放 =====
|
||||||
|
|
||||||
public String forcedToolCall() {
|
public String forcedToolCall() {
|
||||||
@ -457,10 +437,6 @@ public final class MateClawStateAccessor {
|
|||||||
return put(FINAL_ANSWER_DRAFT, draft);
|
return put(FINAL_ANSWER_DRAFT, draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutputBuilder longFormDraft(String draft) {
|
|
||||||
return put(LONG_FORM_DRAFT, draft);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 终止 ----
|
// ---- 终止 ----
|
||||||
public OutputBuilder finalAnswer(String answer) {
|
public OutputBuilder finalAnswer(String answer) {
|
||||||
return put(FINAL_ANSWER, answer);
|
return put(FINAL_ANSWER, answer);
|
||||||
@ -547,22 +523,6 @@ public final class MateClawStateAccessor {
|
|||||||
return put(SOURCE_EVIDENCE_LEDGER, ledger);
|
return put(SOURCE_EVIDENCE_LEDGER, ledger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutputBuilder actionExecutionLedger(ActionExecutionLedger ledger) {
|
|
||||||
return put(ACTION_EXECUTION_LEDGER, ledger);
|
|
||||||
}
|
|
||||||
|
|
||||||
public OutputBuilder actionCompletionRequired(boolean required) {
|
|
||||||
return put(ACTION_COMPLETION_REQUIRED, required);
|
|
||||||
}
|
|
||||||
|
|
||||||
public OutputBuilder actionCompletionRetryCount(int count) {
|
|
||||||
return put(ACTION_COMPLETION_RETRY_COUNT, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public OutputBuilder continueReasoning(boolean shouldContinue) {
|
|
||||||
return put(CONTINUE_REASONING, shouldContinue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 审批重放 ----
|
// ---- 审批重放 ----
|
||||||
public OutputBuilder forcedToolCall(String json) {
|
public OutputBuilder forcedToolCall(String json) {
|
||||||
return put(FORCED_TOOL_CALL, json);
|
return put(FORCED_TOOL_CALL, json);
|
||||||
|
|||||||
@ -64,8 +64,6 @@ public final class MateClawStateKeys {
|
|||||||
|
|
||||||
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
|
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
|
||||||
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft";
|
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft";
|
||||||
/** Accumulated visible body for an explicit long-form generation request. */
|
|
||||||
public static final String LONG_FORM_DRAFT = "long_form_draft";
|
|
||||||
|
|
||||||
/** 是否需要进入 summarizing 阶段 */
|
/** 是否需要进入 summarizing 阶段 */
|
||||||
public static final String SHOULD_SUMMARIZE = "should_summarize";
|
public static final String SHOULD_SUMMARIZE = "should_summarize";
|
||||||
@ -186,18 +184,6 @@ public final class MateClawStateKeys {
|
|||||||
/** Source references observed from successful tool results during this run. */
|
/** Source references observed from successful tool results during this run. */
|
||||||
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
|
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
|
||||||
|
|
||||||
/** Authoritative terminal tool receipts accumulated during the current graph run. */
|
|
||||||
public static final String ACTION_EXECUTION_LEDGER = "action_execution_ledger";
|
|
||||||
|
|
||||||
/** True when structured runtime context says this turn must perform an executable action. */
|
|
||||||
public static final String ACTION_COMPLETION_REQUIRED = "action_completion_required";
|
|
||||||
|
|
||||||
/** Number of completion-gate continuations consumed in the current run. */
|
|
||||||
public static final String ACTION_COMPLETION_RETRY_COUNT = "action_completion_retry_count";
|
|
||||||
|
|
||||||
/** One-shot edge signal routing a rejected final candidate back to ReasoningNode. */
|
|
||||||
public static final String CONTINUE_REASONING = "continue_reasoning";
|
|
||||||
|
|
||||||
// ===== Persistent goal — cross-turn objective lock-in =====
|
// ===== Persistent goal — cross-turn objective lock-in =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -26,14 +26,6 @@ public class AgentEntity {
|
|||||||
/** Agent 类型:react / plan_execute */
|
/** Agent 类型:react / plan_execute */
|
||||||
private String agentType;
|
private String agentType;
|
||||||
|
|
||||||
/** Runtime provider type: native / dsh / other registered providers. */
|
|
||||||
@TableField(value = "runtime_type")
|
|
||||||
private String runtimeType;
|
|
||||||
|
|
||||||
/** Runtime-specific JSON configuration. Null means provider defaults. */
|
|
||||||
@TableField(value = "runtime_config", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String runtimeConfig;
|
|
||||||
|
|
||||||
/** 系统提示词 */
|
/** 系统提示词 */
|
||||||
@TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import vip.mate.channel.web.ChatStreamTracker;
|
|||||||
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
|
import vip.mate.channel.web.ChatStreamTracker.RunSnapshot;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -117,26 +116,18 @@ public class AgentRuntimeAggregator {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public RuntimeSnapshot snapshot() {
|
public RuntimeSnapshot snapshot() {
|
||||||
return snapshot(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public RuntimeSnapshot snapshot(Long workspaceId) {
|
|
||||||
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
||||||
Set<Long> agentIds = rawRuns.stream()
|
Set<Long> agentIds = rawRuns.stream()
|
||||||
.map(RunSnapshot::agentId)
|
.map(RunSnapshot::agentId)
|
||||||
.filter(java.util.Objects::nonNull)
|
.filter(java.util.Objects::nonNull)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
Collection<SubagentRegistry.SubagentRecord> rawSubagents = subagentRegistry.allActive();
|
for (var rec : subagentRegistry.allActive()) {
|
||||||
for (var rec : rawSubagents) {
|
|
||||||
if (rec.agentId() != null) agentIds.add(rec.agentId());
|
if (rec.agentId() != null) agentIds.add(rec.agentId());
|
||||||
}
|
}
|
||||||
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
|
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
|
||||||
|
|
||||||
Map<String, Long> subagentCountByParent = new HashMap<>();
|
Map<String, Long> subagentCountByParent = new HashMap<>();
|
||||||
for (var rec : rawSubagents) {
|
for (var rec : subagentRegistry.allActive()) {
|
||||||
if (!belongsToWorkspace(rec.agentId(), agentInfo, workspaceId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String parent = rec.parentConversationId();
|
String parent = rec.parentConversationId();
|
||||||
if (parent != null) {
|
if (parent != null) {
|
||||||
subagentCountByParent.merge(parent, 1L, Long::sum);
|
subagentCountByParent.merge(parent, 1L, Long::sum);
|
||||||
@ -150,7 +141,6 @@ public class AgentRuntimeAggregator {
|
|||||||
int runningCount = 0;
|
int runningCount = 0;
|
||||||
for (RunSnapshot s : rawRuns) {
|
for (RunSnapshot s : rawRuns) {
|
||||||
if (s.done()) continue;
|
if (s.done()) continue;
|
||||||
if (!belongsToWorkspace(s.agentId(), agentInfo, workspaceId)) continue;
|
|
||||||
runningCount++;
|
runningCount++;
|
||||||
String stuckReason = computeStuckReason(s);
|
String stuckReason = computeStuckReason(s);
|
||||||
boolean orphan = s.subscriberCount() == 0;
|
boolean orphan = s.subscriberCount() == 0;
|
||||||
@ -191,8 +181,7 @@ public class AgentRuntimeAggregator {
|
|||||||
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
|
return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent());
|
||||||
});
|
});
|
||||||
|
|
||||||
List<SubagentCard> subCards = rawSubagents.stream()
|
List<SubagentCard> subCards = subagentRegistry.allActive().stream()
|
||||||
.filter(rec -> belongsToWorkspace(rec.agentId(), agentInfo, workspaceId))
|
|
||||||
.map(rec -> {
|
.map(rec -> {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
|
AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId());
|
||||||
@ -227,33 +216,6 @@ public class AgentRuntimeAggregator {
|
|||||||
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis());
|
return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis());
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean runBelongsToWorkspace(String conversationId, Long workspaceId) {
|
|
||||||
if (conversationId == null || workspaceId == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
|
|
||||||
for (RunSnapshot run : rawRuns) {
|
|
||||||
if (run.done() || !conversationId.equals(run.conversationId())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
AgentEntity agent = resolveAgent(run.agentId());
|
|
||||||
return agent != null && workspaceId.equals(agent.getWorkspaceId());
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean subagentBelongsToWorkspace(String subagentId, Long workspaceId) {
|
|
||||||
if (subagentId == null || workspaceId == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return subagentRegistry.get(subagentId)
|
|
||||||
.map(rec -> {
|
|
||||||
AgentEntity agent = resolveAgent(rec.agentId());
|
|
||||||
return agent != null && workspaceId.equals(agent.getWorkspaceId());
|
|
||||||
})
|
|
||||||
.orElse(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns null when the run looks healthy. The returned tag is a stable
|
* Returns null when the run looks healthy. The returned tag is a stable
|
||||||
* machine-readable code (not a translated label) so the frontend can
|
* machine-readable code (not a translated label) so the frontend can
|
||||||
@ -282,25 +244,4 @@ public class AgentRuntimeAggregator {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentEntity resolveAgent(Long id) {
|
|
||||||
if (id == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return agentService.getAgent(id);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("agent lookup failed for id={}: {}", id, e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean belongsToWorkspace(Long agentId, Map<Long, AgentEntity> agentInfo,
|
|
||||||
Long workspaceId) {
|
|
||||||
if (workspaceId == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
AgentEntity agent = agentId == null ? null : agentInfo.get(agentId);
|
|
||||||
return agent != null && workspaceId.equals(agent.getWorkspaceId());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,13 +19,13 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||||
import vip.mate.agent.runtime.dsh.DshRuntimeService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-only live runtime surface: the workspace view of every in-flight agent
|
* Admin-only live runtime surface: the global view of every in-flight agent
|
||||||
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
|
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
|
||||||
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
|
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
|
||||||
* owner-scoped.
|
* owner-scoped — this controller is intentionally cross-tenant for the
|
||||||
|
* operator role.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Tag(name = "Agent Runtime (Live)")
|
@Tag(name = "Agent Runtime (Live)")
|
||||||
@ -40,35 +40,21 @@ public class AgentRuntimeController {
|
|||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final I18nService i18nService;
|
private final I18nService i18nService;
|
||||||
private final DshRuntimeService dshRuntimeService;
|
|
||||||
|
|
||||||
@Operation(summary = "Snapshot of every in-flight agent turn")
|
@Operation(summary = "Snapshot of every in-flight agent turn")
|
||||||
@GetMapping("/snapshot")
|
@GetMapping("/snapshot")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(
|
public R<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
|
||||||
Authentication auth) {
|
|
||||||
requireAdmin(auth);
|
requireAdmin(auth);
|
||||||
requireWorkspace(workspaceId);
|
return R.ok(aggregator.snapshot());
|
||||||
return R.ok(aggregator.snapshot(workspaceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "DSH runtime availability and capability diagnostics")
|
|
||||||
@GetMapping("/dsh/diagnostics")
|
|
||||||
@RequireGlobalAdmin
|
|
||||||
public R<Map<String, Object>> dshDiagnostics(Authentication auth) {
|
|
||||||
requireAdmin(auth);
|
|
||||||
return R.ok(dshRuntimeService.diagnostics());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
|
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
|
||||||
@PostMapping("/runs/{conversationId}/stop")
|
@PostMapping("/runs/{conversationId}/stop")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
|
public R<Map<String, Object>> stopFriendly(@PathVariable String conversationId,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
requireAdmin(auth);
|
requireAdmin(auth);
|
||||||
requireRunInWorkspace(conversationId, workspaceId);
|
|
||||||
boolean ok = streamTracker.requestStop(conversationId);
|
boolean ok = streamTracker.requestStop(conversationId);
|
||||||
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
|
recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok));
|
||||||
return R.ok(Map.of("stopped", ok));
|
return R.ok(Map.of("stopped", ok));
|
||||||
@ -78,10 +64,8 @@ public class AgentRuntimeController {
|
|||||||
@PostMapping("/runs/{conversationId}/recycle")
|
@PostMapping("/runs/{conversationId}/recycle")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
|
public R<Map<String, Object>> recycle(@PathVariable String conversationId,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
requireAdmin(auth);
|
requireAdmin(auth);
|
||||||
requireRunInWorkspace(conversationId, workspaceId);
|
|
||||||
boolean ok = streamTracker.forceRecycle(conversationId);
|
boolean ok = streamTracker.forceRecycle(conversationId);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
finalizeRecycledConversation(conversationId);
|
finalizeRecycledConversation(conversationId);
|
||||||
@ -94,10 +78,8 @@ public class AgentRuntimeController {
|
|||||||
@PostMapping("/subagents/{subagentId}/interrupt")
|
@PostMapping("/subagents/{subagentId}/interrupt")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
|
public R<Map<String, Object>> interruptSubagent(@PathVariable String subagentId,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
requireAdmin(auth);
|
requireAdmin(auth);
|
||||||
requireSubagentInWorkspace(subagentId, workspaceId);
|
|
||||||
boolean ok = subagentRegistry.interrupt(subagentId);
|
boolean ok = subagentRegistry.interrupt(subagentId);
|
||||||
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
|
recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok));
|
||||||
return R.ok(Map.of("interrupted", ok));
|
return R.ok(Map.of("interrupted", ok));
|
||||||
@ -111,12 +93,9 @@ public class AgentRuntimeController {
|
|||||||
@Operation(summary = "Recycle every run currently flagged as stuck")
|
@Operation(summary = "Recycle every run currently flagged as stuck")
|
||||||
@PostMapping("/sweep")
|
@PostMapping("/sweep")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
public R<Map<String, Object>> sweep(
|
public R<Map<String, Object>> sweep(Authentication auth) {
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
|
||||||
Authentication auth) {
|
|
||||||
requireAdmin(auth);
|
requireAdmin(auth);
|
||||||
requireWorkspace(workspaceId);
|
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
|
||||||
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(workspaceId);
|
|
||||||
List<String> ids = snap.runs().stream()
|
List<String> ids = snap.runs().stream()
|
||||||
.filter(r -> r.stuckReason() != null)
|
.filter(r -> r.stuckReason() != null)
|
||||||
.map(AgentRuntimeAggregator.RunCard::conversationId)
|
.map(AgentRuntimeAggregator.RunCard::conversationId)
|
||||||
@ -175,26 +154,6 @@ public class AgentRuntimeController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireWorkspace(Long workspaceId) {
|
|
||||||
if (workspaceId == null) {
|
|
||||||
throw new MateClawException(400, "workspace id required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireRunInWorkspace(String conversationId, Long workspaceId) {
|
|
||||||
requireWorkspace(workspaceId);
|
|
||||||
if (!aggregator.runBelongsToWorkspace(conversationId, workspaceId)) {
|
|
||||||
throw new MateClawException(404, "runtime run not found in workspace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireSubagentInWorkspace(String subagentId, Long workspaceId) {
|
|
||||||
requireWorkspace(workspaceId);
|
|
||||||
if (!aggregator.subagentBelongsToWorkspace(subagentId, workspaceId)) {
|
|
||||||
throw new MateClawException(404, "subagent not found in workspace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void recordAudit(Authentication auth, String action,
|
private void recordAudit(Authentication auth, String action,
|
||||||
String resourceId, Map<String, Object> detail) {
|
String resourceId, Map<String, Object> detail) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
package vip.mate.agent.runtime;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/** Atomic local admission shared by interactive, replay and autonomous turns. */
|
|
||||||
@Component
|
|
||||||
public class ConversationTurnGate {
|
|
||||||
private final ConcurrentHashMap<String, Permit> owners = new ConcurrentHashMap<>();
|
|
||||||
private final ThreadLocal<Permit> admitted = new ThreadLocal<>();
|
|
||||||
|
|
||||||
public Permit tryAcquire(String conversationId) {
|
|
||||||
if (conversationId == null || conversationId.isBlank()) return new Permit(null);
|
|
||||||
Permit current = admitted.get();
|
|
||||||
if (current != null && conversationId.equals(current.conversationId)
|
|
||||||
&& owners.get(conversationId) == current) return new Permit(null);
|
|
||||||
Permit permit = new Permit(conversationId);
|
|
||||||
return owners.putIfAbsent(conversationId, permit) == null ? permit : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enter the already-admitted call synchronously; inner lifecycle cleanup must not release its owner. */
|
|
||||||
public <T> T withPermit(Permit permit, java.util.function.Supplier<T> call) {
|
|
||||||
Permit previous=admitted.get();
|
|
||||||
admitted.set(permit);
|
|
||||||
try { return call.get(); }
|
|
||||||
finally { if (previous==null) admitted.remove(); else admitted.set(previous); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public final class Permit implements AutoCloseable {
|
|
||||||
private final String conversationId;
|
|
||||||
private Permit(String conversationId) { this.conversationId = conversationId; }
|
|
||||||
@Override public void close() {
|
|
||||||
if (conversationId != null) owners.remove(conversationId, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package vip.mate.agent.runtime;
|
|
||||||
|
|
||||||
import vip.mate.agent.AgentService;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeEvent;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeEventType;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** Projects normalized runtime events onto the existing chat stream vocabulary. */
|
|
||||||
public final class RuntimeEventProjector {
|
|
||||||
private RuntimeEventProjector() {}
|
|
||||||
|
|
||||||
public static AgentService.StreamDelta project(RuntimeEvent event) {
|
|
||||||
if (event == null) return AgentService.StreamDelta.empty();
|
|
||||||
Map<String, Object> data = new LinkedHashMap<>(event.data());
|
|
||||||
data.putIfAbsent("runtimeSessionId", event.sessionId());
|
|
||||||
data.putIfAbsent("runtimeSequence", event.sequence());
|
|
||||||
return switch (event.type()) {
|
|
||||||
case RUNTIME_READY -> AgentService.StreamDelta.event("phase",
|
|
||||||
with(data, "phase", "runtime_ready"));
|
|
||||||
case ASSISTANT_DELTA -> new AgentService.StreamDelta(
|
|
||||||
text(event, data), null, null, null, false, false, null);
|
|
||||||
case THINKING_DELTA -> new AgentService.StreamDelta(
|
|
||||||
null, text(event, data), null, null, false, false, null);
|
|
||||||
case TOOL_STARTED -> AgentService.StreamDelta.event("tool_call_started",
|
|
||||||
rename(data, "toolName", "name", "callId", "toolCallId"));
|
|
||||||
case TOOL_APPROVAL_REQUIRED -> AgentService.StreamDelta.event("tool_approval_requested",
|
|
||||||
rename(data, "requestId", "pendingId", "toolName", "toolName"));
|
|
||||||
case TOOL_FINISHED -> AgentService.StreamDelta.event("tool_call_completed",
|
|
||||||
rename(data, "callId", "toolCallId", "toolName", "toolName"));
|
|
||||||
case SUBAGENT_STARTED -> AgentService.StreamDelta.event("subagent_start", data);
|
|
||||||
case SUBAGENT_FINISHED -> AgentService.StreamDelta.event("subagent_complete", data);
|
|
||||||
case CONTEXT_USAGE -> AgentService.StreamDelta.event("_usage_final", data);
|
|
||||||
case COMPLETED -> AgentService.StreamDelta.event("done", data);
|
|
||||||
case FAILED -> AgentService.StreamDelta.event("error", data);
|
|
||||||
case CANCELLED -> AgentService.StreamDelta.event("cancelled", data);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> with(Map<String, Object> source, String key, Object value) {
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>(source);
|
|
||||||
result.put(key, value);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String text(RuntimeEvent event, Map<String, Object> data) {
|
|
||||||
Object delta = data.get("delta");
|
|
||||||
return delta != null ? String.valueOf(delta) : event.text() == null ? "" : event.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> rename(Map<String, Object> source, String from, String to,
|
|
||||||
String secondFrom, String secondTo) {
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>(source);
|
|
||||||
copyIfPresent(result, from, to);
|
|
||||||
copyIfPresent(result, secondFrom, secondTo);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void copyIfPresent(Map<String, Object> data, String from, String to) {
|
|
||||||
if (!data.containsKey(to) && data.containsKey(from)) data.put(to, data.get(from));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package vip.mate.agent.runtime;
|
|
||||||
|
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
import vip.mate.agent.AgentService;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeEvent;
|
|
||||||
|
|
||||||
/** Adapts provider event streams to the native chat stream contract. */
|
|
||||||
public final class RuntimeEventStreamAdapter {
|
|
||||||
private RuntimeEventStreamAdapter() {}
|
|
||||||
|
|
||||||
public static Flux<AgentService.StreamDelta> adapt(Flux<RuntimeEvent> events) {
|
|
||||||
if (events == null) return Flux.empty();
|
|
||||||
return events.map(RuntimeEventProjector::project);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package vip.mate.agent.runtime;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import vip.mate.agent.runtime.contract.AgentRuntimeCoordinator;
|
|
||||||
import vip.mate.agent.runtime.contract.AgentRuntimeProvider;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeProviderRegistry;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** Spring wiring for the runtime SPI. Native execution remains owned by AgentService. */
|
|
||||||
@Configuration
|
|
||||||
public class RuntimeProviderConfiguration {
|
|
||||||
@Bean
|
|
||||||
RuntimeProviderRegistry runtimeProviderRegistry(List<AgentRuntimeProvider> providers) {
|
|
||||||
return new RuntimeProviderRegistry(providers);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
AgentRuntimeCoordinator agentRuntimeCoordinator(RuntimeProviderRegistry registry,
|
|
||||||
ObjectMapper objectMapper) {
|
|
||||||
return new AgentRuntimeCoordinator(registry, objectMapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
public interface AgentRuntimeConnection extends AutoCloseable {
|
|
||||||
Flux<RuntimeEvent> prompt(String message);
|
|
||||||
|
|
||||||
Mono<Void> cancel();
|
|
||||||
|
|
||||||
Mono<RuntimeContextUsage> contextUsage();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
default void close() {
|
|
||||||
cancel().block();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import vip.mate.agent.model.AgentEntity;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
/** Selects, validates, and starts the provider chosen by an employee. */
|
|
||||||
public final class AgentRuntimeCoordinator {
|
|
||||||
private final RuntimeProviderRegistry providerRegistry;
|
|
||||||
private final RuntimeSessionFactory sessionFactory;
|
|
||||||
|
|
||||||
public AgentRuntimeCoordinator(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) {
|
|
||||||
this.providerRegistry = providerRegistry;
|
|
||||||
this.sessionFactory = new RuntimeSessionFactory(providerRegistry, objectMapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AgentRuntimeConnection start(AgentEntity agent, String conversationId, String sessionId,
|
|
||||||
String modelName, Path workspaceRoot, Path workingDirectory) {
|
|
||||||
RuntimeSession session = sessionFactory.create(agent, conversationId, sessionId,
|
|
||||||
modelName, workspaceRoot, workingDirectory);
|
|
||||||
return providerRegistry.resolve(agent.getRuntimeType()).start(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
public interface AgentRuntimeProvider {
|
|
||||||
String type();
|
|
||||||
|
|
||||||
RuntimeValidation validate(RuntimeSession session);
|
|
||||||
|
|
||||||
RuntimeCapabilities capabilities();
|
|
||||||
|
|
||||||
AgentRuntimeConnection start(RuntimeSession session);
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
public record RuntimeCapabilities(
|
|
||||||
boolean supportsCancellation,
|
|
||||||
boolean supportsApprovals,
|
|
||||||
boolean supportsSubagents,
|
|
||||||
boolean supportsContextUsage
|
|
||||||
) {}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
public record RuntimeContextUsage(long inputTokens, long outputTokens, long contextWindow) {
|
|
||||||
public RuntimeContextUsage {
|
|
||||||
if (inputTokens < 0 || outputTokens < 0 || contextWindow < 0) {
|
|
||||||
throw new IllegalArgumentException("usage values must be non-negative");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public record RuntimeEvent(
|
|
||||||
String sessionId,
|
|
||||||
long sequence,
|
|
||||||
RuntimeEventType type,
|
|
||||||
String text,
|
|
||||||
Map<String, Object> data,
|
|
||||||
boolean terminal
|
|
||||||
) {
|
|
||||||
public RuntimeEvent {
|
|
||||||
if (sessionId == null || sessionId.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("sessionId is required");
|
|
||||||
}
|
|
||||||
if (sequence < 0) {
|
|
||||||
throw new IllegalArgumentException("sequence must be non-negative");
|
|
||||||
}
|
|
||||||
if (type == null) {
|
|
||||||
throw new IllegalArgumentException("type is required");
|
|
||||||
}
|
|
||||||
if (terminal != type.terminal()) {
|
|
||||||
throw new IllegalArgumentException("terminal flag does not match event type");
|
|
||||||
}
|
|
||||||
data = data == null ? Map.of() : Map.copyOf(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RuntimeEvent of(String sessionId, long sequence, RuntimeEventType type,
|
|
||||||
String text, Map<String, Object> data) {
|
|
||||||
return new RuntimeEvent(sessionId, sequence, type, text, data, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RuntimeEvent terminal(String sessionId, long sequence, RuntimeEventType type,
|
|
||||||
Map<String, Object> data) {
|
|
||||||
return new RuntimeEvent(sessionId, sequence, type, null, data, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class RuntimeEventLog {
|
|
||||||
private final String sessionId;
|
|
||||||
private final List<RuntimeEvent> events = new ArrayList<>();
|
|
||||||
private boolean terminal;
|
|
||||||
private long lastSequence = -1;
|
|
||||||
|
|
||||||
public RuntimeEventLog(String sessionId) {
|
|
||||||
if (sessionId == null || sessionId.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("sessionId is required");
|
|
||||||
}
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void append(RuntimeEvent event) {
|
|
||||||
if (!sessionId.equals(event.sessionId())) {
|
|
||||||
throw new IllegalArgumentException("event belongs to another session");
|
|
||||||
}
|
|
||||||
if (event.sequence() <= lastSequence) {
|
|
||||||
throw new IllegalArgumentException("event sequence must increase");
|
|
||||||
}
|
|
||||||
if (terminal) {
|
|
||||||
throw new IllegalStateException("terminal event already appended");
|
|
||||||
}
|
|
||||||
events.add(event);
|
|
||||||
lastSequence = event.sequence();
|
|
||||||
terminal = event.terminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized List<RuntimeEvent> snapshot() {
|
|
||||||
return List.copyOf(events);
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized boolean terminal() {
|
|
||||||
return terminal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
public enum RuntimeEventType {
|
|
||||||
RUNTIME_READY,
|
|
||||||
ASSISTANT_DELTA,
|
|
||||||
THINKING_DELTA,
|
|
||||||
TOOL_STARTED,
|
|
||||||
TOOL_APPROVAL_REQUIRED,
|
|
||||||
TOOL_FINISHED,
|
|
||||||
SUBAGENT_STARTED,
|
|
||||||
SUBAGENT_FINISHED,
|
|
||||||
CONTEXT_USAGE,
|
|
||||||
COMPLETED,
|
|
||||||
FAILED,
|
|
||||||
CANCELLED;
|
|
||||||
|
|
||||||
public boolean terminal() {
|
|
||||||
return this == COMPLETED || this == FAILED || this == CANCELLED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public final class RuntimeProviderRegistry {
|
|
||||||
public static final String DEFAULT_RUNTIME = "native";
|
|
||||||
|
|
||||||
private final Map<String, AgentRuntimeProvider> providers;
|
|
||||||
|
|
||||||
public RuntimeProviderRegistry(List<AgentRuntimeProvider> providers) {
|
|
||||||
Map<String, AgentRuntimeProvider> registered = new LinkedHashMap<>();
|
|
||||||
for (AgentRuntimeProvider provider : providers == null ? List.<AgentRuntimeProvider>of() : providers) {
|
|
||||||
if (provider == null || provider.type() == null || provider.type().isBlank()) {
|
|
||||||
throw new IllegalArgumentException("runtime provider type is required");
|
|
||||||
}
|
|
||||||
String type = normalize(provider.type());
|
|
||||||
if (registered.putIfAbsent(type, provider) != null) {
|
|
||||||
throw new IllegalArgumentException("duplicate runtime provider: " + type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.providers = Map.copyOf(registered);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AgentRuntimeProvider resolve(String requestedType) {
|
|
||||||
String type = requestedType == null || requestedType.isBlank()
|
|
||||||
? DEFAULT_RUNTIME
|
|
||||||
: normalize(requestedType);
|
|
||||||
AgentRuntimeProvider provider = providers.get(type);
|
|
||||||
if (provider == null) {
|
|
||||||
throw new IllegalArgumentException("unknown runtime provider: " + type);
|
|
||||||
}
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalize(String type) {
|
|
||||||
return type.trim().toLowerCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
public record RuntimeResult(Status status, String answer, String errorCode, String errorMessage) {
|
|
||||||
public enum Status { COMPLETED, FAILED, CANCELLED }
|
|
||||||
|
|
||||||
public RuntimeResult {
|
|
||||||
if (status == null) throw new IllegalArgumentException("status is required");
|
|
||||||
if (status == Status.COMPLETED && (errorCode != null || errorMessage != null)) {
|
|
||||||
throw new IllegalArgumentException("completed result cannot contain an error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RuntimeResult completed(String answer) {
|
|
||||||
return new RuntimeResult(Status.COMPLETED, answer, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RuntimeResult failed(String code, String message) {
|
|
||||||
return new RuntimeResult(Status.FAILED, null, code, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RuntimeResult cancelled() {
|
|
||||||
return new RuntimeResult(Status.CANCELLED, null, null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public record RuntimeSession(
|
|
||||||
String sessionId,
|
|
||||||
String conversationId,
|
|
||||||
Long agentId,
|
|
||||||
Long workspaceId,
|
|
||||||
String modelName,
|
|
||||||
Path workingDirectory,
|
|
||||||
Map<String, Object> configuration
|
|
||||||
) {
|
|
||||||
public RuntimeSession {
|
|
||||||
if (sessionId == null || sessionId.isBlank()) throw new IllegalArgumentException("sessionId is required");
|
|
||||||
if (conversationId == null || conversationId.isBlank()) throw new IllegalArgumentException("conversationId is required");
|
|
||||||
configuration = configuration == null ? Map.of() : Map.copyOf(configuration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import vip.mate.agent.model.AgentEntity;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** Builds and validates the runtime-neutral session boundary for an employee turn. */
|
|
||||||
public final class RuntimeSessionFactory {
|
|
||||||
private static final TypeReference<Map<String, Object>> CONFIG_TYPE = new TypeReference<>() {};
|
|
||||||
|
|
||||||
private final RuntimeProviderRegistry providerRegistry;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public RuntimeSessionFactory(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) {
|
|
||||||
this.providerRegistry = providerRegistry;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public RuntimeSession create(AgentEntity agent, String conversationId, String sessionId,
|
|
||||||
String modelName, Path workspaceRoot, Path workingDirectory) {
|
|
||||||
if (agent == null) throw new IllegalArgumentException("agent is required");
|
|
||||||
AgentRuntimeProvider provider = providerRegistry.resolve(agent.getRuntimeType());
|
|
||||||
String runtimeType = agent.getRuntimeType() == null || agent.getRuntimeType().isBlank()
|
|
||||||
? RuntimeProviderRegistry.DEFAULT_RUNTIME : agent.getRuntimeType().trim().toLowerCase();
|
|
||||||
|
|
||||||
Path normalizedRoot = normalize(workspaceRoot);
|
|
||||||
Path normalizedWorkingDirectory = normalize(workingDirectory);
|
|
||||||
if ("dsh".equals(runtimeType)) {
|
|
||||||
if (agent.getWorkspaceId() == null) {
|
|
||||||
throw new IllegalArgumentException("dsh runtime requires a workspace");
|
|
||||||
}
|
|
||||||
if (normalizedRoot == null || normalizedWorkingDirectory == null
|
|
||||||
|| !normalizedWorkingDirectory.startsWith(normalizedRoot)) {
|
|
||||||
throw new IllegalArgumentException("dsh working directory must stay inside workspace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RuntimeSession session = new RuntimeSession(sessionId, conversationId, agent.getId(),
|
|
||||||
agent.getWorkspaceId(), modelName, normalizedWorkingDirectory,
|
|
||||||
parseConfig(agent.getRuntimeConfig()));
|
|
||||||
RuntimeValidation validation = provider.validate(session);
|
|
||||||
if (validation == null || !validation.valid()) {
|
|
||||||
String code = validation == null ? "runtime.invalid" : validation.code();
|
|
||||||
String message = validation == null ? "runtime provider rejected session" : validation.message();
|
|
||||||
throw new IllegalArgumentException(code + ": " + message);
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> parseConfig(String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) return Map.of();
|
|
||||||
try {
|
|
||||||
JsonNode node = objectMapper.readTree(raw);
|
|
||||||
if (node == null || !node.isObject()) {
|
|
||||||
throw new IllegalArgumentException("runtime config must be a JSON object");
|
|
||||||
}
|
|
||||||
return objectMapper.convertValue(node, CONFIG_TYPE);
|
|
||||||
} catch (IOException | IllegalArgumentException e) {
|
|
||||||
if (e instanceof IllegalArgumentException iae
|
|
||||||
&& "runtime config must be a JSON object".equals(iae.getMessage())) {
|
|
||||||
throw iae;
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("runtime config must be valid JSON", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Path normalize(Path path) {
|
|
||||||
return path == null ? null : path.toAbsolutePath().normalize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.contract;
|
|
||||||
|
|
||||||
public record RuntimeValidation(boolean valid, String code, String message) {
|
|
||||||
public static RuntimeValidation success() {
|
|
||||||
return new RuntimeValidation(true, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RuntimeValidation invalid(String code, String message) {
|
|
||||||
return new RuntimeValidation(false, code, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface DshBinaryResolver {
|
|
||||||
Optional<Path> resolve();
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
|
|
||||||
public final class DshBridgeAuthenticator {
|
|
||||||
private final byte[] expectedToken;
|
|
||||||
|
|
||||||
public DshBridgeAuthenticator(String expectedToken) {
|
|
||||||
if (expectedToken == null || expectedToken.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("bridge token is required");
|
|
||||||
}
|
|
||||||
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean accepts(String providedToken) {
|
|
||||||
if (providedToken == null) return false;
|
|
||||||
return MessageDigest.isEqual(expectedToken,
|
|
||||||
providedToken.getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.BufferedWriter;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.io.OutputStreamWriter;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
public final class DshBridgeConnection implements AutoCloseable {
|
|
||||||
private static final int MAX_LINE_BYTES = 1_048_576;
|
|
||||||
|
|
||||||
private final BufferedReader reader;
|
|
||||||
private final BufferedWriter writer;
|
|
||||||
private final DshBridgeProtocol protocol;
|
|
||||||
private final DshBridgeAuthenticator authenticator;
|
|
||||||
private boolean authenticated;
|
|
||||||
|
|
||||||
public DshBridgeConnection(InputStream input, OutputStream output,
|
|
||||||
DshBridgeProtocol protocol,
|
|
||||||
DshBridgeAuthenticator authenticator) {
|
|
||||||
this.reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
|
||||||
this.writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
|
|
||||||
this.protocol = protocol;
|
|
||||||
this.authenticator = authenticator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean authenticate(String token) {
|
|
||||||
authenticated = authenticator.accepts(token);
|
|
||||||
return authenticated;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DshBridgeMessage receive() throws IOException {
|
|
||||||
requireAuthenticated();
|
|
||||||
String line = reader.readLine();
|
|
||||||
if (line == null) throw new IOException("DSH bridge closed");
|
|
||||||
if (line.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) {
|
|
||||||
throw new IOException("DSH bridge message exceeds size limit");
|
|
||||||
}
|
|
||||||
return protocol.decode(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void send(DshBridgeMessage message) throws IOException {
|
|
||||||
requireAuthenticated();
|
|
||||||
String encoded = protocol.encode(message);
|
|
||||||
if (encoded.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) {
|
|
||||||
throw new IOException("DSH bridge message exceeds size limit");
|
|
||||||
}
|
|
||||||
writer.write(encoded);
|
|
||||||
writer.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireAuthenticated() throws IOException {
|
|
||||||
if (!authenticated) throw new IOException("DSH bridge authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws IOException {
|
|
||||||
reader.close();
|
|
||||||
writer.close();
|
|
||||||
authenticated = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public final class DshBridgeEvents {
|
|
||||||
private DshBridgeEvents() {}
|
|
||||||
|
|
||||||
public static DshBridgeMessage ready(String sessionId) {
|
|
||||||
return DshBridgeMessage.notification("ready", Map.of("sessionId", require(sessionId, "sessionId")));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage toolCall(String callId, String toolName, Map<String, Object> arguments) {
|
|
||||||
Map<String, Object> params = new LinkedHashMap<>();
|
|
||||||
params.put("toolName", require(toolName, "toolName"));
|
|
||||||
params.put("arguments", arguments == null ? Map.of() : Map.copyOf(arguments));
|
|
||||||
return DshBridgeMessage.request(require(callId, "callId"), "tool/call", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage approvalAsk(String requestId, String toolName, String reason) {
|
|
||||||
Map<String, Object> params = new LinkedHashMap<>();
|
|
||||||
params.put("toolName", require(toolName, "toolName"));
|
|
||||||
params.put("reason", reason == null ? "" : reason);
|
|
||||||
return DshBridgeMessage.request(require(requestId, "requestId"), "approval/ask", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage subagentLifecycle(String subagentId, String phase,
|
|
||||||
Map<String, Object> data) {
|
|
||||||
Map<String, Object> params = new LinkedHashMap<>();
|
|
||||||
params.put("subagentId", require(subagentId, "subagentId"));
|
|
||||||
params.put("phase", require(phase, "phase"));
|
|
||||||
if (data != null) params.putAll(Map.copyOf(data));
|
|
||||||
return DshBridgeMessage.notification("subagent/lifecycle", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage toolCancel(String callId) {
|
|
||||||
return DshBridgeMessage.notification("tool/cancel",
|
|
||||||
Map.of("callId", require(callId, "callId")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String require(String value, String name) {
|
|
||||||
if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public record DshBridgeMessage(
|
|
||||||
String id,
|
|
||||||
String method,
|
|
||||||
Map<String, Object> params,
|
|
||||||
Object result,
|
|
||||||
String errorCode,
|
|
||||||
String errorMessage
|
|
||||||
) {
|
|
||||||
public DshBridgeMessage {
|
|
||||||
if (method == null || method.isBlank()) throw new IllegalArgumentException("method is required");
|
|
||||||
params = params == null ? Map.of() : Map.copyOf(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage request(String id, String method, Map<String, Object> params) {
|
|
||||||
if (id == null || id.isBlank()) throw new IllegalArgumentException("request id is required");
|
|
||||||
return new DshBridgeMessage(id, method, params, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage notification(String method, Map<String, Object> params) {
|
|
||||||
return new DshBridgeMessage(null, method, params, null, null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public final class DshBridgeMethods {
|
|
||||||
private static final Set<String> SUPPORTED = Set.of(
|
|
||||||
"session/open", "session/prompt", "session/cancel", "policy/update", "context/usage",
|
|
||||||
"ready", "tool/call", "approval/ask", "subagent/lifecycle", "tool/cancel");
|
|
||||||
|
|
||||||
private DshBridgeMethods() {}
|
|
||||||
|
|
||||||
public static boolean isSupported(String method) {
|
|
||||||
return method != null && SUPPORTED.contains(method);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
public final class DshBridgeProtocol {
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public DshBridgeProtocol(ObjectMapper objectMapper) {
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String encode(DshBridgeMessage message) {
|
|
||||||
try {
|
|
||||||
return objectMapper.writeValueAsString(message) + "\n";
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalArgumentException("Unable to encode DSH bridge message", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public DshBridgeMessage decode(String line) {
|
|
||||||
if (line == null || line.isBlank()) throw new IllegalArgumentException("bridge message is empty");
|
|
||||||
try {
|
|
||||||
return objectMapper.readValue(line.trim(), DshBridgeMessage.class);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalArgumentException("Invalid DSH bridge message", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isNotification(DshBridgeMessage message) {
|
|
||||||
return message.id() == null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeSession;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public final class DshBridgeRequests {
|
|
||||||
private DshBridgeRequests() {}
|
|
||||||
|
|
||||||
public static DshBridgeMessage sessionOpen(RuntimeSession session, Map<String, Object> policy) {
|
|
||||||
Map<String, Object> params = new LinkedHashMap<>();
|
|
||||||
params.put("sessionId", session.sessionId());
|
|
||||||
params.put("conversationId", session.conversationId());
|
|
||||||
putIfPresent(params, "agentId", session.agentId());
|
|
||||||
putIfPresent(params, "workspaceId", session.workspaceId());
|
|
||||||
putIfPresent(params, "model", session.modelName());
|
|
||||||
putIfPresent(params, "cwd", session.workingDirectory() == null
|
|
||||||
? null : session.workingDirectory().toString());
|
|
||||||
params.putAll(session.configuration());
|
|
||||||
if (policy != null) params.put("policy", Map.copyOf(policy));
|
|
||||||
return DshBridgeMessage.request("open-" + session.sessionId(), "session/open", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage prompt(String requestId, String message) {
|
|
||||||
require(requestId, "requestId");
|
|
||||||
if (message == null) throw new IllegalArgumentException("message is required");
|
|
||||||
return DshBridgeMessage.request(requestId, "session/prompt", Map.of("message", message));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage cancel(String requestId, String sessionId) {
|
|
||||||
require(requestId, "requestId");
|
|
||||||
require(sessionId, "sessionId");
|
|
||||||
return DshBridgeMessage.request(requestId, "session/cancel", Map.of("sessionId", sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage policyUpdate(String requestId, Map<String, Object> policy) {
|
|
||||||
require(requestId, "requestId");
|
|
||||||
return DshBridgeMessage.request(requestId, "policy/update",
|
|
||||||
Map.of("policy", policy == null ? Map.of() : Map.copyOf(policy)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshBridgeMessage contextUsage(String requestId, String sessionId) {
|
|
||||||
require(requestId, "requestId");
|
|
||||||
require(sessionId, "sessionId");
|
|
||||||
return DshBridgeMessage.request(requestId, "context/usage", Map.of("sessionId", sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void require(String value, String name) {
|
|
||||||
if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void putIfPresent(Map<String, Object> target, String key, Object value) {
|
|
||||||
if (value != null) target.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
public final class DshManagedProcess implements AutoCloseable {
|
|
||||||
private final DshProcessHandle process;
|
|
||||||
private final String sessionId;
|
|
||||||
private final Path binary;
|
|
||||||
private final Path sessionHome;
|
|
||||||
private final String bridgeToken;
|
|
||||||
private final Runnable onClosed;
|
|
||||||
private final AtomicBoolean closed = new AtomicBoolean();
|
|
||||||
|
|
||||||
DshManagedProcess(DshProcessHandle process, String sessionId, Path binary,
|
|
||||||
Path sessionHome, String bridgeToken) {
|
|
||||||
this(process, sessionId, binary, sessionHome, bridgeToken, () -> { });
|
|
||||||
}
|
|
||||||
|
|
||||||
DshManagedProcess(DshProcessHandle process, String sessionId, Path binary,
|
|
||||||
Path sessionHome, String bridgeToken, Runnable onClosed) {
|
|
||||||
this.process = process;
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
this.binary = binary;
|
|
||||||
this.sessionHome = sessionHome;
|
|
||||||
this.bridgeToken = bridgeToken;
|
|
||||||
this.onClosed = onClosed == null ? () -> { } : onClosed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DshProcessDiagnostics diagnostics() {
|
|
||||||
return new DshProcessDiagnostics(sessionId, binary, sessionHome,
|
|
||||||
process.isAlive(), bridgeToken != null && !bridgeToken.isBlank());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
if (!closed.compareAndSet(false, true)) return;
|
|
||||||
if (process.isAlive()) {
|
|
||||||
process.destroy();
|
|
||||||
if (!process.awaitExit(1_000L) && process.isAlive()) {
|
|
||||||
process.destroyForcibly();
|
|
||||||
process.awaitExit(1_000L);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deleteRecursively(sessionHome);
|
|
||||||
onClosed.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void deleteRecursively(Path root) {
|
|
||||||
if (root == null || !Files.exists(root)) return;
|
|
||||||
try (var paths = Files.walk(root)) {
|
|
||||||
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(path);
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
// Cleanup is best effort; the process is already stopped.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
// Cleanup is best effort; diagnostics retain the path for operators.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
public record DshProcessDiagnostics(
|
|
||||||
String sessionId,
|
|
||||||
Path binary,
|
|
||||||
Path sessionHome,
|
|
||||||
boolean alive,
|
|
||||||
boolean bridgeTokenRedacted
|
|
||||||
) {}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
public interface DshProcessHandle {
|
|
||||||
boolean isAlive();
|
|
||||||
|
|
||||||
void destroy();
|
|
||||||
|
|
||||||
void destroyForcibly();
|
|
||||||
|
|
||||||
boolean awaitExit(long millis);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeSession;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface DshProcessLauncher {
|
|
||||||
DshProcessHandle launch(Path binary, RuntimeSession session, Path sessionHome, String bridgeToken);
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeSession;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
public final class DshProcessManager {
|
|
||||||
private final DshBinaryResolver binaryResolver;
|
|
||||||
private final DshProcessLauncher launcher;
|
|
||||||
private final Map<String, DshManagedProcess> active = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public DshProcessManager(DshBinaryResolver binaryResolver, DshProcessLauncher launcher) {
|
|
||||||
this.binaryResolver = binaryResolver;
|
|
||||||
this.launcher = launcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DshManagedProcess start(RuntimeSession session) {
|
|
||||||
stop(session.sessionId());
|
|
||||||
Path binary = binaryResolver.resolve()
|
|
||||||
.filter(Files::isExecutable)
|
|
||||||
.orElseThrow(() -> new IllegalStateException("DSH binary is unavailable"));
|
|
||||||
Path sessionHome;
|
|
||||||
try {
|
|
||||||
sessionHome = Files.createTempDirectory("mateclaw-dsh-" + safeSessionId(session.sessionId()) + "-");
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Unable to create DSH session home", e);
|
|
||||||
}
|
|
||||||
String bridgeToken = UUID.randomUUID().toString();
|
|
||||||
try {
|
|
||||||
DshProcessHandle process = launcher.launch(binary, session, sessionHome, bridgeToken);
|
|
||||||
if (process == null) throw new IllegalStateException("DSH launcher returned no process");
|
|
||||||
DshManagedProcess managed = new DshManagedProcess(process, session.sessionId(), binary,
|
|
||||||
sessionHome, bridgeToken, () -> active.remove(session.sessionId()));
|
|
||||||
active.put(session.sessionId(), managed);
|
|
||||||
return managed;
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
deleteSessionHome(sessionHome);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean stop(String sessionId) {
|
|
||||||
DshManagedProcess process = active.remove(sessionId);
|
|
||||||
if (process == null) return false;
|
|
||||||
process.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Set<String> activeSessionIds() {
|
|
||||||
return Set.copyOf(active.keySet());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String safeSessionId(String sessionId) {
|
|
||||||
return sessionId.replaceAll("[^A-Za-z0-9._-]", "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void deleteSessionHome(Path path) {
|
|
||||||
try (var paths = Files.walk(path)) {
|
|
||||||
paths.sorted(java.util.Comparator.reverseOrder()).forEach(candidate -> {
|
|
||||||
try { Files.deleteIfExists(candidate); } catch (IOException ignored) { }
|
|
||||||
});
|
|
||||||
} catch (IOException ignored) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,655 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
import reactor.core.scheduler.Schedulers;
|
|
||||||
import vip.mate.agent.model.AgentEntity;
|
|
||||||
import vip.mate.agent.runtime.RuntimeEventProjector;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeEvent;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeEventType;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeSession;
|
|
||||||
import vip.mate.agent.runtime.contract.AgentRuntimeConnection;
|
|
||||||
import vip.mate.agent.runtime.contract.AgentRuntimeProvider;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeCapabilities;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeContextUsage;
|
|
||||||
import vip.mate.agent.runtime.contract.RuntimeValidation;
|
|
||||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
|
||||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
|
||||||
import vip.mate.agent.AgentService;
|
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
|
||||||
import vip.mate.llm.service.ModelProviderService;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.BufferedWriter;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.OutputStreamWriter;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter for the official DeepSeek Harness SDK JSON-RPC runtime.
|
|
||||||
*
|
|
||||||
* <p>The runtime is intentionally an external process. This keeps the Node
|
|
||||||
* plugin graph out of the Spring classpath and lets deployments pin the DSH
|
|
||||||
* runtime independently from MateClaw.</p>
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@Slf4j
|
|
||||||
public class DshRuntimeService implements AgentRuntimeProvider {
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final ModelConfigService modelConfigService;
|
|
||||||
private final ModelProviderService modelProviderService;
|
|
||||||
private final DshRuntimeConfigService runtimeConfigService;
|
|
||||||
|
|
||||||
public DshRuntimeService(
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
ModelConfigService modelConfigService,
|
|
||||||
ModelProviderService modelProviderService,
|
|
||||||
DshRuntimeConfigService runtimeConfigService) {
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.modelConfigService = modelConfigService;
|
|
||||||
this.modelProviderService = modelProviderService;
|
|
||||||
this.runtimeConfigService = runtimeConfigService;
|
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
|
||||||
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
|
|
||||||
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
private DshRuntimeConfiguration runtimeConfig() {
|
|
||||||
DshRuntimeConfiguration raw = runtimeConfigService.resolve();
|
|
||||||
String command = raw.executablePath();
|
|
||||||
if (command == null || command.isBlank()) command = "dsh-jsonrpc-agent";
|
|
||||||
String cordis = resolveCordisConfig(raw.cordisConfigPath());
|
|
||||||
String cwd = raw.workingDirectory();
|
|
||||||
if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir");
|
|
||||||
return new DshRuntimeConfiguration(command, cordis, cwd, raw.baseUrl(), raw.modelName(), raw.apiKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveCordisConfig(String configuredPath) {
|
|
||||||
if (configuredPath == null || configuredPath.isBlank()) return "";
|
|
||||||
Path path = Path.of(configuredPath).toAbsolutePath().normalize();
|
|
||||||
if (Files.isRegularFile(path)) return path.toString();
|
|
||||||
// The documented source checkout path points at the package directory;
|
|
||||||
// the checked-in composition lives below its runtime subdirectory.
|
|
||||||
Path packageDirectory = Files.isDirectory(path) ? path : path.getParent();
|
|
||||||
Path packagedConfig = packageDirectory == null
|
|
||||||
? path
|
|
||||||
: packageDirectory.resolve("runtime").resolve("cordis.yml");
|
|
||||||
return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String type() {
|
|
||||||
return "dsh";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RuntimeValidation validate(RuntimeSession session) {
|
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
|
||||||
if (session == null || session.workspaceId() == null) {
|
|
||||||
return RuntimeValidation.invalid("dsh.workspace_required", "DSH runtime requires a workspace");
|
|
||||||
}
|
|
||||||
if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) {
|
|
||||||
return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable");
|
|
||||||
}
|
|
||||||
if (configuration.executablePath().isBlank()) {
|
|
||||||
return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured");
|
|
||||||
}
|
|
||||||
Path executable = Path.of(commandLine(configuration.executablePath()).get(0));
|
|
||||||
if (!executable.isAbsolute() || !Files.isExecutable(executable)) {
|
|
||||||
return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable");
|
|
||||||
}
|
|
||||||
if (!configuration.cordisConfigPath().isBlank() && !Files.isRegularFile(Path.of(configuration.cordisConfigPath()))) {
|
|
||||||
return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable");
|
|
||||||
}
|
|
||||||
return RuntimeValidation.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RuntimeCapabilities capabilities() {
|
|
||||||
return new RuntimeCapabilities(true, false, true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> diagnostics() {
|
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
|
||||||
Path executable = configuration.executablePath().isBlank() ? null : Path.of(commandLine(configuration.executablePath()).get(0));
|
|
||||||
return Map.of(
|
|
||||||
"type", type(),
|
|
||||||
"commandConfigured", !configuration.executablePath().isBlank(),
|
|
||||||
"command", configuration.executablePath(),
|
|
||||||
"executable", executable == null ? "" : executable.toString(),
|
|
||||||
"executableAvailable", executable != null && Files.isExecutable(executable),
|
|
||||||
"cordisConfig", configuration.cordisConfigPath(),
|
|
||||||
"cordisConfigAvailable", !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())),
|
|
||||||
"workingDirectory", configuration.workingDirectory(),
|
|
||||||
"apiKeyConfigured", configuration.apiKey() != null && !configuration.apiKey().isBlank(),
|
|
||||||
"capabilities", Map.of(
|
|
||||||
"cancellation", true,
|
|
||||||
"approvals", false,
|
|
||||||
"subagents", true,
|
|
||||||
"contextUsage", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void validateAgentConfiguration(AgentEntity agent) {
|
|
||||||
if (agent == null || agent.getWorkspaceId() == null) {
|
|
||||||
throw new IllegalArgumentException("dsh.workspace_required: DSH runtime requires a workspace");
|
|
||||||
}
|
|
||||||
if (agent.getRuntimeConfig() != null && !agent.getRuntimeConfig().isBlank()) {
|
|
||||||
try {
|
|
||||||
JsonNode node = objectMapper.readTree(agent.getRuntimeConfig());
|
|
||||||
if (node == null || !node.isObject()) throw new IllegalArgumentException();
|
|
||||||
} catch (Exception error) {
|
|
||||||
throw new IllegalArgumentException("dsh.runtime_config_invalid: runtime config must be a JSON object", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AgentRuntimeConnection start(RuntimeSession session) {
|
|
||||||
RuntimeValidation validation = validate(session);
|
|
||||||
if (!validation.valid()) {
|
|
||||||
throw new IllegalArgumentException(validation.code() + ": " + validation.message());
|
|
||||||
}
|
|
||||||
AgentEntity agent = new AgentEntity();
|
|
||||||
agent.setId(session.agentId());
|
|
||||||
agent.setWorkspaceId(session.workspaceId());
|
|
||||||
agent.setModelName(session.modelName());
|
|
||||||
AtomicReference<Process> activeProcess = new AtomicReference<>();
|
|
||||||
AtomicReference<RuntimeContextUsage> latestUsage = new AtomicReference<>(
|
|
||||||
new RuntimeContextUsage(0, 0, 0));
|
|
||||||
return new AgentRuntimeConnection() {
|
|
||||||
@Override
|
|
||||||
public Flux<RuntimeEvent> prompt(String message) {
|
|
||||||
return stream(agent, message, session.conversationId(), session.modelName(),
|
|
||||||
session.workingDirectory(), activeProcess, latestUsage)
|
|
||||||
.map(DshRuntimeService.this::toRuntimeEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public reactor.core.publisher.Mono<Void> cancel() {
|
|
||||||
return reactor.core.publisher.Mono.fromRunnable(
|
|
||||||
() -> cancelProcess(activeProcess.get()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public reactor.core.publisher.Mono<RuntimeContextUsage> contextUsage() {
|
|
||||||
return reactor.core.publisher.Mono.just(latestUsage.get());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private RuntimeEvent toRuntimeEvent(AgentService.StreamDelta delta) {
|
|
||||||
if (delta == null) return RuntimeEvent.of("dsh", 0, RuntimeEventType.RUNTIME_READY, null, Map.of());
|
|
||||||
if (delta.content() != null) {
|
|
||||||
return RuntimeEvent.of("dsh", 0, RuntimeEventType.ASSISTANT_DELTA, delta.content(), Map.of());
|
|
||||||
}
|
|
||||||
if (delta.thinking() != null) {
|
|
||||||
return RuntimeEvent.of("dsh", 0, RuntimeEventType.THINKING_DELTA, delta.thinking(), Map.of());
|
|
||||||
}
|
|
||||||
RuntimeEventType type = switch (delta.eventType() == null ? "" : delta.eventType()) {
|
|
||||||
case "done" -> RuntimeEventType.COMPLETED;
|
|
||||||
case "error" -> RuntimeEventType.FAILED;
|
|
||||||
case "cancelled" -> RuntimeEventType.CANCELLED;
|
|
||||||
case "tool_call_started" -> RuntimeEventType.TOOL_STARTED;
|
|
||||||
case "tool_call_completed" -> RuntimeEventType.TOOL_FINISHED;
|
|
||||||
case "tool_approval_requested" -> RuntimeEventType.TOOL_APPROVAL_REQUIRED;
|
|
||||||
default -> RuntimeEventType.RUNTIME_READY;
|
|
||||||
};
|
|
||||||
return type.terminal()
|
|
||||||
? RuntimeEvent.terminal("dsh", 0, type, delta.eventData())
|
|
||||||
: RuntimeEvent.of("dsh", 0, type, null, delta.eventData());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
|
||||||
String conversationId, String modelName) {
|
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
|
||||||
return stream(agent, message, conversationId, modelName,
|
|
||||||
resolveWorkingDirectory(null, configuration), new AtomicReference<>(),
|
|
||||||
new AtomicReference<>(new RuntimeContextUsage(0, 0, 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Flux<AgentService.StreamDelta> stream(AgentEntity agent, String message,
|
|
||||||
String conversationId, String modelName,
|
|
||||||
Path workingDirectory,
|
|
||||||
AtomicReference<Process> processRef,
|
|
||||||
AtomicReference<RuntimeContextUsage> latestUsage) {
|
|
||||||
return Flux.<AgentService.StreamDelta>create(sink -> {
|
|
||||||
Process process = null;
|
|
||||||
try {
|
|
||||||
if (sink.isCancelled()) return;
|
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
|
||||||
RuntimeSession session = new RuntimeSession(
|
|
||||||
conversationId,
|
|
||||||
conversationId,
|
|
||||||
agent.getId(),
|
|
||||||
agent.getWorkspaceId(),
|
|
||||||
modelName,
|
|
||||||
workingDirectory,
|
|
||||||
Map.of());
|
|
||||||
// Each prompt runs in a fresh child process. DSH persists its
|
|
||||||
// own session log, so reusing the MateClaw conversation id
|
|
||||||
// would make the next turn look like a conflicting live session.
|
|
||||||
String dshSessionId = conversationId + "-" + UUID.randomUUID();
|
|
||||||
Files.createDirectories(session.workingDirectory());
|
|
||||||
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
|
|
||||||
ModelProviderEntity provider = resolveProvider(requestedModel);
|
|
||||||
String effectiveModelName = resolveModelName(requestedModel);
|
|
||||||
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
|
|
||||||
modelName == null || modelName.isBlank() ? "<default>" : modelName,
|
|
||||||
effectiveModelName,
|
|
||||||
provider == null ? "<missing>" : provider.getProviderId(),
|
|
||||||
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
|
|
||||||
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
|
|
||||||
List<String> command = commandLine(configuration.executablePath());
|
|
||||||
ProcessBuilder builder = new ProcessBuilder(command)
|
|
||||||
.directory(session.workingDirectory().toFile())
|
|
||||||
.redirectError(ProcessBuilder.Redirect.PIPE);
|
|
||||||
Map<String, String> environment = builder.environment();
|
|
||||||
Map<String, String> childEnvironment = childEnvironment(environment, session, configuration, provider);
|
|
||||||
environment.clear();
|
|
||||||
environment.putAll(childEnvironment);
|
|
||||||
log.debug("[DSH] child environment: keys={}, cordisConfig={}, exists={}",
|
|
||||||
environment.keySet(),
|
|
||||||
environment.getOrDefault("DSH_CORDIS_CONFIG", "<empty>"),
|
|
||||||
!configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())));
|
|
||||||
process = builder.start();
|
|
||||||
processRef.set(process);
|
|
||||||
if (sink.isCancelled()) {
|
|
||||||
cancelProcess(process);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Process startedProcess = process;
|
|
||||||
Thread stderrLogger = new Thread(() -> logProcessStderr(startedProcess),
|
|
||||||
"dsh-runtime-stderr-" + conversationId);
|
|
||||||
stderrLogger.setDaemon(true);
|
|
||||||
stderrLogger.start();
|
|
||||||
sink.onCancel(() -> cancelProcess(startedProcess));
|
|
||||||
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
|
|
||||||
process.getOutputStream(), StandardCharsets.UTF_8));
|
|
||||||
BufferedReader reader = new BufferedReader(new InputStreamReader(
|
|
||||||
process.getInputStream(), StandardCharsets.UTF_8))) {
|
|
||||||
send(writer, request("initialize", "init-" + conversationId, Map.of(
|
|
||||||
"cwd", session.workingDirectory().toString(),
|
|
||||||
"provider", "deepseek-official",
|
|
||||||
"model", effectiveModelName)));
|
|
||||||
awaitResponse(reader, "init-" + conversationId);
|
|
||||||
long sequence = 0;
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
|
||||||
conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null,
|
|
||||||
Map.of("runtimeProvider", "dsh", "runtimeCommand", configuration.executablePath()))));
|
|
||||||
|
|
||||||
String promptId = "prompt-" + conversationId;
|
|
||||||
send(writer, request("session/prompt", promptId, Map.of(
|
|
||||||
"sessionId", dshSessionId,
|
|
||||||
"contentBlocks", List.of(Map.of("type", "text", "text", message)))));
|
|
||||||
|
|
||||||
// DSH may emit session events before the JSON-RPC response
|
|
||||||
// for session/prompt. Read both on the same loop so those
|
|
||||||
// notifications are not discarded while waiting for id.
|
|
||||||
boolean terminal = false;
|
|
||||||
boolean promptResponseReceived = false;
|
|
||||||
String line;
|
|
||||||
while (!terminal && (line = reader.readLine()) != null) {
|
|
||||||
JsonNode payload = objectMapper.readTree(line);
|
|
||||||
if (payload == null) continue;
|
|
||||||
if (payload.has("id") && promptId.equals(payload.path("id").asText(null))) {
|
|
||||||
promptResponseReceived = true;
|
|
||||||
log.debug("[DSH] prompt response received: id={}, error={}", promptId,
|
|
||||||
payload.has("error"));
|
|
||||||
if (payload.has("error")) {
|
|
||||||
throw new IllegalStateException(payload.path("error").path("message")
|
|
||||||
.asText("DSH prompt failed"));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!payload.has("method")) continue;
|
|
||||||
String method = payload.path("method").asText();
|
|
||||||
JsonNode params = payload.path("params");
|
|
||||||
if (payload.has("id")) {
|
|
||||||
send(writer, errorResponse(payload.get("id"), -32601, "MateClaw does not support runtime request: " + method));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ("session.event".equals(method)) {
|
|
||||||
JsonNode event = params.path("event");
|
|
||||||
log.debug("[DSH] event: type={}", event.path("type").asText("<missing>"));
|
|
||||||
logChunkMetadata(event);
|
|
||||||
logTerminalReason(event);
|
|
||||||
RuntimeEvent mapped = mapEvent(conversationId, sequence++, event);
|
|
||||||
if (mapped != null) {
|
|
||||||
if (mapped.type() == RuntimeEventType.CONTEXT_USAGE) {
|
|
||||||
latestUsage.set(usageFrom(mapped));
|
|
||||||
}
|
|
||||||
sink.next(RuntimeEventProjector.project(mapped));
|
|
||||||
terminal = mapped.terminal();
|
|
||||||
}
|
|
||||||
} else if ("session.status".equals(method)
|
|
||||||
&& promptResponseReceived
|
|
||||||
&& "idle".equals(params.path("status").asText())) {
|
|
||||||
log.debug("[DSH] session idle after prompt");
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
|
|
||||||
conversationId, sequence++, RuntimeEventType.COMPLETED, Map.of())));
|
|
||||||
terminal = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!terminal) {
|
|
||||||
int exitCode = process.waitFor();
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(
|
|
||||||
conversationId, sequence, RuntimeEventType.FAILED,
|
|
||||||
Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")"))));
|
|
||||||
}
|
|
||||||
sink.complete();
|
|
||||||
}
|
|
||||||
} catch (Exception error) {
|
|
||||||
sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error));
|
|
||||||
if (process != null) process.destroyForcibly();
|
|
||||||
} finally {
|
|
||||||
if (process != null) processRef.compareAndSet(process, null);
|
|
||||||
}
|
|
||||||
}).subscribeOn(Schedulers.boundedElastic());
|
|
||||||
}
|
|
||||||
|
|
||||||
static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) {
|
|
||||||
if (session != null && session.workingDirectory() != null) {
|
|
||||||
return session.workingDirectory().toAbsolutePath().normalize();
|
|
||||||
}
|
|
||||||
return Path.of(configuration.workingDirectory()).toAbsolutePath().normalize();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void cancelProcess(Process process) {
|
|
||||||
if (process == null || !process.isAlive()) return;
|
|
||||||
|
|
||||||
// DSH tools can spawn commands such as `sleep` that inherit the
|
|
||||||
// JSON-RPC process' stdout pipe. Close the pipes and terminate the
|
|
||||||
// descendants first; otherwise the parent may die while readLine()
|
|
||||||
// remains blocked until the child exits naturally.
|
|
||||||
try {
|
|
||||||
var descendants = process.descendants();
|
|
||||||
if (descendants != null) {
|
|
||||||
descendants.toList().forEach(DshRuntimeService::cancelProcessHandle);
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// The parent teardown below is still the best-effort fallback.
|
|
||||||
}
|
|
||||||
closeQuietly(process.getInputStream());
|
|
||||||
closeQuietly(process.getErrorStream());
|
|
||||||
closeQuietly(process.getOutputStream());
|
|
||||||
process.destroy();
|
|
||||||
if (process.isAlive()) process.destroyForcibly();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void cancelProcessHandle(ProcessHandle process) {
|
|
||||||
if (process == null || !process.isAlive()) return;
|
|
||||||
process.destroy();
|
|
||||||
if (process.isAlive()) process.destroyForcibly();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void closeQuietly(java.io.Closeable stream) {
|
|
||||||
if (stream == null) return;
|
|
||||||
try {
|
|
||||||
stream.close();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// Cancellation is best effort; the process termination is authoritative.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logProcessStderr(Process process) {
|
|
||||||
try (BufferedReader errors = new BufferedReader(new InputStreamReader(
|
|
||||||
process.getErrorStream(), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
|
||||||
while ((line = errors.readLine()) != null) {
|
|
||||||
log.warn("[DSH] {}", line);
|
|
||||||
}
|
|
||||||
} catch (IOException error) {
|
|
||||||
log.debug("[DSH] stderr reader closed: {}", error.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> commandLine(String commandLine) {
|
|
||||||
List<String> result = new ArrayList<>();
|
|
||||||
StringBuilder token = new StringBuilder();
|
|
||||||
char quote = 0;
|
|
||||||
boolean escaped = false;
|
|
||||||
for (char current : commandLine == null ? "".toCharArray() : commandLine.toCharArray()) {
|
|
||||||
if (escaped) {
|
|
||||||
token.append(current);
|
|
||||||
escaped = false;
|
|
||||||
} else if (current == '\\') {
|
|
||||||
escaped = true;
|
|
||||||
} else if (quote != 0) {
|
|
||||||
if (current == quote) quote = 0;
|
|
||||||
else token.append(current);
|
|
||||||
} else if (current == '\'' || current == '"') {
|
|
||||||
quote = current;
|
|
||||||
} else if (Character.isWhitespace(current)) {
|
|
||||||
if (!token.isEmpty()) {
|
|
||||||
result.add(token.toString());
|
|
||||||
token.setLength(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
token.append(current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (escaped) token.append('\\');
|
|
||||||
if (quote != 0) throw new IllegalArgumentException("DSH runtime command has an unterminated quote");
|
|
||||||
if (!token.isEmpty()) result.add(token.toString());
|
|
||||||
if (result.isEmpty()) throw new IllegalStateException("DSH runtime command is empty");
|
|
||||||
log.debug("[DSH] launching command: {}", result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, String> childEnvironment(Map<String, String> inherited,
|
|
||||||
RuntimeSession session,
|
|
||||||
DshRuntimeConfiguration configuration,
|
|
||||||
ModelProviderEntity provider) {
|
|
||||||
Map<String, String> environment = new LinkedHashMap<>();
|
|
||||||
copyIfPresent(inherited, environment, "PATH");
|
|
||||||
copyIfPresent(inherited, environment, "HOME");
|
|
||||||
copyIfPresent(inherited, environment, "USERPROFILE");
|
|
||||||
copyIfPresent(inherited, environment, "TMPDIR");
|
|
||||||
copyIfPresent(inherited, environment, "TEMP");
|
|
||||||
copyIfPresent(inherited, environment, "TMP");
|
|
||||||
copyIfPresent(inherited, environment, "SystemRoot");
|
|
||||||
copyIfPresent(inherited, environment, "WINDIR");
|
|
||||||
|
|
||||||
environment.put("DSH_CWD", session.workingDirectory().toString());
|
|
||||||
putIfPresent(environment, "DSH_CORDIS_CONFIG", configuration.cordisConfigPath());
|
|
||||||
putIfPresent(environment, "DEEPSEEK_API_KEY",
|
|
||||||
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()));
|
|
||||||
putIfPresent(environment, "DEEPSEEK_BASE_URL",
|
|
||||||
firstNonBlank(configuration.baseUrl(), provider == null ? null : provider.getBaseUrl()));
|
|
||||||
return environment;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void copyIfPresent(Map<String, String> source, Map<String, String> target, String key) {
|
|
||||||
if (source == null) return;
|
|
||||||
putIfPresent(target, key, source.get(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void putIfPresent(Map<String, String> target, String key, String value) {
|
|
||||||
if (value == null || value.isBlank()) return;
|
|
||||||
target.put(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String firstNonBlank(String primary, String fallback) {
|
|
||||||
return primary != null && !primary.isBlank() ? primary : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ModelProviderEntity resolveProvider(String modelName) {
|
|
||||||
ModelConfigEntity model = null;
|
|
||||||
try {
|
|
||||||
model = modelConfigService.resolveModel(modelName);
|
|
||||||
} catch (RuntimeException ignored) {
|
|
||||||
// Fall back to the dedicated DeepSeek provider below.
|
|
||||||
}
|
|
||||||
if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) {
|
|
||||||
try {
|
|
||||||
return modelProviderService.getProviderConfig(model.getProvider());
|
|
||||||
} catch (RuntimeException ignored) {
|
|
||||||
// The model row may outlive its provider row; use the runtime default.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return modelProviderService.getProviderConfig("deepseek");
|
|
||||||
} catch (RuntimeException ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveModelName(String modelName) {
|
|
||||||
try {
|
|
||||||
ModelConfigEntity model = modelConfigService.resolveModel(modelName);
|
|
||||||
if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) {
|
|
||||||
return model.getModelName();
|
|
||||||
}
|
|
||||||
} catch (RuntimeException ignored) {
|
|
||||||
// Fall back to the DSH catalog default for a not-yet-configured agent.
|
|
||||||
}
|
|
||||||
return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
|
||||||
String type = event.path("type").asText("");
|
|
||||||
JsonNode data = event.path("data");
|
|
||||||
if ("assistant/chunk".equals(type)) {
|
|
||||||
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
|
|
||||||
if ("usage".equals(chunk.path("type").asText())) {
|
|
||||||
JsonNode usage = chunk.path("usage");
|
|
||||||
long inputTokens = usage.path("inputTokens").asLong(0);
|
|
||||||
long outputTokens = usage.path("outputTokens").asLong(0);
|
|
||||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.CONTEXT_USAGE,
|
|
||||||
null, Map.of(
|
|
||||||
"promptTokens", inputTokens,
|
|
||||||
"completionTokens", outputTokens,
|
|
||||||
"inputTokens", inputTokens,
|
|
||||||
"outputTokens", outputTokens));
|
|
||||||
}
|
|
||||||
String text = firstText(chunk, data);
|
|
||||||
if (text != null && !text.isEmpty()) {
|
|
||||||
RuntimeEventType eventType = "reasoning-delta".equals(chunk.path("type").asText())
|
|
||||||
? RuntimeEventType.THINKING_DELTA
|
|
||||||
: RuntimeEventType.ASSISTANT_DELTA;
|
|
||||||
return RuntimeEvent.of(sessionId, sequence, eventType, text,
|
|
||||||
Map.of("chunkType", chunk.path("type").asText("unknown")));
|
|
||||||
}
|
|
||||||
if ("finish".equals(chunk.path("type").asText())
|
|
||||||
&& "error".equals(chunk.path("reason").path("kind").asText())) {
|
|
||||||
JsonNode failure = chunk.path("reason").path("failure");
|
|
||||||
return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED,
|
|
||||||
Map.of("error", failure.path("message").asText("DSH assistant failed"),
|
|
||||||
"code", failure.path("code").asText("DSH_RUNTIME_ERROR")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The DSH stream emits text-delta chunks followed by an assistant/message
|
|
||||||
// snapshot. Mapping both would append the same answer twice to the UI.
|
|
||||||
if ("text-delta".equals(type)) {
|
|
||||||
String text = firstText(data, event);
|
|
||||||
if (text != null && !text.isEmpty()) {
|
|
||||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.ASSISTANT_DELTA, text, Map.of());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (type.contains("tool") && (type.contains("start") || type.contains("call"))) {
|
|
||||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_STARTED, null,
|
|
||||||
Map.of("toolName", data.path("toolName").asText("dsh-tool")));
|
|
||||||
}
|
|
||||||
if (type.contains("tool") && (type.contains("end") || type.contains("result"))) {
|
|
||||||
return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_FINISHED, null, Map.of());
|
|
||||||
}
|
|
||||||
if ("turn/end".equals(type)) {
|
|
||||||
String kind = data.path("reason").path("kind").asText("");
|
|
||||||
if ("error".equals(kind)) {
|
|
||||||
return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED,
|
|
||||||
Map.of("error", data.path("reason").path("error").path("message").asText("DSH turn failed")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private RuntimeContextUsage usageFrom(RuntimeEvent event) {
|
|
||||||
return new RuntimeContextUsage(
|
|
||||||
number(event.data().get("inputTokens")),
|
|
||||||
number(event.data().get("outputTokens")),
|
|
||||||
number(event.data().get("contextWindow")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private long number(Object value) {
|
|
||||||
return value instanceof Number number ? Math.max(0, number.longValue()) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String firstText(JsonNode primary, JsonNode fallback) {
|
|
||||||
String text = primary.path("text").asText(null);
|
|
||||||
if (text != null) return text;
|
|
||||||
text = primary.path("delta").path("text").asText(null);
|
|
||||||
if (text != null) return text;
|
|
||||||
text = fallback.path("text").asText(null);
|
|
||||||
if (text != null) return text;
|
|
||||||
return fallback.path("delta").path("text").asText(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logChunkMetadata(JsonNode event) {
|
|
||||||
if (!"assistant/chunk".equals(event.path("type").asText())) return;
|
|
||||||
JsonNode data = event.path("data");
|
|
||||||
JsonNode chunk = data.has("chunk") ? data.path("chunk") : data;
|
|
||||||
log.debug("[DSH] assistant chunk: type={}, fields={}, dataFields={}, textPresent={}, textLength={}",
|
|
||||||
chunk.path("type").asText("<missing>"),
|
|
||||||
chunk.fieldNames().hasNext(), data.fieldNames().hasNext(),
|
|
||||||
chunk.has("text"), chunk.path("text").isTextual() ? chunk.path("text").textValue().length() : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logTerminalReason(JsonNode event) {
|
|
||||||
String type = event.path("type").asText("");
|
|
||||||
if (!"assistant/chunk".equals(type) && !"turn/end".equals(type)) return;
|
|
||||||
JsonNode reason = "assistant/chunk".equals(type)
|
|
||||||
? event.path("data").path("chunk").path("reason")
|
|
||||||
: event.path("data").path("reason");
|
|
||||||
if (reason.isMissingNode() || reason.isNull()) return;
|
|
||||||
JsonNode failure = reason.path("failure").isMissingNode()
|
|
||||||
? reason.path("error") : reason.path("failure");
|
|
||||||
log.warn("[DSH] terminal reason: eventType={}, kind={}, code={}, message={}",
|
|
||||||
type,
|
|
||||||
reason.path("kind").asText("<missing>"),
|
|
||||||
failure.path("code").asText("<none>"),
|
|
||||||
failure.path("message").asText("<none>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void awaitResponse(BufferedReader reader, String id) throws IOException {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
JsonNode payload = objectMapper.readTree(line);
|
|
||||||
if (payload != null && id.equals(payload.path("id").asText(null))) {
|
|
||||||
if (payload.has("error")) {
|
|
||||||
throw new IllegalStateException(payload.path("error").path("message").asText("DSH JSON-RPC error"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IOException("DSH runtime closed while waiting for " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> request(String method, String id, Map<String, Object> params) {
|
|
||||||
return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> errorResponse(JsonNode id, int code, String message) {
|
|
||||||
return Map.of("jsonrpc", "2.0", "id", objectMapper.convertValue(id, Object.class),
|
|
||||||
"error", Map.of("code", code, "message", message));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(BufferedWriter writer, Map<String, Object> payload) throws IOException {
|
|
||||||
writer.write(objectMapper.writeValueAsString(payload));
|
|
||||||
writer.newLine();
|
|
||||||
writer.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class DshToolCatalog {
|
|
||||||
private DshToolCatalog() {}
|
|
||||||
|
|
||||||
public static List<DshToolDescriptor> fromCallbacks(List<ToolCallback> callbacks) {
|
|
||||||
LinkedHashMap<String, DshToolDescriptor> descriptors = new LinkedHashMap<>();
|
|
||||||
if (callbacks == null) return List.of();
|
|
||||||
for (ToolCallback callback : callbacks) {
|
|
||||||
if (callback == null || callback.getToolDefinition() == null) continue;
|
|
||||||
var definition = callback.getToolDefinition();
|
|
||||||
descriptors.putIfAbsent(definition.name(), new DshToolDescriptor(
|
|
||||||
definition.name(), definition.description(), definition.inputSchema()));
|
|
||||||
}
|
|
||||||
return List.copyOf(descriptors.values());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
public enum DshToolDecision {
|
|
||||||
ALLOW,
|
|
||||||
APPROVAL,
|
|
||||||
DENY
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
public record DshToolDescriptor(String name, String description, String inputSchema) {
|
|
||||||
public DshToolDescriptor {
|
|
||||||
if (name == null || name.isBlank()) throw new IllegalArgumentException("tool name is required");
|
|
||||||
description = description == null ? "" : description;
|
|
||||||
inputSchema = inputSchema == null || inputSchema.isBlank() ? "{}" : inputSchema;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
public record DshToolDispatchResult(DshToolDecision decision, String output, String error) {
|
|
||||||
public static DshToolDispatchResult allowed(String output) {
|
|
||||||
return new DshToolDispatchResult(DshToolDecision.ALLOW, output, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshToolDispatchResult denied(String error) {
|
|
||||||
return new DshToolDispatchResult(DshToolDecision.DENY, null, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DshToolDispatchResult approval(String reason) {
|
|
||||||
return new DshToolDispatchResult(DshToolDecision.APPROVAL, null, reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public final class DshToolDispatcher {
|
|
||||||
private final Map<String, ToolCallback> callbacks;
|
|
||||||
private final DshToolPolicy policy;
|
|
||||||
private final DshToolPolicyEvaluator policyEvaluator;
|
|
||||||
|
|
||||||
public DshToolDispatcher(List<ToolCallback> callbacks, DshToolPolicy policy,
|
|
||||||
DshToolPolicyEvaluator policyEvaluator) {
|
|
||||||
Map<String, ToolCallback> byName = new LinkedHashMap<>();
|
|
||||||
if (callbacks != null) {
|
|
||||||
for (ToolCallback callback : callbacks) {
|
|
||||||
if (callback != null && callback.getToolDefinition() != null) {
|
|
||||||
byName.putIfAbsent(callback.getToolDefinition().name(), callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.callbacks = Map.copyOf(byName);
|
|
||||||
this.policy = policy;
|
|
||||||
this.policyEvaluator = policyEvaluator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DshToolDispatchResult dispatch(String toolName, String argumentsJson, Path targetPath) {
|
|
||||||
ToolCallback callback = callbacks.get(toolName);
|
|
||||||
if (callback == null) return DshToolDispatchResult.denied("unknown tool");
|
|
||||||
DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath);
|
|
||||||
if (decision == DshToolDecision.DENY) return DshToolDispatchResult.denied("tool denied by policy");
|
|
||||||
if (decision == DshToolDecision.APPROVAL) return DshToolDispatchResult.approval("tool approval required");
|
|
||||||
try {
|
|
||||||
return DshToolDispatchResult.allowed(callback.call(argumentsJson == null ? "{}" : argumentsJson));
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
return DshToolDispatchResult.denied("tool execution failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public record DshToolPolicy(
|
|
||||||
Path workspaceRoot,
|
|
||||||
String permissionMode,
|
|
||||||
Set<String> disabledTools,
|
|
||||||
Set<String> readTools,
|
|
||||||
Set<String> editTools,
|
|
||||||
Set<String> autoApprovedTools
|
|
||||||
) {
|
|
||||||
public DshToolPolicy {
|
|
||||||
permissionMode = permissionMode == null ? "read-only" : permissionMode;
|
|
||||||
disabledTools = disabledTools == null ? Set.of() : Set.copyOf(disabledTools);
|
|
||||||
readTools = readTools == null ? Set.of() : Set.copyOf(readTools);
|
|
||||||
editTools = editTools == null ? Set.of() : Set.copyOf(editTools);
|
|
||||||
autoApprovedTools = autoApprovedTools == null ? Set.of() : Set.copyOf(autoApprovedTools);
|
|
||||||
}
|
|
||||||
}
|
|
||||||