feat(runtime): migrate to Spring Boot 4, Spring AI 2 and AgentScope 2 (#660)

This commit is contained in:
matevip 2026-10-12 05:41:24 -04:00
parent 10aad3be6c
commit 93ba4258e5
232 changed files with 19674 additions and 3980 deletions

View File

@ -15,7 +15,7 @@
[![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1.1-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![Last Commit](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
@ -38,7 +38,7 @@
>
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
>
> **And underneath, a real Agent 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 SDK JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native AgentScope runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over SDK JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
@ -162,12 +162,14 @@ Same "whole widget" philosophy. Different center of gravity.
```bash
# Backend
cd mateclaw-server
mvn spring-boot:run # http://localhost:18088
# From the repository root; use its configured models and workspace
SPRING_DATASOURCE_URL="jdbc:h2:file:$PWD/data/mateclaw;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE" \
MATECLAW_WORKSPACE_SANDBOX_ROOT="$PWD/data/workspace" \
mvn -pl mateclaw-server spring-boot:run # http://localhost:18088
# Frontend
# Frontend (new terminal, from the repository root)
cd mateclaw-ui
npm install && npm run dev # http://localhost:5173
npm install && npm run dev -- --host 127.0.0.1 # http://localhost:5173
```
Login: `admin` / `admin123`
@ -204,7 +206,7 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 backend (Agent Runtime contract, native StateGraph + DSH)
├── mateclaw-server/ Spring Boot 4.1.1 backend (Agent Runtime contract, native AgentScope + DSH)
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized)
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
@ -222,8 +224,8 @@ Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
| Backend | Spring Boot 4.1.1 · Spring AI 2.0.1 · AgentScope Java 2.0.4 · MyBatis Plus · Flyway |
| Agent Runtime | `AgentRuntimeProvider` contract · Native AgentScope (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
@ -321,7 +323,7 @@ Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/
git clone https://github.com/mateaix/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && npm install && npm run dev
cd ../mateclaw-ui && npm install && npm run dev -- --host 127.0.0.1
```
---

View File

@ -15,7 +15,7 @@
[![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java 版本](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1.1-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![最后提交](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
@ -38,7 +38,7 @@
>
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
>
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run,也可以通过 SDK JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 AgentScope 运行 ReAct、Plan-and-Execute、Goal 与 Team Run,也可以通过 SDK JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
@ -162,12 +162,14 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**We
```bash
# 后端
cd mateclaw-server
mvn spring-boot:run # http://localhost:18088
# 从仓库根目录运行,使用根目录下已配置的模型数据库与工作空间
SPRING_DATASOURCE_URL="jdbc:h2:file:$PWD/data/mateclaw;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE" \
MATECLAW_WORKSPACE_SANDBOX_ROOT="$PWD/data/workspace" \
mvn -pl mateclaw-server spring-boot:run # http://localhost:18088
# 前端
# 前端(新终端,从仓库根目录进入)
cd mateclaw-ui
npm install && npm run dev # http://localhost:5173
npm install && npm run dev -- --host 127.0.0.1 # http://localhost:5173
```
默认登录:`admin` / `admin123`
@ -204,7 +206,7 @@ docker compose up -d # http://localhost:18080
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 后端(Agent Runtime contract · Native StateGraph + DSH)
├── mateclaw-server/ Spring Boot 4.1.1 后端(Agent Runtime contract · Native AgentScope + DSH)
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
@ -222,8 +224,8 @@ mateclaw/
| 层次 | 技术 |
|---|---|
| 后端 | 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 |
| 后端 | Spring Boot 4.1.1 · Spring AI 2.0.1 · AgentScope Java 2.0.4 · MyBatis Plus · Flyway |
| Agent Runtime | `AgentRuntimeProvider` contract · Native AgentScope(ReAct + Plan-Execute)· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
| 数据库 | H2(开发)· PostgreSQL 16(Docker 默认)· MySQL 8.0+(支持)· Kingbase(按需驱动)|
@ -321,7 +323,7 @@ mateclaw/
git clone https://github.com/mateaix/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && npm install && npm run dev
cd ../mateclaw-ui && npm install && npm run dev -- --host 127.0.0.1
```
---

View File

@ -15,7 +15,7 @@
<packaging>jar</packaging>
<name>MateClaw Server</name>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI and AgentScope</description>
<dependencies>
<!-- ===== MateClaw Plugin API ===== -->
@ -24,10 +24,44 @@
<artifactId>mateclaw-plugin-api</artifactId>
</dependency>
<!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
<!-- Servlet endpoints and reactive HTTP clients are configured separately. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-core</artifactId>
<version>${agentscope.version}</version>
<exclusions>
<exclusion>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-jvm</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${networknt-schema.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.12</version>
</dependency>
<!-- ===== Actuator - exposes Spring AI observation metrics (gen_ai.*) ===== -->
@ -36,23 +70,6 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- ===== Spring AI Alibaba DashScope ===== -->
<!--
Version is managed centrally because this artifact is outside the Spring AI BOM.
Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- CosyVoice uses the native WebSocket API, not OpenAI audio/speech. -->
<dependency>
<groupId>com.alibaba</groupId>
@ -67,10 +84,9 @@
</exclusions>
</dependency>
<!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<!-- ===== Spring AI OpenAI Compatible ===== -->
@ -85,19 +101,10 @@
<artifactId>spring-ai-anthropic</artifactId>
</dependency>
<!-- ===== Spring AI MCP Client (dynamic MCP server connection management) ===== -->
<!--
Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
-->
<!-- Application code owns MCP discovery, callbacks and client lifecycle. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</exclusion>
</exclusions>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
</dependency>
<!-- ===== H2 embedded database (development) ===== -->
@ -117,7 +124,7 @@
<!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<!-- MyBatis Plus pagination support is split into a separate module. -->
<dependency>
@ -211,6 +218,10 @@
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<exclusions>
<exclusion>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-jvm</artifactId>
</exclusion>
<!-- Exclude audio dependencies because voice features are not used. -->
<exclusion>
<groupId>club.minnced</groupId>
@ -396,6 +407,19 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-tls</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<!-- ===== ArchUnit architecture invariants =====
test-scope only, guards:
- every ToolCallback implementation overrides call(String, ToolContext)

View File

@ -19,26 +19,12 @@ import java.sql.Connection;
/**
* MateClaw - Personal AI Assistant
* Powered by Spring AI Alibaba
* Application-owned agent runtime with Spring AI model boundaries
*
* @author MateClaw Team
*/
@Slf4j
@SpringBootApplication(exclude = {
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
// not the chat model. We don't use it — model configuration is admin-UI driven and
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
// ApplicationContext fail when users deploy via Docker without setting the key.
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
})
@SpringBootApplication
@EnableScheduling
@MapperScan("vip.mate.**.repository")
public class MateClawApplication {

View File

@ -4,16 +4,20 @@ import vip.mate.goal.service.GoalDecisionAdapter;
import vip.mate.planning.service.AgentRoutingDecisionAdapter;
// PR-0b: DashScope imports moved with the construction code into DashScopeChatModelBuilder.
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.CompileConfig;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
import vip.mate.agent.runtime.AgentRuntime;
import vip.mate.agent.runtime.agentscope.NativePolicyDriver;
import vip.mate.agent.runtime.agentscope.SpringToolSchemaAdapter;
import vip.mate.agent.runtime.agentscope.ToolBatchResultAdapter;
import vip.mate.agent.runtime.plan.ExplicitPlanRuntime;
import vip.mate.agent.runtime.state.EdgeAction;
import vip.mate.agent.runtime.state.NodeAction;
import vip.mate.agent.runtime.state.RuntimeExecutionScope;
import vip.mate.agent.graph.edge.GoalEvaluationDispatcher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.retry.support.RetryTemplate;
@ -24,7 +28,6 @@ import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.graph.edge.ObservationDispatcher;
import vip.mate.agent.graph.edge.ReasoningDispatcher;
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
import vip.mate.agent.graph.node.*;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.observation.ObservationProcessor;
@ -40,6 +43,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import vip.mate.config.GraphObservationProperties;
import vip.mate.config.ReasoningRetentionProperties;
import vip.mate.exception.MateClawException;
import vip.mate.agent.graph.executor.ToolExecutionCheckpoint;
import vip.mate.llm.chatmodel.HttpTimeouts;
import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder;
import vip.mate.llm.chatmodel.ProviderGenerateKwargs;
@ -107,6 +111,13 @@ public class AgentGraphBuilder {
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled;
private ToolExecutionCheckpoint executionCheckpoint;
@Autowired
public void setExecutionCheckpoint(ToolExecutionCheckpoint checkpoint) {
this.executionCheckpoint = checkpoint;
}
private ExecutionEvidenceRecorder executionEvidenceRecorder;
@Autowired
@ -619,7 +630,7 @@ public class AgentGraphBuilder {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
AgentRuntime compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet);
@ -652,36 +663,36 @@ public class AgentGraphBuilder {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
AgentRuntime graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager, toolSet);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
AgentRuntime buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, Set.of());
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
Set<String> autoDemotedTools) {
@ -712,6 +723,7 @@ public class AgentGraphBuilder {
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
executor.setExecutionCheckpoint(executionCheckpoint);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -738,114 +750,7 @@ public class AgentGraphBuilder {
planSummaryNode.setGoalService(goalService);
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
// 共享键
.addStrategy(MateClawStateKeys.PENDING_EVENTS, KeyStrategy.APPEND)
.addStrategy(MateClawStateKeys.CURRENT_PHASE, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SYSTEM_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONVERSATION_ID, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TRACE_ID, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.AGENT_ID, KeyStrategy.REPLACE)
// 会话消息(复用 ReAct 的 MESSAGES key,APPEND 策略)
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
// Plan 特有键
.addStrategy(PlanStateKeys.GOAL, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_ID, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_STEPS, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_VALID, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.NEEDS_PLANNING, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_INDEX, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
.addStrategy(PlanStateKeys.PLAN_REPLAN_COUNT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
// 工作上下文(REPLACE 策略,每次重新生成)
.addStrategy(PlanStateKeys.WORKING_CONTEXT, KeyStrategy.REPLACE)
// Thinking 键
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE)
// 流式防重键
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
// 流式内容暂存(AWAITING_APPROVAL 路径持久化使用)
.addStrategy(MateClawStateKeys.STREAMED_CONTENT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STREAMED_THINKING, KeyStrategy.REPLACE)
// 请求者身份(审批身份校验使用)
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
// 审批重放键
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, KeyStrategy.REPLACE)
// RFC-063r §2.5: ChatOrigin must survive every node merge so
// sub-graph nodes (StepExecutionNode + DelegateAgentTool's
// child agents) can read the originating channel binding.
// Without explicit REPLACE the framework's merge drops it
// on multi-iteration paths — root cause of the channel-binding
// flakiness reported on first deployment.
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
// Caught by StateKeyRegistrationCoverageTest — these state keys
// were silently unregistered before the post-deploy audit.
// WORKSPACE_BASE_PATH: written by buildInitialState; sub-graph
// tools read it via WorkspacePathGuard.
// STOP_REQUESTED: external cancel flag checked by every node.
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS (RFC-052):
// Plan-Execute itself doesn't trigger returnDirect, but
// DelegateAgentTool sub-agents could; register defensively.
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger,
// 后续 ReasoningNode / FinalAnswerNode 调 validateAnswer 校验
// 模型引用是否有真实证据。漏注册时框架在多 node merge 时会偶发
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
// StateKeyRegistrationCoverageTest 专门兜这条。
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
// Multimodal sidecar routing decision for the current turn.
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
// RFC 48 — persistent goal state keys must be registered in
// BOTH graph KeyStrategyFactory blocks. The architecture
// coverage test only checks "appears somewhere"; the
// GoalStateKeyDoubleRegistrationTest below verifies the
// double registration explicitly.
.addStrategy(MateClawStateKeys.ACTIVE_GOAL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATION_RESULT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure — pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
.addStrategy(MateClawStateKeys.LOADED_SKILLS, KeyStrategy.REPLACE)
// Tool progressive disclosure — extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
// Tool-call loop guard counters + one-shot post-mutation
// verification reminder flag. Read-merge-write by
// ObservationNode; registered in BOTH graphs so the
// counters survive multi-node merges.
.addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
// Graph 拓扑:
// START → PLAN_GENERATION → (PlanGenerationDispatcher)
@ -865,84 +770,20 @@ public class AgentGraphBuilder {
vip.mate.goal.service.GraphFlavor.PLAN_EXECUTE);
goalEvalNode.setDecisionAdapter(goalDecisionAdapter);
StateGraph graph = new StateGraph("plan-execute-agent", keyStrategyFactory)
.addNode(PlanStateKeys.PLAN_GENERATION_NODE,
AsyncNodeAction.node_async(planGenerationNode))
.addNode(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncNodeAction.node_async(stepExecutionNode))
.addNode(PlanStateKeys.PLAN_SUMMARY_NODE,
AsyncNodeAction.node_async(planSummaryNode))
.addNode(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncNodeAction.node_async(directAnswerNode))
.addNode(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncNodeAction.node_async(goalEvalNode))
.addEdge(StateGraph.START, PlanStateKeys.PLAN_GENERATION_NODE)
.addConditionalEdges(PlanStateKeys.PLAN_GENERATION_NODE,
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE,
// Board-delegated plan settled: step results were
// rebuilt from team tasks — summarize directly.
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncEdgeAction.edge_async(new StepProgressDispatcher(goalService)),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
// Step-failure recovery: re-plan the remaining work
// (StepProgressDispatcher returns this on phase=plan_replan).
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END))
.addConditionalEdges(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncEdgeAction.edge_async(new vip.mate.agent.graph.edge.GoalEvaluationDispatcher(
PlanStateKeys.PLAN_GENERATION_NODE, StateGraph.END)),
Map.of(
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
// DIRECT_ANSWER_NODE handles trivial requests that bypass the
// multi-step plan. For active goals, the direct answer is still
// a turn — without this edge, turns_used / score / completion
// would never tick on plan-execute conversations whose every
// reply happened to be simple enough to short-circuit through
// the direct path. Mirror PLAN_SUMMARY_NODE's gate so non-goal
// turns still go straight to END (no goal node invocation).
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END));
return graph.compile(CompileConfig.builder()
.recursionLimit(frameworkRecursionLimit())
.build());
EdgeAction goalGate = state -> {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
return hasGoal && !accessor.goalEvaluatedThisRun()
? MateClawStateKeys.GOAL_EVALUATION_NODE : ExplicitPlanRuntime.END;
};
return new ExplicitPlanRuntime(new ExplicitPlanRuntime.Policies(
planGenerationNode, new PlanGenerationDispatcher(),
stepExecutionNode, new StepProgressDispatcher(goalService),
planSummaryNode, directAnswerNode, goalGate,
goalEvalNode, new GoalEvaluationDispatcher(
PlanStateKeys.PLAN_GENERATION_NODE, ExplicitPlanRuntime.END)),
frameworkRecursionLimit());
} catch (Exception e) {
throw new MateClawException("err.agent.plan_compile_failed", "Plan-Execute StateGraph 编译失败: " + e.getMessage());
}
@ -987,30 +828,30 @@ public class AgentGraphBuilder {
return HttpTimeouts.resolveStreamIdleTimeout(override).toSeconds();
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
AgentRuntime buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of());
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
AgentRuntime buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
@ -1041,6 +882,7 @@ public class AgentGraphBuilder {
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
executor.setExecutionCheckpoint(executionCheckpoint);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -1086,121 +928,7 @@ public class AgentGraphBuilder {
chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService);
FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache, markdownNormalizeEnabled);
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
// 输入字段
.addStrategy(MateClawStateKeys.USER_MESSAGE, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONVERSATION_ID, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SYSTEM_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.AGENT_ID, KeyStrategy.REPLACE)
// 消息列表(追加策略)
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
// 迭代控制
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ITERATION_REFUND_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
// 工具调用
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TOOL_RESULTS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TOOL_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
// 控制流
.addStrategy(MateClawStateKeys.FINAL_ANSWER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.NEEDS_TOOL_CALL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ERROR, KeyStrategy.REPLACE)
// 观察历史(REPLACE 策略,由 ObservationNode 手动累加,SummarizingNode 可清空)
.addStrategy(MateClawStateKeys.OBSERVATION_HISTORY, KeyStrategy.REPLACE)
// Summarizing
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
// 终止控制
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LIMIT_EXCEEDED, KeyStrategy.REPLACE)
// 统计与追踪
.addStrategy(MateClawStateKeys.ERROR_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TRACE_ID, KeyStrategy.REPLACE)
// 事件流
.addStrategy(MateClawStateKeys.PENDING_EVENTS, KeyStrategy.APPEND)
.addStrategy(MateClawStateKeys.CURRENT_PHASE, KeyStrategy.REPLACE)
// Thinking
.addStrategy(MateClawStateKeys.FINAL_THINKING, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CURRENT_THINKING, KeyStrategy.REPLACE)
// 流式防重
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
// 审批控制
.addStrategy(MateClawStateKeys.AWAITING_APPROVAL, KeyStrategy.REPLACE)
// 流式内容暂存(AWAITING_APPROVAL 路径持久化使用)
.addStrategy(MateClawStateKeys.STREAMED_CONTENT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STREAMED_THINKING, KeyStrategy.REPLACE)
// 请求者身份(审批身份校验使用)
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
// 审批重放
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
// RFC-063r §2.5: ChatOrigin must survive every node merge so
// ActionNode (and DelegateAgentTool's child agents) can read
// the originating channel binding across multi-iteration ReAct
// loops. Without explicit REPLACE the framework's merge drops
// it after the first node transition — root cause of the
// channel-binding flakiness reported on first deployment.
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
// Caught by StateKeyRegistrationCoverageTest — silently
// unregistered before the audit. WORKSPACE_BASE_PATH from
// initial state; STOP_REQUESTED is the external cancel flag;
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS are RFC-052
// returnDirect short-circuit signals consumed by ObservationDispatcher.
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger,
// 后续 ReasoningNode / FinalAnswerNode 调 validateAnswer 校验
// 模型引用是否有真实证据。漏注册时框架在多 node merge 时会偶发
// 丢这个键,evidence_insufficient 检查会"静默地不生效" ——
// StateKeyRegistrationCoverageTest 专门兜这条。
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
// Multimodal sidecar routing decision for the current turn.
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
// RFC 48 — persistent goal state keys must be registered in
// BOTH graph KeyStrategyFactory blocks. See
// GoalStateKeyDoubleRegistrationTest for the strict
// double-registration check.
.addStrategy(MateClawStateKeys.ACTIVE_GOAL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATION_RESULT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure — pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
.addStrategy(MateClawStateKeys.LOADED_SKILLS, KeyStrategy.REPLACE)
// Tool progressive disclosure — extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
// Tool-call loop guard counters + one-shot post-mutation
// verification reminder flag. Read-merge-write by
// ObservationNode; registered in BOTH graphs so the
// counters survive multi-node merges.
.addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
goalEvaluationService, goalFollowupService, goalService, goalProperties,
@ -1208,67 +936,28 @@ public class AgentGraphBuilder {
vip.mate.goal.service.GraphFlavor.REACT);
goalEvalNode.setDecisionAdapter(goalDecisionAdapter);
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
.addNode(MateClawStateKeys.REASONING_NODE,
AsyncNodeAction.node_async(reasoningNode))
.addNode(MateClawStateKeys.ACTION_NODE,
AsyncNodeAction.node_async(actionNode))
.addNode(MateClawStateKeys.OBSERVATION_NODE,
AsyncNodeAction.node_async(observationNode))
.addNode(MateClawStateKeys.SUMMARIZING_NODE,
AsyncNodeAction.node_async(summarizingNode))
.addNode(MateClawStateKeys.LIMIT_EXCEEDED_NODE,
AsyncNodeAction.node_async(limitExceededNode))
.addNode(MateClawStateKeys.FINAL_ANSWER_NODE,
AsyncNodeAction.node_async(finalAnswerNode))
.addNode(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncNodeAction.node_async(goalEvalNode))
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE,
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE))
.addEdge(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.OBSERVATION_NODE)
.addConditionalEdges(MateClawStateKeys.OBSERVATION_NODE,
AsyncEdgeAction.edge_async(new ObservationDispatcher()),
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE,
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE))
.addEdge(MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.REASONING_NODE)
.addEdge(MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.FINAL_ANSWER_NODE)
// FinalAnswer -> (active goal && not yet evaluated this run) ? GoalEvaluation : END
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END))
// GoalEvaluation -> (followup injected) ? Reasoning : END
.addConditionalEdges(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncEdgeAction.edge_async(new vip.mate.agent.graph.edge.GoalEvaluationDispatcher(
MateClawStateKeys.REASONING_NODE, StateGraph.END)),
Map.of(
MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
StateGraph.END, StateGraph.END));
return graph.compile(CompileConfig.builder()
.recursionLimit(frameworkRecursionLimit())
.withLifecycleListener(new ReActLifecycleListener())
.build());
EdgeAction goalGate = state -> {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
return hasGoal && !accessor.goalEvaluatedThisRun()
? MateClawStateKeys.GOAL_EVALUATION_NODE : NativePolicyDriver.END;
};
NodeAction guardedBatch = state -> {
List<AssistantMessage.ToolCall> calls =
state.value(MateClawStateKeys.TOOL_CALLS, List.of());
ToolExecutionExecutor.ToolExecutionResult result = actionNode.executeBatch(state);
Map<String, Object> projection = new LinkedHashMap<>(actionNode.completeBatch(state, result));
projection.put(NativePolicyDriver.BATCH_RESULT, ToolBatchResultAdapter.project(calls, result));
return projection;
};
return new NativePolicyDriver(new NativePolicyDriver.Policies(
reasoningNode, new ReasoningDispatcher(), guardedBatch, observationNode,
new ObservationDispatcher(), summarizingNode, limitExceededNode,
finalAnswerNode, goalGate, goalEvalNode,
new GoalEvaluationDispatcher(MateClawStateKeys.REASONING_NODE, NativePolicyDriver.END)),
SpringToolSchemaAdapter.convert(toolSet.callbacks()), frameworkRecursionLimit(),
RuntimeExecutionScope::open, NativePolicyDriver.ToolNamePolicy.APPLICATION_OWNER);
} catch (Exception e) {
throw new MateClawException("err.agent.graph_compile_failed", "StateGraph v2 编译失败: " + e.getMessage());
}
@ -1672,7 +1361,7 @@ public class AgentGraphBuilder {
## About You
You are powered by MateClaw — a multi-user AI Agent platform built on
Spring Boot 3.5 and Spring AI Alibaba Graph. You are reachable through
Java 21, Spring Boot 4, Spring AI 2 and AgentScope 2. You are reachable through
WebChat and 8+ IM channels (DingTalk, Feishu, WeCom, WeChat, Telegram,
Discord, QQ, Slack). If asked who you are or what you are based on,
answer with MateClaw and the technology stack above.

View File

@ -336,12 +336,13 @@ public class AgentService {
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin previousOrigin = ChatOriginHolder.get();
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, message, conversationId,
(msg, convId) -> agent.chat(msg, convId));
} finally {
ChatOriginHolder.clear();
restoreOrigin(previousOrigin);
}
}
@ -379,15 +380,11 @@ public class AgentService {
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
// Capture request context for initial state assembly on the subscription thread.
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId),
chunk -> chunk);
}).doFinally(signal -> ChatOriginHolder.clear());
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId), chunk -> chunk,
captured, ThinkingLevelHolder.get());
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
@ -426,47 +423,26 @@ public class AgentService {
connection.prompt(dshConversationHistory.enrich(
convId, message, msg, origin))),
connection -> connection.close()),
StreamDelta::content)
.doFinally(signal -> ThinkingLevelHolder.clear());
StreamDelta::content);
}
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
ThinkingLevelHolder.set(thinkingLevel);
} else {
// 尝试从 Agent 默认配置读取
String capturedThinking = thinkingLevel;
if (capturedThinking == null || capturedThinking.isBlank()) {
AgentEntity entity = getAgent(agentId);
if (entity != null && entity.getDefaultThinkingLevel() != null) {
ThinkingLevelHolder.set(entity.getDefaultThinkingLevel());
} else {
ThinkingLevelHolder.clear();
}
capturedThinking = entity != null ? entity.getDefaultThinkingLevel() : null;
}
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
if (agent instanceof StructuredStreamCapable capable) {
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> capable.chatStructuredStream(msg, convId,
requesterId != null ? requesterId : "")
.doFinally(signal -> ThinkingLevelHolder.clear()),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> capable.chatStructuredStream(msg, convId,
requesterId != null ? requesterId : ""),
StreamDelta::content, captured, capturedThinking);
}
// 降级:不支持结构化流的 Agent,包装为纯内容流
ThinkingLevelHolder.clear();
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId)
.map(chunk -> new StreamDelta(chunk, null)),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId)
.map(chunk -> new StreamDelta(chunk, null)),
StreamDelta::content, captured, null);
}
public String execute(Long agentId, String goal, String conversationId) {
@ -477,12 +453,13 @@ public class AgentService {
clearAutoRecordedForNewTurn(conversationId);
trackMemoryRecalls(agentId, goal, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin previousOrigin = ChatOriginHolder.get();
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, goal, conversationId,
(msg, convId) -> agent.execute(msg, convId));
} finally {
ChatOriginHolder.clear();
restoreOrigin(previousOrigin);
}
}
@ -504,12 +481,13 @@ public class AgentService {
String toolCallPayload, ChatOrigin origin) {
trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin previousOrigin = ChatOriginHolder.get();
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
} finally {
ChatOriginHolder.clear();
restoreOrigin(previousOrigin);
}
}
@ -569,14 +547,10 @@ public class AgentService {
}
}), vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
}
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
requesterId != null ? requesterId : ""),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
return withLifecycleFlux(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
requesterId != null ? requesterId : ""),
StreamDelta::content, captured, ThinkingLevelHolder.get());
}
public AgentState getAgentState(Long agentId) {
@ -705,7 +679,30 @@ public class AgentService {
*
* P1-1 fix: prefetchAll result is now prepended to userMessage.
* P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error.
* Initial state is captured on the subscription thread, which is restored immediately.
*/
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor,
ChatOrigin origin, String thinking) {
return Flux.using(() -> acquireTurn(conversationId), permit -> {
ChatOrigin previousOrigin = ChatOriginHolder.get();
String previousThinking = ThinkingLevelHolder.get();
ChatOriginHolder.set(origin);
if (thinking == null) ThinkingLevelHolder.clear(); else ThinkingLevelHolder.set(thinking);
try {
return invokeWithLifecycleFlux(agentId, message, conversationId, invoke, contentExtractor);
} finally {
restoreOrigin(previousOrigin);
if (previousThinking == null) ThinkingLevelHolder.clear(); else ThinkingLevelHolder.set(previousThinking);
}
}, vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
}
private static void restoreOrigin(ChatOrigin origin) {
if (ChatOrigin.EMPTY.equals(origin)) ChatOriginHolder.clear(); else ChatOriginHolder.set(origin);
}
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {

View File

@ -1,6 +1,6 @@
package vip.mate.agent;
import com.alibaba.cloud.ai.graph.NodeOutput;
import vip.mate.agent.runtime.state.RunStep;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.List;
@ -12,7 +12,7 @@ import java.util.LinkedHashMap;
* <p>
* 所有方法都是 static,不做状态管理。
* 节点内部收集 List&lt;GraphEvent&gt;,最终写入 PENDING_EVENTS。
* StateGraph*Agent 从 NodeOutput 中读取这些事件。
* StateGraph*Agent 从 RunStep 中读取这些事件。
*
* @author MateClaw Team
*/
@ -285,10 +285,10 @@ public final class GraphEventPublisher {
// ===== 提取方法 =====
/**
* 从 NodeOutput 中提取 PENDING_EVENTS
* 从 RunStep 中提取 PENDING_EVENTS
*/
@SuppressWarnings("unchecked")
public static List<GraphEvent> extractEvents(NodeOutput output) {
public static List<GraphEvent> extractEvents(RunStep output) {
if (output == null || output.state() == null) {
return List.of();
}

View File

@ -1,5 +1,6 @@
package vip.mate.agent.context;
import vip.mate.llm.chatmodel.ChatModelDefaults;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
@ -13,7 +14,6 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.stereotype.Component;
import vip.mate.agent.graph.executor.ToolResultStorage;
import vip.mate.agent.prompt.PromptLoader;
@ -1403,8 +1403,9 @@ public class ConversationWindowManager {
promptMessages.add(new SystemMessage(systemPrompt));
promptMessages.add(new UserMessage(userPrompt));
ChatOptions options = DashScopeChatOptions.builder()
.withMaxToken(properties.getSummaryMaxTokens())
ChatOptions defaults = ChatModelDefaults.optionsFor(chatModel);
ChatOptions options = (defaults != null ? defaults.mutate() : ChatOptions.builder())
.maxTokens(properties.getSummaryMaxTokens())
.build();
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));

View File

@ -0,0 +1,47 @@
package vip.mate.agent.graph;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
import vip.mate.llm.chatmodel.ChatModelDefaults;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import java.util.List;
import java.util.Locale;
/** Builds immutable tool-request options from the bound model's configured defaults. */
public final class ModelToolOptions {
private ModelToolOptions() {}
public static ChatOptions forModel(
ChatModel model, List<ToolCallback> callbacks, String reasoningEffort) {
ChatOptions defaults = ChatModelDefaults.optionsFor(model);
if (defaults instanceof OpenAiChatOptions openAi) {
OpenAiChatOptions.Builder builder = openAi.mutate().toolCallbacks(callbacks);
String requestLevel = ThinkingLevelHolder.get();
String effective = requestLevel != null ? requestLevel : reasoningEffort;
if ("off".equalsIgnoreCase(effective)) {
builder.reasoningEffort(null);
} else if (StringUtils.hasText(effective)) {
builder.reasoningEffort(
switch (effective.toLowerCase(Locale.ROOT)) {
case "minimal", "low" -> "low";
case "max" -> "high";
default -> effective;
});
}
return builder.build();
}
if (defaults != null
&& defaults.mutate() instanceof ToolCallingChatOptions.Builder<?> builder) {
return builder.toolCallbacks(callbacks).build();
}
ToolCallingChatOptions.Builder<?> builder = ToolCallingChatOptions.builder();
if (defaults != null) builder.combineWith(defaults.mutate());
return builder.toolCallbacks(callbacks).build();
}
}

View File

@ -1,5 +1,6 @@
package vip.mate.agent.graph;
import vip.mate.llm.chatmodel.ChatModelDefaults;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
@ -8,10 +9,11 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.ProviderHttpError;
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import vip.mate.llm.chatmodel.ReasoningContentCache;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
@ -26,6 +28,7 @@ import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CancellationException;
@ -702,8 +705,8 @@ public class NodeStreamingChatHelper {
HttpHeaders headers = null;
if (cur instanceof WebClientResponseException wre) {
headers = wre.getHeaders();
} else if (cur instanceof RestClientResponseException rre) {
headers = rre.getResponseHeaders();
} else {
headers = ProviderHttpError.headers(cur);
}
if (headers == null) continue;
long ms = parseRetryWindowMs(headers);
@ -785,11 +788,29 @@ public class NodeStreamingChatHelper {
} catch (Exception ignored) {
}
}
int status = ProviderHttpError.status(cur);
if (status != 0) {
sb.append("HTTP ").append(status).append(" | ");
String body = ProviderHttpError.body(cur);
if (!body.isEmpty()) sb.append(body.length() > 1024 ? body.substring(0, 1024) : body).append(" | ");
}
cur = cur.getCause();
}
return sb.toString();
}
private void throwIfCancelled(String conversationId) {
if (streamTracker.isStopRequested(conversationId) || Thread.currentThread().isInterrupted()) {
throw new CancellationException("LLM call cancelled");
}
}
private static CancellationException cancellation(InterruptedException interrupted) {
CancellationException cancelled = new CancellationException("LLM call interrupted");
cancelled.initCause(interrupted);
return cancelled;
}
private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast) {
@ -861,6 +882,7 @@ public class NodeStreamingChatHelper {
llmCallCount++;
if (attempt > 0) retryCount++;
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true, retryType, retryHint);
if (lastResult == null || !lastResult.stopped()) throwIfCancelled(conversationId);
if (lastResult != null) {
ErrorType errType = lastResult.errorType();
// Success — reaffirm health / pool membership and return.
@ -947,6 +969,7 @@ public class NodeStreamingChatHelper {
// Providers in cooldown (RFC-009 P3.3) are also skipped so a known-bad
// provider doesn't add latency to every conversation turn.
for (int i = 0; i < fallbackChain.size(); i++) {
throwIfCancelled(conversationId);
vip.mate.llm.failover.FallbackEntry entry = fallbackChain.get(i);
ChatModel fallback = entry.chatModel();
if (fallback == chatModel) continue;
@ -976,6 +999,7 @@ public class NodeStreamingChatHelper {
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
phase + "_fallback_" + (i + 1), broadcast, 0, false,
new AtomicReference<>(), new AtomicReference<>(0L));
if (fallbackResult == null || !fallbackResult.stopped()) throwIfCancelled(conversationId);
// Accept only fully successful fallbacks. Non-successful results (auth
// error, client error, still-rate-limited) propagate to the next
// fallback instead of being surfaced as the final result.
@ -1068,15 +1092,11 @@ public class NodeStreamingChatHelper {
// provider. We only activate relay on OpenAiChatOptions paths — Anthropic has
// its own thinking mechanism (extended thinking via AnthropicChatOptions.thinking).
String relayToken = null;
String originalUser = null;
org.springframework.ai.openai.OpenAiChatOptions oaiOptsForRelay = null;
if (outbound.getOptions() instanceof org.springframework.ai.openai.OpenAiChatOptions oaiOpts) {
List<String> thinkings = extractAssistantThinkings(outbound);
if (thinkings.stream().anyMatch(s -> !s.isEmpty())) {
originalUser = oaiOpts.getUser();
relayToken = AssistantThinkingRelay.stash(thinkings, originalUser);
oaiOpts.setUser(relayToken);
oaiOptsForRelay = oaiOpts;
relayToken = AssistantThinkingRelay.stash(thinkings, oaiOpts.getUser());
outbound = new Prompt(outbound.getInstructions(), oaiOpts.mutate().user(relayToken).build());
}
}
@ -1086,9 +1106,6 @@ public class NodeStreamingChatHelper {
// Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) {
AssistantThinkingRelay.discard(relayToken);
if (oaiOptsForRelay != null) {
oaiOptsForRelay.setUser(originalUser);
}
}
}
}
@ -1116,6 +1133,7 @@ public class NodeStreamingChatHelper {
boolean broadcast, int attempt, boolean primaryCall,
AtomicReference<ErrorType> retryTypeRef,
AtomicReference<Long> retryHintRef) {
throwIfCancelled(conversationId);
if (attempt > 0) {
boolean overloaded = retryTypeRef.get() == ErrorType.OVERLOADED;
boolean emptyResponse = retryTypeRef.get() == ErrorType.EMPTY_RESPONSE;
@ -1166,7 +1184,7 @@ public class NodeStreamingChatHelper {
Thread.sleep(slice);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return buildErrorResult("LLM 调用被中断", conversationId, phase);
throw cancellation(ie);
}
remaining -= slice;
}
@ -1175,7 +1193,9 @@ public class NodeStreamingChatHelper {
StringBuilder contentAccum = new StringBuilder();
StringBuilder thinkingAccum = new StringBuilder();
List<ToolCallAccumulator> toolCallAccumulators = new ArrayList<>();
AtomicReference<AssistantMessage> lastAssistantMessage = new AtomicReference<>();
AtomicReference<Map<String, Object>> providerMetadata = new AtomicReference<>(Map.of());
boolean cumulativeReasoning = ChatModelDefaults.optionsFor(chatModel) instanceof OpenAiChatOptions;
AtomicReference<String> previousReasoning = new AtomicReference<>("");
AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicReference<String> finishReason = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0);
@ -1278,6 +1298,7 @@ public class NodeStreamingChatHelper {
// 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.
throwIfCancelled(conversationId);
Flux<ChatResponse> streamWithIdleGuard =
streamIdleTimeoutSec > 0
? chatModel.stream(prompt).timeout(
@ -1299,7 +1320,7 @@ public class NodeStreamingChatHelper {
finishReason.set(generation.getMetadata().getFinishReason());
}
AssistantMessage msg = generation.getOutput();
lastAssistantMessage.set(msg);
providerMetadata.updateAndGet(previous -> mergeProviderMetadata(previous, msg.getMetadata()));
// thinking-only soft cap 已触发 → 跳过一切处理(等外层 dispose)
if (thinkingOnlyCapTriggered.get() || contentRepeatCapTriggered.get()) {
@ -1310,6 +1331,14 @@ public class NodeStreamingChatHelper {
// 内联标签提取(此类模型不会再用 <think> 包裹思考,正文里的
// 字面标签是真实内容)。
String nativeThinking = extractReasoningContent(msg);
if (cumulativeReasoning && nativeThinking != null && !nativeThinking.isEmpty()) {
String previous = previousReasoning.get();
if (!nativeThinking.startsWith(previous)) {
throw new IllegalArgumentException("OpenAI cumulative reasoning is not monotonic");
}
previousReasoning.set(nativeThinking);
nativeThinking = nativeThinking.substring(previous.length());
}
if (nativeThinking != null && !nativeThinking.isEmpty()) {
thinkExtractor.disable();
}
@ -1484,7 +1513,7 @@ public class NodeStreamingChatHelper {
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase);
reasoningTokens.get(), phase, providerMetadata.get());
}
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
phase, conversationId);
@ -1499,7 +1528,7 @@ public class NodeStreamingChatHelper {
} catch (InterruptedException e) {
subscription.dispose();
Thread.currentThread().interrupt();
return buildErrorResult("LLM 调用被中断", conversationId, phase);
throw cancellation(e);
}
// Stream is over (complete, error, or disposed by a guard) — drain the
@ -1524,7 +1553,7 @@ public class NodeStreamingChatHelper {
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(),
phase, true, error.getMessage());
phase, true, error.getMessage(), providerMetadata.get());
}
// ===== 无内容:分类错误并决定是否重试 =====
@ -1625,7 +1654,7 @@ public class NodeStreamingChatHelper {
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase,
truncated,
truncationReason);
truncationReason, providerMetadata.get());
}
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
@ -1633,7 +1662,7 @@ public class NodeStreamingChatHelper {
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok,
int reasoningTok, String phase) {
int reasoningTok, String phase, Map<String, Object> providerMetadata) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
@ -1647,7 +1676,7 @@ public class NodeStreamingChatHelper {
}
}
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls, providerMetadata);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns.
@ -1664,7 +1693,7 @@ public class NodeStreamingChatHelper {
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) {
String phase, boolean partial, String errorMsg, Map<String, Object> providerMetadata) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
@ -1680,7 +1709,7 @@ public class NodeStreamingChatHelper {
}
}
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls, providerMetadata);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns. The cache replays real values instead of empty strings.
@ -1708,17 +1737,39 @@ public class NodeStreamingChatHelper {
* {@link #stripThinkingFromPrompt} L937).
*/
private static AssistantMessage buildAssistantMessageWithThinking(
String fullContent, String fullThinking, List<AssistantMessage.ToolCall> finalToolCalls) {
String fullContent, String fullThinking, List<AssistantMessage.ToolCall> finalToolCalls,
Map<String, Object> providerMetadata) {
AssistantMessage.Builder builder = AssistantMessage.builder().content(fullContent);
if (finalToolCalls != null && !finalToolCalls.isEmpty()) {
builder.toolCalls(finalToolCalls);
}
Map<String, Object> metadata = new LinkedHashMap<>(providerMetadata);
if (fullThinking != null && !fullThinking.isEmpty()) {
builder.properties(Map.of("reasoningContent", fullThinking));
metadata.put("reasoningContent", fullThinking);
}
builder.properties(metadata);
return builder.build();
}
private static Map<String, Object> mergeProviderMetadata(Map<String, Object> previous, Map<String, Object> incoming) {
Map<String, Object> merged = new LinkedHashMap<>(previous);
for (String key : List.of("anthropicThinkingBlocks", "openai.tool_calls.additional_properties", "search_info")) {
Object value = incoming.get(key);
if (value == null || value instanceof Map<?, ?> map && map.isEmpty()
|| value instanceof List<?> list && list.isEmpty()
|| value instanceof String text && text.isBlank()) continue;
if ("openai.tool_calls.additional_properties".equals(key) && value instanceof Map<?, ?> properties) {
Map<Object, Object> combined = new LinkedHashMap<>();
if (previous.get(key) instanceof Map<?, ?> oldProperties) combined.putAll(oldProperties);
combined.putAll(properties);
merged.put(key, Map.copyOf(combined));
} else {
merged.put(key, value);
}
}
return Map.copyOf(merged);
}
/**
* Store reasoning content in the cache for cross-turn replay.
* Only caches when there are tool calls (MiMo requires reasoning_content
@ -2438,31 +2489,11 @@ public class NodeStreamingChatHelper {
return total;
}
/**
* Pick a stable model identifier from whatever {@link ChatModel}
* implementation we received — Spring AI doesn't expose a single accessor.
* We try the well-known fields by reflection so this stays decoupled from
* concrete provider classes (Anthropic / OpenAI / DashScope all expose
* {@code defaultOptions.model} or equivalent).
*/
/** Uses the common Spring AI options contract for provider request telemetry. */
private static String identifyModel(ChatModel chatModel) {
if (chatModel == null) return "";
try {
// Common Spring AI shape: getDefaultOptions().getModel()
java.lang.reflect.Method getDefaultOptions = chatModel.getClass().getMethod("getDefaultOptions");
Object opts = getDefaultOptions.invoke(chatModel);
if (opts != null) {
try {
java.lang.reflect.Method getModel = opts.getClass().getMethod("getModel");
Object model = getModel.invoke(opts);
if (model != null) return model.toString();
} catch (NoSuchMethodException ignored) {
// fall through
}
}
} catch (Exception ignored) {
// fall through to class-name fallback
}
var options = ChatModelDefaults.optionsFor(chatModel);
if (options != null && options.getModel() != null) return options.getModel();
return chatModel.getClass().getSimpleName();
}

View File

@ -1,10 +1,10 @@
package vip.mate.agent.graph;
import vip.mate.workspace.core.service.MemberFileIsolation;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.NodeOutput;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import vip.mate.agent.runtime.AgentRuntime;
import vip.mate.tool.builtin.DelegationContext;
import vip.mate.agent.runtime.state.RunStep;
import vip.mate.agent.runtime.state.RunState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
@ -30,30 +30,15 @@ import java.util.concurrent.atomic.AtomicReference;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 基于 StateGraph v2 的 ReAct Agent
* <p>
* 使用 spring-ai-alibaba-graph-core 的 StateGraph 引擎,
* 实现显式可控的 Thought → Action → Observation 循环,
* 含 Summarizing、LimitExceeded 和 FinalAnswer 节点。
* <p>
* 关键特性:
* - 迭代次数强制控制(maxIterations 真正生效)
* - ToolGuard 安全拦截(在 ActionNode 中执行)
* - 工具调用过程可观测
* - Summarizing 阶段收束冗长上下文
* - 超限友好提示
* - 结构化生命周期日志
* <p>
* content_delta 和 thinking_delta 由节点内 {@link NodeStreamingChatHelper} 直推,
* chatStructuredStream() 只处理 phase/tool/事件等结构化事件。
* 不再从 NodeOutput 二次整段下发已流式推送的内容。
*
* @author MateClaw Team
* ReAct application entry point backed by the native AgentScope runtime.
* Application policies own prompts, guarded tool batches, summaries and goal decisions.
* Streaming content and thinking originate in NodeStreamingChatHelper; retained RunSteps
* project structured events without sending already streamed text again.
*/
@Slf4j
public class StateGraphReActAgent extends BaseAgent implements StructuredStreamCapable {
private final CompiledGraph compiledGraph;
private final AgentRuntime compiledGraph;
private final org.springframework.ai.chat.model.ChatModel chatModel;
private final ConversationWindowManager conversationWindowManager;
/**
@ -79,7 +64,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph,
AgentRuntime compiledGraph,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager) {
this(chatClient, conversationService, compiledGraph, chatModel,
@ -87,7 +72,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph,
AgentRuntime compiledGraph,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager,
vip.mate.agent.AgentToolSet toolSet) {
@ -106,13 +91,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
// Fresh thread per invocation so graph state never carries over
// between calls. The CompiledGraph is cached and shared; without a
// between calls. The AgentRuntime is cached and shared; without a
// unique threadId, consecutive sync runs (e.g. back-to-back cron
// executions) inherit the prior run's accumulated messages and
// counters. Mirrors the streaming paths, which already do this.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
Optional<RunState> result = compiledGraph.invoke(inputs);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))
@ -135,10 +119,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
log.info("[{}] StateGraph stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
return compiledGraph.stream(inputs, config)
return compiledGraph.stream(inputs)
.filter(this::hasFinalAnswer)
.map(this::extractFinalAnswer)
.filter(content -> content != null && !content.isEmpty())
@ -172,9 +156,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
}
// Fresh thread per invocation — see chat() for rationale.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
Optional<RunState> result = compiledGraph.invoke(inputs);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))
@ -208,8 +191,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
}
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
@ -229,7 +212,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
@ -410,12 +393,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
inputs.put(REQUESTER_ID, requesterId != null ? requesterId : "");
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
// Lambda 内需要维护已发送事件偏移;这里只是局部可变计数器,不涉及跨会话共享。
AtomicInteger sentEventCount = new AtomicInteger(0);
// Token usage 追踪(每次 NodeOutput 更新最新累计值,最后一次即最终值)
// Token usage 追踪(每次 RunStep 更新最新累计值,最后一次即最终值)
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
@ -423,15 +406,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护:StateGraph 对每个节点都 emit NodeOutput,FINAL_ANSWER 一旦写入后续节点都携带,
// 防重保护:StateGraph 对每个节点都 emit RunStep,FINAL_ANSWER 一旦写入后续节点都携带,
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
// 同 STREAMED_CONTENT:STREAMED_THINKING 也是 REPLACE,用独立游标
// 跟踪已持久化的每轮 thinking,避免后续节点的 NodeOutput 重复发送。
// 跟踪已持久化的每轮 thinking,避免后续节点的 RunStep 重复发送。
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 RunStep 上重复发送同一段内容。
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
// Silent-termination guardrail: track the highest iteration / soft cap
// observed and whether the graph reached a legitimate exit (final answer
@ -445,7 +428,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取所有累积的事件,只发送新增部分
@ -641,6 +624,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路,
// 否则 thinking-on 会把"无限"误算成 5(变成"5 步就停")。
String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
if (thinkingLevel != null) inputs.put(REQUESTED_THINKING_LEVEL, thinkingLevel);
inputs.put(SUBAGENT_RUN_CONTEXT, DelegationContext.current());
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
int effectiveMaxIterations = (maxIterations <= 0)
? 0
@ -813,7 +798,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|| !finalAnswer.contains(streamed);
}
private boolean hasFinalAnswer(NodeOutput output) {
private boolean hasFinalAnswer(RunStep output) {
if (output == null || output.state() == null) {
return false;
}
@ -822,11 +807,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.isPresent();
}
private String extractFinalAnswer(NodeOutput output) {
private String extractFinalAnswer(RunStep output) {
return output.state().<String>value(FINAL_ANSWER).orElse("");
}
private String extractFinalThinking(NodeOutput output) {
private String extractFinalThinking(RunStep output) {
if (output == null || output.state() == null) {
return null;
}

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.EdgeAction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -29,7 +29,7 @@ public class GoalEvaluationDispatcher implements EdgeAction {
private final String terminalTarget;
@Override
public String apply(OverAllState state) {
public String apply(RunState state) {
// Re-enter the loop only when a followup was injected AND this was not a
// terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key
// strategy and is never cleared by the reasoning nodes, so after a

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.EdgeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.graph.state.MateClawStateAccessor;
@ -25,7 +25,7 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
public class ObservationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) throws Exception {
public String apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
int currentIteration = accessor.iterationCount();

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.EdgeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.graph.state.MateClawStateAccessor;
@ -31,7 +31,7 @@ public class ReasoningDispatcher implements EdgeAction {
private static final int LLM_CALL_MULTIPLIER = 5;
@Override
public String apply(OverAllState state) throws Exception {
public String apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 1. 迭代超限检查

View File

@ -1,9 +1,11 @@
package vip.mate.agent.graph.executor;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import java.util.function.Supplier;
/** Interrupts cooperative callbacks in place, preserving their thread-local context. */
@ -32,14 +34,22 @@ final class ToolCallDeadline {
}
static <T> T call(String toolName, long timeoutMs, Supplier<T> callback) throws TimeoutException {
return callWithDeadline(toolName, timeoutMs, guard -> callback.get());
}
static <T> T callWithDeadline(String toolName, long timeoutMs, Function<Runnable, T> callback) throws TimeoutException {
ToolCallDeadline deadline = new ToolCallDeadline();
long startedNanos = System.nanoTime();
long timeoutNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(1L, timeoutMs));
Runnable guard = () -> deadline.checkEntry(startedNanos, timeoutNanos);
ScheduledFuture<?> timer = TIMER.schedule(deadline::expire, Math.max(1L, timeoutMs), TimeUnit.MILLISECONDS);
try {
T result = callback.get();
T result = callback.apply(guard);
guard.run();
if (deadline.finish()) throw timeout(toolName, timeoutMs);
return result;
} catch (RuntimeException failure) {
if (deadline.finish()) {
if (deadline.finish() || System.nanoTime() - startedNanos >= timeoutNanos) {
TimeoutException timeout = timeout(toolName, timeoutMs);
timeout.initCause(failure);
throw timeout;
@ -54,6 +64,16 @@ final class ToolCallDeadline {
}
}
private synchronized void checkEntry(long startedNanos, long timeoutNanos) {
if (expired || System.nanoTime() - startedNanos >= timeoutNanos) {
expired = true;
throw new CancellationException("Tool callback deadline expired before entry");
}
if (Thread.currentThread().isInterrupted()) {
throw new CancellationException("Tool callback cancelled before entry");
}
}
private static TimeoutException timeout(String name, long timeoutMs) {
return new TimeoutException("Tool " + name + " timed out after " + timeoutMs + "ms");
}

View File

@ -0,0 +1,11 @@
package vip.mate.agent.graph.executor;
import vip.mate.agent.context.ChatOrigin;
/** Durable authority check performed immediately before entering a tool callback. */
@FunctionalInterface
public interface ToolExecutionCheckpoint {
void beforeInvocation(ChatOrigin origin, String invocationKey, String providerCallId);
default void markUnknownOutcome(ChatOrigin origin, Throwable cause) {}
}

View File

@ -0,0 +1,12 @@
package vip.mate.agent.graph.executor;
/** A rejected execution fence must abort the run, never become a normal tool result. */
public final class ToolExecutionCheckpointRejectedException extends RuntimeException {
public ToolExecutionCheckpointRejectedException(String message) {
super(message);
}
public ToolExecutionCheckpointRejectedException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -15,6 +15,8 @@ import vip.mate.tool.mcp.runtime.McpToolNameResolver;
import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
import vip.mate.tool.mcp.runtime.McpToolResultCapture;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.delegation.SubagentRunContext;
import vip.mate.tool.builtin.DelegationContext;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
@ -37,6 +39,8 @@ import vip.mate.tool.guard.service.ToolGuardService;
import java.util.*;
import java.util.Collections;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
/**
@ -727,7 +731,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, UUID.randomUUID().toString(), rawEvidenceRef));
conversationId, requesterId, workspaceBasePath, safeOrigin, DelegationContext.current(), new AtomicBoolean(), UUID.randomUUID().toString(), rawEvidenceRef));
// 占位,Phase 2 填充
allResponses.add(null);
}
@ -842,18 +846,20 @@ public class ToolExecutionExecutor {
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), toolName, redirect.response(), true));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, redirect.response());
toolCall.id(), toolCall.name(), redirect.response());
}
String msg = skillAwareNotFoundMessage(toolName, replayOriginForRedirect);
log.warn("[ToolExecutor] Pre-approved {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolCall.name(), msg);
}
Thread executionThread = Thread.currentThread();
Runnable removeCancellationHook = streamTracker != null
? streamTracker.registerCancellationHook(conversationId, executionThread::interrupt)
: () -> { };
McpToolResultCapture resultCapture = new McpToolResultCapture();
boolean callbackCompleted = false;
try {
throwIfStopRequested(conversationId);
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
@ -867,9 +873,9 @@ public class ToolExecutionExecutor {
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
replayOrigin = MemberFileIsolation.scope(replayOrigin);
if (MemberFileIsolation.isEnabled()) workspaceBasePath = replayOrigin.workspaceBasePath();
McpToolResultCapture resultCapture = new McpToolResultCapture();
String result = invokeObserved(callback, callArguments, resultCapture.attach(toolContextWithScopedCatalog(replayOrigin)),
UUID.randomUUID().toString(), toolCall.id());
UUID.randomUUID().toString(), toolCall.id(), DelegationContext.current(), new AtomicBoolean(), new BatchAbort());
callbackCompleted = true;
throwIfStopRequested(conversationId);
int rawLen = result != null ? result.length() : 0;
@ -896,7 +902,7 @@ public class ToolExecutionExecutor {
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, !resultCapture.isError(),
resultCapture.structuredContent()));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
toolCall.id(), toolCall.name(), DIRECT_TOOL_PLACEHOLDER);
}
// Raw-first spill, inline truncate as fallback. Symmetric with the
@ -913,19 +919,29 @@ public class ToolExecutionExecutor {
// Append the card-rendering directive to the LLM-facing response only,
// leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "", resultCapture.structuredContent()));
toolCall.id(), toolCall.name(), withProductCardDirective(toolName, result != null ? result : "", resultCapture.structuredContent()));
} catch (Error fatal) {
if (callbackCompleted) markPostCallbackUnknown(origin, fatal, resultCapture, DelegationContext.current());
throw fatal;
} catch (CancellationException e) {
if (callbackCompleted) markPostCallbackUnknown(origin, e, resultCapture, DelegationContext.current());
throw e;
} catch (ToolExecutionCheckpointRejectedException e) {
throw e;
} catch (Exception e) {
if (callbackCompleted) markPostCallbackUnknown(origin, e, resultCapture, DelegationContext.current());
rethrowCheckpointRejection(e);
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
String validationError = safeInputValidationMessage(e);
String safeError = validationError != null
? validationError
: isReturnDirect(callback)
? "Tool execution failed (details withheld per returnDirect policy)"
: "Tool execution failed: " + e.getMessage();
: resultCapture.isError() && resultCapture.errorText() != null && !resultCapture.errorText().isBlank()
? resultCapture.errorText()
: "Tool execution failed: " + e.getMessage();
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolCall.name(), safeError);
} finally {
removeCancellationHook.run();
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
@ -972,10 +988,19 @@ public class ToolExecutionExecutor {
for (List<PreparedToolCall> batch : batches) {
throwIfStopRequested(preparedCalls.isEmpty() ? null : preparedCalls.get(0).conversationId);
if (executionCheckpoint != null && !batch.isEmpty()) {
PreparedToolCall first = batch.getFirst();
DelegationContext.push(first.delegation);
try {
executionCheckpoint.beforeInvocation(first.origin, first.invocationKey, first.toolCall.id());
} finally {
DelegationContext.exit();
}
}
if (batch.size() == 1) {
// 单个工具(safe 或 unsafe),直接执行
PreparedToolCall pc = batch.get(0);
ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events, directOutputs);
ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events, directOutputs, new BatchAbort());
allResponses.set(pc.resultIndex, response);
} else {
// 多个 safe 工具,并行执行
@ -1030,59 +1055,114 @@ public class ToolExecutionExecutor {
long batchStartMs = System.currentTimeMillis();
Map<Integer, CompletableFuture<ToolResponseMessage.ToolResponse>> futures = new LinkedHashMap<>();
for (PreparedToolCall pc : batch) {
CompletableFuture<ToolResponseMessage.ToolResponse> future =
CompletableFuture.supplyAsync(() -> executeSingleTool(pc, events, directOutputs), TOOL_EXECUTOR);
futures.put(pc.resultIndex, future);
}
// 等待所有并行工具完成,按原始顺序填入结果
for (var entry : futures.entrySet()) {
try {
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
futures.values().forEach(future -> future.cancel(true));
throw new CancellationException("Stream stopped by user during tool execution");
}
// 按工具名查找配置的超时时间
PreparedToolCall matchedPc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null);
ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS);
allResponses.set(entry.getKey(), response);
} catch (CancellationException e) {
futures.values().forEach(future -> future.cancel(true));
throw e;
} catch (Exception e) {
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
futures.values().forEach(future -> future.cancel(true));
throw new CancellationException("Stream stopped by user during tool execution");
}
// 超时或异常 — 填入错误响应
PreparedToolCall pc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey())
.findFirst().orElse(null);
String toolName = pc != null ? pc.responseName : "unknown";
String toolId = pc != null ? pc.toolCall.id() : "";
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
toolId, toolName, normalizeToolExecutionError(
e instanceof ExecutionException ? (Exception) e.getCause() : (Exception) e)));
BatchAbort batchAborted = new BatchAbort();
try {
for (PreparedToolCall pc : batch) {
CompletableFuture<ToolResponseMessage.ToolResponse> future =
CompletableFuture.supplyAsync(() -> {
batchAborted.registerWorker();
try {
return executeSingleTool(pc, events, directOutputs, batchAborted);
} finally {
batchAborted.unregisterWorker();
}
}, TOOL_EXECUTOR);
futures.put(pc.resultIndex, future);
}
// 等待所有并行工具完成,按原始顺序填入结果
for (var entry : futures.entrySet()) {
try {
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
cancelParallelBatch(batchAborted, futures);
throw new CancellationException("Stream stopped by user during tool execution");
}
// 按工具名查找配置的超时时间
PreparedToolCall matchedPc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null);
ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS);
allResponses.set(entry.getKey(), response);
} catch (CancellationException e) {
cancelParallelBatch(batchAborted, futures);
batchAborted.rethrowFailure();
throw e;
} catch (Exception e) {
batchAborted.rethrowFailure();
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
cancelParallelBatch(batchAborted, futures);
CancellationException cancelled = new CancellationException("Parallel tool batch interrupted");
cancelled.initCause(e);
throw cancelled;
}
if (e instanceof ExecutionException && e.getCause() instanceof CancellationException cancelled) {
cancelParallelBatch(batchAborted, futures);
throw cancelled;
}
if (e instanceof ExecutionException && e.getCause() instanceof Error fatal) {
cancelParallelBatch(batchAborted, futures);
throw fatal;
}
Throwable cause = e;
while (cause != null && !(cause instanceof ToolExecutionCheckpointRejectedException)) cause = cause.getCause();
if (cause instanceof ToolExecutionCheckpointRejectedException rejected) {
cancelParallelBatch(batchAborted, futures);
throw rejected;
}
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
cancelParallelBatch(batchAborted, futures);
throw new CancellationException("Stream stopped by user during tool execution");
}
if (e instanceof TimeoutException) {
entry.getValue().cancel(true);
PreparedToolCall timedOut = batch.stream().filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
if (executionCheckpoint != null && timedOut != null && timedOut.entered.get()) {
DelegationContext.push(timedOut.delegation);
try { executionCheckpoint.markUnknownOutcome(timedOut.origin, e); }
finally { DelegationContext.exit(); }
}
}
// 超时或异常 — 填入错误响应
PreparedToolCall pc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey())
.findFirst().orElse(null);
String toolName = pc != null ? pc.responseName : "unknown";
String toolId = pc != null ? pc.toolCall.id() : "";
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
toolId, toolName, normalizeToolExecutionError(
e instanceof ExecutionException ? (Exception) e.getCause() : (Exception) e)));
}
}
} catch (RuntimeException | Error failure) {
batchAborted.abort(failure);
cancelParallelBatch(batchAborted, futures);
throw failure;
}
log.info("[ToolExecutor] Parallel batch completed: {} tools in {}ms",
batch.size(), System.currentTimeMillis() - batchStartMs);
}
private void cancelParallelBatch(BatchAbort batchAborted,
Map<Integer, CompletableFuture<ToolResponseMessage.ToolResponse>> futures) {
batchAborted.abort(new CancellationException("Parallel tool batch aborted"));
futures.values().forEach(future -> future.cancel(true));
}
private ToolResponseMessage.ToolResponse executeSingleTool(PreparedToolCall pc,
List<GraphEventPublisher.GraphEvent> events,
List<DirectToolOutput> directOutputs) {
List<DirectToolOutput> directOutputs,
BatchAbort batchAborted) {
String toolName = pc.toolCall.name();
Thread executionThread = Thread.currentThread();
Runnable removeCancellationHook = streamTracker != null
? streamTracker.registerCancellationHook(pc.conversationId, executionThread::interrupt)
: () -> { };
McpToolResultCapture resultCapture = new McpToolResultCapture();
boolean callbackCompleted = false;
try {
throwIfStopRequested(pc.conversationId);
if (streamTracker != null) {
@ -1098,9 +1178,8 @@ public class ToolExecutionExecutor {
// Spring AI ToolContext (preferred — read via ChatOrigin.from(ctx))
// AND the legacy ToolExecutionContext ThreadLocal so tools that have
// not yet migrated to ToolContext keep working unchanged.
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
var legacyScope = ToolExecutionContext.openScope(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
String result;
McpToolResultCapture resultCapture = new McpToolResultCapture();
String progressToken = null;
try {
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
@ -1120,14 +1199,18 @@ public class ToolExecutionExecutor {
toolContext = new ToolContext(ctxMap);
}
result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id());
result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id(), pc.delegation, pc.entered, batchAborted);
callbackCompleted = true;
throwIfStopRequested(pc.conversationId);
} finally {
if (progressToken != null) {
progressContext.remove(progressToken);
progressContext.removeSnapshot(pc.conversationId, pc.toolCall.id());
try {
if (progressToken != null) {
progressContext.remove(progressToken);
progressContext.removeSnapshot(pc.conversationId, pc.toolCall.id());
}
} finally {
legacyScope.close();
}
ToolExecutionContext.clear();
}
// Recency feed for budget-driven disclosure demotion: recently used
@ -1207,12 +1290,23 @@ public class ToolExecutionExecutor {
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), pc.responseName,
withProductCardDirective(toolName, result != null ? result : "", resultCapture.structuredContent()));
} catch (Error fatal) {
batchAborted.abort(fatal);
if (callbackCompleted) markPostCallbackUnknown(pc.origin, fatal, resultCapture, pc.delegation);
throw fatal;
} catch (CancellationException e) {
batchAborted.abort(e);
if (callbackCompleted) markPostCallbackUnknown(pc.origin, e, resultCapture, pc.delegation);
if (streamTracker != null) {
streamTracker.updateRunningTool(pc.conversationId, null);
}
throw e;
} catch (ToolExecutionCheckpointRejectedException e) {
batchAborted.abort(e);
throw e;
} catch (Exception e) {
if (callbackCompleted) markPostCallbackUnknown(pc.origin, e, resultCapture, pc.delegation);
rethrowCheckpointRejection(e);
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
// RFC-052: for returnDirect tools, even the error message is
// suspect — exception text may carry stack traces, SQL fragments,
@ -1224,7 +1318,9 @@ public class ToolExecutionExecutor {
? validationError
: isReturnDirect(pc.callback)
? "Tool execution failed (details withheld per returnDirect policy)"
: normalizeToolExecutionError(e);
: resultCapture.isError() && resultCapture.errorText() != null && !resultCapture.errorText().isBlank()
? resultCapture.errorText()
: normalizeToolExecutionError(e);
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
@ -1451,6 +1547,9 @@ public class ToolExecutionExecutor {
}
private String safeInputValidationMessage(Throwable error) {
for (Throwable current = error; current != null; current = current.getCause()) {
if (current instanceof TimeoutException || current instanceof CancellationException) return null;
}
Throwable current = error;
while (current != null) {
if (current instanceof ToolInputValidationException validation) {
@ -1816,33 +1915,134 @@ public class ToolExecutionExecutor {
return new ToolContext(context);
}
private static void rethrowCheckpointRejection(Throwable failure) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (current instanceof ToolExecutionCheckpointRejectedException rejected) throw rejected;
}
}
private ToolExecutionCheckpoint executionCheckpoint;
public void setExecutionCheckpoint(ToolExecutionCheckpoint checkpoint) {
this.executionCheckpoint = checkpoint;
}
private ExecutionEvidenceRecorder executionEvidenceRecorder;
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
private void markPostCallbackUnknown(ChatOrigin origin, Throwable failure, McpToolResultCapture capture,
SubagentRunContext delegation) {
if (executionCheckpoint == null || capture.isError()) return;
DelegationContext.push(delegation);
try {
executionCheckpoint.markUnknownOutcome(MemberFileIsolation.scope(origin), failure);
} catch (RuntimeException | Error checkpointFailure) {
if (!(failure instanceof Error)) throw checkpointFailure;
if (checkpointFailure != failure) failure.addSuppressed(checkpointFailure);
} finally {
DelegationContext.exit();
}
}
private String invokeObserved(ToolCallback callback, String arguments, ToolContext context,
String invocationKey, String providerCallId) throws TimeoutException {
String invocationKey, String providerCallId, SubagentRunContext delegation, AtomicBoolean entered, BatchAbort batchAborted) throws TimeoutException {
String toolName = callback.getToolDefinition().name();
MemberFileIsolation.requireAuditedTool(toolName);
ChatOrigin scoped = MemberFileIsolation.scope(ChatOrigin.from(context));
java.util.Map<String, Object> values = new java.util.HashMap<>(context.getContext());
values.put(ChatOrigin.CTX_KEY, scoped);
ToolContext scopedContext = new ToolContext(values);
return ToolCallDeadline.call(toolName, getToolTimeoutMs(toolName), () -> {
// Deadlines may use another thread; install the identity inside the callback.
ToolExecutionContext.set(scoped.conversationId(), scoped.requesterId(), scoped.workspaceBasePath());
ToolExecutionContext.setOrigin(scoped);
try {
return executionEvidenceRecorder == null ? callback.call(arguments, scopedContext)
: executionEvidenceRecorder.invoke(callback, arguments, scopedContext, invocationKey, providerCallId);
} finally { ToolExecutionContext.clear(); }
});
try {
return ToolCallDeadline.callWithDeadline(toolName, getToolTimeoutMs(toolName), deadlineGuard -> {
DelegationContext.push(delegation);
var legacyScope = ToolExecutionContext.openScope(scoped.conversationId(), scoped.requesterId(), scoped.workspaceBasePath());
ToolExecutionContext.setOrigin(scoped);
try {
batchAborted.check();
if (Thread.currentThread().isInterrupted()) throw new CancellationException("Tool callback cancelled before entry");
if (executionCheckpoint != null) executionCheckpoint.beforeInvocation(scoped, invocationKey, providerCallId);
deadlineGuard.run();
batchAborted.check();
throwIfStopRequested(scoped.conversationId());
entered.set(true);
String result = executionEvidenceRecorder == null ? callback.call(arguments, scopedContext)
: executionEvidenceRecorder.invoke(callback, arguments, scopedContext, invocationKey, providerCallId);
batchAborted.check();
return result;
} finally {
legacyScope.close();
DelegationContext.exit();
}
});
} catch (RuntimeException | TimeoutException | Error failure) {
if (failure instanceof Error) batchAborted.abort(failure);
else {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof ToolExecutionCheckpointRejectedException) {
batchAborted.abort(cause);
break;
}
}
}
McpToolResultCapture capture = McpToolResultCapture.from(scopedContext);
if (entered.get() && executionCheckpoint != null
&& (failure instanceof TimeoutException || failure instanceof CancellationException
|| safeInputValidationMessage(failure) == null && (capture == null || !capture.isError()))) {
DelegationContext.push(delegation);
try {
executionCheckpoint.markUnknownOutcome(scoped, failure);
} catch (RuntimeException | Error checkpointFailure) {
if (!(failure instanceof Error)) throw checkpointFailure;
if (checkpointFailure != failure) failure.addSuppressed(checkpointFailure);
} finally {
DelegationContext.exit();
}
}
throw failure;
}
}
// ==================== 内部数据类 ====================
private static final class BatchAbort {
private final AtomicReference<Throwable> failure = new AtomicReference<>();
private final Set<Thread> workers = new HashSet<>();
synchronized void registerWorker() {
check();
workers.add(Thread.currentThread());
}
synchronized void unregisterWorker() {
workers.remove(Thread.currentThread());
}
synchronized void abort(Throwable reason) {
if (!failure.compareAndSet(null, reason)) return;
Thread current = Thread.currentThread();
for (Thread worker : workers) {
if (worker != current) worker.interrupt();
}
}
void check() {
Throwable reason = failure.get();
if (reason == null) return;
CancellationException cancelled = new CancellationException("Parallel tool batch aborted");
cancelled.initCause(reason);
throw cancelled;
}
void rethrowFailure() {
Throwable reason = failure.get();
if (reason instanceof Error fatal) throw fatal;
if (reason instanceof ToolExecutionCheckpointRejectedException rejected) throw rejected;
}
}
private record PreparedToolCall(
AssistantMessage.ToolCall toolCall,
String responseName,
@ -1854,6 +2054,8 @@ public class ToolExecutionExecutor {
String requesterId,
String workspaceBasePath,
ChatOrigin origin,
SubagentRunContext delegation,
AtomicBoolean entered,
String invocationKey,
/**
* Shared reference (one per execute() invocation) where each

View File

@ -1,140 +0,0 @@
package vip.mate.agent.graph.lifecycle;
import com.alibaba.cloud.ai.graph.GraphLifecycleListener;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* ReAct 状态图生命周期监听器
* <p>
* 利用 spring-ai-alibaba-graph-core 的 {@link GraphLifecycleListener} 接口,
* 在图执行的关键节点输出结构化日志,不与业务逻辑耦合。
* <p>
* 通过 {@code CompileConfig.builder().withLifecycleListener(new ReActLifecycleListener())} 注册。
* <p>
* 输出日志示例:
* <pre>
* [ReAct] node=reasoning event=start iteration=2 traceId=abc123
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
* </pre>
* <p>
* Note: this listener is intentionally read-only / log-only. Surfacing
* graph state to channel-side accumulators (e.g. publishing the resolved
* {@code FinishReason} to message metadata) lives in {@code FinalAnswerNode}
* via a {@code finish_reason} GraphEvent — that path goes through the
* PENDING_EVENTS → StreamDelta pipeline that {@code ChatController.StreamAccumulator}
* actually consumes. A sibling sink that called {@code streamTracker.broadcastObject}
* here would only reach the browser SSE bus and bypass the accumulator entirely.
*
* @author MateClaw Team
*/
@Slf4j
public class ReActLifecycleListener implements GraphLifecycleListener {
/** 记录每个节点的开始时间,key = nodeId + threadId */
private final ConcurrentHashMap<String, Long> nodeStartTimes = new ConcurrentHashMap<>();
@Override
public void onStart(String nodeId, Map<String, Object> state, RunnableConfig config) {
String traceId = getStringValue(state, TRACE_ID);
int iteration = getIntValue(state, CURRENT_ITERATION);
log.info("[ReAct] node={} event=start iteration={} traceId={}",
nodeId, iteration, traceId);
}
@Override
public void before(String nodeId, Map<String, Object> state, RunnableConfig config, Long curTime) {
String key = nodeId + ":" + Thread.currentThread().getId();
nodeStartTimes.put(key, curTime != null ? curTime : System.currentTimeMillis());
}
@Override
public void after(String nodeId, Map<String, Object> state, RunnableConfig config, Long curTime) {
String key = nodeId + ":" + Thread.currentThread().getId();
Long startTime = nodeStartTimes.remove(key);
long durationMs = 0;
if (startTime != null && curTime != null) {
durationMs = curTime - startTime;
}
int iteration = getIntValue(state, CURRENT_ITERATION);
int toolCallCount = getIntValue(state, TOOL_CALL_COUNT);
String traceId = getStringValue(state, TRACE_ID);
String finishReason = getStringValue(state, FINISH_REASON);
boolean shouldSummarize = getBooleanValue(state, SHOULD_SUMMARIZE);
// 结构化日志
StringBuilder logMsg = new StringBuilder();
logMsg.append(String.format("[ReAct] node=%s event=complete iteration=%d durationMs=%d",
nodeId, iteration, durationMs));
logMsg.append(String.format(" toolCallCount=%d", toolCallCount));
if (!traceId.isEmpty()) {
logMsg.append(String.format(" traceId=%s", traceId));
}
if (!finishReason.isEmpty()) {
logMsg.append(String.format(" finishReason=%s", finishReason));
}
if (shouldSummarize) {
logMsg.append(" shouldSummarize=true");
}
// 观察历史大小
Object obsHistory = state.get(OBSERVATION_HISTORY);
if (obsHistory instanceof List<?> list) {
logMsg.append(String.format(" observationCount=%d", list.size()));
}
log.info(logMsg.toString());
}
@Override
public void onError(String nodeId, Map<String, Object> state, Throwable ex, RunnableConfig config) {
String traceId = getStringValue(state, TRACE_ID);
int iteration = getIntValue(state, CURRENT_ITERATION);
log.error("[ReAct] node={} event=error iteration={} traceId={} error={}",
nodeId, iteration, traceId, ex.getMessage(), ex);
}
@Override
public void onComplete(String nodeId, Map<String, Object> state, RunnableConfig config) {
String finishReason = getStringValue(state, FINISH_REASON);
String traceId = getStringValue(state, TRACE_ID);
int iteration = getIntValue(state, CURRENT_ITERATION);
int toolCallCount = getIntValue(state, TOOL_CALL_COUNT);
boolean limitExceeded = getBooleanValue(state, LIMIT_EXCEEDED);
if (FINAL_ANSWER_NODE.equals(nodeId)) {
log.info("[ReAct] graph=complete node={} iteration={} toolCallCount={} " +
"finishReason={} limitExceeded={} traceId={}",
nodeId, iteration, toolCallCount, finishReason, limitExceeded, traceId);
}
}
// ===== 安全取值工具方法 =====
private static String getStringValue(Map<String, Object> state, String key) {
Object val = state.get(key);
return val instanceof String s ? s : "";
}
private static int getIntValue(Map<String, Object> state, String key) {
Object val = state.get(key);
if (val instanceof Integer i) return i;
if (val instanceof Number n) return n.intValue();
return 0;
}
private static boolean getBooleanValue(Map<String, Object> state, String key) {
Object val = state.get(key);
return val instanceof Boolean b && b;
}
}

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@ -118,7 +118,12 @@ public class ActionNode implements NodeAction {
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
return completeBatch(state, executeBatch(state));
}
/** Executes the guarded batch once; runtime orchestration retains the returned result. */
public ToolExecutionExecutor.ToolExecutionResult executeBatch(RunState state) throws Exception {
List<AssistantMessage.ToolCall> toolCalls = state.<List<AssistantMessage.ToolCall>>value(TOOL_CALLS)
.orElse(List.of());
@ -147,10 +152,22 @@ public class ActionNode implements NodeAction {
vip.mate.agent.context.ChatOrigin origin = accessor.chatOrigin();
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
return executor.execute(
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin,
accessor.loadedSkills());
}
/** Projects an already-executed result into business state without invoking tools. */
public Map<String, Object> completeBatch(RunState state,
ToolExecutionExecutor.ToolExecutionResult result) {
Objects.requireNonNull(result, "executed batch result");
List<AssistantMessage.ToolCall> toolCalls = state.<List<AssistantMessage.ToolCall>>value(TOOL_CALLS)
.orElse(List.of());
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
String conversationId = accessor.conversationId();
boolean isReplay = "forced_replay".equals(state.value(MateClawStateKeys.CURRENT_PHASE, ""));
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses())
.build();

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
@ -62,7 +62,7 @@ public class FinalAnswerNode implements NodeAction {
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
String finalAnswer;

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
@ -83,7 +83,7 @@ public class GoalEvaluationNode implements NodeAction {
public void setDecisionAdapter(GoalDecisionAdapter decisionAdapter) { this.decisionAdapter = decisionAdapter; }
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
// Master kill switch — when disabled the node stays inert.
if (!properties.isEnabled()) {
return Map.of();
@ -509,7 +509,7 @@ public class GoalEvaluationNode implements NodeAction {
* is empty; callers should additionally gate on {@code properties.isEnabled()}
* to skip it when the feature is off.
*/
public static Optional<GoalEntity> resolveActiveGoal(OverAllState state, GoalService goalService) {
public static Optional<GoalEntity> resolveActiveGoal(RunState state, GoalService goalService) {
MateClawStateAccessor a = new MateClawStateAccessor(state);
Optional<Object> snapshot = a.activeGoal();
if (snapshot.isPresent() && snapshot.get() instanceof GoalEntity ge) {

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
@ -85,7 +85,7 @@ public class LimitExceededNode implements NodeAction {
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
int maxIterations = accessor.maxIterations();

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
@ -73,7 +73,7 @@ public class ObservationNode implements NodeAction {
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 检查停止标志

View File

@ -1,7 +1,8 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.llm.chatmodel.ChatModelDefaults;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
@ -13,6 +14,9 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import vip.mate.llm.dashscope.NativeDashScopeChatModel;
import vip.mate.llm.dashscope.NativeDashScopeChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
@ -46,7 +50,7 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
* 推理节点(ReAct Thought 阶段)
* <p>
* 调用 LLM 进行单次推理,判断是否需要工具调用。
* 关键:通过 internalToolExecutionEnabled=false 禁用 ChatModel 内部工具循环,
* Tool execution remains owned by the application runtime; model calls return tool requests.
* 使 StateGraph 完全控制 ReAct 循环。
* <p>
* 支持 forced_tool_call 机制:当审批通过后的重放请求到达时,
@ -726,7 +730,7 @@ public class ReasoningNode implements NodeAction {
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// ======= 取消检查(LLM 调用前,尚未计数) =======
@ -1630,16 +1634,13 @@ public class ReasoningNode implements NodeAction {
*/
private ChatOptions buildChatOptions(String effectiveReasoning, List<ToolCallback> activeCallbacks) {
// Anthropic 协议模型(AnthropicChatModel):MiniMax 也用此协议但不支持 thinking
if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) {
org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder =
org.springframework.ai.anthropic.AnthropicChatOptions.builder()
.toolCallbacks(activeCallbacks)
.internalToolExecutionEnabled(false);
if (ChatModelDefaults.optionsFor(chatModel) instanceof AnthropicChatOptions defaults) {
AnthropicChatOptions.Builder builder = defaults.mutate().toolCallbacks(activeCallbacks);
// 仅对真正的 Claude 模型启用 extended thinking(MiniMax 等走 Anthropic 协议但不支持)
String thinkingLevel = ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
String currentModel = getAnthropicModelName(anthropicModel);
String currentModel = defaults.getModel();
boolean isClaudeModel = currentModel != null && currentModel.toLowerCase().contains("claude");
if (thinkingOn && isClaudeModel) {
@ -1650,12 +1651,12 @@ public class ReasoningNode implements NodeAction {
case "max" -> 32768;
default -> 16384;
};
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
builder.thinkingEnabled(budgetTokens);
builder.maxTokens(budgetTokens + effectiveMaxOutputTokens());
builder.temperature(1.0);
log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens);
} else {
builder.maxTokens(effectiveMaxOutputTokens());
builder.thinkingDisabled().maxTokens(effectiveMaxOutputTokens());
if (thinkingOn && !isClaudeModel) {
log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel);
}
@ -1671,22 +1672,30 @@ public class ReasoningNode implements NodeAction {
// the failover layer misreads as "model not found"; clamp so a
// DashScope-backed model never overflows the provider limit.
int effectiveMaxTokens = effectiveMaxOutputTokens();
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
if (chatModel instanceof NativeDashScopeChatModel
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model",
effectiveMaxTokens, DASHSCOPE_MAX_OUTPUT_TOKENS);
effectiveMaxTokens = DASHSCOPE_MAX_OUTPUT_TOKENS;
}
OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder()
.toolCallbacks(activeCallbacks)
.maxTokens(effectiveMaxTokens);
if (StringUtils.hasText(effectiveReasoning)) {
oaiBuilder.reasoningEffort(effectiveReasoning);
if (ChatModelDefaults.optionsFor(chatModel) instanceof NativeDashScopeChatOptions defaults) {
NativeDashScopeChatOptions.Builder nativeBuilder = defaults.mutate()
.toolCallbacks(activeCallbacks).maxTokens(effectiveMaxTokens);
String thinkingLevel = ThinkingLevelHolder.get();
if (thinkingLevel != null) nativeBuilder.enableThinking(!"off".equalsIgnoreCase(thinkingLevel));
return nativeBuilder.build();
}
OpenAiChatOptions oaiOpts = oaiBuilder.build();
oaiOpts.setInternalToolExecutionEnabled(false);
oaiOpts.setStreamUsage(true);
return oaiOpts;
if (ChatModelDefaults.optionsFor(chatModel) instanceof OpenAiChatOptions defaults) {
OpenAiChatOptions.Builder builder = defaults.mutate()
.toolCallbacks(activeCallbacks).maxTokens(effectiveMaxTokens).streamUsage(true);
if (StringUtils.hasText(effectiveReasoning)) builder.reasoningEffort(effectiveReasoning);
if ("off".equalsIgnoreCase(ThinkingLevelHolder.get())) builder.reasoningEffort(null);
return builder.build();
}
ToolCallingChatOptions.Builder<?> builder = ToolCallingChatOptions.builder();
ChatOptions defaults = ChatModelDefaults.optionsFor(chatModel);
if (defaults != null) builder.combineWith(defaults.mutate());
return builder.toolCallbacks(activeCallbacks).maxTokens(effectiveMaxTokens).build();
}
/**
@ -1725,19 +1734,4 @@ public class ReasoningNode implements NodeAction {
return this.reasoningEffort;
}
/**
* 从 AnthropicChatModel 的 defaultOptions 中提取模型名称。
* 用于判断是否为真正的 Claude 模型(vs MiniMax 等走 Anthropic 协议的非 Claude 模型)。
*/
private String getAnthropicModelName(org.springframework.ai.anthropic.AnthropicChatModel model) {
try {
var options = model.getDefaultOptions();
if (options instanceof org.springframework.ai.anthropic.AnthropicChatOptions aOpts) {
return aOpts.getModel();
}
} catch (Exception e) {
log.debug("[ReasoningNode] Failed to extract Anthropic model name: {}", e.getMessage());
}
return null;
}
}

View File

@ -1,7 +1,8 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.llm.chatmodel.ChatModelDefaults;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
@ -13,6 +14,8 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import vip.mate.llm.dashscope.NativeDashScopeChatOptions;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.NodeStreamingChatHelper;
@ -71,7 +74,7 @@ public class SummarizingNode implements NodeAction {
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 取消检查
@ -203,15 +206,22 @@ public class SummarizingNode implements NodeAction {
*/
private Prompt buildNoThinkingPrompt(List<Message> messages) {
ChatOptions opts;
if (chatModel instanceof AnthropicChatModel) {
opts = AnthropicChatOptions.builder()
.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.DISABLED, 0)
.build();
if (ChatModelDefaults.optionsFor(chatModel) instanceof AnthropicChatOptions defaults) {
opts = defaults.mutate().thinkingDisabled().toolCallbacks(List.of()).build();
} else {
// OpenAI / DashScope / other: omit reasoningEffort to disable chain-of-thought
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder().build();
oaiOpts.setStreamUsage(true);
opts = oaiOpts;
if (ChatModelDefaults.optionsFor(chatModel) instanceof OpenAiChatOptions defaults) {
opts = defaults.mutate().reasoningEffort(null).streamUsage(true).toolCallbacks(List.of()).build();
} else if (ChatModelDefaults.optionsFor(chatModel) instanceof NativeDashScopeChatOptions defaults) {
opts = defaults.mutate().enableThinking(false).toolCallbacks(List.of()).build();
} else {
ChatOptions defaults = ChatModelDefaults.optionsFor(chatModel);
if (defaults instanceof ToolCallingChatOptions tools) {
opts = tools.mutate().toolCallbacks(List.of()).build();
} else {
opts = defaults != null ? defaults.mutate().build() : ChatOptions.builder().build();
}
}
}
return new Prompt(messages, opts);
}

View File

@ -1,8 +1,8 @@
package vip.mate.agent.graph.plan;
import vip.mate.workspace.core.service.MemberFileIsolation;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import vip.mate.agent.runtime.AgentRuntime;
import vip.mate.tool.builtin.DelegationContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
@ -26,24 +26,14 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* 基于 StateGraph 的 Plan-Execute Agent
* <p>
* 使用 spring-ai-alibaba-graph-core 的 StateGraph 引擎实现:
* <ol>
* <li>简单问答快速退出(PlanGenerationNode 前置判断)</li>
* <li>多步任务:规划 → 逐步执行(带工具调用)→ 汇总</li>
* </ol>
* <p>
* content_delta 和 thinking_delta 由节点内 NodeStreamingChatHelper 直推,
* chatStructuredStream() 只处理 phase/tool/plan/step 等结构化事件。
* 不再从 NodeOutput 二次整段下发已流式推送的内容。
*
* @author MateClaw Team
* Plan application entry point backed by explicit planning and execution phases.
* Individual step tool loops use the native agent runtime. Streaming callbacks deliver
* content and thinking; retained RunSteps project plan, step and tool events.
*/
@Slf4j
public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredStreamCapable {
private final CompiledGraph compiledGraph;
private final AgentRuntime compiledGraph;
private final PlanningService planningService;
private final org.springframework.ai.chat.model.ChatModel chatModel;
private final ConversationWindowManager conversationWindowManager;
@ -51,7 +41,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
private final vip.mate.agent.AgentToolSet toolSet;
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph, PlanningService planningService,
AgentRuntime compiledGraph, PlanningService planningService,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager) {
this(chatClient, conversationService, compiledGraph, planningService,
@ -59,7 +49,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
}
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph, PlanningService planningService,
AgentRuntime compiledGraph, PlanningService planningService,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager,
vip.mate.agent.AgentToolSet toolSet) {
@ -145,8 +135,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
/** 公共流执行逻辑,由 chatStructuredStream 和 chatWithReplayStream 共用 */
private Flux<AgentService.StreamDelta> executeStream(Map<String, Object> inputs) {
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
@ -167,12 +157,12 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// "answeranswer" and keeps live output identical to history replay.
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
// 最终汇总同样需要游标:FINAL_SUMMARY / FINAL_SUMMARY_THINKING 也是 REPLACE,
// 一旦写入就会出现在此后每个 NodeOutput 上。
// 一旦写入就会出现在此后每个 RunStep 上。
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)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取事件(只发送新增部分)
@ -217,7 +207,7 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
});
// 2b. 最终汇总(同样 thinking 先于 content)
// 两个 key 都是 REPLACE:值会滞留在后续每个 NodeOutput 里。
// 两个 key 都是 REPLACE:值会滞留在后续每个 RunStep 里。
// 没有游标时每批都会重发一次 —— 汇总正文被反复追加进
// mate_message.content,而 thinking 会在正文之后再落一段,
// 于是气泡末尾挂出一个孤立的思考框。与 2a 的 step 级
@ -393,6 +383,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
origin = MemberFileIsolation.scope(origin);
inputs.put(MateClawStateKeys.WORKSPACE_BASE_PATH, origin.workspaceBasePath() != null ? origin.workspaceBasePath() : "");
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
if (thinkingLevel != null) inputs.put(MateClawStateKeys.REQUESTED_THINKING_LEVEL, thinkingLevel);
inputs.put(MateClawStateKeys.SUBAGENT_RUN_CONTEXT, DelegationContext.current());
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
// Mirrors StateGraphReActAgent.buildInitialState exactly.

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
@ -22,7 +22,7 @@ import vip.mate.agent.graph.state.MateClawStateKeys;
public class PlanGenerationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) {
public String apply(RunState state) {
// A board-delegated plan whose tasks all settled resumes straight into
// the summary — its step results were rebuilt from the team task board
// by the resume gate; the step loop has nothing left to execute.

View File

@ -1,8 +1,8 @@
package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.plan.ExplicitPlanRuntime;
import vip.mate.agent.runtime.state.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
@ -28,7 +28,7 @@ public class StepProgressDispatcher implements EdgeAction {
@Override
@SuppressWarnings("unchecked")
public String apply(OverAllState state) {
public String apply(RunState state) {
// 审批暂停态或步骤执行失败中止态:直接结束当前图 tick
String currentPhase = state.value(MateClawStateKeys.CURRENT_PHASE, "");
if ("plan_aborted".equals(currentPhase) && goals != null) {
@ -39,7 +39,7 @@ public class StepProgressDispatcher implements EdgeAction {
}
}
if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
return StateGraph.END;
return ExplicitPlanRuntime.END;
}
// Step-failure recovery: a failed step requested a re-plan of the
// remaining work. Route back to PlanGeneration instead of aborting;

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import java.util.Map;
@ -40,7 +40,7 @@ import java.util.Map;
public class DirectAnswerNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) {
public Map<String, Object> apply(RunState state) {
String directAnswer = state.value(PlanStateKeys.DIRECT_ANSWER, "");
return Map.of(PlanStateKeys.FINAL_SUMMARY, directAnswer);
}

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
@ -474,7 +474,7 @@ public class PlanGenerationNode implements NodeAction {
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
String goal = accessor.goal();

View File

@ -1,7 +1,7 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
@ -52,7 +52,7 @@ public class PlanSummaryNode implements NodeAction {
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
Long planId = accessor.planId();
String goal = accessor.goal();

View File

@ -1,8 +1,9 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
@ -11,13 +12,13 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.ModelToolOptions;
import vip.mate.agent.graph.node.ActionNode;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
@ -35,6 +36,15 @@ import vip.mate.tool.builtin.DelegateAgentTool.ChildResult;
import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.agent.runtime.agentscope.NativePolicyDriver;
import vip.mate.agent.runtime.agentscope.GuardedToolBatchResult;
import vip.mate.agent.runtime.agentscope.ToolBatchResultAdapter;
import vip.mate.agent.runtime.agentscope.SpringToolSchemaAdapter;
import vip.mate.agent.runtime.state.RuntimeExecutionScope;
import io.agentscope.core.model.ToolSchema;
import java.util.LinkedHashMap;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@ -42,23 +52,13 @@ import java.util.Map;
import java.util.Set;
/**
* 步骤执行节点
* <p>
* 执行当前步骤,使用显式工具执行循环(internalToolExecutionEnabled=false)。
* 单步最大工具调用次数限制为 {@link #MAX_TOOL_CALLS_PER_STEP} 次,与
* {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING} 对齐——因此实际生效的上限
* 永远是 agent 的 {@code max_iterations}(DB 列),单步本身不会先于 agent
* 的整体预算被打掉。早期 5 次的硬限制对"查新闻 + 整理 Word"这种合理多
* 工具任务过紧,被 LimitExceededNode 提前拦截后用户看到的是冷冰冰的
* "工具调用次数超出最大限制"。
* <p>
* 支持 NEEDS_APPROVAL 审批流程:对需要审批的工具调用创建 pending,
* 发出 SSE 事件后立即返回审批提示(非阻塞)。审批通过后通过 replay 重新执行。
*
* @author MateClaw Team
* Executes one plan step through native AgentScope ReAct orchestration.
* Private step history, usage, progress and approval state belong to one invocation.
* A step permits at most 100 completed tool batch rounds; the outer plan owns
* delegation, result persistence, summary and bounded replanning.
*/
@Slf4j
public class StepExecutionNode implements NodeAction {
private static final Logger log = LoggerFactory.getLogger(StepExecutionNode.class);
private final ChatModel chatModel;
private final AgentToolSet toolSet;
@ -184,7 +184,7 @@ public class StepExecutionNode implements NodeAction {
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
public Map<String, Object> apply(RunState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
int stepIndex = accessor.currentStepIndex();
List<String> steps = accessor.planSteps();
@ -279,183 +279,26 @@ public class StepExecutionNode implements NodeAction {
// the step as stuck past a hard threshold so we re-plan instead of
// burning the whole tool-call budget and advancing with junk.
StepProgressTracker progressTracker = new StepProgressTracker();
boolean preApprovedConsumed = false;
try {
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
if (elapsedMs > stepWallClockTimeoutMs) {
log.warn("[StepExecution] Step {} exceeded wall-clock budget " +
"({} ms > {} ms) after {} tool round(s); aborting step",
stepIndex, elapsedMs, stepWallClockTimeoutMs, toolCallCount);
wallClockExceeded = true;
break;
}
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
// producer in NodeStreamingChatHelper.doStreamCall can attach the
// user-token. Using ToolCallingChatOptions when reasoningEffort is
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
// would 400 again.
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.build();
if (StringUtils.hasText(reasoningEffort)) {
oaiOpts.setReasoningEffort(reasoningEffort);
}
oaiOpts.setInternalToolExecutionEnabled(false);
ChatOptions options = oaiOpts;
if (conversationWindowManager != null) {
// Pass conversationId + workspaceBasePath so oversized
// older tool results can be spilled to disk instead of
// being rewritten into a lossy single-line summary.
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(messages, options), conversationId,
"step_execution[" + stepIndex + "]");
// PTL 处理:压缩后重试
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[StepExecution] Prompt too long at step {}, attempting compaction", stepIndex);
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
messages.subList(1, messages.size()));
if (compactedMessages != null) {
List<Message> retryMessages = new ArrayList<>();
retryMessages.add(messages.get(0));
retryMessages.addAll(compactedMessages);
result = streamingHelper.streamCall(
chatModel, new Prompt(retryMessages, options), conversationId,
"step_execution_compact_retry[" + stepIndex + "]");
}
}
stepPromptTokens += result.promptTokens();
stepCompletionTokens += result.completionTokens();
stepCacheReadTokens += result.cacheReadTokens();
stepCacheWriteTokens += result.cacheWriteTokens();
stepReasoningTokens += result.reasoningTokens();
if (!result.thinking().isEmpty()) {
stepThinking = result.thinking();
}
messages.add(result.assistantMessage());
if (!result.hasToolCalls()) {
finalResult = result.text();
break;
}
// 手动执行 tool calls
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
List<AssistantMessage.ToolCall> allToolCalls = result.toolCalls();
// 从 state 读取预批准的工具调用(replay 注入)
String preApprovedPayload = state.value(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, "");
if (!preApprovedPayload.isEmpty()) {
// Replay 路径:处理预批准工具
for (AssistantMessage.ToolCall toolCall : allToolCalls) {
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolCall.name(), toolCall.arguments()));
// RFC-052: pass the directOutputs collector so that an
// approved direct tool's full content is captured here
// (instead of leaking into the next LLM round).
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, storedArguments, events, conversationId, workspaceBasePath,
stepDirectOutputs, chatOrigin);
toolResponses.add(response);
preApprovedPayload = ""; // 只消费一次
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = toolCall.name();
break;
}
}
}
} else {
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
if (!allToolCalls.isEmpty()) {
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
? execResult.barrierToolName() : "unknown";
}
}
}
Set<String> requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls);
if (!requestedSkills.isEmpty() && loadedSkills.addAll(requestedSkills)) {
log.debug("[StepExecution] pinned loaded skills in plan state: {}", requestedSkills);
}
// 将工具响应追加到消息
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(toolResponses)
.build();
messages.add(toolResponseMessage);
toolCallCount++;
// 如果审批触发,退出 while 循环
if (approvalTriggered) {
break;
}
// Progress tracking: feed this round's tool results to the
// detector. A WARN-level stall injects a one-shot "change
// strategy" SystemMessage the model sees on its next call; a
// HALT-level stall stops the inner loop so the post-loop logic
// re-plans instead of spinning to the tool-call ceiling.
java.util.Map<String, String> idToArgs = new java.util.HashMap<>();
for (AssistantMessage.ToolCall tc : allToolCalls) {
if (tc != null && tc.id() != null) {
idToArgs.put(tc.id(), tc.arguments());
}
}
for (ToolResponseMessage.ToolResponse tr : toolResponses) {
var nudge = progressTracker.record(
tr.name(), idToArgs.getOrDefault(tr.id(), ""), tr.responseData());
if (nudge.isPresent()) {
messages.add(new SystemMessage(nudge.get()));
}
}
if (progressTracker.isStuck()) {
log.warn("[StepExecution] Step {} stalled ({}); stopping inner loop to re-plan",
stepIndex, progressTracker.haltReason());
break;
}
// RFC-052: returnDirect short-circuit. Any direct tool in this
// step ends the plan immediately; the dispatcher routes via
// currentPhase=plan_aborted so no further LLM call happens.
if (!stepDirectOutputs.isEmpty()) {
log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " +
"tool output(s); aborting plan execution",
stepIndex, stepDirectOutputs.size());
break;
}
StepSession session = new StepSession(state, messages, events, stepDirectOutputs, loadedSkills,
stepIndex, conversationId, agentId, workspaceBasePath, chatOrigin, stepStartedAtMs, progressTracker);
try {
session.run();
} finally {
preApprovedConsumed = session.preApprovedConsumed;
finalResult = session.finalResult;
stepThinking = session.stepThinking;
toolCallCount = session.toolCallCount;
approvalTriggered = session.approvalTriggered;
approvalToolName = session.approvalToolName;
wallClockExceeded = session.wallClockExceeded;
stepPromptTokens = session.stepPromptTokens;
stepCompletionTokens = session.stepCompletionTokens;
stepCacheReadTokens = session.stepCacheReadTokens;
stepCacheWriteTokens = session.stepCacheWriteTokens;
stepReasoningTokens = session.stepReasoningTokens;
}
// 处理审批暂停
@ -463,6 +306,7 @@ public class StepExecutionNode implements NodeAction {
planningService.updateSubPlanStatus(planId, stepIndex, "awaiting_approval");
String awaitingResult = "[APPROVAL_PENDING] " + approvalToolName + " awaiting user decision";
return PlanStateAccessor.output()
.put(PRE_APPROVED_TOOL_CALL, preApprovedConsumed ? "" : state.value(PRE_APPROVED_TOOL_CALL, ""))
.currentStepResult(awaitingResult)
.currentStepIndex(stepIndex) // 不递增!下次重放从同一步开始
.currentPhase("awaiting_approval")
@ -498,6 +342,7 @@ public class StepExecutionNode implements NodeAction {
assembled != null ? assembled.length() : 0, 0));
}
return PlanStateAccessor.output()
.put(PRE_APPROVED_TOOL_CALL, preApprovedConsumed ? "" : state.value(PRE_APPROVED_TOOL_CALL, ""))
.currentStepResult(assembled)
.currentStepIndex(steps.size()) // 越界 → dispatcher 收束
.currentPhase("plan_aborted")
@ -545,6 +390,7 @@ public class StepExecutionNode implements NodeAction {
log.warn("[StepExecution] Step {} produced no usable result ({}); re-planning (attempt {}/{})",
stepIndex + 1, reason, noProgressReplanCount + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.put(PRE_APPROVED_TOOL_CALL, preApprovedConsumed ? "" : state.value(PRE_APPROVED_TOOL_CALL, ""))
.workingContext(buildReplanContext(accessor, stepIndex, reason))
.currentPhase("plan_replan")
.replanCount(noProgressReplanCount + 1)
@ -602,6 +448,7 @@ public class StepExecutionNode implements NodeAction {
log.warn("[StepExecution] Step {} failed; re-planning remaining work (attempt {}/{})",
stepIndex + 1, replanCount + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.put(PRE_APPROVED_TOOL_CALL, preApprovedConsumed ? "" : state.value(PRE_APPROVED_TOOL_CALL, ""))
.workingContext(replanContext)
.currentPhase("plan_replan")
.replanCount(replanCount + 1)
@ -625,6 +472,7 @@ public class StepExecutionNode implements NodeAction {
log.warn("[StepExecution] Step {} failed and re-plan budget exhausted ({}); aborting plan",
stepIndex + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.put(PRE_APPROVED_TOOL_CALL, preApprovedConsumed ? "" : state.value(PRE_APPROVED_TOOL_CALL, ""))
.currentStepResult(shortError)
.currentPhase("plan_aborted")
// Terminal failures still need a canonical assistant body.
@ -669,6 +517,7 @@ public class StepExecutionNode implements NodeAction {
: appendStepIncremental(prevWorkingContext, formattedNewStep);
return PlanStateAccessor.output()
.put(PRE_APPROVED_TOOL_CALL, preApprovedConsumed ? "" : state.value(PRE_APPROVED_TOOL_CALL, ""))
.currentStepResult(finalResult)
.completedResults(formatStepResult(stepIndex, finalResult))
.currentStepIndex(stepIndex + 1)
@ -801,6 +650,192 @@ public class StepExecutionNode implements NodeAction {
}
}
private final class StepSession {
private final RunState initial;
private List<Message> messages;
private final List<GraphEventPublisher.GraphEvent> events;
private final List<DirectToolOutput> stepDirectOutputs;
private final Set<String> loadedSkills;
private final int stepIndex;
private final String conversationId;
private final String agentId;
private final String workspaceBasePath;
private final ChatOrigin chatOrigin;
private final long stepStartedAtMs;
private final StepProgressTracker progressTracker;
private String preApprovedPayload;
private boolean preApprovedConsumed;
private String finalResult;
private String stepThinking = "";
private int toolCallCount;
private boolean approvalTriggered;
private String approvalToolName;
private boolean wallClockExceeded;
private int stepPromptTokens;
private int stepCompletionTokens;
private int stepCacheReadTokens;
private int stepCacheWriteTokens;
private int stepReasoningTokens;
private NodeStreamingChatHelper.StreamResult result;
private List<AssistantMessage.ToolCall> allToolCalls = List.of();
private List<ToolResponseMessage.ToolResponse> toolResponses = List.of();
private StepSession(RunState state, List<Message> messages, List<GraphEventPublisher.GraphEvent> events,
List<DirectToolOutput> direct, Set<String> skills, int index, String conversation,
String agent, String workspace, ChatOrigin origin, long started, StepProgressTracker tracker) {
initial = state.snapshot(); this.messages = messages; this.events = events;
stepDirectOutputs = direct; loadedSkills = skills; stepIndex = index;
conversationId = conversation; agentId = agent; workspaceBasePath = workspace;
chatOrigin = origin; stepStartedAtMs = started; progressTracker = tracker;
// Approval replay belongs to this run and is consumed only once.
preApprovedPayload = state.value(PRE_APPROVED_TOOL_CALL, "");
initial.replace(MESSAGES, messages);
}
private void run() throws Exception {
var policies = NativePolicyDriver.Policies.singleTask(this::modelRound,
state -> allToolCalls.isEmpty() ? FINAL_ANSWER_NODE : ACTION_NODE,
this::executeBatch, this::completeBatch,
state -> approvalTriggered || !stepDirectOutputs.isEmpty() || progressTracker.isStuck()
? FINAL_ANSWER_NODE : REASONING_NODE,
state -> { throw new IllegalStateException("SDK summary is not a plan-step policy"); },
state -> Map.of(), state -> Map.of());
var driver = new NativePolicyDriver(policies, schemas(), MAX_TOOL_CALLS_PER_STEP, RuntimeExecutionScope::open, NativePolicyDriver.ToolNamePolicy.APPLICATION_OWNER);
// The helper already streams application events. SDK events are orchestration only.
driver.run(initial).then().block();
}
private List<ToolSchema> schemas() {
return SpringToolSchemaAdapter.convert(toolSet.callbacks());
}
private Map<String, Object> modelRound(RunState ignored) throws Exception {
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
if (elapsedMs > stepWallClockTimeoutMs) {
wallClockExceeded = true;
allToolCalls = List.of();
return Map.of(TOOL_CALLS, List.of());
}
ChatOptions options = ModelToolOptions.forModel(chatModel, toolSet.callbacks(), reasoningEffort);
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
}
result = streamingHelper.streamCall(chatModel, new Prompt(messages, options), conversationId,
"step_execution[" + stepIndex + "]");
if (result.isPromptTooLong() && conversationWindowManager != null) {
List<Message> systemPrompts = messages.stream().filter(SystemMessage.class::isInstance).toList();
List<Message> history = messages.stream()
.filter(message -> !(message instanceof SystemMessage)).toList();
List<Message> compacted = conversationWindowManager.compactForRetry(history);
if (compacted != null) {
List<Message> retry = new ArrayList<>(systemPrompts);
retry.addAll(compacted);
result = streamingHelper.streamCall(chatModel, new Prompt(retry, options), conversationId,
"step_execution_compact_retry[" + stepIndex + "]");
}
}
stepPromptTokens += result.promptTokens(); stepCompletionTokens += result.completionTokens();
stepCacheReadTokens += result.cacheReadTokens(); stepCacheWriteTokens += result.cacheWriteTokens();
stepReasoningTokens += result.reasoningTokens();
if (!result.thinking().isEmpty()) stepThinking = result.thinking();
messages.add(result.assistantMessage());
allToolCalls = result.hasToolCalls() ? List.copyOf(result.toolCalls()) : List.of();
if (allToolCalls.isEmpty()) finalResult = result.text();
return Map.of(MESSAGES, List.of(result.assistantMessage()), TOOL_CALLS, allToolCalls);
}
private Map<String, Object> executeBatch(RunState ignored) {
int eventsBefore = events.size();
int directBefore = stepDirectOutputs.size();
ToolExecutionExecutor.ToolExecutionResult canonical = null;
toolResponses = new ArrayList<>();
if (!preApprovedPayload.isEmpty()) {
// Replay 路径:处理预批准工具
for (AssistantMessage.ToolCall toolCall : allToolCalls) {
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
preApprovedPayload = "";
preApprovedConsumed = true;
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolCall.name(), toolCall.arguments()));
// RFC-052: pass the directOutputs collector so that an
// approved direct tool's full content is captured here
// (instead of leaking into the next LLM round).
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, storedArguments, events, conversationId, workspaceBasePath,
stepDirectOutputs, chatOrigin);
toolResponses.add(response);
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = toolCall.name();
break;
}
}
}
} else {
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
if (!allToolCalls.isEmpty()) {
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
chatOrigin, loadedSkills);
canonical = execResult;
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
? execResult.barrierToolName() : "unknown";
}
}
}
if (canonical == null) {
canonical = new ToolExecutionExecutor.ToolExecutionResult(toolResponses,
List.copyOf(events.subList(eventsBefore, events.size())), approvalTriggered, null, approvalToolName,
List.copyOf(stepDirectOutputs.subList(directBefore, stepDirectOutputs.size())));
}
GuardedToolBatchResult projected = ToolBatchResultAdapter.project(allToolCalls, canonical);
// Only projection synthesizes skipped results; it cannot execute any callback.
toolResponses = projected.results().stream().map(block -> {
Object response = block.getMetadata().get("productResponse");
return response instanceof ToolResponseMessage.ToolResponse actual ? actual
: new ToolResponseMessage.ToolResponse(block.getId(), block.getName(), "");
}).toList();
return Map.of(NativePolicyDriver.BATCH_RESULT, projected);
}
private Map<String, Object> completeBatch(RunState ignored) {
Set<String> requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls);
loadedSkills.addAll(requestedSkills);
var response = ToolResponseMessage.builder().responses(toolResponses).build();
messages.add(response);
toolCallCount++;
if (!approvalTriggered) {
Map<String, String> args = new LinkedHashMap<>();
for (var call : allToolCalls) args.put(call.id(), call.arguments());
for (var item : toolResponses) {
progressTracker.record(item.name(), args.getOrDefault(item.id(), ""), item.responseData())
.ifPresent(nudge -> messages.add(new SystemMessage(nudge)));
}
}
return Map.of(MESSAGES, List.of(response));
}
}
/**
* RFC-052: assemble the final answer text from direct tool outputs in this
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user

View File

@ -1,6 +1,6 @@
package vip.mate.agent.graph.plan.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import vip.mate.agent.runtime.state.RunState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
@ -20,9 +20,9 @@ import static vip.mate.agent.graph.plan.state.PlanStateKeys.*;
*/
public final class PlanStateAccessor {
private final OverAllState state;
private final RunState state;
public PlanStateAccessor(OverAllState state) {
public PlanStateAccessor(RunState state) {
this.state = Objects.requireNonNull(state, "state must not be null");
}
@ -268,7 +268,7 @@ public final class PlanStateAccessor {
// ---- Token Usage(写入共享键)----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
public OutputBuilder mergeUsage(OverAllState currentState,
public OutputBuilder mergeUsage(RunState currentState,
NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
@ -289,7 +289,7 @@ public final class PlanStateAccessor {
* 将一个 step 的累计 usage(含 cache / reasoning 分项)加到 state 已有值上。
* StepExecutionNode 在多个出口路径上写回同一组键,统一走这里避免漏项。
*/
public OutputBuilder addStepUsage(OverAllState currentState,
public OutputBuilder addStepUsage(RunState currentState,
int promptTokens, int completionTokens,
int cacheReadTokens, int cacheWriteTokens,
int reasoningTokens) {

View File

@ -1,6 +1,6 @@
package vip.mate.agent.graph.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import vip.mate.agent.runtime.state.RunState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
@ -13,16 +13,16 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 类型安全的状态访问器
* <p>
* 封装 {@link OverAllState} 的字符串 key 读写,
* 封装 {@link RunState} 的字符串 key 读写,
* 提供带默认值的强类型方法,避免业务代码散落 state.value("xxx") 调用。
*
* @author MateClaw Team
*/
public final class MateClawStateAccessor {
private final OverAllState state;
private final RunState state;
public MateClawStateAccessor(OverAllState state) {
public MateClawStateAccessor(RunState state) {
this.state = Objects.requireNonNull(state, "state must not be null");
}
@ -595,7 +595,7 @@ public final class MateClawStateAccessor {
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
public OutputBuilder mergeUsage(OverAllState currentState,
public OutputBuilder mergeUsage(RunState currentState,
NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);

View File

@ -289,6 +289,12 @@ public final class MateClawStateKeys {
*/
public static final String CHAT_ORIGIN = "chat_origin";
/** Thinking preference captured before asynchronous runtime callbacks. */
public static final String REQUESTED_THINKING_LEVEL = "requested_thinking_level";
/** Immutable delegation identity carried across runtime worker hops. */
public static final String SUBAGENT_RUN_CONTEXT = "subagent_run_context";
// ===== Skill progressive disclosure (REPLACE strategy) =====
/**

View File

@ -0,0 +1,20 @@
package vip.mate.agent.runtime;
import reactor.core.publisher.Flux;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.RunStep;
import java.util.Map;
import java.util.Optional;
/** Application execution boundary with detached state for every subscription. */
@FunctionalInterface
public interface AgentRuntime {
Flux<RunStep> stream(Map<String, Object> inputs);
default Optional<RunState> invoke(Map<String, Object> inputs) {
RunStep last = stream(inputs).blockLast();
return Optional.ofNullable(last).map(RunStep::state);
}
}

View File

@ -0,0 +1,331 @@
package vip.mate.agent.runtime.agentscope;
import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.AgentResultEvent;
import io.agentscope.core.event.RequestStopEvent;
import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.middleware.ActingInput;
import io.agentscope.core.middleware.AgentInput;
import io.agentscope.core.middleware.MiddlewareBase;
import io.agentscope.core.middleware.ModelCallInput;
import io.agentscope.core.middleware.ReasoningInput;
import io.agentscope.core.model.ExecutionConfig;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.ToolRequestConfig;
import io.agentscope.core.tool.Toolkit;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Function;
/** SDK orchestration with one application-owned guarded batch executor. */
public final class ApplicationGuardedToolkit extends Toolkit {
public enum UnknownToolPolicy {
FAIL_CLOSED,
APPLICATION_OWNER
}
private final UnknownToolPolicy unknownToolPolicy;
private final GuardedToolBatchExecutor executor;
private final List<ToolSchema> schemas;
public ApplicationGuardedToolkit(List<ToolSchema> schemas, GuardedToolBatchExecutor executor) {
this(schemas, executor, UnknownToolPolicy.FAIL_CLOSED);
}
public ApplicationGuardedToolkit(
List<ToolSchema> schemas,
GuardedToolBatchExecutor executor,
UnknownToolPolicy unknownToolPolicy) {
this.unknownToolPolicy = Objects.requireNonNull(unknownToolPolicy);
this.executor = Objects.requireNonNull(executor);
this.schemas =
schemas.stream()
.map(
schema ->
ToolSchema.builder()
.name(schema.getName())
.description(schema.getDescription())
.strict(schema.getStrict())
.parameters(immutableSchema(schema.getParameters()))
.outputSchema(
schema.getOutputSchema() == null
? null
: immutableSchema(
schema.getOutputSchema()))
.build())
.toList();
for (ToolSchema schema : this.schemas) registerAgentTool(new SchemaOnlyTool(schema));
}
private static Map<String, Object> immutableSchema(Map<String, Object> schema) {
Map<String, Object> snapshot = new LinkedHashMap<>();
schema.forEach((key, value) -> snapshot.put(key, immutableSchemaValue(value)));
return Collections.unmodifiableMap(snapshot);
}
private static Object immutableSchemaValue(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> snapshot = new LinkedHashMap<>();
map.forEach(
(key, item) -> {
if (!(key instanceof String name))
throw new IllegalArgumentException("Schema keys must be strings");
snapshot.put(name, immutableSchemaValue(item));
});
return Collections.unmodifiableMap(snapshot);
}
if (value instanceof Collection<?> items) {
List<Object> snapshot = new ArrayList<>();
items.forEach(item -> snapshot.add(immutableSchemaValue(item)));
return Collections.unmodifiableList(snapshot);
}
if (value == null
|| value instanceof String
|| value instanceof Number
|| value instanceof Boolean) return value;
throw new IllegalArgumentException(
"Unsupported schema value: " + value.getClass().getName());
}
@Override
public Toolkit copy() {
return new ApplicationGuardedToolkit(schemas, executor, unknownToolPolicy);
}
/** Install once; stop before the next model call, after SDK batch result persistence. */
public MiddlewareBase boundaryMiddleware() {
return new MiddlewareBase() {
@Override
public Flux<AgentEvent> onAgent(
Agent agent,
RuntimeContext context,
AgentInput input,
Function<AgentInput, Flux<AgentEvent>> next) {
return Flux.defer(
() -> {
if (context == null)
return Flux.error(
new IllegalArgumentException(
"Guarded toolkit requires an explicit per-run"
+ " RuntimeContext"));
context.put(BatchState.class, new BatchState());
return next.apply(input)
.filter(
event ->
!(event instanceof AgentResultEvent)
|| outcome(context).failure() == null)
.concatWith(
Flux.defer(
() -> {
BoundaryOutcome outcome = outcome(context);
return outcome.failure() == null
? Flux.empty()
: Flux.error(outcome.failure());
}));
});
}
@Override
public Flux<AgentEvent> onActing(
Agent agent,
RuntimeContext context,
ActingInput input,
Function<ActingInput, Flux<AgentEvent>> next) {
return Flux.defer(
() -> {
BatchState state = new BatchState();
context.put(BatchState.class, state);
return next.apply(input);
});
}
@Override
public Flux<AgentEvent> onModelCall(
Agent agent,
RuntimeContext context,
ModelCallInput input,
Function<ModelCallInput, Flux<AgentEvent>> next) {
BoundaryOutcome outcome = outcome(context);
if (outcome.failure() != null)
return Flux.just(
new RequestStopEvent(
"guarded_boundary_failed",
GenerateReason.MIDDLEWARE_STOP_REQUESTED));
if (outcome.result() != null && outcome.result().stopReason() != null)
return Flux.just(
new RequestStopEvent(
outcome.result().stopReason(),
GenerateReason.MIDDLEWARE_STOP_REQUESTED));
return next.apply(input);
}
@Override
public Flux<AgentEvent> onReasoning(
Agent agent,
RuntimeContext context,
ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> next) {
BatchState state = context.get(BatchState.class);
if (state != null && state.failure != null)
return Flux.just(
new RequestStopEvent(
"guarded_boundary_failed",
GenerateReason.MIDDLEWARE_STOP_REQUESTED));
if (state != null && state.result != null && state.result.stopReason() != null)
return Flux.just(
new RequestStopEvent(
state.result.stopReason(),
GenerateReason.MIDDLEWARE_STOP_REQUESTED));
return next.apply(input);
}
};
}
@Override
public Mono<List<ToolResultBlock>> callTools(
List<ToolUseBlock> toolCalls,
ExecutionConfig ignored,
Agent agent,
RuntimeContext context,
ToolRequestConfig request,
BiConsumer<ToolUseBlock, ToolResultBlock> chunks) {
return Mono.defer(
() -> {
BatchState state = context.get(BatchState.class);
if (state == null)
return Mono.error(
new IllegalStateException(
"Guarded toolkit requires its boundary"
+ " middleware"));
List<ToolUseBlock> calls = List.copyOf(toolCalls);
Map<String, ToolUseBlock> byId = new LinkedHashMap<>();
for (ToolUseBlock call : calls) {
if (call.getId() == null
|| call.getId().isBlank()
|| byId.put(call.getId(), call) != null)
return Mono.error(
new IllegalArgumentException(
"Tool call IDs must be nonblank and unique"));
if (unknownToolPolicy == UnknownToolPolicy.FAIL_CLOSED
&& getTool(call.getName()) == null)
return Mono.error(
new IllegalArgumentException(
"Unknown tool: " + call.getName()));
}
return executor.execute(calls, context)
.switchIfEmpty(
Mono.error(
new IllegalStateException(
"Missing guarded batch result")))
.map(
result -> {
Map<String, ToolResultBlock> results =
new LinkedHashMap<>();
for (ToolResultBlock block : result.results()) {
ToolUseBlock call = byId.get(block.getId());
if (call == null
|| !call.getName()
.equals(block.getName())
|| results.put(block.getId(), block)
!= null)
throw new IllegalArgumentException(
"Guarded result IDs and names must"
+ " match the original calls");
}
if (results.size() != calls.size())
throw new IllegalArgumentException(
"Every tool call requires one guarded"
+ " result");
List<ToolResultBlock> ordered =
calls.stream()
.map(
call ->
results.get(
call
.getId()))
.toList();
state.result =
new GuardedToolBatchResult(
ordered,
result.metadata(),
result.stopReason());
if (chunks != null)
for (int i = 0; i < calls.size(); i++)
chunks.accept(calls.get(i), ordered.get(i));
return ordered;
});
})
.doOnError(
failure -> {
BatchState state = context.get(BatchState.class);
if (state != null) state.failure = failure;
});
}
/** Application binding remains available after SDK completion, cancellation, or failure. */
public static BoundaryOutcome outcome(RuntimeContext context) {
BatchState state = context.get(BatchState.class);
return state == null
? new BoundaryOutcome(null, null)
: new BoundaryOutcome(state.result, state.failure);
}
public record BoundaryOutcome(GuardedToolBatchResult result, Throwable failure) {}
private static final class BatchState {
private volatile GuardedToolBatchResult result;
private volatile Throwable failure;
}
/** No executable business callback is registered in the SDK toolkit. */
private record SchemaOnlyTool(ToolSchema schema) implements AgentTool {
@Override
public String getName() {
return schema.getName();
}
@Override
public String getDescription() {
return schema.getDescription();
}
@Override
public Map<String, Object> getParameters() {
return schema.getParameters();
}
@Override
public Boolean getStrict() {
return schema.getStrict();
}
@Override
public Map<String, Object> getOutputSchema() {
return schema.getOutputSchema();
}
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam ignored) {
return Mono.error(
new IllegalStateException(
"SDK tool callbacks must never execute; the application owns the whole"
+ " batch"));
}
}
}

View File

@ -0,0 +1,13 @@
package vip.mate.agent.runtime.agentscope;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
import java.util.List;
@FunctionalInterface
public interface GuardedToolBatchExecutor {
Mono<GuardedToolBatchResult> execute(List<ToolUseBlock> calls, RuntimeContext context);
}

View File

@ -0,0 +1,14 @@
package vip.mate.agent.runtime.agentscope;
import io.agentscope.core.message.ToolResultBlock;
import java.util.List;
import java.util.Map;
public record GuardedToolBatchResult(
List<ToolResultBlock> results, Map<String, Object> metadata, String stopReason) {
public GuardedToolBatchResult {
results = List.copyOf(results);
metadata = Map.copyOf(metadata);
}
}

View File

@ -0,0 +1,558 @@
package vip.mate.agent.runtime.agentscope;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.RequestStopEvent;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostReasoningEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.middleware.AgentInput;
import io.agentscope.core.middleware.MiddlewareBase;
import io.agentscope.core.middleware.ModelCallInput;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import vip.mate.agent.runtime.AgentRuntime;
import vip.mate.agent.runtime.state.EdgeAction;
import vip.mate.agent.runtime.state.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.RunStep;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
/** Narrow policy callbacks; SDK ReActAgent owns all reasoning/acting iteration. */
@Slf4j
public final class NativePolicyDriver implements AgentRuntime {
private static final ObjectMapper TOOL_JSON =
new ObjectMapper()
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private static final String EMPTY_SCRATCH_TEXT =
"Application policy round completed without visible text.";
public static final String BATCH_RESULT = "native_guarded_batch_result";
public static final String END = "end";
public record Policies(
NodeAction reasoning,
EdgeAction afterReasoning,
NodeAction action,
NodeAction observation,
EdgeAction afterObservation,
NodeAction summarizing,
NodeAction limitExceeded,
NodeAction finalAnswer,
EdgeAction afterFinal,
NodeAction goalEvaluation,
EdgeAction afterGoal) {
public Policies {
Objects.requireNonNull(reasoning, "reasoning");
Objects.requireNonNull(afterReasoning, "afterReasoning");
Objects.requireNonNull(action, "action");
Objects.requireNonNull(observation, "observation");
Objects.requireNonNull(afterObservation, "afterObservation");
Objects.requireNonNull(summarizing, "summarizing");
Objects.requireNonNull(limitExceeded, "limitExceeded");
Objects.requireNonNull(finalAnswer, "finalAnswer");
Objects.requireNonNull(afterFinal, "afterFinal");
Objects.requireNonNull(goalEvaluation, "goalEvaluation");
Objects.requireNonNull(afterGoal, "afterGoal");
}
/**
* Single application task; outer plan orchestration owns goal evaluation and persistence.
*/
public static Policies singleTask(
NodeAction reasoning,
EdgeAction afterReasoning,
NodeAction action,
NodeAction observation,
EdgeAction afterObservation,
NodeAction summarizing,
NodeAction limitExceeded,
NodeAction terminal) {
return new Policies(
reasoning,
afterReasoning,
action,
observation,
afterObservation,
summarizing,
limitExceeded,
terminal,
state -> END,
state -> Map.of(),
state -> END);
}
}
@FunctionalInterface
public interface ScopeFactory {
AutoCloseable open(RunState state) throws Exception;
}
public enum ToolNamePolicy {
FAIL_CLOSED,
APPLICATION_OWNER
}
private final ToolNamePolicy toolNamePolicy;
private final ScopeFactory scopes;
private final Policies policies;
private final List<ToolSchema> schemas;
private final int hardRoundCeiling;
public NativePolicyDriver(
Policies policies,
List<ToolSchema> schemas,
int hardRoundCeiling,
ScopeFactory scopes) {
this(policies, schemas, hardRoundCeiling, scopes, ToolNamePolicy.FAIL_CLOSED);
}
public NativePolicyDriver(
Policies policies,
List<ToolSchema> schemas,
int hardRoundCeiling,
ScopeFactory scopes,
ToolNamePolicy toolNamePolicy) {
this.toolNamePolicy = Objects.requireNonNull(toolNamePolicy);
this.scopes = Objects.requireNonNull(scopes);
this.policies = Objects.requireNonNull(policies);
this.schemas = List.copyOf(schemas);
if (hardRoundCeiling < 1 || hardRoundCeiling == Integer.MAX_VALUE)
throw new IllegalArgumentException("Finite positive native policy ceiling required");
this.hardRoundCeiling = hardRoundCeiling;
}
@Override
public Flux<RunStep> stream(Map<String, Object> inputs) {
return run(new RunState(inputs));
}
/**
* The supplied application history is copied; SDK scratch messages are never persisted here.
*/
public Flux<RunStep> run(RunState initial) {
return Flux.defer(
() -> {
Session session = new Session(initial.snapshot());
RuntimeContext context = RuntimeContext.empty();
var toolkit =
new ApplicationGuardedToolkit(
schemas,
(calls, ignored) -> session.act(calls),
toolNamePolicy == ToolNamePolicy.APPLICATION_OWNER
? ApplicationGuardedToolkit.UnknownToolPolicy
.APPLICATION_OWNER
: ApplicationGuardedToolkit.UnknownToolPolicy
.FAIL_CLOSED);
var agent =
ReActAgent.builder()
.name("native-policy-driver")
.model(session.model())
.toolkit(toolkit)
.hook(session)
.middleware(toolkit.boundaryMiddleware())
.middleware(session.middleware())
.maxIters(hardRoundCeiling + 1)
.maxRetries(1)
.enableMetaTool(false)
.enablePendingToolRecovery(false)
.build();
Flux<RunStep> execution =
agent.streamEvents("Internal orchestration only", context)
.doOnComplete(() -> session.steps.tryEmitComplete())
.doOnError(session.steps::tryEmitError)
.thenMany(Flux.empty());
return Flux.merge(session.steps.asFlux(), execution)
.doFinally(
signal -> {
session.cancelled.set(true);
agent.close();
});
});
}
private final class Session implements Hook {
private final RunState state;
private final Sinks.Many<RunStep> steps = Sinks.many().unicast().onBackpressureBuffer();
private final AtomicBoolean cancelled = new AtomicBoolean();
private volatile boolean terminal;
private int rounds;
private String reasoningRoute;
private final Set<String> dispatchedCallIds = new HashSet<>();
private final Map<String, String> scratchArguments = new LinkedHashMap<>();
private Session(RunState state) {
this.state = state;
}
private void ensureRunning() {
if (cancelled.get() || Thread.currentThread().isInterrupted())
throw new CancellationException("Application run cancelled");
}
private void node(String name, NodeAction action) throws Exception {
ensureRunning();
long started = System.nanoTime();
String traceId = state.value(TRACE_ID, "");
log.info(
"[ReAct] node={} event=start iteration={} traceId={}",
name,
state.value(CURRENT_ITERATION, 0),
traceId);
try {
Map<String, Object> patch;
try (AutoCloseable ignored = scopes.open(state)) {
patch = action.apply(state);
}
ensureRunning();
state.applyPatch(patch);
log.info(
"[ReAct] node={} event=complete iteration={} durationMs={} toolCallCount={}"
+ " finishReason={} traceId={}",
name,
state.value(CURRENT_ITERATION, 0),
(System.nanoTime() - started) / 1_000_000,
state.value(TOOL_CALL_COUNT, 0),
state.value(FINISH_REASON, ""),
traceId);
steps.tryEmitNext(new RunStep(name, state));
} catch (Exception failure) {
log.error(
"[ReAct] node={} event=error iteration={} traceId={}",
name,
state.value(CURRENT_ITERATION, 0),
traceId,
failure);
throw failure;
}
}
private String edge(EdgeAction edge) throws Exception {
ensureRunning();
try (AutoCloseable ignored = scopes.open(state)) {
return edge.apply(state);
}
}
private List<AssistantMessage.ToolCall> calls() {
return state.value(TOOL_CALLS, List.<AssistantMessage.ToolCall>of());
}
private Model model() {
return new Model() {
@Override
public String getModelName() {
return "application-reasoning-policy";
}
@Override
public Flux<ChatResponse> stream(
List<Msg> scratch, List<ToolSchema> schemas, GenerateOptions ignored) {
return Mono.fromCallable(
() -> {
ensureRunning();
if (terminal || rounds >= hardRoundCeiling)
throw new IllegalStateException(
"Unexpected SDK model call beyond application"
+ " boundary");
rounds++;
state.applyPatch(Map.of(TOOL_CALLS, List.of()));
node(REASONING_NODE, policies.reasoning());
reasoningRoute = edge(policies.afterReasoning());
List<ContentBlock> blocks = new ArrayList<>();
scratchArguments.clear();
for (AssistantMessage.ToolCall call :
ACTION_NODE.equals(reasoningRoute)
? calls()
: List.<AssistantMessage.ToolCall>of()) {
if (call.id() == null
|| call.id().isBlank()
|| !dispatchedCallIds.add(call.id())) {
throw new IllegalArgumentException(
"Tool call ID must be nonblank and unique"
+ " within this run: "
+ call.id());
}
if (call.arguments() == null)
throw new IllegalArgumentException(
"Original raw tool arguments are required");
String scratchContent = call.arguments();
Map<String, Object> arguments;
try {
arguments =
TOOL_JSON.readValue(
call.arguments(),
new TypeReference<>() {});
if (arguments == null)
throw new IllegalArgumentException(
"Tool arguments must be a JSON object");
} catch (Exception invalidArguments) {
if (toolNamePolicy == ToolNamePolicy.FAIL_CLOSED)
throw invalidArguments;
// Scratch input only: the application owner
// receives the exact raw content.
arguments = Map.of();
scratchContent = "{}";
}
scratchArguments.put(call.id(), scratchContent);
blocks.add(
new ToolUseBlock(
call.id(),
call.name(),
arguments,
scratchContent,
null));
}
if (blocks.isEmpty()) {
List<Message> history =
state.value(MESSAGES, List.<Message>of());
String text =
history.stream()
.filter(
AssistantMessage.class
::isInstance)
.map(AssistantMessage.class::cast)
.reduce((first, last) -> last)
.map(AssistantMessage::getText)
.orElse("");
// SDK drops fully empty responses before its
// post-reasoning routing hook.
// This marker exists only in discarded SDK scratch
// history, never application history.
blocks.add(
TextBlock.builder()
.text(
text == null || text.isEmpty()
? EMPTY_SCRATCH_TEXT
: text)
.build());
}
return ChatResponse.builder()
.content(blocks)
.finishReason(
calls().isEmpty() ? "stop" : "tool_calls")
.build();
})
.subscribeOn(Schedulers.boundedElastic())
.flux();
}
};
}
private Mono<GuardedToolBatchResult> act(List<ToolUseBlock> batch) {
return Mono.fromCallable(
() -> {
ensureRunning();
List<AssistantMessage.ToolCall> original = calls();
if (!batch.stream()
.map(ToolUseBlock::getId)
.toList()
.equals(
original.stream()
.map(AssistantMessage.ToolCall::id)
.toList())
|| !batch.stream()
.map(ToolUseBlock::getName)
.toList()
.equals(
original.stream()
.map(
AssistantMessage.ToolCall
::name)
.toList())
|| !batch.stream()
.map(ToolUseBlock::getContent)
.toList()
.equals(
original.stream()
.map(
call ->
scratchArguments
.get(
call
.id()))
.toList()))
throw new IllegalArgumentException(
"SDK batch must preserve original IDs and raw"
+ " arguments");
state.replace(BATCH_RESULT, null);
node(ACTION_NODE, policies.action());
GuardedToolBatchResult result =
state.value(BATCH_RESULT, GuardedToolBatchResult.class)
.orElseThrow();
node(OBSERVATION_NODE, policies.observation());
String stop = result.stopReason();
String observationRoute = edge(policies.afterObservation());
if (stop != null) {
finish(false);
terminal = true;
} else routeAfterObservation(observationRoute);
return new GuardedToolBatchResult(
result.results(),
result.metadata(),
stop != null
? stop
: terminal ? "application_terminal" : null);
})
.subscribeOn(Schedulers.boundedElastic());
}
private void routeAfterObservation(String route) throws Exception {
switch (route) {
case REASONING_NODE -> {
if (rounds >= hardRoundCeiling) ceiling();
}
case SUMMARIZING_NODE -> {
node(SUMMARIZING_NODE, policies.summarizing());
if (rounds >= hardRoundCeiling) ceiling();
}
case LIMIT_EXCEEDED_NODE -> {
node(LIMIT_EXCEEDED_NODE, policies.limitExceeded());
finish(true);
}
case FINAL_ANSWER_NODE -> finish(true);
default ->
throw new IllegalArgumentException(
"Unsupported observation route: " + route);
}
}
private void ceiling() throws Exception {
node(LIMIT_EXCEEDED_NODE, policies.limitExceeded());
finish(false);
terminal = true;
}
/**
* Returns true only when an application goal follow-up should enter native reasoning again.
*/
private boolean finish(boolean allowGoalContinuation) throws Exception {
node(FINAL_ANSWER_NODE, policies.finalAnswer());
String finalRoute = edge(policies.afterFinal());
if (GOAL_EVALUATION_NODE.equals(finalRoute)) {
node(GOAL_EVALUATION_NODE, policies.goalEvaluation());
String goalRoute = edge(policies.afterGoal());
if (REASONING_NODE.equals(goalRoute)
&& allowGoalContinuation
&& rounds < hardRoundCeiling) return true;
if (!END.equals(goalRoute) && !REASONING_NODE.equals(goalRoute))
throw new IllegalArgumentException("Unsupported goal route: " + goalRoute);
} else if (!END.equals(finalRoute))
throw new IllegalArgumentException("Unsupported final route: " + finalRoute);
terminal = true;
return false;
}
@Override
public <T extends HookEvent> Mono<T> onEvent(T event) {
if (!(event instanceof PostReasoningEvent post)) return Mono.just(event);
return Mono.fromCallable(
() -> {
ensureRunning();
String route =
Objects.requireNonNull(
reasoningRoute,
"Reasoning route must be captured once");
if (ACTION_NODE.equals(route)) {
if (calls().isEmpty())
throw new IllegalStateException(
"Action route requires real tool calls");
return event;
}
switch (route) {
case REASONING_NODE -> {
if (rounds >= hardRoundCeiling) {
ceiling();
post.stopAgent();
} else post.gotoReasoning();
}
case SUMMARIZING_NODE -> {
node(SUMMARIZING_NODE, policies.summarizing());
if (rounds >= hardRoundCeiling) {
ceiling();
post.stopAgent();
} else post.gotoReasoning();
}
case LIMIT_EXCEEDED_NODE -> {
node(LIMIT_EXCEEDED_NODE, policies.limitExceeded());
if (finish(true)) post.gotoReasoning();
else post.stopAgent();
}
case FINAL_ANSWER_NODE -> {
if (finish(true)) post.gotoReasoning();
else post.stopAgent();
}
default ->
throw new IllegalArgumentException(
"Unsupported reasoning route: " + route);
}
return event;
})
.subscribeOn(Schedulers.boundedElastic());
}
private MiddlewareBase middleware() {
return new MiddlewareBase() {
@Override
public Flux<AgentEvent> onAgent(
Agent agent,
RuntimeContext context,
AgentInput input,
Function<AgentInput, Flux<AgentEvent>> next) {
return next.apply(input).doOnCancel(() -> cancelled.set(true));
}
@Override
public Flux<AgentEvent> onModelCall(
Agent agent,
RuntimeContext context,
ModelCallInput input,
Function<ModelCallInput, Flux<AgentEvent>> next) {
if (terminal || rounds >= hardRoundCeiling)
return Flux.just(
new RequestStopEvent(
"application_terminal",
GenerateReason.MIDDLEWARE_STOP_REQUESTED));
return next.apply(input);
}
};
}
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.agent.runtime.agentscope;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.model.ToolSchema;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Projects scoped Spring tool definitions without registering executable callbacks. */
public final class SpringToolSchemaAdapter {
private static final ObjectMapper JSON =
new ObjectMapper()
.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private SpringToolSchemaAdapter() {}
public static List<ToolSchema> convert(List<ToolCallback> callbacks) {
Objects.requireNonNull(callbacks);
return callbacks.stream()
.map(
callback -> {
ToolDefinition definition =
Objects.requireNonNull(callback.getToolDefinition());
try {
JsonNode root = JSON.readTree(definition.inputSchema());
if (root == null || !root.isObject())
throw new IllegalArgumentException(
"Tool schema must be a JSON object: "
+ definition.name());
Map<String, Object> parameters =
JSON.convertValue(root, new TypeReference<>() {});
return ToolSchema.builder()
.name(definition.name())
.description(definition.description())
.parameters(parameters)
.build();
} catch (JsonProcessingException error) {
throw new IllegalArgumentException(
"Invalid tool schema: " + definition.name(), error);
}
})
.toList();
}
}

View File

@ -0,0 +1,121 @@
package vip.mate.agent.runtime.agentscope;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolResultState;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.executor.ToolExecutionExecutor.ToolExecutionResult;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Pure projection of an already executed canonical product batch. */
public final class ToolBatchResultAdapter {
private ToolBatchResultAdapter() {}
public static GuardedToolBatchResult project(
List<AssistantMessage.ToolCall> calls, ToolExecutionResult result) {
Map<String, AssistantMessage.ToolCall> originals = new LinkedHashMap<>();
Objects.requireNonNull(calls);
Objects.requireNonNull(result);
for (AssistantMessage.ToolCall call : calls) {
if (call.id() == null
|| call.id().isBlank()
|| call.arguments() == null
|| originals.put(call.id(), call) != null)
throw new IllegalArgumentException(
"Original tool calls require unique IDs and raw arguments");
}
Map<String, GraphEventPublisher.GraphEvent> completions = new LinkedHashMap<>();
String approvalId = null;
for (GraphEventPublisher.GraphEvent event : result.events()) {
Object id = event.data().get("toolCallId");
if (!(id instanceof String) || ((String) id).isBlank()) {
if (GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type()))
throw new IllegalArgumentException("Completion event requires a call ID");
continue;
}
String key = (String) id;
if (GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())) {
if (!originals.containsKey(key) || completions.put(key, event) != null)
throw new IllegalArgumentException(
"Completion event must match one original call ID");
}
if (GraphEventPublisher.EVENT_TOOL_APPROVAL_REQUESTED.equals(event.type())) {
if (!originals.containsKey(key) || approvalId != null)
throw new IllegalArgumentException(
"Approval event must identify one original call ID");
approvalId = key;
}
}
String exactApprovalId = approvalId;
Map<String, ToolResponseMessage.ToolResponse> responses = new LinkedHashMap<>();
for (ToolResponseMessage.ToolResponse response : result.responses()) {
AssistantMessage.ToolCall original = originals.get(response.id());
if (original == null
|| !Objects.equals(original.name(), response.name())
|| responses.put(response.id(), response) != null)
throw new IllegalArgumentException(
"Product responses require unique original call ID/name pairs");
}
List<ToolResultBlock> blocks =
calls.stream()
.map(
original -> {
ToolResponseMessage.ToolResponse response =
responses.get(original.id());
if (response == null
&& (!result.awaitingApproval()
|| completions.containsKey(original.id())))
throw new IllegalArgumentException(
"Missing product response without an approval"
+ " barrier");
Map<String, Object> metadata = new LinkedHashMap<>();
if (response != null) metadata.put("productResponse", response);
else metadata.put("skipped", true);
metadata.put("rawArguments", original.arguments());
GraphEventPublisher.GraphEvent completion =
completions.get(original.id());
ToolResultState state = ToolResultState.ERROR;
if (completion != null) {
metadata.put("completionEvent", completion.data());
Object canonicalName = completion.data().get("toolName");
if (canonicalName instanceof String name && !name.isBlank())
metadata.put("canonicalToolName", name);
metadata.put("completionStatus", "reported");
if (Boolean.TRUE.equals(completion.data().get("success")))
state = ToolResultState.SUCCESS;
} else metadata.put("completionStatus", "unreported");
if (original.id().equals(exactApprovalId))
metadata.put("awaitingApproval", true);
return new ToolResultBlock(
original.id(),
original.name(),
List.of(
TextBlock.builder()
.text(
response == null
|| response
.responseData()
== null
? ""
: response
.responseData())
.build()),
metadata,
state);
})
.toList();
String stop =
result.awaitingApproval()
? "awaiting_approval"
: result.hasDirectOutputs() ? "direct_output" : null;
return new GuardedToolBatchResult(blocks, Map.of("productResult", result), stop);
}
}

View File

@ -0,0 +1,142 @@
package vip.mate.agent.runtime.plan;
import static vip.mate.agent.graph.plan.state.PlanStateKeys.*;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATION_NODE;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import vip.mate.agent.runtime.AgentRuntime;
import vip.mate.agent.runtime.state.EdgeAction;
import vip.mate.agent.runtime.state.NodeAction;
import vip.mate.agent.runtime.state.RunState;
import vip.mate.agent.runtime.state.RunStep;
import vip.mate.agent.runtime.state.RuntimeExecutionScope;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CancellationException;
/** Explicit plan phases. Individual step tool loops belong to the native agent runtime. */
public final class ExplicitPlanRuntime implements AgentRuntime {
public static final String END = "end";
public record Policies(
NodeAction generation,
EdgeAction afterGeneration,
NodeAction step,
EdgeAction afterStep,
NodeAction summary,
NodeAction directAnswer,
EdgeAction afterAnswer,
NodeAction goalEvaluation,
EdgeAction afterGoal) {
public Policies {
Objects.requireNonNull(generation);
Objects.requireNonNull(afterGeneration);
Objects.requireNonNull(step);
Objects.requireNonNull(afterStep);
Objects.requireNonNull(summary);
Objects.requireNonNull(directAnswer);
Objects.requireNonNull(afterAnswer);
Objects.requireNonNull(goalEvaluation);
Objects.requireNonNull(afterGoal);
}
}
private final Policies policies;
private final int phaseCeiling;
public ExplicitPlanRuntime(Policies policies, int phaseCeiling) {
this.policies = Objects.requireNonNull(policies);
if (phaseCeiling < 1)
throw new IllegalArgumentException("A positive plan phase ceiling is required");
this.phaseCeiling = phaseCeiling;
}
@Override
public Flux<RunStep> stream(Map<String, Object> inputs) {
return Flux.defer(
() ->
Flux.<RunStep, Session>generate(
() -> new Session(new RunState(inputs)),
(session, sink) -> {
if (END.equals(session.next)) {
sink.complete();
return session;
}
try {
if (Thread.currentThread().isInterrupted())
throw new CancellationException(
"Plan execution cancelled");
if (session.phases >= phaseCeiling)
throw new IllegalStateException(
"Plan phase ceiling reached");
String phase = session.next;
session.phases++;
NodeAction node =
switch (phase) {
case PLAN_GENERATION_NODE ->
policies.generation();
case STEP_EXECUTION_NODE ->
policies.step();
case PLAN_SUMMARY_NODE ->
policies.summary();
case DIRECT_ANSWER_NODE ->
policies.directAnswer();
case GOAL_EVALUATION_NODE ->
policies.goalEvaluation();
default ->
throw new IllegalStateException(
"Unsupported plan"
+ " phase: "
+ phase);
};
Map<String, Object> patch =
RuntimeExecutionScope.call(
session.state,
() -> node.apply(session.state));
if (Thread.currentThread().isInterrupted())
throw new CancellationException(
"Plan execution cancelled");
session.state.applyPatch(patch);
EdgeAction edge =
switch (phase) {
case PLAN_GENERATION_NODE ->
policies.afterGeneration();
case STEP_EXECUTION_NODE ->
policies.afterStep();
case PLAN_SUMMARY_NODE,
DIRECT_ANSWER_NODE ->
policies.afterAnswer();
case GOAL_EVALUATION_NODE ->
policies.afterGoal();
default ->
throw new IllegalStateException(
"Unsupported plan"
+ " phase: "
+ phase);
};
session.next =
RuntimeExecutionScope.call(
session.state,
() -> edge.apply(session.state));
sink.next(new RunStep(phase, session.state));
} catch (Exception failure) {
sink.error(failure);
}
return session;
}))
.subscribeOn(Schedulers.boundedElastic());
}
private static final class Session {
private final RunState state;
private String next = PLAN_GENERATION_NODE;
private int phases;
private Session(RunState state) {
this.state = state;
}
}
}

View File

@ -0,0 +1,6 @@
package vip.mate.agent.runtime.state;
@FunctionalInterface
public interface EdgeAction {
String apply(RunState state) throws Exception;
}

View File

@ -0,0 +1,8 @@
package vip.mate.agent.runtime.state;
import java.util.Map;
@FunctionalInterface
public interface NodeAction {
Map<String, Object> apply(RunState state) throws Exception;
}

View File

@ -0,0 +1,153 @@
package vip.mate.agent.runtime.state;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.BeanUtils;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.goal.model.GoalEntity;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Per-run state with owned container, event payload, and message metadata copies. GoalEntity scalar
* fields are copied. Other typed leaves (including media resources) retain identity and must be
* treated as immutable by callers; no POJO serialization is performed.
*/
public final class RunState {
private static final Set<String> APPEND_KEYS =
Set.of(
MateClawStateKeys.MESSAGES,
MateClawStateKeys.PENDING_EVENTS,
PlanStateKeys.COMPLETED_RESULTS);
private final Map<String, Object> values = new LinkedHashMap<>();
public RunState() {
this(Map.of());
}
public RunState(Map<String, Object> initial) {
if (initial != null)
initial.forEach(
(key, value) -> {
if (value != null) values.put(key, copy(value));
});
}
@SuppressWarnings("unchecked")
public synchronized <T> Optional<T> value(String key) {
return Optional.ofNullable((T) copy(values.get(key)));
}
public synchronized <T> Optional<T> value(String key, Class<T> type) {
Object value = copy(values.get(key));
if (type == null) return value(key);
return type.isInstance(value) ? Optional.of(type.cast(value)) : Optional.empty();
}
public synchronized <T> T value(String key, T defaultValue) {
return this.<T>value(key).orElse(defaultValue);
}
public synchronized Map<String, Object> data() {
Map<String, Object> result = new LinkedHashMap<>();
values.forEach((key, value) -> result.put(key, copy(value)));
return Collections.unmodifiableMap(result);
}
/** History keys append; a null history patch is a no-op. Other keys replace. */
public synchronized void applyPatch(Map<String, Object> patch) {
if (patch == null) return;
patch.forEach(
(key, value) -> {
if (value == null) {
if (!APPEND_KEYS.contains(key)) values.remove(key);
} else if (APPEND_KEYS.contains(key)) {
List<Object> merged = new ArrayList<>();
Object previous = values.get(key);
if (previous instanceof List<?> list) merged.addAll(list);
append(merged, value);
values.put(key, copy(merged));
} else values.put(key, copy(value));
});
}
public synchronized void replace(String key, Object value) {
if (value == null) values.remove(key);
else values.put(key, copy(value));
}
public synchronized RunState snapshot() {
return new RunState(data());
}
private static void append(List<Object> target, Object value) {
if (value instanceof Collection<?> collection) target.addAll(collection);
else if (value.getClass().isArray())
for (int i = 0; i < Array.getLength(value); i++) target.add(Array.get(value, i));
else target.add(value);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> metadata(Map<String, Object> value) {
return (Map<String, Object>) copy(value);
}
private static Object copy(Object value) {
if (value instanceof GoalEntity goal) {
// GoalEntity fields are immutable scalars, enums, and LocalDateTime values.
GoalEntity result = new GoalEntity();
BeanUtils.copyProperties(goal, result);
return result;
}
if (value instanceof GraphEventPublisher.GraphEvent event)
return new GraphEventPublisher.GraphEvent(
event.type(), metadata(event.data()), event.timestamp());
if (value instanceof AssistantMessage message)
return message.mutate().properties(metadata(message.getMetadata())).build();
if (value instanceof UserMessage message)
return message.mutate().metadata(metadata(message.getMetadata())).build();
if (value instanceof SystemMessage message)
return message.mutate().metadata(metadata(message.getMetadata())).build();
if (value instanceof ToolResponseMessage message)
return ToolResponseMessage.builder()
.responses(List.copyOf(message.getResponses()))
.metadata(metadata(message.getMetadata()))
.build();
if (value instanceof Map<?, ?> map) {
Map<Object, Object> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(key, copy(item)));
return Collections.unmodifiableMap(result);
}
if (value instanceof Set<?> set) {
Set<Object> result = new LinkedHashSet<>();
set.forEach(item -> result.add(copy(item)));
return Collections.unmodifiableSet(result);
}
if (value instanceof Collection<?> collection) {
List<Object> result = new ArrayList<>();
collection.forEach(item -> result.add(copy(item)));
return Collections.unmodifiableList(result);
}
if (value != null && value.getClass().isArray()) {
int length = Array.getLength(value);
Object result = Array.newInstance(value.getClass().getComponentType(), length);
for (int i = 0; i < length; i++) Array.set(result, i, copy(Array.get(value, i)));
return result;
}
return value;
}
}

View File

@ -0,0 +1,26 @@
package vip.mate.agent.runtime.state;
import java.util.Objects;
/** Internal retained snapshot, distinct from product SSE and stored conversation formats. */
public record RunStep(int snapshotVersion, String node, RunState state) {
public static final int CURRENT_SNAPSHOT_VERSION = 1;
public RunStep(String node, RunState state) {
this(CURRENT_SNAPSHOT_VERSION, node, state);
}
public RunStep {
if (snapshotVersion != CURRENT_SNAPSHOT_VERSION) {
throw new IllegalArgumentException(
"Unsupported runtime snapshot version: " + snapshotVersion);
}
Objects.requireNonNull(node);
state = Objects.requireNonNull(state).snapshot();
}
@Override
public RunState state() {
return state.snapshot();
}
}

View File

@ -0,0 +1,60 @@
package vip.mate.agent.runtime.state;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.delegation.SubagentRunContext;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.tool.builtin.DelegationContext;
import java.util.Objects;
import java.util.concurrent.Callable;
/** Restores captured per-run context for synchronous worker callbacks. */
public final class RuntimeExecutionScope {
private RuntimeExecutionScope() {}
public static <T> T call(RunState state, Callable<T> callback) throws Exception {
Objects.requireNonNull(callback);
try (AutoCloseable scope = open(state)) {
return callback.call();
}
}
/** Open and close on the same thread, in reverse nesting order. Closing twice is harmless. */
public static AutoCloseable open(RunState state) {
Objects.requireNonNull(state);
Thread owner = Thread.currentThread();
ChatOrigin previousOrigin = ChatOriginHolder.get();
String previousThinking = ThinkingLevelHolder.get();
ChatOrigin origin =
state.value(MateClawStateKeys.CHAT_ORIGIN, ChatOrigin.class)
.orElse(ChatOrigin.EMPTY);
String thinking =
state.value(MateClawStateKeys.REQUESTED_THINKING_LEVEL, String.class).orElse(null);
SubagentRunContext delegation =
state.value(MateClawStateKeys.SUBAGENT_RUN_CONTEXT, SubagentRunContext.class)
.orElse(SubagentRunContext.ROOT);
DelegationContext.push(delegation);
ChatOriginHolder.set(origin);
if (thinking == null) ThinkingLevelHolder.clear();
else ThinkingLevelHolder.set(thinking);
return new AutoCloseable() {
private boolean closed;
@Override
public void close() {
if (Thread.currentThread() != owner)
throw new IllegalStateException(
"Runtime scope must close on its opening thread");
if (closed) return;
closed = true;
DelegationContext.exit();
if (ChatOrigin.EMPTY.equals(previousOrigin)) ChatOriginHolder.clear();
else ChatOriginHolder.set(previousOrigin);
if (previousThinking == null) ThinkingLevelHolder.clear();
else ThinkingLevelHolder.set(previousThinking);
}
};
}
}

View File

@ -1,8 +1,8 @@
package vip.mate.channel.health;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import vip.mate.channel.ChannelAdapter;
import vip.mate.channel.ChannelManager;

View File

@ -1104,7 +1104,7 @@ public class ChatStreamTracker {
/**
* 直推事件(Object 自动序列化为 JSON)。
* <p>
* 用于在 Node 内部直接向前端推送 SSE 事件,绕过 NodeOutput 管道。
* 用于在 Node 内部直接向前端推送 SSE 事件,绕过 RunStep 管道。
* 典型场景:审批请求在 awaitDecision() 阻塞前必须先送达前端。
*
* @param conversationId 会话 ID

View File

@ -3,7 +3,7 @@ package vip.mate.config;
import lombok.extern.slf4j.Slf4j;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationInitializer;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@ -2,7 +2,7 @@ package vip.mate.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.boot.jackson2.autoconfigure.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@ -0,0 +1,27 @@
package vip.mate.config;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.concurrent.Future;
/** Cancels pending ticks and releases lifecycle-paused workers during graceful shutdown. */
final class PausedShutdownTaskScheduler extends ThreadPoolTaskScheduler {
@Override
protected void beforeExecute(Thread thread, Runnable task) {
super.beforeExecute(thread, task);
// A worker may have dequeued this future before the lifecycle pause.
// Releasing that gate during shutdown must not execute the pending tick.
if (getScheduledExecutor().isShutdown() && task instanceof Future<?> future) {
future.cancel(false);
}
}
@Override
public void shutdown() {
// Stop accepting work and cancel queued future ticks before waking workers.
initiateShutdown();
start();
// Already-running tasks retain the configured graceful completion window.
super.shutdown();
}
}

View File

@ -0,0 +1,123 @@
package vip.mate.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.RetryContext;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
import vip.mate.llm.ProviderHttpError;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Retains the configured inner retry policy while provider SDK retries are disabled. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ProviderRetryConfig.Settings.class)
public class ProviderRetryConfig {
@Bean
@ConditionalOnMissingBean(RetryTemplate.class)
public RetryTemplate retryTemplate(Settings settings) {
if (settings.getMaxAttempts() < 1)
throw new IllegalArgumentException("Retry max attempts must be positive");
Set<Integer> statuses = Set.copyOf(settings.getOnHttpCodes());
SimpleRetryPolicy policy =
new SimpleRetryPolicy(settings.getMaxAttempts(), Map.of(Exception.class, true)) {
@Override
public boolean canRetry(RetryContext context) {
if (!super.canRetry(context)) return false;
Throwable failure = context.getLastThrowable();
if (failure == null) return true;
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
while (failure != null && visited.add(failure)) {
int status = ProviderHttpError.status(failure);
if (status != 0) return statuses.contains(status);
failure = failure.getCause();
}
return false;
}
};
Backoff configured = settings.getBackoff();
if (configured.getInitialInterval() < 1
|| configured.getMultiplier() < 1
|| configured.getMaxInterval() < configured.getInitialInterval()) {
throw new IllegalArgumentException("Invalid provider retry backoff configuration");
}
ExponentialBackOffPolicy backoff = new ExponentialBackOffPolicy();
backoff.setInitialInterval(configured.getInitialInterval());
backoff.setMultiplier(configured.getMultiplier());
backoff.setMaxInterval(configured.getMaxInterval());
RetryTemplate retry = new RetryTemplate();
retry.setRetryPolicy(policy);
retry.setBackOffPolicy(backoff);
return retry;
}
@ConfigurationProperties("spring.ai.retry")
public static class Settings {
private int maxAttempts = 2;
private List<Integer> onHttpCodes = List.of(429, 503, 529);
private Backoff backoff = new Backoff();
public int getMaxAttempts() {
return maxAttempts;
}
public void setMaxAttempts(int value) {
maxAttempts = value;
}
public List<Integer> getOnHttpCodes() {
return onHttpCodes;
}
public void setOnHttpCodes(List<Integer> value) {
onHttpCodes = value;
}
public Backoff getBackoff() {
return backoff;
}
public void setBackoff(Backoff value) {
backoff = value;
}
}
public static class Backoff {
private long initialInterval = 3000;
private double multiplier = 3;
private long maxInterval = 60000;
public long getInitialInterval() {
return initialInterval;
}
public void setInitialInterval(long value) {
initialInterval = value;
}
public double getMultiplier() {
return multiplier;
}
public void setMultiplier(double value) {
multiplier = value;
}
public long getMaxInterval() {
return maxInterval;
}
public void setMaxInterval(long value) {
maxInterval = value;
}
}
}

View File

@ -37,7 +37,7 @@ public class SchedulingConfig implements SchedulingConfigurer {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
ThreadPoolTaskScheduler scheduler = new PausedShutdownTaskScheduler();
scheduler.setPoolSize(POOL_SIZE);
scheduler.setThreadNamePrefix("sched-");
scheduler.setRemoveOnCancelPolicy(true);

View File

@ -10,6 +10,10 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.cfg.EnumFeature;
import tools.jackson.databind.json.JsonMapper;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.evaluation.Evaluator;
@ -101,10 +105,14 @@ public class GoalEvaluationService implements Evaluator {
this.objectMapper = objectMapper;
// Preserve converter tolerance for extra fields, but never silently
// choose the last value of an ambiguous model-produced JSON key.
ObjectMapper evaluatorJson = objectMapper.copy()
.disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
// The AI2 converter owns a Jackson3 boundary; stored application data remains Jackson2.
JsonMapper evaluatorJson = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.enable(EnumFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.build();
this.draftConverter = new BeanOutputConverter<>(GoalCriteriaDraft.class, evaluatorJson);
this.verdictConverter = new BeanOutputConverter<>(GoalChecklistVerdict.class, evaluatorJson);
}

View File

@ -19,6 +19,7 @@ import java.util.UUID;
@Service
public class GoalRunCoordinator {
private static final int LEASE_SECONDS=60;
private static final String TOOL_OUTCOME_UNCERTAIN="tool_outcome_uncertain";
private final GoalContinuationStore continuations;
private final GoalAttemptStore attempts;
private final GoalService goals;
@ -134,10 +135,25 @@ public class GoalRunCoordinator {
long nowEpoch=instant.getEpochSecond();
now=LocalDateTime.ofInstant(instant,java.time.ZoneId.systemDefault());
if(!current(run,nowEpoch)) return false;
GoalAttempt persisted=attempts.get(run.attempt().id());
if (unknownToolOutcome(persisted)) {
// Completed event projection is not proof of a timed-out external effect.
if ("uncertain".equals(replaySafety) && "tool_completed".equals(checkpointType)) return true;
if (!"uncertain".equals(replaySafety) || !TOOL_OUTCOME_UNCERTAIN.equals(checkpointType)) return false;
}
return attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),replaySafety,
checkpointType,assistantMessageId,now);
}
public boolean hasUncertainToolOutcome(ClaimedRun run) {
return run!=null && unknownToolOutcome(attempts.get(run.attempt().id()));
}
private static boolean unknownToolOutcome(GoalAttempt attempt) {
return attempt!=null && TOOL_OUTCOME_UNCERTAIN.equals(attempt.checkpointType())
&& "uncertain".equals(attempt.replaySafety());
}
@Transactional
public boolean settle(ClaimedRun run,SegmentOutcome outcome,LocalDateTime now) {
return settleWithDecision(run, outcome, now, null);
@ -157,6 +173,7 @@ public class GoalRunCoordinator {
return rejectDecision(ticket, "GOAL_NOT_RUNNABLE");
}
if((outcome instanceof SegmentOutcome.Continue || outcome instanceof SegmentOutcome.Complete)
&& !hasUncertainToolOutcome(run)
&& !attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),"resolved",
"evaluation_saved",null,now)) return rejectDecision(ticket, "FENCE_REJECTED");
if(!attempts.finish(run.attempt().id(),run.attempt().leaseToken(),settlement.attemptState,
@ -211,8 +228,15 @@ public class GoalRunCoordinator {
private Settlement classify(ClaimedRun run,SegmentOutcome outcome,GoalEntity fresh,LocalDateTime now) {
int failures=run.candidate().failures();
if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED
|| outcome instanceof SegmentOutcome.Complete && (fresh==null || !fresh.isJsonAcceptanceRequired())) {
if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED) {
return new Settlement("succeeded","completed",now,0,"goal_completed",null);
}
// A committed Goal remains authoritative; every other unknown effect requires review.
if (hasUncertainToolOutcome(run)) {
return new Settlement("blocked","blocked",now,Math.min(1000,failures+1),
"uncertain_tool_outcome_requires_review","execution");
}
if(outcome instanceof SegmentOutcome.Complete && (fresh==null || !fresh.isJsonAcceptanceRequired())) {
return new Settlement("succeeded","completed",now,0,"goal_completed",null);
}
if(outcome instanceof SegmentOutcome.Complete && fresh!=null && fresh.isJsonAcceptanceRequired()) {
@ -230,6 +254,12 @@ public class GoalRunCoordinator {
return new Settlement("succeeded","waiting_input",now,0,outcome.reason(),null);
}
if(outcome instanceof SegmentOutcome.Retry retry) {
// ClaimedRun is an immutable claim snapshot; effects update the persisted attempt.
GoalAttempt currentAttempt=attempts.get(run.attempt().id());
if(currentAttempt!=null && "uncertain".equals(currentAttempt.replaySafety())) {
return new Settlement("blocked","blocked",now,Math.min(1000,failures+1),
"uncertain_tool_outcome_requires_review","execution");
}
int nextFailures=Math.min(1000,failures+1);
long delay=Math.min(300,5L << Math.min(6,nextFailures-1));
return new Settlement("retryable","retry",now.plusSeconds(delay),nextFailures,

View File

@ -188,6 +188,8 @@ public class GoalSegmentRunner {
return new SegmentOutcome.Cancelled("stopped");
}
result=runSegment(goal,input,origin,permit,worker,claimedRun);
if (claimedRun!=null && coordinator!=null && coordinator.hasUncertainToolOutcome(claimedRun))
return new SegmentOutcome.Blocked("execution","uncertain_tool_outcome_requires_review");
if (result.awaitingApproval()) return new SegmentOutcome.AwaitApproval("approval_required");
if ("stopped".equals(result.finishReason())) return new SegmentOutcome.Cancelled("stopped");
queued=claimNextInput(convId,claimedRun);
@ -231,6 +233,7 @@ public class GoalSegmentRunner {
AtomicReference<Throwable> failure=new AtomicReference<>();
AtomicBoolean evaluationUnavailable=new AtomicBoolean();
AtomicBoolean persisted=new AtomicBoolean();
AtomicBoolean sourceCompleted=new AtomicBoolean();
CountDownLatch finished=new CountDownLatch(1);
Disposable subscription=null;
try {
@ -248,15 +251,19 @@ public class GoalSegmentRunner {
.doOnNext(delta -> {
accumulator.accept(delta,convId);
if(claimedRun!=null && "tool_call_started".equals(delta.eventType())) {
checkpoint(claimedRun,"uncertain","tool_started",null);
if (!checkpoint(claimedRun,"uncertain","tool_started",null))
throw new IllegalStateException("Goal attempt lost its tool checkpoint fence");
} else if(claimedRun!=null && "tool_call_completed".equals(delta.eventType())) {
checkpoint(claimedRun,"resolved","tool_completed",null);
// Completion events can arrive after a whole batch; other effects may still be outstanding.
if (!checkpoint(claimedRun,"uncertain","tool_completed",null))
throw new IllegalStateException("Goal attempt lost its tool checkpoint fence");
}
if ("goal_evaluated".equals(delta.eventType()) && delta.eventData()!=null
&& (Boolean.TRUE.equals(delta.eventData().get("skipped"))
|| "fallback".equals(delta.eventData().get("decision")))) evaluationUnavailable.set(true);
});
})
.doOnComplete(() -> sourceCompleted.set(true))
.doOnSubscribe(s -> streams.setDisposable(handle, s::cancel))
.doFinally(signal -> finished.countDown())
.subscribe(delta -> {},failure::set)));
@ -269,7 +276,13 @@ public class GoalSegmentRunner {
String status="stopped".equals(reason) ? "stopped" : "interrupted".equals(reason) ? "interrupted" : accumulator.isAwaitingApproval()
? "awaiting_approval" : failure.get()!=null || "error_fallback".equals(reason) ? "error" : "completed";
MessageEntity saved=persist(convId,accumulator,status);
if(claimedRun!=null && !checkpoint(claimedRun,"resolved","message_saved",
// Persisting a partial/error message does not prove that external effects completed.
boolean normalTerminal=sourceCompleted.get() && failure.get()==null
&& !worker.cancelled.get() && !worker.interrupted.get()
&& !"stopped".equals(reason) && !"interrupted".equals(reason)
&& !"error_fallback".equals(reason)
&& (claimedRun==null || coordinator==null || !coordinator.hasUncertainToolOutcome(claimedRun));
if(claimedRun!=null && normalTerminal && !checkpoint(claimedRun,"resolved","message_saved",
saved==null ? null : saved.getId())) {
throw new IllegalStateException("Goal attempt lost its checkpoint fence");
}

View File

@ -0,0 +1,167 @@
package vip.mate.goal.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.agent.graph.executor.ToolExecutionCheckpoint;
import vip.mate.agent.graph.executor.ToolExecutionCheckpointRejectedException;
import vip.mate.goal.model.GoalEntity;
import vip.mate.tool.builtin.DelegationContext;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
/** Persists uncertainty under a live Goal fence before entering a tool callback. */
@Service
public final class GoalToolExecutionCheckpoint implements ToolExecutionCheckpoint {
public static final String UNKNOWN_OUTCOME = "tool_outcome_uncertain";
private final GoalAttemptStore attempts;
private final GoalContinuationStore continuations;
private final GoalService goals;
private final GoalRunCoordinator coordinator;
private final TransactionTemplate transactions;
private final JdbcTemplate jdbc;
private final Clock clock;
@Autowired
public GoalToolExecutionCheckpoint(
GoalAttemptStore attempts,
GoalContinuationStore continuations,
GoalService goals,
GoalRunCoordinator coordinator,
PlatformTransactionManager manager,
JdbcTemplate jdbc) {
this(attempts, continuations, goals, coordinator, manager, jdbc, Clock.systemDefaultZone());
}
GoalToolExecutionCheckpoint(
GoalAttemptStore attempts,
GoalContinuationStore continuations,
GoalService goals,
GoalRunCoordinator coordinator,
PlatformTransactionManager manager,
JdbcTemplate jdbc,
Clock clock) {
this.attempts = attempts;
this.continuations = continuations;
this.goals = goals;
this.coordinator = coordinator;
this.transactions = new TransactionTemplate(manager);
this.jdbc = jdbc;
this.clock = clock;
}
@Override
public void beforeInvocation(ChatOrigin origin, String invocationKey, String providerCallId) {
checkpoint(origin, false);
}
@Override
public void markUnknownOutcome(ChatOrigin origin, Throwable cause) {
checkpoint(origin, true);
}
private void checkpoint(ChatOrigin origin, boolean unknown) {
ExecutionAttribution attribution = origin == null ? null : origin.executionAttribution();
// Cron and approval-only attribution do not imply a Goal attempt.
if (attribution == null
|| attribution.goalId() == null && attribution.goalAttemptId() == null) return;
// Joining a caller transaction could enter an external callback before the fence commits.
if (TransactionSynchronizationManager.isActualTransactionActive()) {
throw new ToolExecutionCheckpointRejectedException(
"Goal tool checkpoint cannot join an active transaction");
}
try {
Boolean accepted =
transactions.execute(
status -> {
if (attribution.goalId() == null
|| attribution.goalAttemptId() == null
|| attribution.ownerFence() == null
|| !continuations.lockGoal(attribution.goalId()))
return false;
GoalEntity goal = goals.getById(attribution.goalId());
var attempt = attempts.getForUpdate(attribution.goalAttemptId());
var continuation = continuations.get(attribution.goalId());
if (goal == null
|| attempt == null
|| continuation == null
|| !Objects.equals(attempt.goalId(), goal.getId())
|| !Objects.equals(
attempt.conversationId(), goal.getConversationId())
|| !Objects.equals(
goal.getWorkspaceId(), origin.workspaceId())
|| !Objects.equals(
goal.getCreatedBy(), origin.requesterId())
|| !Objects.equals(
attempt.leaseToken(), attribution.ownerFence())
|| !validConversationScope(goal, origin)) return false;
if (UNKNOWN_OUTCOME.equals(attempt.checkpointType()) && !unknown)
return false;
return coordinator.checkpoint(
new GoalRunCoordinator.ClaimedRun(
continuation,
goal,
attempt,
continuation.revision()),
"uncertain",
unknown ? UNKNOWN_OUTCOME : "tool_started",
null,
LocalDateTime.now(clock));
});
if (!Boolean.TRUE.equals(accepted))
throw new ToolExecutionCheckpointRejectedException(
"Goal tool execution fence rejected");
} catch (ToolExecutionCheckpointRejectedException failure) {
throw failure;
} catch (RuntimeException failure) {
throw new ToolExecutionCheckpointRejectedException(
"Goal tool checkpoint could not be persisted", failure);
}
}
private boolean validConversationScope(GoalEntity goal, ChatOrigin origin) {
if (Objects.equals(goal.getConversationId(), origin.conversationId())) {
return Objects.equals(goal.getAgentId(), origin.agentId());
}
var frame = DelegationContext.current();
if (!frame.isDelegated()
|| !Objects.equals(frame.rootConversationId(), goal.getConversationId()))
return false;
Set<String> visited = new HashSet<>();
String conversationId = origin.conversationId();
String expectedParent = frame.parentConversationId();
for (int depth = 0; depth <= frame.depth(); depth++) {
if (conversationId == null || !visited.add(conversationId)) return false;
var rows =
jdbc.queryForList(
"SELECT agent_id,workspace_id,username,parent_conversation_id FROM"
+ " mate_conversation WHERE conversation_id=? AND deleted=0",
conversationId);
if (rows.size() != 1) return false;
var row = rows.getFirst();
Long workspaceId =
row.get("workspace_id") instanceof Number number ? number.longValue() : null;
if (!Objects.equals(workspaceId, origin.workspaceId())) return false;
Long agentId = row.get("agent_id") instanceof Number number ? number.longValue() : null;
if (depth == 0
&& (!Objects.equals(agentId, origin.agentId())
|| !Objects.equals(row.get("parent_conversation_id"), expectedParent)))
return false;
if (Objects.equals(conversationId, goal.getConversationId()))
return Objects.equals(agentId, goal.getAgentId())
&& Objects.equals(row.get("username"), goal.getCreatedBy());
conversationId = (String) row.get("parent_conversation_id");
}
return false;
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.llm;
import com.anthropic.errors.AnthropicServiceException;
import com.openai.errors.OpenAIServiceException;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClientResponseException;
import vip.mate.llm.dashscope.NativeDashScopeException;
/** HTTP details shared by model retry and error classification. */
public final class ProviderHttpError {
private ProviderHttpError() {}
public static int status(Throwable error) {
if (error instanceof OpenAIServiceException sdk) return sdk.statusCode();
if (error instanceof AnthropicServiceException sdk) return sdk.statusCode();
if (error instanceof NativeDashScopeException nativeError) return nativeError.getStatus();
if (error instanceof RestClientResponseException response)
return response.getStatusCode().value();
return 0;
}
public static HttpHeaders headers(Throwable error) {
if (error instanceof OpenAIServiceException sdk) {
HttpHeaders headers = new HttpHeaders();
sdk.headers().names().forEach(name -> headers.put(name, sdk.headers().values(name)));
return headers;
}
if (error instanceof AnthropicServiceException sdk) {
HttpHeaders headers = new HttpHeaders();
sdk.headers().names().forEach(name -> headers.put(name, sdk.headers().values(name)));
return headers;
}
if (error instanceof RestClientResponseException response)
return response.getResponseHeaders();
return null;
}
public static String body(Throwable error) {
if (error instanceof OpenAIServiceException sdk) return sdk.body().toString();
if (error instanceof AnthropicServiceException sdk) return sdk.body().toString();
if (error instanceof RestClientResponseException response)
return response.getResponseBodyAsString();
return "";
}
}

View File

@ -1,36 +1,18 @@
package vip.mate.llm.cache;
import org.springframework.ai.anthropic.api.AnthropicCacheOptions;
import org.springframework.ai.anthropic.api.AnthropicCacheStrategy;
import org.springframework.ai.anthropic.api.AnthropicCacheTtl;
import org.springframework.ai.anthropic.AnthropicCacheOptions;
import org.springframework.ai.anthropic.AnthropicCacheStrategy;
import org.springframework.ai.anthropic.AnthropicCacheTtl;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
import java.util.Map;
/**
* 把 MateClaw 的 {@link CacheProperties} 翻译成 spring-ai 1.1.4 一等支持的 {@link AnthropicCacheOptions}。
*
* <p>设计说明:spring-ai 已内置完整的 Anthropic prompt cache 框架({@code CacheEligibilityResolver} +
* {@code CacheBreakpointTracker}),它会按 {@link AnthropicCacheStrategy} 自动决策在 system / tools /
* conversation history 上挂 {@code cache_control}。我们只需配置好策略与 TTL/min-length 即可,
* 不必自己写 HTTP body 拦截器。</p>
*
* <p>映射规则(对应 RFC-014 Change 1 的 SystemAndTailCacheStrategy 默认行为):
* <ul>
* <li>{@code includeToolsBlock=true} → {@link AnthropicCacheStrategy#CONVERSATION_HISTORY}(system + tools + 最新一段对话;覆盖最完整)</li>
* <li>{@code includeToolsBlock=false} → {@link AnthropicCacheStrategy#SYSTEM_ONLY}</li>
* <li>{@code ttl=extended-1h} → SYSTEM/USER 两个消息类型映射为 {@link AnthropicCacheTtl#ONE_HOUR}</li>
* <li>{@code minPromptTokens} → 转字符长度(× 4,粗粒度估算)作为 SYSTEM 段的 min content length</li>
* </ul></p>
*
* <p>无状态、线程安全。</p>
*/
/** Maps application cache settings to the Spring AI 2 Anthropic cache contract. */
@Component
public class AnthropicCacheOptionsFactory {
/** token → 字符 的粗略乘子(英文 4 字符 ≈ 1 token;中文略低,但作为门槛足矣)。 */
private static final int CHARS_PER_TOKEN_HEURISTIC = 4;
private final CacheProperties props;
@ -39,13 +21,9 @@ public class AnthropicCacheOptionsFactory {
this.props = props;
}
/**
* 构建启用了缓存的 {@link AnthropicCacheOptions};当 {@link CacheProperties#isEnabled()} 为 false
* 时返回 {@link AnthropicCacheOptions#DISABLED}(spring-ai 内置的 NONE 策略快速路径)。
*/
public AnthropicCacheOptions build() {
if (!props.isEnabled()) {
return AnthropicCacheOptions.DISABLED;
return AnthropicCacheOptions.disabled();
}
AnthropicCacheStrategy strategy = props.isIncludeToolsBlock()

View File

@ -3,6 +3,7 @@ package vip.mate.llm.cache;
import org.springframework.ai.chat.metadata.Usage;
import java.lang.reflect.Method;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@ -13,13 +14,13 @@ import java.util.concurrent.ConcurrentMap;
* 没有 cache / reasoning 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生
* usage 对象。各 provider 的字段位置:</p>
* <ul>
* <li><b>Anthropic</b>({@code AnthropicApi.Usage}):顶层 {@code cacheReadInputTokens} /
* <li><b>Anthropic</b>(native SDK usage):顶层 {@code cacheReadInputTokens} /
* {@code cacheCreationInputTokens}。注意其 {@code inputTokens} <b>不含</b>缓存部分
* (加法口径)。无 reasoning 计数。</li>
* <li><b>OpenAI 兼容</b>({@code OpenAiApi.Usage}):嵌套 {@code promptTokensDetails.cachedTokens}
* <li><b>OpenAI 兼容</b>(native SDK usage):嵌套 {@code promptTokensDetails.cachedTokens}
* 与 {@code completionTokenDetails.reasoningTokens};{@code promptTokens} <b>已含</b>
* 缓存命中部分(包含口径)。无 cache 写入计数。</li>
* <li><b>DashScope</b>({@code DashScopeApi.TokenUsage}):嵌套
* <li><b>DashScope</b>(native transport usage):嵌套
* {@code promptTokenDetailed.cachedTokens};包含口径,无写入/reasoning 计数。</li>
* </ul>
*
@ -105,7 +106,10 @@ public final class CacheUsageExtractor {
Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor));
if (m == NULL_METHOD) return null;
try {
return m.invoke(target);
Object value = m.invoke(target);
// Native SDK details and counters are Optional rather than nullable records.
while (value instanceof Optional<?> optional) value = optional.orElse(null);
return value;
} catch (ReflectiveOperationException ignored) {
return null;
}

View File

@ -136,8 +136,8 @@ public class ChatGPTChatModel implements ChatModel {
}
@Override
public ChatOptions getDefaultOptions() {
return ChatOptions.builder()
public ChatOptions getOptions() {
return ToolCallingChatOptions.builder()
.model(modelName)
.temperature(temperature)
.build();

View File

@ -2,9 +2,7 @@ package vip.mate.llm.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.client.JdkClientHttpRequestFactory;
@ -21,7 +19,8 @@ import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Map;
import org.springframework.http.client.reactive.JdkClientHttpConnector;
/**
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_MESSAGES}.
@ -60,47 +59,23 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
AnthropicApi api = buildAnthropicApi(provider, model.getRequestTimeoutSeconds());
AnthropicChatOptions options = buildAnthropicOptions(model);
return AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
AnthropicChatOptions options = buildClientOptions(model, provider);
return AnthropicSdkChatModel.create(options,
observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP), Map.of(), false, retry);
}
AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
return buildAnthropicApi(provider, null);
}
/**
* Overload — accepts a per-model read-timeout override
* (seconds). Null falls back to the default 180s.
*/
AnthropicApi buildAnthropicApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
AnthropicChatOptions buildClientOptions(ModelConfigEntity model, ModelProviderEntity provider) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.anthropic_not_configured",
"Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
"Anthropic provider needs a valid API key and base URL");
}
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.agent.anthropic_key_invalid",
"Anthropic API Key 未配置或无效: " + provider.getProviderId());
}
String baseUrl = provider.getBaseUrl();
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
AnthropicApi.Builder builder = AnthropicApi.builder()
.apiKey(apiKey.trim())
.restClientBuilder(restClientBuilder)
.webClientBuilder(webClientBuilder);
if (StringUtils.hasText(baseUrl)) {
builder.baseUrl(baseUrl.trim());
if (!modelProviderService.hasUsableApiKey(provider.getApiKey())) {
throw new MateClawException("err.agent.anthropic_key_invalid", "Invalid Anthropic API key");
}
AnthropicChatOptions.Builder builder = buildAnthropicOptions(model).mutate()
.apiKey(provider.getApiKey().trim())
.timeout(HttpTimeouts.resolveReadTimeout(model.getRequestTimeoutSeconds())).maxRetries(0);
if (StringUtils.hasText(provider.getBaseUrl())) builder.baseUrl(provider.getBaseUrl().trim());
return builder.build();
}
@ -185,7 +160,7 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
case "max" -> 32768;
default -> 16384;
};
builder.thinking(AnthropicApi.ThinkingType.ENABLED, budgetTokens);
builder.thinkingEnabled(budgetTokens);
builder.maxTokens(Math.max(budgetTokens + 4096,
runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192));
// Claude 4.7+: omit temperature entirely. Pre-4.7 thinking mode requires
@ -224,7 +199,7 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
// Prompt cache (system / tools / conversation history) — Spring AI 1.1.4+ first-class.
builder.cacheOptions(anthropicCacheOptionsFactory.build());
return builder.internalToolExecutionEnabled(false).build();
return builder.build();
}
/**
@ -242,15 +217,15 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
/**
* Overload — accepts a per-model read-timeout override
* (seconds). Null / zero / negative falls back to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_READ_TIMEOUT}
* (seconds). Null / zero / negative falls back to {@link HttpTimeouts#DEFAULT_READ_TIMEOUT}
* so unset model configs keep the historical 180s.
*/
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
rf.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
@ -264,7 +239,7 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
* <b>not</b> caught here — the body Flux would hang indefinitely. The
* body-level gap is closed by a reactor inter-frame idle timeout applied
* at the streaming chokepoint ({@code NodeStreamingChatHelper}, driven by
* {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}).
* {@link HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}).
* This WebClient timeout still catches the "provider never sends headers"
* case (connection accepted, no response), so both layers are kept.
* <p>
@ -282,11 +257,11 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
JdkClientHttpConnector connector =
new JdkClientHttpConnector(httpClient);
connector.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
}

View File

@ -0,0 +1,239 @@
package vip.mate.llm.chatmodel;
import com.anthropic.core.Timeout;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.retry.support.RetryTemplate;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Official SDK boundary preserving live reasoning and serializable signed thinking history. */
public final class AnthropicSdkChatModel implements ChatModel {
public static final String THINKING_BLOCKS = "anthropicThinkingBlocks";
private final AnthropicChatModel delegate;
private final AnthropicSdkWireInterceptor wire;
private final RetryTemplate retry;
private AnthropicSdkChatModel(
AnthropicChatModel delegate, AnthropicSdkWireInterceptor wire, RetryTemplate retry) {
this.delegate = delegate;
this.wire = wire;
this.retry = retry;
}
public static AnthropicSdkChatModel create(
AnthropicChatOptions options,
ObservationRegistry observations,
Map<String, String> headers,
boolean systemArray) {
return create(options, observations, headers, systemArray, null);
}
public static AnthropicSdkChatModel create(
AnthropicChatOptions options,
ObservationRegistry observations,
Map<String, String> headers,
boolean systemArray,
RetryTemplate retry) {
AnthropicSdkWireInterceptor wire =
new AnthropicSdkWireInterceptor(
headers,
systemArray,
options.getTimeout() == null
? HttpTimeouts.DEFAULT_READ_TIMEOUT
: options.getTimeout());
AnthropicChatModel delegate =
AnthropicChatModel.builder()
.options(options.mutate().maxRetries(0).build())
.observationRegistry(observations)
.httpClientBuilderCustomizer(
builder ->
builder.timeout(
Timeout.builder()
.connect(
HttpTimeouts
.CONNECT_TIMEOUT)
.read(options.getTimeout())
.write(options.getTimeout())
.request(options.getTimeout())
.build())
.interceptor(wire))
.build();
return new AnthropicSdkChatModel(delegate, wire, retry);
}
@Override
public AnthropicChatOptions getOptions() {
return delegate.getOptions();
}
@Override
public ChatResponse call(Prompt prompt) {
return retry == null ? callOnce(prompt) : retry.execute(context -> callOnce(prompt));
}
private ChatResponse callOnce(Prompt prompt) {
AnthropicThinkingScope scope = new AnthropicThinkingScope(prompt);
String id = UUID.randomUUID().toString();
wire.scopes.put(id, scope);
try {
return normalize(delegate.call(prepare(prompt, id, false)), scope, false);
} finally {
scope.cancellation.close();
wire.scopes.remove(id);
}
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return Flux.defer(
() -> {
AnthropicThinkingScope scope = new AnthropicThinkingScope(prompt);
String id = UUID.randomUUID().toString();
wire.scopes.put(id, scope);
try {
return delegate.stream(prepare(prompt, id, true))
.map(response -> normalize(response, scope, true))
.doOnCancel(scope.cancellation::cancel)
.doFinally(signal -> {
scope.cancellation.close();
wire.scopes.remove(id);
});
} catch (RuntimeException | Error failure) {
scope.cancellation.cancel();
scope.cancellation.close();
wire.scopes.remove(id);
throw failure;
}
});
}
private Prompt prepare(Prompt prompt, String id, boolean stream) {
AnthropicChatOptions.Builder builder = getOptions().mutate();
if (prompt.getOptions() != null) builder.combineWith(prompt.getOptions().mutate());
if (prompt.getOptions() instanceof ToolCallingChatOptions tools
&& tools.getToolCallbacks() != null) {
builder.toolCallbacks(tools.getToolCallbacks());
}
Map<String, String> headers = new LinkedHashMap<>();
Map<String, String> existing = builder.build().getHttpHeaders();
if (existing != null) headers.putAll(existing);
headers.keySet()
.removeIf(
key ->
AnthropicSdkWireInterceptor.CORRELATION_HEADER.equalsIgnoreCase(
key));
headers.put(AnthropicSdkWireInterceptor.CORRELATION_HEADER, id);
// Read gaps remain bounded by the transport; healthy response bodies have no overall
// deadline.
builder.timeout(Duration.ZERO);
return new Prompt(
prompt.getInstructions(), builder.httpHeaders(headers).maxRetries(0).build());
}
private static ChatResponse normalize(
ChatResponse response, AnthropicThinkingScope scope, boolean stream) {
List<Generation> generations = new ArrayList<>();
List<Generation> source = response.getResults();
if (!stream && !source.isEmpty()) source = List.of(source.getLast());
for (Generation generation : source) {
AssistantMessage original = generation.getOutput();
Map<String, Object> metadata = new LinkedHashMap<>(original.getMetadata());
boolean thinkingDelta = Boolean.TRUE.equals(metadata.get("thinking"));
if (thinkingDelta) {
String delta = scope.deltas.poll();
if (delta != null) metadata.put("reasoningContent", delta);
}
boolean terminal =
!stream
|| metadata.containsKey("anthropicThinkingContents")
|| (generation.getMetadata().getFinishReason() != null
&& !generation.getMetadata().getFinishReason().isEmpty());
if (terminal) {
List<Map<String, Object>> blocks = scope.thinkingBlocks();
if (!blocks.isEmpty()) metadata.put(THINKING_BLOCKS, blocks);
if (!stream && !scope.thinkingText().isEmpty())
metadata.put("reasoningContent", scope.thinkingText());
}
AssistantMessage normalized =
AssistantMessage.builder()
.content(original.getText())
.properties(metadata)
.toolCalls(original.getToolCalls())
.media(original.getMedia())
.build();
generations.add(new Generation(normalized, generation.getMetadata()));
}
ChatResponseMetadata original = response.getMetadata();
ChatResponseMetadata metadata = original;
Usage usage = original.getUsage();
if (usage.getTotalTokens() != null && usage.getTotalTokens() > 0) {
Long cacheRead =
scope.cacheRead() == null ? usage.getCacheReadInputTokens() : scope.cacheRead();
Long cacheWrite =
scope.cacheWrite() == null
? usage.getCacheWriteInputTokens()
: scope.cacheWrite();
NativeUsage nativeUsage =
new NativeUsage(
cacheRead,
cacheWrite,
Map.of(
"input_tokens",
usage.getPromptTokens(),
"output_tokens",
usage.getCompletionTokens(),
"total_tokens",
usage.getTotalTokens()));
DefaultUsage corrected =
new DefaultUsage(
usage.getPromptTokens(),
usage.getCompletionTokens(),
usage.getTotalTokens(),
nativeUsage,
cacheRead,
cacheWrite);
metadata =
ChatResponseMetadata.builder()
.metadata(
original.entrySet().stream()
.collect(
LinkedHashMap::new,
(map, entry) ->
map.put(
entry.getKey(),
entry.getValue()),
Map::putAll))
.id(original.getId())
.model(original.getModel())
.rateLimit(original.getRateLimit())
.promptMetadata(original.getPromptMetadata())
.usage(corrected)
.build();
}
return new ChatResponse(generations, metadata);
}
/** Stable native cache accessors consumed by application usage accounting. */
public record NativeUsage(
Long cacheReadInputTokens, Long cacheCreationInputTokens, Map<String, Object> tokens) {}
}

View File

@ -0,0 +1,184 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.ForwardingSource;
import okio.Okio;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
/** Observes native SDK payloads without changing stream events or exposing internal headers. */
final class AnthropicSdkWireInterceptor implements Interceptor {
static final String CORRELATION_HEADER = "X-MateClaw-Anthropic-Request";
final ConcurrentMap<String, AnthropicThinkingScope> scopes = new ConcurrentHashMap<>();
private final Map<String, String> headers;
private final boolean systemArray;
private final Duration readTimeout;
AnthropicSdkWireInterceptor(
Map<String, String> headers, boolean systemArray, Duration readTimeout) {
this.headers = Map.copyOf(headers);
this.systemArray = systemArray;
this.readTimeout = readTimeout;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
String requestId = request.header(CORRELATION_HEADER);
AnthropicThinkingScope scope = requestId == null ? null : scopes.get(requestId);
if (requestId != null && scope == null)
throw new IOException("Provider request scope has ended");
if (scope != null) scope.cancellation.register(chain.call());
Request.Builder outgoing = request.newBuilder().removeHeader(CORRELATION_HEADER);
if (headers.keySet().stream().anyMatch(key -> "Authorization".equalsIgnoreCase(key)))
outgoing.removeHeader("x-api-key");
headers.forEach((name, value) -> {
if (!CORRELATION_HEADER.equalsIgnoreCase(name)) outgoing.header(name, value);
});
if (request.body() != null && (scope != null || systemArray)) {
Buffer buffer = new Buffer();
request.body().writeTo(buffer);
ObjectNode body =
(ObjectNode) AnthropicThinkingScope.JSON.readTree(buffer.readByteArray());
if (systemArray && scope != null && !scope.systemTexts.isEmpty()) {
JsonNode oldSystem = body.path("system");
JsonNode cacheControl = null;
if (oldSystem.isArray()) {
for (JsonNode block : oldSystem) {
if (block.hasNonNull("cache_control"))
cacheControl = block.get("cache_control");
}
}
ArrayNode system = body.putArray("system");
scope.systemTexts.forEach(
value -> system.addObject().put("type", "text").put("text", value));
if (cacheControl != null)
((ObjectNode) system.get(system.size() - 1)).set("cache_control", cacheControl);
}
if (scope != null) restoreThinking(body, scope);
outgoing.method(
request.method(),
RequestBody.create(
AnthropicThinkingScope.JSON.writeValueAsBytes(body),
request.body().contentType()));
}
Response response =
chain.withConnectTimeout(
Math.toIntExact(HttpTimeouts.CONNECT_TIMEOUT.toMillis()),
TimeUnit.MILLISECONDS)
.withReadTimeout(
Math.toIntExact(readTimeout.toMillis()), TimeUnit.MILLISECONDS)
.proceed(outgoing.build());
ResponseBody body = response.body();
if (scope == null || body == null || !response.isSuccessful()) return response;
boolean streaming =
body.contentType() != null
&& body.contentType().toString().contains("text/event-stream");
Buffer captured = new Buffer();
StringBuilder data = new StringBuilder();
BufferedSource source =
Okio.buffer(
new ForwardingSource(body.source()) {
@Override
public long read(Buffer sink, long byteCount) throws IOException {
long before = sink.size();
long read = super.read(sink, byteCount);
if (read > 0) {
sink.copyTo(captured, before, read);
if (streaming) {
while (captured.indexOf((byte) '\n') >= 0) {
String line = captured.readUtf8Line();
if (line.isEmpty()) {
if (!data.isEmpty()) {
scope.accept(
AnthropicThinkingScope.JSON.readTree(
data.toString()));
data.setLength(0);
}
} else if (line.startsWith("data:")) {
if (!data.isEmpty()) data.append('\n');
data.append(line.substring(5).stripLeading());
}
}
} else {
try {
scope.accept(
AnthropicThinkingScope.JSON.readTree(
captured.clone().readByteArray()));
} catch (IOException ignored) {
}
}
}
return read;
}
});
return response.newBuilder()
.body(
new ResponseBody() {
@Override
public MediaType contentType() {
return body.contentType();
}
@Override
public long contentLength() {
return body.contentLength();
}
@Override
public BufferedSource source() {
return source;
}
})
.build();
}
private static void restoreThinking(ObjectNode request, AnthropicThinkingScope scope) {
int assistantIndex = 0;
for (JsonNode message : request.path("messages")) {
if (!"assistant".equals(message.path("role").asText())) continue;
List<Map<String, Object>> saved =
assistantIndex < scope.history.size()
? scope.history.get(assistantIndex)
: List.of();
assistantIndex++;
if (saved.isEmpty()) continue;
JsonNode content = message.path("content");
List<JsonNode> ordinary = new ArrayList<>();
if (content.isArray()) {
content.forEach(
block -> {
String type = block.path("type").asText();
if (!"thinking".equals(type) && !"redacted_thinking".equals(type))
ordinary.add(block);
});
} else if (content.isTextual() && !content.asText().isBlank()) {
ordinary.add(
AnthropicThinkingScope.JSON.valueToTree(
Map.of("type", "text", "text", content.asText())));
}
ArrayNode restored = ((ObjectNode) message).putArray("content");
saved.forEach(block -> restored.add(AnthropicThinkingScope.JSON.valueToTree(block)));
ordinary.forEach(restored::add);
}
}
}

View File

@ -0,0 +1,133 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentLinkedQueue;
/** Subscription-local wire state for signed thinking and reasoning deltas. */
final class AnthropicThinkingScope {
static final ObjectMapper JSON = new ObjectMapper();
final ProviderCallCancellation cancellation = new ProviderCallCancellation();
final List<String> systemTexts = new ArrayList<>();
final List<List<Map<String, Object>>> history = new ArrayList<>();
final ConcurrentLinkedQueue<String> deltas = new ConcurrentLinkedQueue<>();
private final Map<Integer, Map<String, Object>> blocks = new TreeMap<>();
private Long cacheRead;
private Long cacheWrite;
AnthropicThinkingScope(Prompt prompt) {
prompt.getInstructions()
.forEach(
message -> {
if (message instanceof SystemMessage system)
systemTexts.add(system.getText());
if (message instanceof AssistantMessage assistant) {
Object value =
assistant
.getMetadata()
.get(AnthropicSdkChatModel.THINKING_BLOCKS);
List<Map<String, Object>> saved =
value == null
? List.of()
: JSON.convertValue(
value, new TypeReference<>() {});
saved.forEach(
block -> {
String type = String.valueOf(block.get("type"));
if ("thinking".equals(type)
&& block.get("thinking") instanceof String
&& block.get("signature")
instanceof String signature
&& !signature.isBlank()) return;
if ("redacted_thinking".equals(type)
&& block.get("data") instanceof String) return;
throw new IllegalArgumentException(
"Invalid signed Anthropic thinking history");
});
history.add(saved);
}
});
}
synchronized void accept(JsonNode event) {
String type = event.path("type").asText();
int index = event.path("index").asInt();
if ("message".equals(type)) {
int i = 0;
for (JsonNode block : event.path("content")) captureBlock(i++, block);
usage(event.path("usage"));
} else if ("message_start".equals(type)) {
usage(event.path("message").path("usage"));
} else if ("message_delta".equals(type)) {
usage(event.path("usage"));
} else if ("content_block_start".equals(type)) {
captureBlock(index, event.path("content_block"));
} else if ("content_block_delta".equals(type)) {
JsonNode delta = event.path("delta");
String deltaType = delta.path("type").asText();
Map<String, Object> block = blocks.get(index);
if ("thinking_delta".equals(deltaType)) {
String thinking = delta.path("thinking").asText("");
deltas.add(thinking);
if (block != null)
block.put("thinking", block.getOrDefault("thinking", "") + thinking);
} else if ("signature_delta".equals(deltaType) && block != null) {
block.put(
"signature",
block.getOrDefault("signature", "") + delta.path("signature").asText(""));
}
}
}
private void captureBlock(int index, JsonNode block) {
String type = block.path("type").asText();
if (!"thinking".equals(type) && !"redacted_thinking".equals(type)) return;
blocks.put(
index,
JSON.convertValue(block, new TypeReference<LinkedHashMap<String, Object>>() {}));
}
private void usage(JsonNode usage) {
if (usage.hasNonNull("cache_read_input_tokens"))
cacheRead = usage.get("cache_read_input_tokens").asLong();
if (usage.hasNonNull("cache_creation_input_tokens"))
cacheWrite = usage.get("cache_creation_input_tokens").asLong();
}
synchronized List<Map<String, Object>> thinkingBlocks() {
return blocks.values().stream()
.filter(
block ->
"redacted_thinking".equals(block.get("type"))
|| block.get("signature") instanceof String signature
&& !signature.isBlank())
.map(Map::copyOf)
.toList();
}
synchronized String thinkingText() {
return blocks.values().stream()
.filter(block -> "thinking".equals(block.get("type")))
.map(block -> String.valueOf(block.getOrDefault("thinking", "")))
.reduce("", String::concat);
}
synchronized Long cacheRead() {
return cacheRead;
}
synchronized Long cacheWrite() {
return cacheWrite;
}
}

View File

@ -0,0 +1,15 @@
package vip.mate.llm.chatmodel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
/** Reads defaults from current models and plugins implementing the legacy model contract. */
public final class ChatModelDefaults {
private ChatModelDefaults() {}
@SuppressWarnings("removal")
public static ChatOptions optionsFor(ChatModel model) {
ChatOptions legacyDefaults = model.getDefaultOptions();
return legacyDefaults != null ? legacyDefaults : model.getOptions();
}
}

View File

@ -3,13 +3,9 @@ package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.NoopApiKey;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@ -20,39 +16,10 @@ import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
*
* <p>Sends Anthropic Messages API requests authenticated with the user's
* Claude Code OAuth subscription token instead of an API key — letting users
* with a Claude Pro/Max plan run MateClaw against their existing entitlement.
*
* <h2>How OAuth changes the wire format</h2>
* <ol>
* <li>{@code Authorization: Bearer <oauth-token>} replaces {@code x-api-key}.
* Spring AI's {@link AnthropicApi} only sets {@code x-api-key} when the
* supplied {@code ApiKey.getValue()} returns a non-blank string, so we
* pass a {@link NoopApiKey} to satisfy the non-null assertion without
* leaking a key header.</li>
* <li>{@code anthropic-beta} must include {@code claude-code-20250219} and
* {@code oauth-2025-04-20} or Anthropic's edge intermittently 500s.
* We push these via {@link AnthropicApi.Builder#anthropicBetaFeatures}
* so Spring AI's existing header-merging logic still applies.</li>
* <li>{@code User-Agent: claude-cli/<ver>} (bare — no suffix) and
* {@code x-app: cli} masquerade as the Claude Code CLI. Suffix variants
* like {@code (external, cli)} are anti-abuse fingerprints; see
* {@link ClaudeCodeApiHeaders#userAgent()}.</li>
* </ol>
*
* <h2>Token lifecycle</h2>
* <p>Each {@link #build} call asks {@link ClaudeCodeOAuthService} for a valid
* access token. The service auto-refreshes when within 60s of expiry and
* persists the fresh credential back to whichever source (Keychain / JSON
* file) it originally read from. The constructed {@link AnthropicApi} pins
* the token at build time — for a multi-hour session this is fine because
* tokens last hours and Spring AI's call-site retry covers the rare case
* where a token rolls mid-call (next request rebuilds with a fresh token).
*/
import java.util.LinkedHashMap;
import java.util.Map;
/** Constructs the official SDK transport with Claude Code OAuth identity headers. */
@Slf4j
@Component
public class ClaudeCodeChatModelBuilder implements ChatModelBuilder {
@ -89,95 +56,24 @@ public class ClaudeCodeChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
// 1) Pull a fresh access token (auto-refreshes when near expiry; throws
// err.anthropic.no_claude_code or err.anthropic.token_expired_no_refresh
// so the UI / global handler can present an actionable message).
String accessToken = oauthService.getValidToken();
// 2) Build the Anthropic API client wired with OAuth headers.
AnthropicApi api = buildOauthAnthropicApi(accessToken, model.getRequestTimeoutSeconds());
// 3) Reuse the canonical Anthropic options builder — same Claude 4.7
// sampling-params handling, thinking-budget mapping, prompt cache.
AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model);
AnthropicChatModel raw = AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
// 4) Wrap with the OAuth identity decorator. Anthropic's edge rate-limits /
// 5xxs requests that don't claim Claude Code identity in the system
// prompt — symptom: 429 rate_limit_error with body "Error" on quiet
// accounts. See ClaudeCodeIdentityChatModelDecorator javadoc.
AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model).mutate()
.apiKey("oauth-transport-placeholder")
.timeout(HttpTimeouts.resolveReadTimeout(model.getRequestTimeoutSeconds())).maxRetries(0).build();
ChatModel raw = AnthropicSdkChatModel.create(options,
observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP),
buildOauthHeaders(accessToken), true, retry);
return new ClaudeCodeIdentityChatModelDecorator(raw);
}
/**
* Construct an {@link AnthropicApi} whose underlying RestClient + WebClient
* are pre-stamped with OAuth-mode headers. Package-private so unit tests
* can verify header composition without spinning up a chat model.
*/
AnthropicApi buildOauthAnthropicApi(String accessToken) {
return buildOauthAnthropicApi(accessToken, null);
}
/**
* Overload — same OAuth-stamped Anthropic client, with a
* per-model read-timeout override threaded through to the underlying
* RestClient + WebClient timeouts.
*/
AnthropicApi buildOauthAnthropicApi(String accessToken, Integer readTimeoutOverride) {
String authHeader = apiHeaders.bearerAuth(accessToken);
String userAgent = apiHeaders.userAgent();
String xApp = apiHeaders.xApp();
String betas = apiHeaders.allBetas();
// Real Claude Code is an Electron + Node app that uses the official
// Anthropic JS SDK. The SDK auto-sets `accept: application/json` and
// `anthropic-dangerous-direct-browser-access: true` on every request.
// Spring AI's Java client doesn't, so Anthropic's edge fingerprint
// sees the missing headers and treats the traffic as suspicious —
// rate-limited harder than spec'd.
RestClient.Builder restClientBuilder = AnthropicChatModelBuilder.applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
.defaultHeader("x-app", xApp)
// Rewrite system string → array before the request hits the wire.
// Anthropic's OAuth anti-abuse gate requires system to be an array;
// see ClaudeCodeSystemArrayInterceptor for the full explanation.
.requestInterceptor(new ClaudeCodeSystemArrayInterceptor(objectMapper))
// Diagnostic: log Anthropic's rate-limit headers on 429 so we
// can tell apart "5h Pro quota exhausted" (tokens-remaining=0,
// retry-after huge) from "anti-abuse gate" (tokens-remaining
// large, retry-after small) from "burst limit hit" without
// staring at SDK internals.
.requestInterceptor(new RateLimitDiagnosticInterceptor());
WebClient.Builder webClientBuilder = AnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
.defaultHeader("x-app", xApp)
// Rewrite system string → array (streaming path counterpart).
.filter(new ClaudeCodeSystemArrayExchangeFilter(objectMapper))
.filter(new RateLimitDiagnosticExchangeFilter());
// NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing
// skips x-api-key. The Builder.build() Assert.notNull on apiKey still
// passes because the object is non-null.
return AnthropicApi.builder()
.apiKey(new NoopApiKey())
.anthropicBetaFeatures(betas)
.restClientBuilder(restClientBuilder)
.webClientBuilder(webClientBuilder)
.build();
Map<String, String> buildOauthHeaders(String accessToken) {
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Authorization", apiHeaders.bearerAuth(accessToken));
headers.put("User-Agent", apiHeaders.userAgent());
headers.put("Accept", "application/json");
headers.put("anthropic-beta", apiHeaders.allBetas());
headers.put("anthropic-dangerous-direct-browser-access", "true");
headers.put("x-app", apiHeaders.xApp());
return Map.copyOf(headers);
}
}

View File

@ -19,9 +19,7 @@ import org.springframework.ai.tool.metadata.ToolMetadata;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Claude Code OAuth identity transform applied to every Anthropic request
@ -86,8 +84,8 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
}
@Override
public ChatOptions getDefaultOptions() {
return delegate.getDefaultOptions();
public ChatOptions getOptions() {
return delegate.getOptions();
}
/* ====================================================================== */
@ -147,34 +145,12 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
return options;
}
List<ToolCallback> originalCallbacks = anthropicOpts.getToolCallbacks();
Set<String> originalToolNames = anthropicOpts.getToolNames();
boolean hasCallbacks = originalCallbacks != null && !originalCallbacks.isEmpty();
boolean hasToolNames = originalToolNames != null && !originalToolNames.isEmpty();
if (!hasCallbacks && !hasToolNames) {
return options;
if (originalCallbacks == null || originalCallbacks.isEmpty()) return options;
List<ToolCallback> wrapped = new ArrayList<>(originalCallbacks.size());
for (ToolCallback callback : originalCallbacks) {
wrapped.add(callback instanceof PrefixedToolCallback ? callback : new PrefixedToolCallback(callback));
}
AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(anthropicOpts);
if (hasCallbacks) {
List<ToolCallback> wrapped = new ArrayList<>(originalCallbacks.size());
for (ToolCallback cb : originalCallbacks) {
wrapped.add(cb instanceof PrefixedToolCallback ? cb : new PrefixedToolCallback(cb));
}
copy.setToolCallbacks(wrapped);
}
if (hasToolNames) {
// toolNames is a set used by Spring AI's tool resolver to filter from
// a wider registry. If MateClaw populates it (most paths use callbacks
// directly so this is rare), prefix the names so they line up with
// the wrapped callbacks above.
Set<String> prefixed = new LinkedHashSet<>(originalToolNames.size());
for (String n : originalToolNames) {
prefixed.add(n.startsWith(MCP_TOOL_PREFIX) ? n : MCP_TOOL_PREFIX + n);
}
copy.setToolNames(prefixed);
}
return copy;
return anthropicOpts.mutate().toolCallbacks(wrapped).build();
}
/* ====================================================================== */

View File

@ -1,263 +1,110 @@
package vip.mate.llm.chatmodel;
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.dashscope.NativeDashScopeChatModel;
import vip.mate.llm.dashscope.NativeDashScopeChatOptions;
import vip.mate.llm.dashscope.NativeDashScopeConnectionProperties;
import vip.mate.llm.dashscope.NativeDashScopeTransport;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.lang.reflect.Field;
import java.net.http.HttpClient;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Strategy implementation for {@link ModelProtocol#DASHSCOPE_NATIVE}.
*
* <p>Owns all DashScope-specific construction logic (api + options) plus the
* fallback-chain helpers for resolving API key / Base URL when the provider
* row is incomplete, so the agent package no longer carries any DashScope
* schema knowledge.</p>
*
* <p>DashScopeChatModel is injected via ObjectProvider so that the builder
* degrades gracefully when DashScope auto-configuration is disabled or the
* dependency is absent, rather than failing the entire application context.</p>
*/
@Slf4j
/** Builds native models directly from persisted configuration and environment defaults. */
@Component
@EnableConfigurationProperties(NativeDashScopeConnectionProperties.class)
public class DashScopeChatModelBuilder implements ChatModelBuilder {
private static final List<String> SEARCH_MODELS = List.of("qwen-plus", "qwen-max", "qwen-turbo",
"qwen3-max", "qwen3.5-flash", "qwen3.6-flash");
private static final Set<String> TRANSPORT_OPTIONS = Set.of("request_timeout_seconds", "timeout", "read_timeout");
private final NativeDashScopeConnectionProperties properties;
private final ModelProviderService providerService;
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
private final DashScopeConnectionProperties dashScopeConnectionProperties;
private final ModelProviderService modelProviderService;
public DashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
DashScopeConnectionProperties dashScopeConnectionProperties,
ModelProviderService modelProviderService) {
this.dashScopeChatModelProvider = dashScopeChatModelProvider;
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
this.modelProviderService = modelProviderService;
public DashScopeChatModelBuilder(NativeDashScopeConnectionProperties properties, ModelProviderService providerService) {
this.properties = properties;
this.providerService = providerService;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.DASHSCOPE_NATIVE;
}
@Override public ModelProtocol supportedProtocol() { return ModelProtocol.DASHSCOPE_NATIVE; }
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
if (defaultModel == null) {
throw new MateClawException("err.agent.dashscope_unavailable",
"DashScope 自动配置未激活(可能缺少依赖或被排除),无法构建 DashScope 模型");
}
DashScopeApi api = buildDashScopeApi(provider);
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
return defaultModel.mutate()
.dashScopeApi(api)
.defaultOptions(options)
.build();
}
/**
* Bailian's built-in web search is only accepted by a subset of models;
* sending {@code enable_search} to a model that doesn't support it returns
* 400 InvalidParameter and the failover layer then evicts the entire
* provider as MODEL_NOT_FOUND. Per the public docs, only Qwen-Plus,
* Qwen-Max, Qwen-Turbo and the Qwen3-Max series accept the parameter.
*
* <p>Resolution order:</p>
* <ol>
* <li>Explicit {@code enableSearch} in the model row (per-model toggle)</li>
* <li>Explicit {@code enableSearch} in provider kwargs (admin-level toggle)</li>
* <li>Default: enabled only when the model name matches a known-supporting
* prefix; disabled for everything else (coder / thinking / DeepSeek /
* Long / Vision)</li>
* </ol>
*
* <p>Public so {@code AgentGraphBuilder.build()} can surface the
* "built-in search active" log once per agent.</p>
*/
public boolean isBuiltinSearchEnabled(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
if (runtimeModel != null && runtimeModel.getEnableSearch() != null) {
return Boolean.TRUE.equals(runtimeModel.getEnableSearch());
}
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
Object kwargsSearch = ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch");
if (kwargsSearch != null) {
return Boolean.TRUE.equals(kwargsSearch);
}
return modelSupportsBuiltinSearch(runtimeModel);
}
/**
* Model id prefixes that the Bailian text-generation endpoint documents as
* accepting {@code enable_search}. Anything else (qwen-coder-*, qwen-long,
* qwen3-*-thinking-*, deepseek-*, qwen*-vl-*) returns 400 InvalidParameter
* when the parameter is sent.
*/
private static final java.util.List<String> BUILTIN_SEARCH_SUPPORTED_PREFIXES = java.util.List.of(
"qwen-plus",
"qwen-max",
"qwen-turbo",
"qwen3-max",
"qwen3.5-flash",
"qwen3.6-flash"
);
private static boolean modelSupportsBuiltinSearch(ModelConfigEntity model) {
if (model == null) return false;
String name = model.getModelName();
if (!StringUtils.hasText(name)) return false;
String lower = name.trim().toLowerCase();
for (String prefix : BUILTIN_SEARCH_SUPPORTED_PREFIXES) {
if (lower.startsWith(prefix)) return true;
}
return false;
}
DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
if (StringUtils.hasText(runtimeModel.getModelName())) {
builder.withModel(runtimeModel.getModelName());
}
if (runtimeModel.getTemperature() != null) {
builder.withTemperature(runtimeModel.getTemperature());
}
if (runtimeModel.getMaxTokens() != null) {
builder.withMaxToken(runtimeModel.getMaxTokens());
}
if (runtimeModel.getTopP() != null) {
builder.withTopP(runtimeModel.getTopP());
}
if (isBuiltinSearchEnabled(runtimeModel, provider)) {
builder.withEnableSearch(true);
String strategy = runtimeModel.getSearchStrategy();
if (!StringUtils.hasText(strategy)) {
strategy = (String) ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy");
}
if (StringUtils.hasText(strategy)) {
builder.withSearchOptions(DashScopeApiSpec.SearchOptions.builder()
.searchStrategy(strategy)
.enableSource(true)
.enableCitation(true)
.build());
}
}
return builder.build();
}
DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
DashScopeApi.Builder builder = DashScopeApi.builder();
// API Key fallback chain: provider UI config → env / application.yml → default bean reflection
String apiKey = provider != null ? provider.getApiKey() : null;
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
apiKey = dashScopeConnectionProperties.getApiKey();
}
if (!StringUtils.hasText(apiKey) || !modelProviderService.hasUsableApiKey(apiKey)) {
apiKey = readApiKeyFromDefaultChatModel();
}
if (!modelProviderService.hasUsableApiKey(apiKey)) {
if (!providerService.hasUsableApiKey(apiKey)) apiKey = properties.getApiKey();
if (!providerService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.agent.dashscope_key_missing",
"DashScope API Key 未配置,请在「设置 → 模型 → 添加供应商」中为 dashscope 填写 API Key");
"DashScope API Key 未配置,请在模型设置中填写 API Key");
}
builder.apiKey(apiKey.trim());
String baseUrl = provider != null && StringUtils.hasText(provider.getBaseUrl())
? provider.getBaseUrl() : properties.getBaseUrl();
Map<String, Object> kwargs = providerService.readProviderGenerateKwargs(provider);
Integer timeout = model.getRequestTimeoutSeconds() != null && model.getRequestTimeoutSeconds() > 0
? model.getRequestTimeoutSeconds() : ProviderGenerateKwargs.resolveIntegerOption("requestTimeoutSeconds", null, kwargs);
HttpClient http = HttpClient.newBuilder().connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1).build();
return new NativeDashScopeChatModel(new NativeDashScopeTransport(apiKey, baseUrl, http,
HttpTimeouts.resolveReadTimeout(timeout)), buildDashScopeOptions(model, provider));
}
// Base URL fallback chain — same priority as API Key
String baseUrl = provider != null ? provider.getBaseUrl() : null;
if (!StringUtils.hasText(baseUrl)) {
baseUrl = dashScopeConnectionProperties.getBaseUrl();
public boolean isBuiltinSearchEnabled(ModelConfigEntity model, ModelProviderEntity provider) {
if (model != null && model.getEnableSearch() != null) return model.getEnableSearch();
Object explicit = ProviderGenerateKwargs.findOptionValue(providerService.readProviderGenerateKwargs(provider), "enableSearch");
if (explicit != null) return Boolean.TRUE.equals(explicit);
String name = model != null && StringUtils.hasText(model.getModelName())
? model.getModelName() : properties.getChat().getOptions().getModel();
if (!StringUtils.hasText(name)) return false;
String normalized = name.trim().toLowerCase(Locale.ROOT);
return SEARCH_MODELS.stream().anyMatch(normalized::startsWith);
}
NativeDashScopeChatOptions buildDashScopeOptions(ModelConfigEntity model, ModelProviderEntity provider) {
Map<String, Object> kwargs = providerService.readProviderGenerateKwargs(provider);
var defaults = properties.getChat().getOptions();
NativeDashScopeChatOptions.Builder builder = NativeDashScopeChatOptions.builder()
.model(StringUtils.hasText(model.getModelName()) ? model.getModelName() : defaults.getModel())
.temperature(model.getTemperature() != null ? model.getTemperature()
: ProviderGenerateKwargs.resolveDoubleOption("temperature", defaults.getTemperature(), kwargs))
.maxTokens(model.getMaxTokens() != null ? model.getMaxTokens()
: ProviderGenerateKwargs.resolveIntegerOption("maxTokens", defaults.getMaxTokens(), kwargs))
.topP(model.getTopP() != null ? model.getTopP()
: ProviderGenerateKwargs.resolveDoubleOption("topP", defaults.getTopP(), kwargs))
.topK(ProviderGenerateKwargs.resolveIntegerOption("topK", null, kwargs));
ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs).forEach((key, value) -> {
String nativeKey = key.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
if (!TRANSPORT_OPTIONS.contains(nativeKey)) builder.nativeParameter(nativeKey, value);
});
// Nested options use the same spelling and precedence as top-level provider settings.
for (String key : List.of("enableThinking", "thinkingBudget", "repetitionPenalty", "seed", "stop", "responseFormat")) {
Object value = ProviderGenerateKwargs.findOptionValue(kwargs, key);
if (value != null) builder.nativeParameter(key.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT), value);
}
if (!StringUtils.hasText(baseUrl)) {
baseUrl = readBaseUrlFromDefaultChatModel();
}
String normalizedBaseUrl = normalizeDashScopeBaseUrl(baseUrl);
if (StringUtils.hasText(normalizedBaseUrl)) {
builder.baseUrl(normalizedBaseUrl);
boolean search = isBuiltinSearchEnabled(model, provider);
builder.enableSearch(search);
if (search) {
Object configured = ProviderGenerateKwargs.findOptionValue(kwargs, "searchOptions");
Map<String, Object> searchOptions = new LinkedHashMap<>();
if (configured instanceof Map<?, ?> map) map.forEach((key, value) -> searchOptions.put(String.valueOf(key), value));
String strategy = StringUtils.hasText(model.getSearchStrategy()) ? model.getSearchStrategy()
: String.valueOf(ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy"));
if (StringUtils.hasText(strategy) && !"null".equals(strategy)) {
searchOptions.put("search_strategy", strategy);
searchOptions.put("enable_source", true);
searchOptions.put("enable_citation", true);
}
if (!searchOptions.isEmpty()) builder.searchOptions(searchOptions);
}
return builder.build();
}
/**
* Strip the OpenAI compatible-mode path off any user-supplied URL
* (common when migrating from compat-mode), trim trailing slash, and
* return null when the result is the SDK default — letting Spring AI's
* built-in default win avoids path-concat surprises.
*/
private String normalizeDashScopeBaseUrl(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
return null;
}
String normalized = baseUrl.trim();
int compatibleIndex = normalized.indexOf("/compatible-mode/");
if (compatibleIndex >= 0) {
normalized = normalized.substring(0, compatibleIndex);
}
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if ("https://dashscope.aliyuncs.com".equals(normalized)) {
return null;
}
return normalized;
}
// ============================================================
// Reflection helpers — read whatever the auto-configured default
// DashScopeChatModel was built with, as a final fallback when no
// explicit credentials reach us.
// ============================================================
private String readApiKeyFromDefaultChatModel() {
try {
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
if (api == null) return null;
Field apiKeyField = DashScopeApi.class.getDeclaredField("apiKey");
apiKeyField.setAccessible(true);
Object apiKey = apiKeyField.get(api);
if (apiKey instanceof org.springframework.ai.model.ApiKey key) {
return key.getValue();
}
} catch (Exception e) {
log.warn("Failed to read API key from default DashScopeChatModel: {}", e.getMessage());
}
return null;
}
private String readBaseUrlFromDefaultChatModel() {
try {
DashScopeApi api = readDashScopeApiFromDefaultChatModel();
if (api == null) return null;
Field baseUrlField = DashScopeApi.class.getDeclaredField("baseUrl");
baseUrlField.setAccessible(true);
Object baseUrl = baseUrlField.get(api);
return baseUrl instanceof String value ? value : null;
} catch (Exception e) {
log.warn("Failed to read baseUrl from default DashScopeChatModel: {}", e.getMessage());
return null;
}
}
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
if (defaultModel == null) {
return null;
}
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
apiField.setAccessible(true);
Object api = apiField.get(defaultModel);
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
}
}

View File

@ -80,8 +80,8 @@ public class DeepSeekV4ThinkingDecorator implements ChatModel {
}
@Override
public ChatOptions getDefaultOptions() {
return delegate.getDefaultOptions();
public ChatOptions getOptions() {
return delegate.getOptions();
}
/* ---------------------------------------------------------------- */
@ -123,30 +123,26 @@ public class DeepSeekV4ThinkingDecorator implements ChatModel {
/**
* Clone {@link OpenAiChatOptions} and inject extraBody.thinking + reasoning_effort.
* Returns the input unchanged for non-OpenAI options (defensive — should
* never happen for V4, but skips ahead-of-binding work in tests that pass
* vanilla {@link ChatOptions}).
* Merge generic runtime values into the provider's model defaults first.
*/
private static ChatOptions patchOptions(ChatOptions original, boolean enabled) {
if (!(original instanceof OpenAiChatOptions oai)) {
return original;
}
OpenAiChatOptions copy = OpenAiChatOptions.fromOptions(oai);
private ChatOptions patchOptions(ChatOptions original, boolean enabled) {
OpenAiChatOptions.Builder builder = OpenAiWirePolicyChatModel.mergeOptions(delegate.getOptions(), original).mutate();
OpenAiChatOptions copy = builder.build();
Map<String, Object> extra = copy.getExtraBody();
Map<String, Object> patched = (extra == null) ? new LinkedHashMap<>() : new LinkedHashMap<>(extra);
if (enabled) {
patched.put(THINKING_FIELD, Map.of("type", "enabled"));
// reasoning_effort is a first-class OpenAiChatOptions field → set via setter.
// Keep effort on the cloned request options.
String level = ThinkingLevelHolder.get();
copy.setReasoningEffort(mapEffort(level));
builder.reasoningEffort(mapEffort(level));
} else {
patched.put(THINKING_FIELD, Map.of("type", "disabled"));
// Drop reasoning_effort — DeepSeek 400s if both are present with thinking disabled.
copy.setReasoningEffort(null);
builder.reasoningEffort(null);
}
copy.setExtraBody(patched);
return copy;
builder.extraBody(patched);
return builder.build();
}
/**
@ -176,7 +172,7 @@ public class DeepSeekV4ThinkingDecorator implements ChatModel {
private static AssistantMessage rewriteAssistant(AssistantMessage am, boolean enabled) {
Map<String, Object> meta = am.getMetadata();
boolean hasTools = am.hasToolCalls();
boolean hasReasoning = meta != null && meta.containsKey(REASONING_CONTENT_KEY);
boolean hasReasoning = meta != null && (meta.containsKey(REASONING_CONTENT_KEY) || meta.containsKey("reasoningContent"));
// Fast path: no rewrite needed.
if (enabled && (!hasTools || hasReasoning)) {
@ -189,10 +185,12 @@ public class DeepSeekV4ThinkingDecorator implements ChatModel {
Map<String, Object> newMeta = (meta == null) ? new HashMap<>() : new HashMap<>(meta);
if (enabled) {
// Tool-call messages need reasoning_content present (empty OK) for replay.
newMeta.putIfAbsent(REASONING_CONTENT_KEY, "");
newMeta.putIfAbsent("reasoningContent", newMeta.getOrDefault(REASONING_CONTENT_KEY, ""));
newMeta.putIfAbsent(REASONING_CONTENT_KEY, newMeta.get("reasoningContent"));
} else {
// Drop reasoning_content entirely — DeepSeek mirrors back stale thinking otherwise.
newMeta.remove(REASONING_CONTENT_KEY);
newMeta.remove("reasoningContent");
}
return AssistantMessage.builder()
.content(am.getText())

View File

@ -3,16 +3,12 @@ package vip.mate.llm.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.NoopApiKey;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.http.okhttp.SpringAiOpenAiHttpClient;
import com.openai.client.OpenAIClientImpl;
import com.openai.core.ClientOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
@ -20,8 +16,6 @@ import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
@ -29,33 +23,18 @@ import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.net.http.HttpClient;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Strategy implementation of {@link ChatModelBuilder} for
* {@link ModelProtocol#OPENAI_COMPATIBLE}.
*
* <p>Owns the full OpenAI-compatible construction path: the {@link OpenAiApi}
* client (HTTP timeouts, header overrides, completions-path resolution), the
* {@link OpenAiChatOptions} (temperature / max tokens / reasoning effort / web
* search), and the outbound request-rewrite pipeline delegated to
* {@link OpenAiRequestRewriter}. DeepSeek V4 reasoning models are wrapped with
* {@link DeepSeekV4ThinkingDecorator}.
*
* <p>Depends only on infrastructure beans, so the {@code llm} package builds a
* {@link ChatModel} without any dependency on the agent graph layer.
*/
/** Builds an immutable Spring AI model with final HTTP request policies for compatible providers. */
@Slf4j
@Component
public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private final ModelProviderService modelProviderService;
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public OpenAiCompatibleChatModelBuilder(
@ -64,8 +43,6 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.modelProviderService = modelProviderService;
this.restClientBuilderProvider = restClientBuilderProvider;
this.webClientBuilderProvider = webClientBuilderProvider;
this.observationRegistryProvider = observationRegistryProvider;
}
@ -76,26 +53,49 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
// Pass model.requestTimeoutSeconds so providers / models with
// extended-thinking p99s don't false-positive on the default read timeout.
OpenAiApi api = buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
OpenAiChatOptions options = buildOpenAiOptions(model, provider);
ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
// those directly — wrap with a per-request payload patcher.
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
validateProvider(provider);
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
OpenAiChatOptions.Builder configured = buildOpenAiOptions(model, provider).mutate();
configured.baseUrl(baseUrl).apiKey(StringUtils.hasText(provider.getApiKey()) && modelProviderService.hasUsableApiKey(provider.getApiKey())
? provider.getApiKey().trim() : "");
configured.maxRetries(0).timeout(HttpTimeouts.resolveReadTimeout(model.getRequestTimeoutSeconds()));
OpenAiChatOptions options = configured.build();
OpenAiCompatibleHttpInterceptor interceptor = new OpenAiCompatibleHttpInterceptor(provider,
baseUrl + resolveOpenAiCompletionsPath(baseUrl, kwargs),
buildOpenAiHeaders(kwargs).toSingleValueMap(),
isKimiProvider(provider) && Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch")),
HttpTimeouts.resolveReadTimeout(model.getRequestTimeoutSeconds()));
ObservationRegistry observations = observationRegistryProvider == null ? ObservationRegistry.NOOP
: observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP);
SpringAiOpenAiHttpClient.Builder observed = SpringAiOpenAiHttpClient.builder()
.observationRegistry(observations).timeout(options.getTimeout()).proxy(options.getProxy());
boolean authenticated = StringUtils.hasText(options.getApiKey());
if (!authenticated) observed.interceptor(chain -> chain.proceed(
chain.request().newBuilder().removeHeader("Authorization").build()));
observed.interceptor(interceptor);
PinnedOpenAiHttpClient transport = new PinnedOpenAiHttpClient(observed.build());
ClientOptions clientOptions = ClientOptions.builder().httpClient(transport).baseUrl(baseUrl)
.apiKey(authenticated ? options.getApiKey() : "mateclaw-keyless-placeholder")
.timeout(options.getTimeout()).maxRetries(0).build();
OpenAIClientImpl sdk = new OpenAIClientImpl(clientOptions);
ChatModel raw = OpenAiChatModel.builder().options(options).openAiClient(sdk).openAiClientAsync(sdk.async())
.observationRegistry(observations).build();
raw = new OpenAiWirePolicyChatModel(raw, retry, transport);
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING)
return new DeepSeekV4ThinkingDecorator(raw);
}
return VllmThinkingDecorator.supports(provider, options) ? new VllmThinkingDecorator(raw) : raw;
}
private void validateProvider(ModelProviderEntity provider) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId()))
throw new MateClawException("err.agent.provider_not_configured", "Provider is not configured");
if (!Boolean.FALSE.equals(provider.getRequireApiKey()) && !modelProviderService.hasUsableApiKey(provider.getApiKey()))
throw new MateClawException("err.agent.provider_apikey_invalid", "Provider API Key is invalid");
if (!StringUtils.hasText(normalizeOpenAiBaseUrl(provider.getBaseUrl())))
throw new MateClawException("err.agent.provider_baseurl_missing", "Provider Base URL is missing");
}
// ==================== chat options ====================
OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
@ -162,37 +162,16 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
if (!StringUtils.hasText(strategy)) {
strategy = (String) ProviderGenerateKwargs.findOptionValue(kwargs, "searchStrategy");
}
OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize contextSize;
try {
contextSize = StringUtils.hasText(strategy)
? OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.valueOf(strategy.toUpperCase())
: OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.MEDIUM;
} catch (IllegalArgumentException e) {
contextSize = OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.MEDIUM;
}
builder.webSearchOptions(new OpenAiApi.ChatCompletionRequest.WebSearchOptions(contextSize, null));
String contextSize = StringUtils.hasText(strategy) ? strategy.toLowerCase(Locale.ROOT) : "medium";
if (!Set.of("low", "medium", "high").contains(contextSize)) contextSize = "medium";
builder.extraBody(Map.of("web_search_options", Map.of("search_context_size", contextSize)));
}
builder.streamUsage(true);
Map<String, Object> extraBody = new LinkedHashMap<>();
if (builder.build().getExtraBody() != null) extraBody.putAll(builder.build().getExtraBody());
extraBody.putAll(ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs));
builder.extraBody(extraBody);
OpenAiChatOptions options = builder.build();
options.setInternalToolExecutionEnabled(false);
// Do not set parallelToolCalls — setting it to false makes OpenAI return 400 when
// there are no tools: "parallel_tool_calls is only allowed when 'tools' are specified".
// Leaving it null keeps Spring AI from serializing the field; each node controls it
// when tools are present.
options.setStreamUsage(true);
// Forward unrecognized top-level generateKwargs keys as-is via extraBody (e.g. vLLM's
// chat_template_kwargs). Get-then-merge rather than overwrite, in case a future addition
// to buildOpenAiOptions ever sets extraBody above this point.
Map<String, Object> passthroughExtraBody = ProviderGenerateKwargs.collectPassthroughExtraBody(kwargs);
if (!passthroughExtraBody.isEmpty()) {
Map<String, Object> existingExtraBody = options.getExtraBody();
Map<String, Object> mergedExtraBody = (existingExtraBody == null)
? new LinkedHashMap<>()
: new LinkedHashMap<>(existingExtraBody);
mergedExtraBody.putAll(passthroughExtraBody);
options.setExtraBody(mergedExtraBody);
}
return options;
}
@ -217,125 +196,6 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
return ProviderGenerateKwargs.resolveDoubleOption("topP", configuredTopP, kwargs);
}
// ==================== OpenAI API client ====================
/**
* Build an {@link OpenAiApi} for the provider. Accepts a per-model
* read-timeout override (seconds), threaded into both the sync RestClient and
* the streaming WebClient. Null falls back to the default 180s.
*/
OpenAiApi buildOpenAiApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.provider_not_configured",
"Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
String apiKey = provider.getApiKey();
// Honor the provider's requireApiKey flag instead of hard-failing on every empty key.
// Local + key-free providers (Ollama, LM Studio, MLX, llama.cpp, OpenCode) declare
// requireApiKey=false; for them an empty / placeholder key means "no Authorization
// header" — Spring AI's NoopApiKey expresses that.
boolean keyRequired = !Boolean.FALSE.equals(provider.getRequireApiKey());
if (keyRequired && !modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.agent.provider_apikey_invalid",
"Provider API Key 未配置或无效: " + provider.getProviderId());
}
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
if (!StringUtils.hasText(baseUrl)) {
throw new MateClawException("err.agent.provider_baseurl_missing",
"Provider Base URL 未配置: " + provider.getProviderId());
}
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
MultiValueMap<String, String> headers = buildOpenAiHeaders(kwargs);
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
restClientBuilder.requestInterceptor(OpenAiReasoningResponseNormalizer.blockingInterceptor());
webClientBuilder.filter(OpenAiReasoningResponseNormalizer.streamingFilter());
// Spring AI's OpenAiApi constructor sets User-Agent to "spring-ai" first, then addAll's
// our headers, so a custom User-Agent is appended rather than replaced. For providers
// that must masquerade as a specific client (e.g. kimi-code), force-override headers
// via a RestClient/WebClient interceptor before the request goes out.
Map<String, String> overrideHeaders = extractOverrideHeaders(kwargs);
if (!overrideHeaders.isEmpty()) {
restClientBuilder = restClientBuilder.requestInterceptor((request, body, execution) -> {
HttpHeaders reqHeaders = request.getHeaders();
overrideHeaders.forEach(reqHeaders::set);
return execution.execute(request, body);
});
webClientBuilder = webClientBuilder.filter((request, next) -> {
org.springframework.web.reactive.function.client.ClientRequest modified =
org.springframework.web.reactive.function.client.ClientRequest.from(request)
.headers(h -> overrideHeaders.forEach(h::set))
.build();
return next.exchange(modified);
});
}
boolean kimiSearchEnabled = isKimiProvider(provider)
&& Boolean.TRUE.equals(ProviderGenerateKwargs.findOptionValue(kwargs, "enableSearch"));
ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey))
? new SimpleApiKey(apiKey.trim())
: new NoopApiKey();
return new OpenAiApi(
baseUrl,
apiKeyImpl,
headers,
completionsPath,
"/v1/embeddings",
restClientBuilder,
webClientBuilder,
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) {
@Override
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = OpenAiRequestRewriter.stripAutoToolChoice(chatRequest);
chatRequest = OpenAiRequestRewriter.patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = OpenAiRequestRewriter.injectKimiWebSearch(chatRequest);
}
logOpenAiRequest(provider, chatRequest);
try {
return super.chatCompletionEntity(chatRequest, additionalHttpHeader);
} catch (WebClientResponseException e) {
logOpenAiError(provider, e);
throw e;
}
}
@Override
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = OpenAiRequestRewriter.stripAutoToolChoice(chatRequest);
chatRequest = OpenAiRequestRewriter.patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = OpenAiRequestRewriter.injectKimiWebSearch(chatRequest);
}
logOpenAiRequest(provider, chatRequest);
return super.chatCompletionStream(chatRequest, additionalHttpHeader)
.doOnError(error -> {
if (error instanceof WebClientResponseException e) {
logOpenAiError(provider, e);
}
});
}
};
}
/**
* Whether the provider is one of Kimi's first-party providers. Public so the
* agent graph builder can surface a "built-in search active" log line.
@ -365,13 +225,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private MultiValueMap<String, String> buildOpenAiHeaders(Map<String, Object> kwargs) {
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("User-Agent", "MateClaw/1.0");
// Force a fresh TCP connection per request (no keep-alive pooling). Self-hosted
// OpenAI-compatible gateways frequently idle-close connections faster than the
// JDK HttpClient evicts them from its pool, so a reused-but-dead socket gets reset
// by the peer before any response byte arrives — "header parser received no bytes".
// This mirrors curl's one-connection-per-call behavior. "Connection" is a JDK
// restricted header, enabled at startup via jdk.httpclient.allowRestrictedHeaders.
// A user-supplied Connection header in kwargs.headers overrides this (set() below).
// Compatible gateways may close idle sockets; keep the configured connection policy.
headers.add("Connection", "close");
Object headerObject = kwargs.get("headers");
if (headerObject instanceof Map<?, ?> headerMap) {
@ -384,24 +238,6 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
return headers;
}
/**
* Extract headers that must be force-overridden, read from
* {@code generateKwargs.headers}. Used by a RestClient/WebClient interceptor
* to bypass Spring AI's default User-Agent.
*/
private Map<String, String> extractOverrideHeaders(Map<String, Object> kwargs) {
Map<String, String> result = new HashMap<>();
Object headerObject = kwargs.get("headers");
if (headerObject instanceof Map<?, ?> headerMap) {
headerMap.forEach((key, value) -> {
if (key != null && value != null) {
result.put(String.valueOf(key), String.valueOf(value));
}
});
}
return result;
}
// Trailing "/v{digits}" segment in a base URL — the OpenAI-compatible convention
// (/v1 OpenAI, /v3 Volcano Ark, /v4 Zhipu). When the baseUrl already carries this
// segment, the default /v1 prefix on the path must be stripped to avoid building
@ -427,92 +263,6 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
return path;
}
// ==================== HTTP timeouts ====================
/**
* Configure an explicit timeout on the RestClient used for LLM calls so a
* socket never hangs forever.
*
* <p>Uses {@link JdkClientHttpRequestFactory} (backed by the Java 11+
* {@link HttpClient}) because it natively supports HTTP/2 / ALPN negotiation
* and transparently decompresses {@code Content-Encoding: gzip} responses.
*
* <p>connectTimeout=10s; readTimeout defaults to 180s (covers an nginx 60s
* gateway timeout plus headroom for a real long response; the upper retry
* layer takes over once it times out).
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
rf.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
/**
* Apply equivalent timeouts to the WebClient backing OpenAI-compatible
* STREAMING calls.
*
* <p><b>Scope caveat (issue #585):</b> {@code setReadTimeout} maps to the
* JDK HttpClient's request timeout, which only protects up to the
* <em>response headers</em>. Once the headers arrive the clock stops, so
* this timeout does <b>not</b> prevent a provider that returns 200 + a
* first SSE frame and then goes silent from hanging the body Flux. The
* body-level gap is closed by a reactor inter-frame idle timeout applied
* at the streaming chokepoint ({@code NodeStreamingChatHelper}, driven by
* {@link HttpTimeouts#DEFAULT_STREAM_IDLE_TIMEOUT}) — that is what actually
* surfaces a stalled provider to the error path / failover chain. Both
* layers are needed: this one catches a provider that never sends headers
* at all, the reactor one catches a provider that sends headers then stalls.
*
* <p>Uses {@link org.springframework.http.client.reactive.JdkClientHttpConnector}
* with the same {@link HttpClient} so the dependency surface stays clean
* (reactor-netty is not on this project's classpath).
*/
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
// Pin HTTP/1.1: many self-hosted OpenAI-compatible servers (vLLM, lmstudio,
// llama.cpp, ollama — all uvicorn/ASGI based) only speak HTTP/1.1 over
// cleartext and slam the socket on the JDK client's default H2C upgrade
// probe, surfacing as "header parser received no bytes" with no body sent.
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
// ==================== logging ====================
private void logOpenAiRequest(ModelProviderEntity provider, OpenAiApi.ChatCompletionRequest chatRequest) {
// Never log the request body: it contains system prompts, workspace
// memory, user content and tool schemas. Besides leaking private
// context, serializing it at INFO made long-running team jobs produce
// multi-megabyte log lines. Cardinality-only diagnostics are enough to
// correlate provider traffic without retaining payloads.
log.debug("OpenAI-compatible request: provider={}, model={}, messages={}, tools={}, stream={}",
provider.getProviderId(), chatRequest.model(),
sizeOf(chatRequest.messages()), sizeOf(chatRequest.tools()), chatRequest.stream());
}
private void logOpenAiError(ModelProviderEntity provider, WebClientResponseException e) {
String body = e.getResponseBodyAsString();
log.error("OpenAI-compatible error: provider={}, status={}, responseBytes={}",
provider.getProviderId(), e.getStatusCode(), body == null ? 0 : body.length());
if (log.isDebugEnabled() && body != null && !body.isBlank()) {
log.debug("OpenAI-compatible error detail: provider={}, body={}",
provider.getProviderId(), redactAndTruncate(body));
}
}
private static int sizeOf(java.util.Collection<?> values) {
return values == null ? 0 : values.size();
}
/** Defensive scrub for provider error bodies, which may echo request data. */
static String redactAndTruncate(String body) {
String redacted = body

View File

@ -0,0 +1,98 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import vip.mate.llm.model.ModelProviderEntity;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** Final request policies shared by blocking and asynchronous SDK calls. */
@Slf4j
final class OpenAiCompatibleHttpInterceptor implements Interceptor {
private static final ObjectMapper JSON = new ObjectMapper();
private final ModelProviderEntity provider;
private final String endpoint;
private final Map<String, String> headers;
private final boolean search;
private final Duration timeout;
OpenAiCompatibleHttpInterceptor(
ModelProviderEntity provider,
String endpoint,
Map<String, String> headers,
boolean search,
Duration timeout) {
this.provider = provider;
this.endpoint = endpoint;
this.headers = Map.copyOf(headers);
this.search = search;
this.timeout = timeout;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request original = chain.request();
Buffer buffer = new Buffer();
original.body().writeTo(buffer);
ObjectNode request = (ObjectNode) JSON.readTree(buffer.readByteArray());
ObjectNode rewritten = OpenAiRequestRewriter.rewrite(request, provider, search);
log.debug(
"OpenAI-compatible request: provider={}, model={}, messages={}, tools={},"
+ " stream={}",
provider.getProviderId(),
rewritten.path("model").asText(),
rewritten.path("messages").size(),
rewritten.path("tools").size(),
rewritten.path("stream").asBoolean());
HttpUrl.Builder url = HttpUrl.get(endpoint).newBuilder();
if (original.url().encodedQuery() != null) url.encodedQuery(original.url().encodedQuery());
Request.Builder updated =
original.newBuilder()
.url(url.build())
.method(
original.method(),
RequestBody.create(
JSON.writeValueAsBytes(rewritten),
original.body().contentType()));
headers.forEach(updated::header);
Response response =
chain.withConnectTimeout(
(int) HttpTimeouts.CONNECT_TIMEOUT.toMillis(),
TimeUnit.MILLISECONDS)
.withReadTimeout((int) timeout.toMillis(), TimeUnit.MILLISECONDS)
.proceed(updated.build());
ResponseBody body = response.body();
if (!response.isSuccessful() || body == null || body.contentType() == null) return response;
if ("text".equals(body.contentType().type())
&& "event-stream".equals(body.contentType().subtype()))
return response.newBuilder()
.removeHeader("Content-Length")
.body(new OpenAiReasoningResponseBody(body))
.build();
if ("application".equals(body.contentType().type())
&& "json".equals(body.contentType().subtype())) {
String normalized = OpenAiReasoningResponseNormalizer.normalize(body.string());
ResponseBody replacement = ResponseBody.create(normalized, body.contentType());
return response.newBuilder()
.header("Content-Length", String.valueOf(replacement.contentLength()))
.body(replacement)
.build();
}
return response;
}
}

View File

@ -0,0 +1,102 @@
package vip.mate.llm.chatmodel;
import okhttp3.MediaType;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.Okio;
import okio.Source;
import okio.Timeout;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Normalizes complete SSE events without buffering the response or changing cancellation ownership.
*/
final class OpenAiReasoningResponseBody extends ResponseBody {
private final ResponseBody delegate;
private final BufferedSource source;
OpenAiReasoningResponseBody(ResponseBody delegate) {
this.delegate = delegate;
BufferedSource upstream = delegate.source();
this.source =
Okio.buffer(
new Source() {
private final Buffer pending = new Buffer();
@Override
public long read(Buffer sink, long byteCount) throws IOException {
if (byteCount < 0)
throw new IllegalArgumentException(
"byteCount must be nonnegative");
if (byteCount == 0) return 0;
if (pending.size() == 0 && !readEvent()) return -1;
return pending.read(sink, Math.min(byteCount, pending.size()));
}
private boolean readEvent() throws IOException {
List<String> lines = new ArrayList<>();
StringBuilder data = new StringBuilder();
String line;
while ((line = upstream.readUtf8Line()) != null) {
if (line.isEmpty()) break;
lines.add(line);
if (line.startsWith("data:")) {
if (!data.isEmpty()) data.append('\n');
String value = line.substring(5);
data.append(
value.startsWith(" ") ? value.substring(1) : value);
}
}
if (line == null && lines.isEmpty()) return false;
String originalData = data.toString();
String normalized =
OpenAiReasoningResponseNormalizer.normalize(originalData);
if (!normalized.equals(originalData)) {
boolean written = false;
for (String eventLine : lines) {
if (eventLine.startsWith("data:")) {
if (!written) {
pending.writeUtf8("data: " + normalized + "\n");
written = true;
}
} else pending.writeUtf8(eventLine + "\n");
}
} else
for (String eventLine : lines)
pending.writeUtf8(eventLine + "\n");
pending.writeUtf8("\n");
return true;
}
@Override
public Timeout timeout() {
return upstream.timeout();
}
@Override
public void close() throws IOException {
upstream.close();
}
});
}
@Override
public MediaType contentType() {
return delegate.contentType();
}
@Override
public long contentLength() {
return -1;
}
@Override
public BufferedSource source() {
return source;
}
}

View File

@ -1,882 +1,76 @@
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.openai.api.OpenAiApi;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProviderEntity;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Set;
/**
* Provider-aware rewrites applied to an outbound OpenAI-compatible
* {@link OpenAiApi.ChatCompletionRequest} just before it hits the wire.
*
* <p>{@link OpenAiCompatibleChatModelBuilder} runs these in a fixed order on both
* the blocking and streaming chat-completion paths. Each method is a pure
* transformation: it returns the original request unchanged when it has nothing
* to do, or a rebuilt request (the Spring AI record is immutable) otherwise.
*
* <p>The rewrites exist because OpenAI-compatible providers diverge in ways
* Spring AI's {@code OpenAiChatOptions} cannot express — reasoning-content
* replay contracts, reasoning-effort acceptance, strict tool-choice validation,
* JSON Schema number preservation, video media encoding, and Kimi's built-in
* web search tool.
*/
@Slf4j
/** Provider policies applied to the complete SDK request without rebuilding SDK DTOs. */
final class OpenAiRequestRewriter {
static final String RELAY_FIELD = "__mateclaw_thinking_relay";
private OpenAiRequestRewriter() {}
// ==================== tool schema number preservation ====================
/**
* Keep integral JSON Schema values numeric on the OpenAI wire.
*
* <p>Spring AI parses every tool's schema string into a nested {@link Map}.
* Values beyond {@link Integer#MAX_VALUE} consequently become {@link Long}s.
* MateClaw's application-wide Jackson configuration intentionally serializes
* {@code Long} as strings to protect Snowflake IDs from JavaScript precision
* loss, but that policy must not leak into protocol metadata: providers reject
* schemas such as {@code "maximum":"9007199254740991"} because JSON Schema
* requires {@code maximum} to be a number.
*
* <p>Replace Long values inside tool parameter schemas with numerically
* equivalent {@link BigInteger}s. Jackson still emits those as JSON numbers,
* while the global Long-to-string policy remains intact for application DTOs.
*/
static OpenAiApi.ChatCompletionRequest preserveToolSchemaNumbers(
OpenAiApi.ChatCompletionRequest request) {
if (request.tools() == null || request.tools().isEmpty()) {
return request;
}
boolean changed = false;
List<OpenAiApi.FunctionTool> tools = new ArrayList<>(request.tools().size());
for (OpenAiApi.FunctionTool tool : request.tools()) {
if (tool == null || tool.getFunction() == null
|| tool.getFunction().getParameters() == null) {
tools.add(tool);
continue;
}
Object normalized = preserveSchemaNumber(tool.getFunction().getParameters());
if (normalized == tool.getFunction().getParameters()) {
tools.add(tool);
continue;
}
OpenAiApi.FunctionTool.Function original = tool.getFunction();
@SuppressWarnings("unchecked")
Map<String, Object> parameters = (Map<String, Object>) normalized;
OpenAiApi.FunctionTool.Function function = new OpenAiApi.FunctionTool.Function(
original.getDescription(), original.getName(), parameters, original.getStrict());
tools.add(new OpenAiApi.FunctionTool(tool.getType(), function));
changed = true;
}
return changed ? rebuildWithTools(request, tools) : request;
}
private static Object preserveSchemaNumber(Object value) {
if (value instanceof Long number) {
return BigInteger.valueOf(number);
}
if (value instanceof Map<?, ?> map) {
Map<Object, Object> copy = null;
for (Map.Entry<?, ?> entry : map.entrySet()) {
Object normalized = preserveSchemaNumber(entry.getValue());
if (normalized != entry.getValue()) {
if (copy == null) {
copy = new LinkedHashMap<>(map);
}
copy.put(entry.getKey(), normalized);
static ObjectNode rewrite(ObjectNode source, ModelProviderEntity provider, boolean kimiSearchEnabled) {
ObjectNode request = source.deepCopy();
JsonNode embedded = request.remove(RELAY_FIELD);
AssistantThinkingRelay.RelayEntry relay = null;
if (embedded != null && embedded.isObject()) {
List<String> values = new ArrayList<>();
embedded.path("thinkings").forEach(value -> values.add(value.asText()));
relay = new AssistantThinkingRelay.RelayEntry(values, embedded.path("originalUser").isTextual()
? embedded.path("originalUser").asText() : null);
} else relay = AssistantThinkingRelay.take(request.path("user").asText(null));
if (relay != null) {
if (relay.originalUser() == null) request.remove("user"); else request.put("user", relay.originalUser());
} else if (AssistantThinkingRelay.isToken(request.path("user").asText(null))) request.remove("user");
ModelFamily family = ModelFamily.detect(request.path("model").asText());
String providerId = provider == null ? null : provider.getProviderId();
if (!Set.of("openai", "azure-openai").contains(providerId == null ? "" : providerId.toLowerCase(Locale.ROOT))
|| !family.supportsReasoningEffort()) request.remove("reasoning_effort");
boolean thinking = family.isThinking() || request.has("reasoning_effort") || relay != null;
JsonNode messages = request.path("messages");
for (JsonNode message : messages) if (message.has("reasoning_content")) thinking = true;
if (thinking && messages.isArray()) {
boolean crossTurn = "deepseek".equalsIgnoreCase(providerId) || "xiaomi-mimo".equalsIgnoreCase(providerId);
int lastUser = -1;
for (int i = 0; i < messages.size(); i++) if ("user".equals(messages.get(i).path("role").asText())) lastUser = i;
int assistantIndex = 0;
for (int i = 0; i < messages.size(); i++) {
if (!(messages.get(i) instanceof ObjectNode message) || !"assistant".equals(message.path("role").asText())) continue;
String value = relay != null && assistantIndex < relay.thinkings().size() ? relay.thinkings().get(assistantIndex) : null;
assistantIndex++;
if (!message.path("reasoning_content").asText("").isBlank()) continue;
if (i < lastUser && !crossTurn) continue;
boolean hasTools = message.path("tool_calls").isArray() && !message.path("tool_calls").isEmpty();
if (!hasTools && !crossTurn) continue;
if (value == null || value.isEmpty()) {
List<String> ids = new ArrayList<>();
message.path("tool_calls").forEach(call -> ids.add(call.path("id").asText()));
value = i <= lastUser ? ReasoningContentCache.get(ids) : null;
}
}
return copy != null ? copy : value;
}
if (value instanceof List<?> list) {
List<Object> copy = null;
for (int i = 0; i < list.size(); i++) {
Object normalized = preserveSchemaNumber(list.get(i));
if (normalized != list.get(i)) {
if (copy == null) {
copy = new ArrayList<>(list);
}
copy.set(i, normalized);
}
}
return copy != null ? copy : value;
}
return value;
}
// ==================== reasoning_content patching ====================
/**
* Consume the {@link AssistantThinkingRelay} entry and rebuild the outbound
* request so assistant tool-call / thinking messages carry the correct
* {@code reasoning_content}.
*
* <p>This is the consumer side of the relay. The producer
* ({@code NodeStreamingChatHelper}) stashes per-assistant thinking keyed on a
* token embedded in {@code request.user()}. Here we:
* <ol>
* <li>{@link AssistantThinkingRelay#take(String)} the entry and restore
* {@code request.user()} to {@code entry.originalUser()} so the
* internal token never reaches the provider.</li>
* <li>Compute {@code lastUserIdx} (the boundary of the current user turn).
* Assistant messages at {@code i <= lastUserIdx} are prior-turn history:
* their {@code reasoning_content} normally stays null. Only
* {@code i > lastUserIdx} messages are eligible for patching, unless the
* provider policy opts into cross-turn patching.</li>
* <li>Select a {@link FallbackPolicy} by {@code providerId}. When the relay
* has a real value we use it; when empty, the policy decides whether to
* inject {@code " "} (legacy tolerance) or leave {@code null} to surface
* an explicit provider error.</li>
* </ol>
*
* <p>The relay iterator advances for every assistant message (including
* prior-turn ones) to stay positionally aligned with the producer's extraction.
*/
static OpenAiApi.ChatCompletionRequest patchReasoningContent(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
// 1. Consume relay (if any) and compute the sanitized user field.
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(request.user());
String sanitizedUser = (entry != null)
? entry.originalUser()
: (AssistantThinkingRelay.isToken(request.user()) ? null : request.user());
// 2. Detect thinking mode — relay presence is also a trigger.
boolean thinkingMode = request.reasoningEffort() != null
|| requiresReasoningContentPatch(request.model())
|| request.messages().stream().anyMatch(m ->
m.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& m.reasoningContent() != null)
|| entry != null;
if (!thinkingMode) {
// Nothing to patch but we may still need to strip a leaked relay token from user.
return request.user() != null && !request.user().equals(sanitizedUser)
? rebuildWithUser(request, sanitizedUser)
: request;
}
// 3. Find lastUserIdx so we can skip cross-turn assistants.
int lastUserIdx = -1;
for (int i = request.messages().size() - 1; i >= 0; i--) {
if (request.messages().get(i).role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
lastUserIdx = i;
break;
message.put("reasoning_content", value == null || value.isEmpty() ? " " : value);
}
}
FallbackPolicy policy = FallbackPolicy.forProvider(provider);
java.util.Iterator<String> it = (entry != null)
? entry.thinkings().iterator()
: java.util.Collections.emptyIterator();
// 4. Walk messages, patching only in-turn assistants; always advance iterator
// for all assistants so producer/consumer positions stay aligned.
boolean anyPatched = false;
List<OpenAiApi.ChatCompletionMessage> patched = new ArrayList<>(request.messages().size());
for (int i = 0; i < request.messages().size(); i++) {
OpenAiApi.ChatCompletionMessage msg = request.messages().get(i);
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.ASSISTANT) {
patched.add(msg);
continue;
if (request.path("model").asText().trim().toLowerCase(Locale.ROOT).startsWith("gpt-5") && !request.path("tools").isEmpty()) request.remove("reasoning_effort");
if ("auto".equals(request.path("tool_choice").asText())) request.remove("tool_choice");
for (JsonNode message : messages) {
if (!"user".equals(message.path("role").asText())) continue;
for (JsonNode item : message.path("content")) if (item instanceof ObjectNode content
&& "image_url".equals(content.path("type").asText())
&& content.path("image_url").path("url").asText().startsWith("data:video/")) {
String url = content.path("image_url").path("url").asText();
content.remove("image_url"); content.put("type", "video_url"); content.put("video_url", url);
}
String next = it.hasNext() ? it.next() : null;
// Already has a real value: leave alone
if (msg.reasoningContent() != null && !msg.reasoningContent().isBlank()) {
patched.add(msg);
continue;
}
// Cross-turn assistant: usually skipped, since thinking resets across
// user turns. But some providers require reasoning_content even on
// prior-turn assistants and reject requests where any prior assistant
// has it null. For policies with patchCrossTurn=true, fall through and
// patch with the empty fallback (" ") so multi-turn conversations
// don't 400.
if (i <= lastUserIdx && !policy.patchCrossTurn) {
patched.add(msg);
continue;
}
boolean hasToolCalls = msg.toolCalls() != null && !msg.toolCalls().isEmpty();
if (!hasToolCalls && !policy.patchNonToolCall) {
patched.add(msg);
continue;
}
String injected;
if (next != null && !next.isEmpty()) {
injected = next;
} else {
// For cross-turn messages, try the reasoning content cache
// (real values from prior responses) before falling back to empty.
injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx);
if (injected == null) {
injected = policy.emptyFallback;
if (injected == null && policy.warnOnMissingReal) {
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
+ "but relay has no value for assistant message at index {}; "
+ "leaving null so provider returns explicit error.",
providerIdOrUnknown(provider), i);
}
}
}
if (injected == null && msg.reasoningContent() == null) {
// No change — keep original
patched.add(msg);
continue;
}
patched.add(new OpenAiApi.ChatCompletionMessage(
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
msg.annotations(), injected));
anyPatched = true;
}
boolean userChanged = request.user() != null && !request.user().equals(sanitizedUser)
|| (request.user() == null && sanitizedUser != null);
if (!anyPatched && !userChanged) {
return request;
}
// 5. Rebuild with patched messages + sanitized user.
return new OpenAiApi.ChatCompletionRequest(
patched,
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
sanitizedUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* Provider-keyed policy for how {@link #patchReasoningContent} behaves when
* the relay has no real thinking for an in-turn assistant message.
*
* <ul>
* <li>{@code emptyFallback}: value to inject when the relay has no real
* value — {@code null} means leave {@code reasoning_content} null;
* {@code " "} preserves legacy tolerance.</li>
* <li>{@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null}
* fires.</li>
* <li>{@code patchNonToolCall}: whether to patch assistant messages without
* tool_calls. DeepSeek's contract applies to all in-turn assistant
* messages; others only to tool_call messages.</li>
* <li>{@code patchCrossTurn}: whether to also patch prior-turn assistants
* ({@code i <= lastUserIdx}). DeepSeek requires reasoning_content on
* every assistant message in the request, including prior-turn history,
* and MateClaw does not persist reasoning_content — so cross-turn
* patching keeps multi-turn conversations from 400-ing.</li>
* </ul>
*
* <p>{@code DEFAULT} keeps the legacy {@code " "} tolerance rather than going
* no-op: an unrecognized provider (self-hosted DeepSeek-like backend, custom
* OpenAI-compatible gateway) might still require the patch.
*/
private enum FallbackPolicy {
DEEPSEEK (" ", false, true, true),
KIMI (" ", false, false, false),
OPENAI (" ", false, false, false),
XIAOMI_MIMO (" ", false, true, true),
DEFAULT (" ", false, false, false);
final String emptyFallback;
final boolean warnOnMissingReal;
final boolean patchNonToolCall;
/** Whether to also patch prior-turn assistants ({@code i <= lastUserIdx}). */
final boolean patchCrossTurn;
FallbackPolicy(String emptyFallback, boolean warnOnMissingReal,
boolean patchNonToolCall, boolean patchCrossTurn) {
this.emptyFallback = emptyFallback;
this.warnOnMissingReal = warnOnMissingReal;
this.patchNonToolCall = patchNonToolCall;
this.patchCrossTurn = patchCrossTurn;
}
static FallbackPolicy forProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return DEFAULT;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI;
case "xiaomi-mimo" -> XIAOMI_MIMO;
default -> DEFAULT;
};
}
}
/**
* Rebuild a request with only the {@code user} field replaced. Used when
* {@link #patchReasoningContent} has no assistant-message changes but must
* strip a relay token from the outbound {@code user} field.
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithUser(
OpenAiApi.ChatCompletionRequest request, String newUser) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
newUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
private static OpenAiApi.ChatCompletionRequest rebuildWithTools(
OpenAiApi.ChatCompletionRequest request, List<OpenAiApi.FunctionTool> tools) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
tools,
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
private static boolean requiresReasoningContentPatch(String modelName) {
ModelFamily family = ModelFamily.detect(modelName);
return family.isThinking();
}
/**
* Look up cached reasoning content for cross-turn assistant messages.
* Returns the cached value, or {@code null} if no cache hit (caller falls
* back to the policy's empty fallback).
*/
private static String resolveCrossTurnReasoning(
OpenAiApi.ChatCompletionMessage msg, boolean isCrossTurn) {
if (!isCrossTurn) return null;
if (msg.toolCalls() == null || msg.toolCalls().isEmpty()) return null;
List<String> ids = msg.toolCalls().stream()
.map(tc -> tc.id())
.filter(id -> id != null && !id.isEmpty())
.toList();
return ReasoningContentCache.get(ids);
}
// ==================== reasoning_effort sanitizing ====================
/**
* Provider-first sanitization of {@code reasoning_effort}.
*
* <p>Authoritative judgement uses {@code provider.getProviderId()} as a
* whitelist (default-deny). Only official OpenAI providers may carry
* {@code reasoning_effort}; everything else — known non-supporters and any
* unrecognized providerId (self-hosted gateways, aggregators) — is stripped.
*
* <p>{@code request.model()} is intentionally distrusted here: the failover
* chain can reuse the same {@code OpenAiChatOptions} across providers, so a
* failover hop from a GPT-5 primary to DeepSeek would still carry model name
* "gpt-5". Checking only the model family would let the primary's
* {@code reasoning_effort} leak to DeepSeek.
*
* <p>Only when the provider is whitelisted do we fall through to the
* {@link ModelFamily} check.
*/
static OpenAiApi.ChatCompletionRequest sanitizeReasoningEffortForProvider(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request == null || request.reasoningEffort() == null) {
return request;
}
if (!isReasoningEffortWhitelistedProvider(provider)) {
log.warn("[reasoning_effort sanitizer] provider={} is not on the reasoning_effort "
+ "whitelist (only openai/azure-openai are); stripping value='{}' "
+ "(request.model()='{}' may be leaked from failover primary).",
providerIdOrUnknown(provider), request.reasoningEffort(), request.model());
return rebuildWithReasoningEffort(request, null);
}
ModelFamily targetFamily = ModelFamily.detect(request.model());
if (!targetFamily.supportsReasoningEffort()) {
log.warn("[reasoning_effort sanitizer] provider={} model={} family={} does not "
+ "support reasoning_effort; stripping value='{}'.",
provider.getProviderId(), request.model(), targetFamily, request.reasoningEffort());
return rebuildWithReasoningEffort(request, null);
if (kimiSearchEnabled) {
ArrayNode tools = request.path("tools").isArray() ? (ArrayNode) request.get("tools") : request.putArray("tools");
tools.addObject().put("type", "builtin_function").putObject("function").put("name", "$web_search");
}
return request;
}
/**
* Whitelist of providers known to accept {@code reasoning_effort} on
* {@code /v1/chat/completions} (or {@code /v1/responses}). Anything else is
* denied. Adding a provider here must come with a corresponding test case.
*/
static boolean isReasoningEffortWhitelistedProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return false;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "openai", "azure-openai" -> true;
default -> false;
};
}
private static String providerIdOrUnknown(ModelProviderEntity p) {
return (p == null || p.getProviderId() == null) ? "<unknown>" : p.getProviderId();
}
/**
* Rebuild a request with a new {@code reasoningEffort} value (typically
* {@code null} to strip).
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithReasoningEffort(
OpenAiApi.ChatCompletionRequest request, String newReasoningEffort) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
newReasoningEffort,
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* GPT-5 compatibility: on the {@code /v1/chat/completions} path, {@code tools}
* and {@code reasoning_effort} cannot both be present.
*
* <p>When a gpt-5* model carries both, {@code reasoning_effort} is removed and
* a warning is logged. To use {@code reasoning_effort}, switch to the
* {@code /v1/responses} endpoint via the {@code completionsPath} generate kwarg.
*/
static OpenAiApi.ChatCompletionRequest stripReasoningEffortIfIncompatible(
OpenAiApi.ChatCompletionRequest request) {
if (request.reasoningEffort() == null) {
return request;
}
if (request.tools() == null || request.tools().isEmpty()) {
return request;
}
String model = request.model();
if (model == null || !model.trim().toLowerCase().startsWith("gpt-5")) {
return request;
}
log.warn("[GPT-5 compat] model {} carries both tools and reasoning_effort on "
+ "chat/completions; removing reasoning_effort to avoid a 400. "
+ "To use reasoning_effort, set completionsPath to /v1/responses",
model);
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
null, // reasoningEffort — removed
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
// ==================== tool_choice / media ====================
/**
* Strip {@code tool_choice="auto"} from outbound requests.
*
* <p>Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is
* non-empty is equivalent to {@code "auto"}. Stripping the explicit literal:
* <ul>
* <li>does not change behavior on compliant servers — they still default to
* auto when tools are present;</li>
* <li>unblocks strict OpenAI-compatible self-hosted serving frameworks that
* reject {@code tool_choice="auto"} at request validation time unless
* launched with an auto-tool-choice opt-in flag.</li>
* </ul>
*
* <p>Explicit values other than {@code "auto"} are passed through unchanged.
*/
static OpenAiApi.ChatCompletionRequest stripAutoToolChoice(OpenAiApi.ChatCompletionRequest request) {
Object tc = request.toolChoice();
if (tc == null || !"auto".equals(String.valueOf(tc))) {
return request;
}
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
null, // toolChoice — strip "auto" so strict OpenAI-compatible servers accept the request
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* Convert video content blocks that Spring AI mis-serializes as
* {@code image_url} into {@code video_url} format.
*
* <p>Spring AI's {@code MediaContent} has no video_url type, so every non-audio
* / non-pdf media block is serialized as {@code image_url}. Models such as
* Zhipu GLM-5V require video to use {@code video_url}; otherwise they report
* an image parse error. This walks user-message content and rewrites any
* {@code data:video/*} {@code image_url} into {@code video_url}.
*/
@SuppressWarnings("unchecked")
static OpenAiApi.ChatCompletionRequest patchVideoMediaContent(OpenAiApi.ChatCompletionRequest request) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
boolean needsPatch = false;
for (var msg : request.messages()) {
if (msg.role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
Object raw = msg.rawContent();
if (raw instanceof List<?> parts) {
for (Object part : parts) {
// MediaContent record
if (part instanceof OpenAiApi.ChatCompletionMessage.MediaContent mc
&& "image_url".equals(mc.type())
&& mc.imageUrl() != null
&& mc.imageUrl().url() != null
&& mc.imageUrl().url().startsWith("data:video/")) {
needsPatch = true;
break;
}
// Map form (Spring AI represents content parts as LinkedHashMap internally)
if (part instanceof java.util.Map<?,?> map) {
Object type = map.get("type");
if ("image_url".equals(type)) {
Object imgUrlObj = map.get("image_url");
if (imgUrlObj instanceof java.util.Map<?,?> imgUrl) {
Object url = imgUrl.get("url");
if (url instanceof String urlStr && urlStr.startsWith("data:video/")) {
needsPatch = true;
break;
}
}
}
}
}
}
}
if (needsPatch) break;
}
if (!needsPatch) {
return request;
}
List<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.USER || !(msg.rawContent() instanceof List<?> parts)) {
return msg;
}
List<Object> newParts = new ArrayList<>();
for (Object part : parts) {
String videoDataUrl = null;
// Case 1: MediaContent record (native Spring AI construction)
if (part instanceof OpenAiApi.ChatCompletionMessage.MediaContent mc
&& "image_url".equals(mc.type())
&& mc.imageUrl() != null && mc.imageUrl().url() != null
&& mc.imageUrl().url().startsWith("data:video/")) {
videoDataUrl = mc.imageUrl().url();
}
// Case 2: Map form (Jackson deserialization or Spring AI internal Map)
if (videoDataUrl == null && part instanceof java.util.Map<?,?> map
&& "image_url".equals(map.get("type"))) {
Object imgUrlObj = map.get("image_url");
if (imgUrlObj instanceof java.util.Map<?,?> imgUrl) {
Object url = imgUrl.get("url");
if (url instanceof String urlStr && urlStr.startsWith("data:video/")) {
videoDataUrl = urlStr;
}
}
}
if (videoDataUrl != null) {
// Rewrite to video_url format
newParts.add(Map.of(
"type", "video_url",
"video_url", Map.of("url", videoDataUrl)
));
} else {
newParts.add(part);
}
}
return new OpenAiApi.ChatCompletionMessage(
newParts, msg.role(), msg.name(), msg.toolCallId(),
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
msg.annotations(), msg.reasoningContent());
}).toList();
return new OpenAiApi.ChatCompletionRequest(
patched,
request.model(), request.store(), request.metadata(),
request.frequencyPenalty(), request.logitBias(),
request.logprobs(), request.topLogprobs(),
request.maxTokens(), request.maxCompletionTokens(),
request.n(), request.outputModalities(), request.audioParameters(),
request.presencePenalty(), request.responseFormat(),
request.seed(), request.serviceTier(), request.stop(),
request.stream(), request.streamOptions(),
request.temperature(), request.topP(),
request.tools(), request.toolChoice(), request.parallelToolCalls(),
request.user(), request.reasoningEffort(),
request.webSearchOptions(), request.verbosity(),
request.promptCacheKey(), request.safetyIdentifier(),
request.extraBody()
);
}
// ==================== Kimi built-in search ====================
/**
* Inject the {@code $web_search} built-in tool into a Kimi request.
*
* <p>Kimi's built-in search is enabled by declaring
* {@code {"type":"builtin_function","function":{"name":"$web_search"}}} in the
* tools array. Spring AI's {@code FunctionTool.Type} only has {@code FUNCTION},
* so this injects the raw JSON structure via {@code extraBody} — overriding the
* tools field with the original tools plus {@code $web_search}.
*/
static OpenAiApi.ChatCompletionRequest injectKimiWebSearch(OpenAiApi.ChatCompletionRequest request) {
// Build the $web_search entry as a Map
Map<String, Object> webSearchTool = Map.of(
"type", "builtin_function",
"function", Map.of("name", "$web_search")
);
// Convert existing tools to List<Map> and append $web_search
List<Map<String, Object>> allTools = new ArrayList<>();
if (request.tools() != null) {
for (OpenAiApi.FunctionTool tool : request.tools()) {
Map<String, Object> toolMap = new LinkedHashMap<>();
toolMap.put("type", "function");
if (tool.getFunction() != null) {
Map<String, Object> funcMap = new LinkedHashMap<>();
funcMap.put("name", tool.getFunction().getName());
if (tool.getFunction().getDescription() != null) {
funcMap.put("description", tool.getFunction().getDescription());
}
if (tool.getFunction().getParameters() != null) {
funcMap.put("parameters", tool.getFunction().getParameters());
}
if (tool.getFunction().getStrict() != null) {
funcMap.put("strict", tool.getFunction().getStrict());
}
toolMap.put("function", funcMap);
}
allTools.add(toolMap);
}
}
allTools.add(webSearchTool);
// Inject tools via extraBody (overrides the tools field), and clear the
// original tools field to avoid duplicate serialization.
Map<String, Object> extraBody = new LinkedHashMap<>();
if (request.extraBody() != null) {
extraBody.putAll(request.extraBody());
}
extraBody.put("tools", allTools);
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
null, // tools — cleared, extraBody takes over
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
extraBody
);
}
}

View File

@ -0,0 +1,151 @@
package vip.mate.llm.chatmodel;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.MimeTypeUtils;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Captures relay ownership before the SDK moves a request to its worker thread. */
final class OpenAiWirePolicyChatModel implements ChatModel {
private final ChatModel delegate;
private final RetryTemplate retry;
private final PinnedOpenAiHttpClient transport;
OpenAiWirePolicyChatModel(ChatModel delegate, RetryTemplate retry, PinnedOpenAiHttpClient transport) {
this.delegate = delegate;
this.retry = retry;
this.transport = transport;
}
@Override
public ChatOptions getOptions() {
return delegate.getOptions();
}
@Override
public ChatResponse call(Prompt prompt) {
Prompt prepared = prepare(prompt);
return retry == null
? delegate.call(prepared)
: retry.execute(context -> delegate.call(prepared));
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
Prompt prepared = prepare(prompt);
OpenAiChatOptions.Builder options = ((OpenAiChatOptions) prepared.getOptions()).mutate();
// Streaming is bounded by the configured read/idle timeout, not a whole-response deadline.
options.timeout(Duration.ZERO);
if (transport == null)
return delegate.stream(new Prompt(prepared.getInstructions(), options.build()));
OpenAiChatOptions streamingOptions = options.build();
return Flux.defer(() -> {
var scope = transport.startStream();
Map<String, String> headers = new LinkedHashMap<>();
if (streamingOptions.getCustomHeaders() != null)
headers.putAll(streamingOptions.getCustomHeaders());
headers.keySet().removeIf(PinnedOpenAiHttpClient.STREAM_HEADER::equalsIgnoreCase);
headers.put(PinnedOpenAiHttpClient.STREAM_HEADER, scope.token());
try {
Prompt scoped = new Prompt(prepared.getInstructions(),
streamingOptions.mutate().customHeaders(headers).build());
return delegate.stream(scoped)
.doOnCancel(scope::cancel)
.doFinally(signal -> scope.close());
} catch (RuntimeException | Error failure) {
scope.cancel();
scope.close();
throw failure;
}
});
}
static OpenAiChatOptions mergeOptions(ChatOptions defaults, ChatOptions runtime) {
OpenAiChatOptions.Builder merged =
defaults instanceof OpenAiChatOptions options
? options.mutate()
: OpenAiChatOptions.builder();
if (runtime != null) merged.combineWith(runtime.mutate());
// combineWith treats empty lists as absent; explicit runtime callbacks replace defaults.
if (runtime instanceof ToolCallingChatOptions tools && tools.getToolCallbacks() != null)
merged.toolCallbacks(tools.getToolCallbacks());
return merged.build();
}
private static List<Message> prepareMedia(List<Message> messages) {
List<Message> prepared = new ArrayList<>(messages.size());
for (Message message : messages) {
if (!(message instanceof UserMessage user)
|| user.getMedia().stream()
.noneMatch(media -> "video".equals(media.getMimeType().getType()))) {
prepared.add(message);
continue;
}
List<Media> mediaParts = new ArrayList<>(user.getMedia().size());
for (Media media : user.getMedia()) {
if (!"video".equals(media.getMimeType().getType())) {
mediaParts.add(media);
continue;
}
Object data = media.getData();
String url =
data instanceof byte[] bytes
? "data:"
+ media.getMimeType()
+ ";base64,"
+ Base64.getEncoder().encodeToString(bytes)
: (String) data;
// Keep typed media separate from ordinary text while the SDK carries the video URL.
Media.Builder transport =
Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(url);
if (media.getId() != null) transport.id(media.getId());
if (media.getName() != null) transport.name(media.getName());
mediaParts.add(transport.build());
}
prepared.add(
UserMessage.builder()
.text(user.getText())
.metadata(user.getMetadata())
.media(mediaParts)
.build());
}
return prepared;
}
private Prompt prepare(Prompt prompt) {
OpenAiChatOptions options = mergeOptions(delegate.getOptions(), prompt.getOptions());
if (!AssistantThinkingRelay.isToken(options.getUser()))
return new Prompt(prepareMedia(prompt.getInstructions()), options);
var relay = AssistantThinkingRelay.take(options.getUser());
var builder = options.mutate();
builder.user(relay == null ? null : relay.originalUser());
if (relay != null) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("thinkings", relay.thinkings());
payload.put("originalUser", relay.originalUser());
Map<String, Object> extra =
options.getExtraBody() == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(options.getExtraBody());
extra.put(OpenAiRequestRewriter.RELAY_FIELD, payload);
builder.extraBody(extra);
}
return new Prompt(prepareMedia(prompt.getInstructions()), builder.build());
}
}

View File

@ -0,0 +1,262 @@
/*
* HTTP adaptation follows Spring AI's SpringAiOpenAiHttpClient and the public OpenAI SDK contract.
* Copyright 2023-present the original author or authors.
* SPDX-License-Identifier: Apache-2.0
*/
package vip.mate.llm.chatmodel;
import com.openai.core.RequestOptions;
import com.openai.core.Timeout;
import com.openai.core.http.Headers;
import com.openai.core.http.HttpClient;
import com.openai.core.http.HttpRequest;
import com.openai.core.http.HttpRequestBody;
import com.openai.core.http.HttpResponse;
import com.openai.errors.OpenAIIoException;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okio.BufferedSink;
import org.springframework.ai.openai.http.okhttp.SpringAiOpenAiHttpClient;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
/** SDK transport retaining Spring AI observations while enforcing compatible gateway policies. */
final class PinnedOpenAiHttpClient implements HttpClient {
static final String STREAM_HEADER = "X-MateClaw-Stream-Cancellation";
private final ConcurrentMap<String, StreamCancellation> streams = new ConcurrentHashMap<>();
private final SpringAiOpenAiHttpClient owner;
private final OkHttpClient client;
private final AtomicBoolean closed = new AtomicBoolean();
PinnedOpenAiHttpClient(SpringAiOpenAiHttpClient observed) {
owner = observed;
client =
observed.getOkHttpClient()
.newBuilder()
.protocols(List.of(Protocol.HTTP_1_1))
.retryOnConnectionFailure(false)
.build();
}
StreamCancellation startStream() {
StreamCancellation scope = new StreamCancellation();
if (closed.get()) throw new IllegalStateException("Provider transport is closed");
streams.put(scope.token(), scope);
if (closed.get()) {
scope.cancel();
scope.close();
throw new IllegalStateException("Provider transport is closed");
}
return scope;
}
int activeStreamCount() {
return streams.size();
}
final class StreamCancellation implements AutoCloseable {
private final String token = UUID.randomUUID().toString();
private final ProviderCallCancellation cancellation = new ProviderCallCancellation();
String token() {
return token;
}
void register(Call call) {
cancellation.register(call);
}
void cancel() {
cancellation.cancel();
}
@Override
public void close() {
cancellation.close();
streams.remove(token, this);
}
}
OkHttpClient client() {
return client;
}
@Override
public HttpResponse execute(HttpRequest request, RequestOptions options) {
try {
return response(call(request, options).execute());
} catch (IOException failure) {
throw new OpenAIIoException("Request failed", failure);
} finally {
closeBody(request);
}
}
@Override
public CompletableFuture<HttpResponse> executeAsync(
HttpRequest request, RequestOptions options) {
CompletableFuture<HttpResponse> result = new CompletableFuture<>();
Call call;
try {
call = call(request, options);
} catch (RuntimeException | Error failure) {
closeBody(request);
throw failure;
}
result.whenComplete(
(response, failure) -> {
if (failure instanceof CancellationException) call.cancel();
closeBody(request);
});
try {
call.enqueue(
new Callback() {
@Override
public void onResponse(Call call, Response response) {
HttpResponse converted = response(response);
if (!result.complete(converted)) converted.close();
}
@Override
public void onFailure(Call call, IOException failure) {
result.completeExceptionally(
new OpenAIIoException("Request failed", failure));
}
});
} catch (RuntimeException failure) {
result.completeExceptionally(failure);
}
return result;
}
private Call call(HttpRequest request, RequestOptions options) {
OkHttpClient.Builder perCall = client.newBuilder();
Timeout timeout = options.getTimeout();
if (timeout != null)
perCall.connectTimeout(timeout.connect())
.readTimeout(timeout.read())
.writeTimeout(timeout.write());
// The configured timeout limits read gaps; complete healthy bodies have no overall
// deadline.
perCall.callTimeout(Duration.ZERO);
OkHttpClient resolved = perCall.build();
HttpRequestBody source = request.body();
RequestBody body =
source == null
? null
: new RequestBody() {
@Override
public MediaType contentType() {
return source.contentType() == null
? null
: MediaType.parse(source.contentType());
}
@Override
public long contentLength() {
return source.contentLength();
}
@Override
public boolean isOneShot() {
return !source.repeatable();
}
@Override
public void writeTo(BufferedSink sink) {
source.writeTo(sink.outputStream());
}
};
if (body == null && List.of("POST", "PUT", "PATCH").contains(request.method().name()))
body = RequestBody.create(new byte[0], null);
Request.Builder outgoing =
new Request.Builder().url(request.url()).method(request.method().name(), body);
Headers headers = request.headers();
StreamCancellation streamScope = null;
for (String name : headers.names()) {
if (STREAM_HEADER.equalsIgnoreCase(name)) {
String token = headers.values(name).getFirst();
streamScope = streams.get(token);
if (streamScope == null)
throw new CancellationException("Stream cancellation scope has ended");
} else {
for (String value : headers.values(name)) outgoing.addHeader(name, value);
}
}
if (!headers.names().contains("X-Stainless-Read-Timeout")
&& resolved.readTimeoutMillis() != 0)
outgoing.addHeader(
"X-Stainless-Read-Timeout",
Long.toString(Duration.ofMillis(resolved.readTimeoutMillis()).getSeconds()));
if (!headers.names().contains("X-Stainless-Timeout") && resolved.callTimeoutMillis() != 0)
outgoing.addHeader(
"X-Stainless-Timeout",
Long.toString(Duration.ofMillis(resolved.callTimeoutMillis()).getSeconds()));
Call call = resolved.newCall(outgoing.build());
if (streamScope != null) streamScope.register(call);
return call;
}
private static HttpResponse response(Response response) {
Headers.Builder headers = Headers.builder();
for (int i = 0; i < response.headers().size(); i++)
headers.put(response.headers().name(i), response.headers().value(i));
Headers copied = headers.build();
return new HttpResponse() {
@Override
public int statusCode() {
return response.code();
}
@Override
public Headers headers() {
return copied;
}
@Override
public InputStream body() {
return response.body() == null
? InputStream.nullInputStream()
: response.body().byteStream();
}
@Override
public void close() {
response.close();
}
};
}
private static void closeBody(HttpRequest request) {
if (request.body() != null) request.body().close();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
streams.values().forEach(scope -> {
scope.cancel();
scope.close();
});
owner.close();
}
}
}

View File

@ -0,0 +1,30 @@
package vip.mate.llm.chatmodel;
import okhttp3.Call;
import java.util.HashSet;
import java.util.Set;
/** Cancels only calls owned by one provider subscription before SDK reader cleanup. */
final class ProviderCallCancellation implements AutoCloseable {
private final Set<Call> calls = new HashSet<>();
private boolean cancelled;
private boolean finished;
synchronized void register(Call call) {
if (cancelled || finished) call.cancel();
else calls.add(call);
}
synchronized void cancel() {
cancelled = true;
// SDK reader close can wait for a blocked read; cancel its socket first.
calls.forEach(Call::cancel);
}
@Override
public synchronized void close() {
finished = true;
calls.clear();
}
}

View File

@ -4,8 +4,6 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import reactor.core.publisher.Flux;
import vip.mate.llm.model.ModelProviderEntity;
@ -33,31 +31,27 @@ final class VllmThinkingDecorator implements ChatModel {
@Override public ChatResponse call(Prompt prompt) { return delegate.call(transform(prompt)); }
@Override public Flux<ChatResponse> stream(Prompt prompt) { return delegate.stream(transform(prompt)); }
@Override public ChatOptions getDefaultOptions() { return delegate.getDefaultOptions(); }
@Override public ChatOptions getOptions() { return delegate.getOptions(); }
private Prompt transform(Prompt prompt) {
// Capture on the caller's thread, before Reactor subscription or worker handoff.
String level = ThinkingLevelHolder.get();
if (level == null || level.isBlank()) return prompt;
ChatOptions runtime = prompt.getOptions();
OpenAiChatOptions patched = runtime instanceof OpenAiChatOptions options
? OpenAiChatOptions.fromOptions(options)
: runtime == null ? new OpenAiChatOptions()
: runtime instanceof ToolCallingChatOptions toolOptions
? ModelOptionsUtils.copyToTarget(toolOptions, ToolCallingChatOptions.class, OpenAiChatOptions.class)
: ModelOptionsUtils.copyToTarget(runtime, ChatOptions.class, OpenAiChatOptions.class);
OpenAiChatOptions.Builder builder = OpenAiWirePolicyChatModel.mergeOptions(delegate.getOptions(), runtime).mutate();
OpenAiChatOptions patched = builder.build();
Map<String, Object> extra = new LinkedHashMap<>();
Map<String, Object> template = new LinkedHashMap<>();
if (delegate.getDefaultOptions() instanceof OpenAiChatOptions defaults) {
if (delegate.getOptions() instanceof OpenAiChatOptions defaults) {
mergeExtra(extra, template, defaults.getExtraBody());
}
mergeExtra(extra, template, patched.getExtraBody());
template.put("enable_thinking", !"off".equalsIgnoreCase(level));
extra.put("chat_template_kwargs", template);
patched.setExtraBody(extra);
builder.extraBody(extra);
// vLLM template switching does not use OpenAI's effort levels.
patched.setReasoningEffort(null);
return new Prompt(prompt.getInstructions(), patched);
builder.reasoningEffort(null);
return new Prompt(prompt.getInstructions(), builder.build());
}
private static void mergeExtra(Map<String, Object> extra, Map<String, Object> template, Map<String, Object> source) {

View File

@ -0,0 +1,412 @@
package vip.mate.llm.dashscope;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
/** Native provider boundary; the agent runtime owns all tool execution and approvals. */
public final class NativeDashScopeChatModel implements ChatModel {
static final String TEXT_PATH = "/api/v1/services/aigc/text-generation/generation";
static final String MULTIMODAL_PATH = "/api/v1/services/aigc/multimodal-generation/generation";
private final NativeDashScopeTransport transport;
private final NativeDashScopeChatOptions options;
public NativeDashScopeChatModel(
NativeDashScopeTransport transport, NativeDashScopeChatOptions options) {
this.transport = Objects.requireNonNull(transport, "transport");
this.options = Objects.requireNonNull(options, "options");
if (options.getModel() == null || options.getModel().isBlank()) {
throw new IllegalArgumentException("DashScope model is required");
}
}
@Override
public NativeDashScopeChatOptions getOptions() {
return options;
}
@Override
public ChatResponse call(Prompt prompt) {
Request request = request(prompt, false);
return new ResponseDecoder(request.model(), false)
.decode(transport.exchange(request.path(), request.body()));
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return Flux.defer(
() -> {
Request request = request(prompt, true);
ResponseDecoder decoder = new ResponseDecoder(request.model(), true);
return transport.stream(request.path(), request.body())
.map(decoder::decode)
.concatWith(
Flux.defer(
() ->
decoder.hasPendingTools()
? Flux.error(
new NativeDashScopeException(
200,
"InvalidResponse",
"Tool stream ended"
+ " before its"
+ " completion"
+ " marker",
null))
: Flux.empty()));
});
}
private Request request(Prompt prompt, boolean stream) {
NativeDashScopeChatOptions.Builder merged = options.mutate();
if (prompt.getOptions() != null) merged.combineWith(prompt.getOptions().mutate());
if (prompt.getOptions() instanceof ToolCallingChatOptions tools
&& tools.getToolCallbacks() != null) {
merged.toolCallbacks(tools.getToolCallbacks());
}
NativeDashScopeChatOptions runtime = merged.build();
boolean multimodal =
runtime.getModel().toLowerCase(Locale.ROOT).contains("vl")
|| runtime.getModel().toLowerCase(Locale.ROOT).contains("omni")
|| prompt.getInstructions().stream()
.anyMatch(
m ->
m instanceof UserMessage user
&& !user.getMedia().isEmpty());
List<Map<String, Object>> messages = new ArrayList<>();
for (Message message : prompt.getInstructions()) {
if (message instanceof ToolResponseMessage tools) {
for (ToolResponseMessage.ToolResponse tool : tools.getResponses()) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("role", "tool");
entry.put("tool_call_id", tool.id());
entry.put("name", tool.name());
entry.put("content", tool.responseData());
messages.add(entry);
}
continue;
}
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("role", message.getMessageType().getValue());
Object content = message.getText() == null ? "" : message.getText();
if (message instanceof UserMessage user && !user.getMedia().isEmpty()) {
content = mediaContent(user);
} else if (message.getMetadata().containsKey("cache_control")) {
content = List.of(textPart(message));
}
entry.put("content", content);
if (message instanceof AssistantMessage assistant && assistant.hasToolCalls()) {
entry.put(
"tool_calls",
assistant.getToolCalls().stream()
.map(
tool ->
Map.of(
"id",
tool.id(),
"type",
tool.type(),
"function",
Map.of(
"name",
tool.name(),
"arguments",
tool.arguments())))
.toList());
}
messages.add(entry);
}
Map<String, Object> parameters = new LinkedHashMap<>(runtime.getNativeParameters());
put(parameters, "temperature", runtime.getTemperature());
put(parameters, "max_tokens", runtime.getMaxTokens());
put(parameters, "top_p", runtime.getTopP());
put(parameters, "top_k", runtime.getTopK());
// The native API uses repetition/presence penalties and has no frequency_penalty
// equivalent.
put(parameters, "presence_penalty", runtime.getPresencePenalty());
put(parameters, "stop", runtime.getStopSequences());
List<Map<String, Object>> tools = new ArrayList<>();
for (ToolCallback callback :
runtime.getToolCallbacks() == null
? List.<ToolCallback>of()
: runtime.getToolCallbacks()) {
var definition = callback.getToolDefinition();
try {
Object schema =
NativeDashScopeTransport.JSON.readValue(
definition.inputSchema(),
new TypeReference<Map<String, Object>>() {});
tools.add(
Map.of(
"type",
"function",
"function",
Map.of(
"name",
definition.name(),
"description",
definition.description(),
"parameters",
schema)));
} catch (IOException e) {
throw new IllegalArgumentException(
"Invalid input schema for tool " + definition.name(), e);
}
}
if (!tools.isEmpty()) parameters.put("tools", tools);
parameters.put("result_format", "message");
if (stream) parameters.put("incremental_output", true);
return new Request(
multimodal ? MULTIMODAL_PATH : TEXT_PATH,
runtime.getModel(),
Map.of(
"model",
runtime.getModel(),
"input",
Map.of("messages", messages),
"parameters",
parameters));
}
private static List<Map<String, Object>> mediaContent(UserMessage user) {
List<Map<String, Object>> content = new ArrayList<>();
List<String> videoFrames = new ArrayList<>();
boolean frameSequence =
"VIDEO".equalsIgnoreCase(String.valueOf(user.getMetadata().get("messageFormat")));
for (Media media : user.getMedia()) {
String mime = media.getMimeType().toString();
String url = mediaUrl(media);
if (mime.startsWith("audio/")) content.add(Map.of("audio", url));
else if (mime.startsWith("video/")) content.add(Map.of("video", url));
else if (frameSequence) videoFrames.add(url);
else content.add(Map.of("image", url));
}
if (!videoFrames.isEmpty()) content.add(Map.of("video", videoFrames));
content.add(textPart(user));
return content;
}
private static String mediaUrl(Media media) {
Object data = media.getData();
if (data instanceof String url) return url;
if (data instanceof URI uri) return uri.toString();
if (data instanceof byte[] bytes) {
return "data:"
+ media.getMimeType()
+ ";base64,"
+ Base64.getEncoder().encodeToString(bytes);
}
throw new IllegalArgumentException(
"Unsupported DashScope media data type: " + data.getClass().getSimpleName());
}
private static Map<String, Object> textPart(Message message) {
Map<String, Object> part = new LinkedHashMap<>();
part.put("text", message.getText() == null ? "" : message.getText());
put(part, "cache_control", message.getMetadata().get("cache_control"));
return part;
}
private static void put(Map<String, Object> map, String key, Object value) {
if (value != null) map.put(key, value);
}
static DefaultUsage usage(JsonNode usage) {
int input = usage.path("input_tokens").asInt(0);
int output = usage.path("output_tokens").asInt(0);
int total = usage.path("total_tokens").asInt(input + output);
JsonNode promptDetails = usage.path("prompt_tokens_details");
if (promptDetails.isMissingNode()) promptDetails = usage.path("prompt_token_details");
Long cached = nullableLong(promptDetails.path("cached_tokens"));
Long written = nullableLong(promptDetails.path("cache_creation_input_tokens"));
if (written == null)
written =
nullableLong(
promptDetails.path("cache_creation").path("ephemeral_5m_input_tokens"));
int reasoning = usage.path("output_tokens_details").path("reasoning_tokens").asInt(0);
Map<String, Object> details =
usage.isObject()
? NativeDashScopeTransport.JSON.convertValue(
usage, new TypeReference<Map<String, Object>>() {})
: Map.of();
NativeDashScopeUsage nativeUsage =
new NativeDashScopeUsage(
input,
output,
total,
cached,
written,
new NativeDashScopeUsage.CompletionTokenDetails(reasoning),
details);
return new DefaultUsage(input, output, total, nativeUsage, cached, written);
}
private static Long nullableLong(JsonNode value) {
return value.isMissingNode() || value.isNull() ? null : value.asLong();
}
private record Request(String path, String model, Map<String, Object> body) {}
private static final class ResponseDecoder {
private final String model;
private final boolean stream;
private final Map<Integer, Map<Integer, ToolFragment>> pendingTools = new TreeMap<>();
private DefaultUsage lastUsage = new DefaultUsage(0, 0);
private String requestId = "";
ResponseDecoder(String model, boolean stream) {
this.model = model;
this.stream = stream;
}
boolean hasPendingTools() {
return !pendingTools.isEmpty();
}
ChatResponse decode(JsonNode envelope) {
if (envelope.hasNonNull("request_id")) requestId = envelope.get("request_id").asText();
if (envelope.hasNonNull("usage")) lastUsage = usage(envelope.get("usage"));
List<Generation> generations = new ArrayList<>();
JsonNode choices = envelope.path("output").path("choices");
if (!stream && (!choices.isArray() || choices.isEmpty())) {
throw new NativeDashScopeException(
200,
"InvalidResponse",
"Synchronous provider response has no choices",
requestId);
}
if (choices.isArray()) {
int offset = 0;
for (JsonNode choice : choices) {
int choiceIndex = choice.path("index").asInt(offset++);
JsonNode message = choice.path("message");
String finish = choice.path("finish_reason").asText("");
List<AssistantMessage.ToolCall> tools =
tools(choiceIndex, message.path("tool_calls"), finish);
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("id", requestId);
metadata.put("role", message.path("role").asText("assistant"));
metadata.put("finishReason", finish);
metadata.put("reasoningContent", message.path("reasoning_content").asText(""));
JsonNode search = envelope.path("output").path("search_info");
metadata.put(
"search_info",
search.isMissingNode() || search.isNull()
? ""
: NativeDashScopeTransport.JSON.convertValue(
search, Object.class));
AssistantMessage output =
AssistantMessage.builder()
.content(text(message.path("content")))
.properties(metadata)
.toolCalls(tools)
.build();
generations.add(
new Generation(
output,
ChatGenerationMetadata.builder().finishReason(finish).build()));
}
} else if (!envelope.hasNonNull("usage")) {
throw new NativeDashScopeException(
200,
"InvalidResponse",
"Provider response has no choices or usage",
requestId);
}
return new ChatResponse(
generations,
ChatResponseMetadata.builder()
.id(requestId)
.model(model)
.usage(lastUsage)
.build());
}
private List<AssistantMessage.ToolCall> tools(int choice, JsonNode calls, String finish) {
Map<Integer, ToolFragment> fragments =
pendingTools.computeIfAbsent(choice, ignored -> new TreeMap<>());
if (calls.isArray()) {
int offset = 0;
for (JsonNode call : calls) {
int index = call.path("index").asInt(offset++);
ToolFragment fragment =
fragments.computeIfAbsent(index, ignored -> new ToolFragment());
fragment.accept(call);
}
}
if (!stream || !finish.isEmpty() && !"null".equals(finish)) {
pendingTools.remove(choice);
return fragments.values().stream().map(ToolFragment::complete).toList();
}
if (fragments.isEmpty()) pendingTools.remove(choice);
return List.of();
}
private static String text(JsonNode content) {
if (content.isTextual()) return content.asText();
StringBuilder text = new StringBuilder();
if (content.isArray())
for (JsonNode part : content) text.append(part.path("text").asText(""));
return text.toString();
}
}
private static final class ToolFragment {
private String id = "";
private String type = "function";
private final StringBuilder name = new StringBuilder();
private final StringBuilder arguments = new StringBuilder();
void accept(JsonNode call) {
if (call.hasNonNull("id") && !call.get("id").asText().isEmpty())
id = call.get("id").asText();
if (call.hasNonNull("type")) type = call.get("type").asText();
String part = call.path("function").path("name").asText("");
if (!part.equals(name.toString())) name.append(part);
arguments.append(call.path("function").path("arguments").asText(""));
}
AssistantMessage.ToolCall complete() {
try {
if (id.isBlank()
|| name.isEmpty()
|| !NativeDashScopeTransport.JSON
.readTree(arguments.toString())
.isObject()) {
throw new IllegalArgumentException("Missing tool identity or object arguments");
}
} catch (IOException | IllegalArgumentException | NullPointerException e) {
throw new NativeDashScopeException(
200, "InvalidResponse", "Provider returned an incomplete tool call", null);
}
return new AssistantMessage.ToolCall(id, type, name.toString(), arguments.toString());
}
}
}

View File

@ -0,0 +1,128 @@
package vip.mate.llm.dashscope;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.model.tool.DefaultToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Immutable chat options with native provider extensions. */
public final class NativeDashScopeChatOptions extends DefaultToolCallingChatOptions {
private final Map<String, Object> nativeParameters;
private NativeDashScopeChatOptions(Builder builder, ToolCallingChatOptions base) {
super(
base.getToolCallbacks() == null ? null : List.copyOf(base.getToolCallbacks()),
base.getToolContext() == null ? Map.of() : Map.copyOf(base.getToolContext()),
base.getModel(),
base.getFrequencyPenalty(),
base.getMaxTokens(),
base.getPresencePenalty(),
base.getStopSequences() == null ? null : List.copyOf(base.getStopSequences()),
base.getTemperature(),
base.getTopK(),
base.getTopP());
nativeParameters = immutableMap(builder.nativeParameters);
}
private static Map<String, Object> immutableMap(Map<String, Object> source) {
Map<String, Object> copy = new LinkedHashMap<>();
source.forEach((key, value) -> copy.put(key, immutableValue(value)));
return Collections.unmodifiableMap(copy);
}
private static Object immutableValue(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> copy = new LinkedHashMap<>();
map.forEach((key, item) -> copy.put((String) key, immutableValue(item)));
return Collections.unmodifiableMap(copy);
}
if (value instanceof List<?> list) {
List<Object> copy = new ArrayList<>();
list.forEach(item -> copy.add(immutableValue(item)));
return Collections.unmodifiableList(copy);
}
if (value == null
|| value instanceof String
|| value instanceof Number
|| value instanceof Boolean) return value;
throw new IllegalArgumentException(
"Native option values must be JSON scalars, objects, or arrays");
}
public Map<String, Object> getNativeParameters() {
return nativeParameters;
}
public static Builder builder() {
return new Builder();
}
@Override
public Builder mutate() {
Builder builder = new Builder();
builder.model(getModel())
.frequencyPenalty(getFrequencyPenalty())
.maxTokens(getMaxTokens())
.presencePenalty(getPresencePenalty())
.stopSequences(getStopSequences())
.temperature(getTemperature())
.topK(getTopK())
.topP(getTopP())
.toolCallbacks(getToolCallbacks())
.toolContext(getToolContext());
builder.nativeParameters.putAll(nativeParameters);
return builder;
}
public static final class Builder extends DefaultToolCallingChatOptions.Builder<Builder> {
private Map<String, Object> nativeParameters = new LinkedHashMap<>();
public Builder nativeParameter(String name, Object value) {
if (value == null) nativeParameters.remove(name);
else nativeParameters.put(name, value);
return this;
}
public Builder enableSearch(Boolean value) {
return nativeParameter("enable_search", value);
}
public Builder searchOptions(Map<String, Object> value) {
return nativeParameter("search_options", value);
}
public Builder enableThinking(Boolean value) {
return nativeParameter("enable_thinking", value);
}
public Builder thinkingBudget(Integer value) {
return nativeParameter("thinking_budget", value);
}
@Override
public Builder clone() {
Builder copy = (Builder) super.clone();
copy.nativeParameters = new LinkedHashMap<>(nativeParameters);
return copy;
}
@Override
public Builder combineWith(ChatOptions.Builder<?> other) {
super.combineWith(other);
if (other.build() instanceof NativeDashScopeChatOptions nativeOptions) {
nativeParameters.putAll(nativeOptions.nativeParameters);
}
return this;
}
@Override
public NativeDashScopeChatOptions build() {
return new NativeDashScopeChatOptions(this, super.build());
}
}
}

View File

@ -0,0 +1,87 @@
package vip.mate.llm.dashscope;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Retains the existing native provider configuration keys. */
@ConfigurationProperties(prefix = "spring.ai.dashscope")
public class NativeDashScopeConnectionProperties {
private Chat chat = new Chat();
public Chat getChat() {
return chat;
}
public void setChat(Chat chat) {
this.chat = chat;
}
private String apiKey;
private String baseUrl = "https://dashscope.aliyuncs.com";
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public static class Chat {
private Options options = new Options();
public Options getOptions() {
return options;
}
public void setOptions(Options options) {
this.options = options;
}
}
public static class Options {
private String model = "qwen-max";
private Double temperature = 0.7;
private Integer maxTokens = 4096;
private Double topP;
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public Double getTemperature() {
return temperature;
}
public void setTemperature(Double value) {
temperature = value;
}
public Integer getMaxTokens() {
return maxTokens;
}
public void setMaxTokens(Integer value) {
maxTokens = value;
}
public Double getTopP() {
return topP;
}
public void setTopP(Double value) {
topP = value;
}
}
}

View File

@ -0,0 +1,195 @@
package vip.mate.llm.dashscope;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
/** Native text embeddings with response validation and input-order preservation. */
public final class NativeDashScopeEmbeddingModel implements EmbeddingModel {
private static final int MAX_BATCH_SIZE = 10;
static final String PATH = "/api/v1/services/embeddings/text-embedding/text-embedding";
private final NativeDashScopeTransport transport;
private final MetadataMode metadataMode;
private final EmbeddingOptions options;
private final String textType;
public NativeDashScopeEmbeddingModel(
NativeDashScopeTransport transport,
MetadataMode metadataMode,
EmbeddingOptions options) {
this(transport, metadataMode, options, "document");
}
public NativeDashScopeEmbeddingModel(
NativeDashScopeTransport transport,
MetadataMode metadataMode,
EmbeddingOptions options,
String textType) {
this.transport = Objects.requireNonNull(transport, "transport");
this.metadataMode = Objects.requireNonNull(metadataMode, "metadataMode");
this.options = Objects.requireNonNull(options, "options");
if (options.getModel() == null || options.getModel().isBlank()) {
throw new IllegalArgumentException("DashScope embedding model is required");
}
if (!"document".equals(textType) && !"query".equals(textType)) {
throw new IllegalArgumentException(
"DashScope embedding text type must be document or query");
}
this.textType = textType;
}
@Override
public float[] embed(Document document) {
return embed(getEmbeddingContent(document));
}
@Override
public String getEmbeddingContent(Document document) {
return Objects.requireNonNull(document, "document").getFormattedContent(metadataMode);
}
@Override
public int dimensions() {
return options.getDimensions() == null
? EmbeddingModel.super.dimensions()
: options.getDimensions();
}
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
String model = options.getModel();
Integer dimension = options.getDimensions();
if (request.getOptions() != null) {
if (request.getOptions().getModel() != null) model = request.getOptions().getModel();
if (request.getOptions().getDimensions() != null)
dimension = request.getOptions().getDimensions();
}
if (dimension != null && dimension <= 0)
throw new IllegalArgumentException("Embedding dimension must be positive");
if (request.getInstructions().isEmpty()) return new EmbeddingResponse(List.of());
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("text_type", textType);
if (dimension != null) parameters.put("dimension", dimension);
TreeMap<Integer, Embedding> ordered = new TreeMap<>();
Integer actualDimension = dimension;
List<String> requestIds = new ArrayList<>();
List<Map<String, Object>> usageDetails = new ArrayList<>();
int inputTokens = 0;
int outputTokens = 0;
int totalTokens = 0;
long cacheRead = 0;
long cacheWritten = 0;
int reasoningTokens = 0;
for (int offset = 0; offset < request.getInstructions().size(); offset += MAX_BATCH_SIZE) {
List<String> texts =
request.getInstructions()
.subList(
offset,
Math.min(
offset + MAX_BATCH_SIZE,
request.getInstructions().size()));
JsonNode result =
transport.exchange(
PATH,
Map.of(
"model",
model,
"input",
Map.of("texts", texts),
"parameters",
parameters));
JsonNode entries = result.path("output").path("embeddings");
if (!entries.isArray() || entries.size() != texts.size()) {
throw invalid("Provider returned a different number of embedding vectors", result);
}
for (JsonNode entry : entries) {
JsonNode indexNode = entry.path("text_index");
int index = indexNode.asInt(-1);
JsonNode values = entry.path("embedding");
int globalIndex = offset + index;
if (!indexNode.isIntegralNumber()
|| !indexNode.canConvertToInt()
|| index < 0
|| index >= entries.size()
|| ordered.containsKey(globalIndex)
|| !values.isArray()
|| values.isEmpty()) {
throw invalid("Provider returned an invalid embedding index or vector", result);
}
if (actualDimension == null) actualDimension = values.size();
if (values.size() != actualDimension) {
throw invalid("Provider returned inconsistent embedding dimensions", result);
}
float[] vector = new float[values.size()];
for (int i = 0; i < vector.length; i++) {
if (!values.get(i).isNumber())
throw invalid("Provider returned a nonnumeric embedding value", result);
vector[i] = values.get(i).floatValue();
if (!Float.isFinite(vector[i]))
throw invalid("Provider returned a nonfinite embedding value", result);
}
ordered.put(globalIndex, new Embedding(vector, globalIndex));
}
if (result.hasNonNull("request_id")) requestIds.add(result.get("request_id").asText());
DefaultUsage batchUsage = NativeDashScopeChatModel.usage(result.path("usage"));
inputTokens = Math.addExact(inputTokens, batchUsage.getPromptTokens());
outputTokens = Math.addExact(outputTokens, batchUsage.getCompletionTokens());
totalTokens = Math.addExact(totalTokens, batchUsage.getTotalTokens());
if (batchUsage.getCacheReadInputTokens() != null)
cacheRead = Math.addExact(cacheRead, batchUsage.getCacheReadInputTokens());
if (batchUsage.getCacheWriteInputTokens() != null)
cacheWritten = Math.addExact(cacheWritten, batchUsage.getCacheWriteInputTokens());
NativeDashScopeUsage nativeUsage = (NativeDashScopeUsage) batchUsage.getNativeUsage();
reasoningTokens =
Math.addExact(
reasoningTokens,
nativeUsage.completionTokenDetails().reasoningTokens());
usageDetails.add(nativeUsage.details());
}
Map<String, Object> metadata = new LinkedHashMap<>();
if (!requestIds.isEmpty()) {
metadata.put("id", requestIds.getFirst());
metadata.put("requestIds", List.copyOf(requestIds));
}
NativeDashScopeUsage nativeUsage =
new NativeDashScopeUsage(
inputTokens,
outputTokens,
totalTokens,
cacheRead,
cacheWritten,
new NativeDashScopeUsage.CompletionTokenDetails(reasoningTokens),
Map.of("batches", List.copyOf(usageDetails)));
DefaultUsage usage =
new DefaultUsage(
inputTokens,
outputTokens,
totalTokens,
nativeUsage,
cacheRead,
cacheWritten);
return new EmbeddingResponse(
new ArrayList<>(ordered.values()),
new EmbeddingResponseMetadata(model, usage, metadata));
}
private static NativeDashScopeException invalid(String message, JsonNode result) {
return new NativeDashScopeException(
200, "InvalidResponse", message, result.path("request_id").asText(null));
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.llm.dashscope;
/** Preserves native provider error codes for model failover classification. */
public final class NativeDashScopeException extends RuntimeException {
private final int status;
private final String code;
private final String requestId;
public NativeDashScopeException(int status, String code, String message, String requestId) {
super(
"DashScope HTTP "
+ status
+ " ["
+ code
+ "] "
+ message
+ (requestId == null ? "" : " (requestId: " + requestId + ")"));
this.status = status;
this.code = code;
this.requestId = requestId;
}
public int getStatus() {
return status;
}
public String getCode() {
return code;
}
public String getRequestId() {
return requestId;
}
}

View File

@ -0,0 +1,325 @@
package vip.mate.llm.dashscope;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/** Native JSON/SSE transport with subscription-local cancellation and timeout. */
public final class NativeDashScopeTransport {
static final ObjectMapper JSON = new ObjectMapper();
private static final ScheduledThreadPoolExecutor READ_DEADLINES = readDeadlines();
private final String apiKey;
private final String baseUrl;
private final HttpClient client;
private final Duration timeout;
public NativeDashScopeTransport(
String apiKey, String baseUrl, HttpClient client, Duration timeout) {
if (apiKey == null || apiKey.isBlank())
throw new IllegalArgumentException("DashScope API key is required");
if (timeout == null || timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("DashScope timeout must be positive");
}
this.apiKey = apiKey.trim();
this.baseUrl = normalizeBaseUrl(baseUrl);
this.client = Objects.requireNonNull(client, "client");
this.timeout = timeout;
}
public NativeDashScopeTransport(String apiKey, String baseUrl) {
this(
apiKey,
baseUrl,
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(),
Duration.ofMinutes(5));
}
static String normalizeBaseUrl(String baseUrl) {
String url =
baseUrl == null || baseUrl.isBlank()
? "https://dashscope.aliyuncs.com"
: baseUrl.trim();
int compatible = url.indexOf("/compatible-mode");
int after = compatible + "/compatible-mode".length();
if (compatible >= 0 && (after == url.length() || url.charAt(after) == '/')) {
url = url.substring(0, compatible);
}
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
URI uri = URI.create(url);
if (!("http".equalsIgnoreCase(uri.getScheme())
|| "https".equalsIgnoreCase(uri.getScheme()))) {
throw new IllegalArgumentException("DashScope base URL must use HTTP or HTTPS");
}
return url;
}
JsonNode exchange(String path, Map<String, Object> body) {
try {
HttpResponse<InputStream> response =
client.send(
request(path, body, false), HttpResponse.BodyHandlers.ofInputStream());
try (InputStream input = response.body()) {
ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int count;
while ((count = readWithDeadline(input, buffer)) != -1)
bodyBytes.write(buffer, 0, count);
return parse(bodyBytes.toString(StandardCharsets.UTF_8), response.statusCode());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("DashScope request interrupted", e);
} catch (IOException e) {
throw new IllegalStateException("DashScope request failed: " + e.getMessage(), e);
}
}
private int readWithDeadline(InputStream input, byte[] buffer) throws IOException {
long started = System.nanoTime();
long timeoutNanos = timeout.toNanos();
AtomicBoolean settled = new AtomicBoolean();
var deadline =
READ_DEADLINES.schedule(
() -> {
if (settled.compareAndSet(false, true)) close(input);
},
timeout.toNanos(),
TimeUnit.NANOSECONDS);
int count;
try {
count = input.read(buffer);
} catch (IOException failure) {
if (!settled.compareAndSet(false, true)) throw readTimeout(failure);
throw failure;
} finally {
deadline.cancel(false);
}
if (!settled.compareAndSet(false, true) || System.nanoTime() - started >= timeoutNanos) {
throw readTimeout(null);
}
return count;
}
private static SocketTimeoutException readTimeout(IOException cause) {
SocketTimeoutException timeout =
new SocketTimeoutException("DashScope response body read timed out");
if (cause != null) timeout.initCause(cause);
return timeout;
}
private static ScheduledThreadPoolExecutor readDeadlines() {
var deadlines =
new ScheduledThreadPoolExecutor(
1,
runnable -> {
Thread thread = new Thread(runnable, "dashscope-read-deadline");
thread.setDaemon(true);
return thread;
});
// Completed reads must not retain response bodies until their original deadline.
deadlines.setRemoveOnCancelPolicy(true);
return deadlines;
}
Flux<JsonNode> stream(String path, Map<String, Object> body) {
return Flux.<JsonNode>create(
sink -> {
AtomicReference<InputStream> input = new AtomicReference<>();
var pending =
client.sendAsync(
request(path, body, true),
HttpResponse.BodyHandlers.ofInputStream());
sink.onDispose(
() -> {
pending.cancel(true);
close(input.getAndSet(null));
});
pending.whenComplete(
(response, failure) -> {
if (failure != null) {
if (!sink.isCancelled()) sink.error(failure);
return;
}
input.set(response.body());
if (sink.isCancelled()) {
close(input.getAndSet(null));
return;
}
Schedulers.boundedElastic()
.schedule(
() -> {
try (InputStream stream =
response.body();
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
stream,
StandardCharsets
.UTF_8))) {
if (response.statusCode() < 200
|| response.statusCode()
>= 300) {
parse(
new String(
stream
.readAllBytes(),
StandardCharsets
.UTF_8),
response.statusCode());
}
String contentType =
response.headers()
.firstValue(
"Content-Type")
.orElse("");
if (!contentType
.toLowerCase()
.contains(
"text/event-stream")) {
sink.next(
parse(
new String(
stream
.readAllBytes(),
StandardCharsets
.UTF_8),
response
.statusCode()));
sink.complete();
return;
}
StringBuilder data =
new StringBuilder();
String line;
while (!sink.isCancelled()
&& (line =
reader
.readLine())
!= null) {
if (line.isEmpty()) {
if (data.length() == 0)
continue;
String event =
data.toString();
data.setLength(0);
if ("[DONE]"
.equals(
event
.trim()))
break;
sink.next(
parse(
event,
response
.statusCode()));
} else if (line.startsWith(
"data:")) {
if (data.length() > 0)
data.append('\n');
String value =
line.substring(5);
data.append(
value.startsWith(
" ")
? value
.substring(
1)
: value);
}
}
if (!sink.isCancelled()
&& data.length() > 0
&& !"[DONE]"
.equals(
data.toString()
.trim())) {
sink.next(
parse(
data.toString(),
response
.statusCode()));
}
if (!sink.isCancelled())
sink.complete();
} catch (Exception e) {
if (!sink.isCancelled())
sink.error(e);
} finally {
input.set(null);
}
});
});
})
.timeout(timeout);
}
private HttpRequest request(String path, Map<String, Object> body, boolean stream) {
try {
HttpRequest.Builder builder =
HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(timeout)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Accept", stream ? "text/event-stream" : "application/json")
.POST(
HttpRequest.BodyPublishers.ofString(
JSON.writeValueAsString(body), StandardCharsets.UTF_8));
if (stream) builder.header("X-DashScope-SSE", "enable");
return builder.build();
} catch (IOException e) {
throw new IllegalArgumentException("Cannot encode DashScope request", e);
}
}
private static JsonNode parse(String body, int status) {
JsonNode result;
try {
result = JSON.readTree(body);
} catch (IOException e) {
throw new NativeDashScopeException(
status, "InvalidResponse", "Provider returned invalid JSON", null);
}
if (result == null || !result.isObject()) {
throw new NativeDashScopeException(
status, "InvalidResponse", "Provider returned no JSON object", null);
}
if (status < 200 || status >= 300 || !result.path("code").asText("").isBlank()) {
throw new NativeDashScopeException(
status,
result.path("code").asText("HTTP_" + status),
result.path("message").asText("Provider request failed"),
result.path("request_id").asText(null));
}
return result;
}
private static void close(InputStream stream) {
if (stream != null) {
try {
stream.close();
} catch (IOException ignored) {
}
}
}
}

View File

@ -0,0 +1,15 @@
package vip.mate.llm.dashscope;
import java.util.Map;
/** Native counters expose stable accessors to cache and reasoning token accounting. */
public record NativeDashScopeUsage(
Integer inputTokens,
Integer outputTokens,
Integer totalTokens,
Long cacheReadInputTokens,
Long cacheCreationInputTokens,
CompletionTokenDetails completionTokenDetails,
Map<String, Object> details) {
public record CompletionTokenDetails(Integer reasoningTokens) {}
}

View File

@ -1,239 +1,99 @@
package vip.mate.llm.embedding;
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.embedding.text.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.dashscope.embedding.text.DashScopeEmbeddingOptions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.NoopApiKey;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.HttpTimeouts;
import vip.mate.llm.chatmodel.ProviderGenerateKwargs;
import vip.mate.llm.dashscope.NativeDashScopeConnectionProperties;
import vip.mate.llm.dashscope.NativeDashScopeEmbeddingModel;
import vip.mate.llm.dashscope.NativeDashScopeTransport;
import vip.mate.llm.model.EmbeddingProtocol;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.net.http.HttpClient;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
/**
* Embedding 模型工厂
* <p>
* 根据 {@link ModelConfigEntity}(model_type='embedding')构造对应的 {@link EmbeddingModel}:
* <ul>
* <li>DashScope:复用 provider UI 配置的 apiKey/baseUrl,构造 {@link DashScopeEmbeddingModel}</li>
* <li>OpenAI 兼容(OpenAI / DeepSeek / 智谱 / Kimi / Moonshot / Ollama 本地等):构造 {@link OpenAiEmbeddingModel}</li>
* </ul>
* <p>
* 设计要点:
* <ol>
* <li><b>Provider api_key 共用</b>:与 chat 模型共用 {@code mate_model_provider.api_key},避免重复配置</li>
* <li><b>缓存</b>:按 ModelConfigEntity.id 缓存 EmbeddingModel 实例,避免每次查询都重建</li>
* <li><b>回落</b>:provider apiKey 未配时,DashScope 走 yml 的 {@code spring.ai.dashscope.api-key}
* (兼容老用户),其他 provider 直接报错让用户去 UI 配置</li>
* <li><b>独立实现</b>:不引用 {@code AgentGraphBuilder} 的 chat-specific 重写(如 reasoning content patch),
* embedding 的请求格式稳定,不需要这些补丁</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
/** Builds native or compatible embedding clients and invalidates them after configuration changes. */
@Component
@RequiredArgsConstructor
public class EmbeddingModelFactory {
private static final Pattern VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
private final ModelProviderService providerService;
private final DashScopeConnectionProperties dashScopeConnectionProperties;
/** 构造 API 时共享的 retry template(用 Spring AI 默认) */
private static final RetryTemplate DEFAULT_RETRY = RetryUtils.DEFAULT_RETRY_TEMPLATE;
/**
* Trailing "/v{digits}" segment in a base URL — the OpenAI-compatible
* convention (/v1 OpenAI, /v3 Volcano Ark, /v4 Zhipu). When the base URL
* already carries a version segment, the default "/v1/embeddings" path
* would build a broken URL like ".../api/paas/v4/v1/embeddings".
*/
private static final Pattern BASE_URL_VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
/** 按 modelConfig.id 缓存 EmbeddingModel,config 变更时调用 {@link #evict} 清除 */
private final NativeDashScopeConnectionProperties properties;
private final ConcurrentHashMap<Long, EmbeddingModel> cache = new ConcurrentHashMap<>();
/**
* 构造或复用指定配置对应的 EmbeddingModel
*
* @throws MateClawException 当 provider 未配置或协议不支持时
*/
public EmbeddingModel build(ModelConfigEntity modelConfig) {
if (modelConfig == null) {
throw new MateClawException("err.embedding.config_null", "Embedding model config is null");
}
if (modelConfig.getId() != null) {
EmbeddingModel cached = cache.get(modelConfig.getId());
if (cached != null) return cached;
}
EmbeddingModel fresh = doBuild(modelConfig);
if (modelConfig.getId() != null) {
cache.put(modelConfig.getId(), fresh);
}
return fresh;
public EmbeddingModelFactory(ModelProviderService providerService, NativeDashScopeConnectionProperties properties) {
this.providerService = providerService;
this.properties = properties;
}
/**
* 清除指定配置的缓存实例(provider api_key 变更 / 模型切换时调用)
*/
public void evict(Long modelConfigId) {
if (modelConfigId != null) {
cache.remove(modelConfigId);
}
public EmbeddingModel build(ModelConfigEntity config) {
if (config == null) throw new MateClawException("err.embedding.config_null", "Embedding model config is null");
return config.getId() == null ? doBuild(config) : cache.computeIfAbsent(config.getId(), ignored -> doBuild(config));
}
public void evict(Long id) { if (id != null) cache.remove(id); }
public void evictAll() { cache.clear(); }
/** 清空所有缓存(provider 表刷新后调用) */
public void evictAll() {
cache.clear();
}
/**
* Pick the embedding protocol from the provider's {@code chatModel} column.
* Package-private for unit testing.
* <p>
* dashscope-compat carries 'dashscope' in its providerId but is wired with
* OpenAIChatModel + compatible-mode base URL, so substring-matching the
* providerId routed it to the native DashScope embedding endpoint and 404'd.
* Using the {@code chatModel} column matches {@link ModelProtocol#fromChatModel}
* for the chat path — same signal, same case-insensitive trim semantics.
*/
static EmbeddingProtocol resolveEmbeddingProtocol(String chatModel) {
if (chatModel == null || chatModel.isBlank()) {
return EmbeddingProtocol.OPENAI_EMBEDDING;
}
return "DashScopeChatModel".equalsIgnoreCase(chatModel.trim())
? EmbeddingProtocol.DASHSCOPE_EMBEDDING
: EmbeddingProtocol.OPENAI_EMBEDDING;
return chatModel != null && "DashScopeChatModel".equalsIgnoreCase(chatModel.trim())
? EmbeddingProtocol.DASHSCOPE_EMBEDDING : EmbeddingProtocol.OPENAI_EMBEDDING;
}
// ==================== 内部实现 ====================
private EmbeddingModel doBuild(ModelConfigEntity modelConfig) {
ModelProviderEntity provider = providerService.getProviderConfig(modelConfig.getProvider());
if (provider == null) {
throw new MateClawException("err.embedding.provider_missing",
"Embedding provider '" + modelConfig.getProvider() + "' not found in mate_model_provider");
}
EmbeddingProtocol protocol = resolveEmbeddingProtocol(provider.getChatModel());
log.info("[EmbeddingFactory] Building embedding model: provider={}, chatModel={}, model={}, protocol={}",
provider.getProviderId(), provider.getChatModel(), modelConfig.getModelName(), protocol);
return switch (protocol) {
case DASHSCOPE_EMBEDDING -> buildDashScope(provider, modelConfig);
case OPENAI_EMBEDDING -> buildOpenAi(provider, modelConfig);
};
private EmbeddingModel doBuild(ModelConfigEntity config) {
ModelProviderEntity provider = providerService.getProviderConfig(config.getProvider());
if (provider == null) throw new MateClawException("err.embedding.provider_missing",
"Embedding provider '" + config.getProvider() + "' not found in mate_model_provider");
Map<String, Object> kwargs = providerService.readProviderGenerateKwargs(provider);
Integer timeout = config.getRequestTimeoutSeconds() != null && config.getRequestTimeoutSeconds() > 0
? config.getRequestTimeoutSeconds() : ProviderGenerateKwargs.resolveIntegerOption("requestTimeoutSeconds", null, kwargs);
Integer dimension = ProviderGenerateKwargs.resolveIntegerOption("dimensions", null, kwargs);
return resolveEmbeddingProtocol(provider.getChatModel()) == EmbeddingProtocol.DASHSCOPE_EMBEDDING
? buildNative(config, provider, kwargs, timeout, dimension) : buildCompatible(config, provider, timeout, dimension);
}
private EmbeddingModel buildDashScope(ModelProviderEntity provider, ModelConfigEntity modelConfig) {
// API Key 回落链:provider UI → yml
private EmbeddingModel buildNative(ModelConfigEntity config, ModelProviderEntity provider,
Map<String, Object> kwargs, Integer timeout, Integer dimension) {
String apiKey = provider.getApiKey();
if (!StringUtils.hasText(apiKey) || !providerService.hasUsableApiKey(apiKey)) {
apiKey = dashScopeConnectionProperties.getApiKey();
}
if (!providerService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.embedding.dashscope_key_missing",
"DashScope API Key 未配置。请在模型设置中填写 dashscope provider 的 API Key。");
}
DashScopeApi.Builder apiBuilder = DashScopeApi.builder().apiKey(apiKey.trim());
// Base URL 可选
String baseUrl = provider.getBaseUrl();
if (!StringUtils.hasText(baseUrl)) {
baseUrl = dashScopeConnectionProperties.getBaseUrl();
}
if (StringUtils.hasText(baseUrl)) {
apiBuilder.baseUrl(baseUrl.trim());
}
DashScopeApi api = apiBuilder.build();
DashScopeEmbeddingOptions options = DashScopeEmbeddingOptions.builder()
.withModel(modelConfig.getModelName())
.build();
return new DashScopeEmbeddingModel(api, MetadataMode.EMBED, options, DEFAULT_RETRY);
if (!providerService.hasUsableApiKey(apiKey)) apiKey = properties.getApiKey();
if (!providerService.hasUsableApiKey(apiKey)) throw new MateClawException("err.embedding.dashscope_key_missing",
"DashScope API Key 未配置。请在模型设置中填写 API Key。");
String baseUrl = StringUtils.hasText(provider.getBaseUrl()) ? provider.getBaseUrl() : properties.getBaseUrl();
HttpClient http = HttpClient.newBuilder().connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1).build();
Object configuredType = ProviderGenerateKwargs.findOptionValue(kwargs, "textType");
String textType = configuredType == null ? "document" : String.valueOf(configuredType);
return new NativeDashScopeEmbeddingModel(new NativeDashScopeTransport(apiKey, baseUrl, http,
HttpTimeouts.resolveReadTimeout(timeout)), MetadataMode.EMBED,
EmbeddingOptions.builder().model(config.getModelName()).dimensions(dimension).build(), textType);
}
private EmbeddingModel buildOpenAi(ModelProviderEntity provider, ModelConfigEntity modelConfig) {
if (!providerService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.embedding.provider_not_configured",
"Provider '" + provider.getProviderId() + "' 未完成配置(缺少 API Key 或 Base URL)");
}
String apiKey = provider.getApiKey();
// Mirror the chat model builder: only require an API key when the provider row says so.
// Keyless providers (requireApiKey=false, e.g. OpenCode, local Ollama) must be allowed
// through; otherwise their cloud-model connection test passes but embedding test fails.
boolean keyRequired = !Boolean.FALSE.equals(provider.getRequireApiKey());
if (keyRequired && !providerService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.embedding.openai_key_invalid",
"Provider API Key 未配置或无效: " + provider.getProviderId());
}
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
if (!StringUtils.hasText(baseUrl)) {
throw new MateClawException("err.embedding.openai_baseurl_missing",
"Provider Base URL 未配置: " + provider.getProviderId());
}
// Mirror the chat path: real keys go through SimpleApiKey so a Bearer header
// is attached, while keyless providers (requireApiKey=false, e.g. Ollama,
// OpenCode) get a NoopApiKey so no Authorization header is sent at all —
// strict gateways reject "Authorization: Bearer " with an empty token.
ApiKey apiKeyImpl = providerService.hasUsableApiKey(apiKey)
? new SimpleApiKey(apiKey.trim())
: new NoopApiKey();
// 最简构造:不做 chat-specific 的 header 重写、reasoning patch 等
OpenAiApi api = OpenAiApi.builder()
.baseUrl(baseUrl)
.apiKey(apiKeyImpl)
.embeddingsPath(resolveEmbeddingsPath(baseUrl))
.build();
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
.model(modelConfig.getModelName())
.build();
return new OpenAiEmbeddingModel(api, MetadataMode.EMBED, options, DEFAULT_RETRY);
}
/** 归一化 OpenAI base URL:去掉末尾的 /v1 / 斜杠(避免双 /v1) */
private String normalizeOpenAiBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) return null;
String u = baseUrl.trim();
while (u.endsWith("/")) u = u.substring(0, u.length() - 1);
if (u.endsWith("/v1")) u = u.substring(0, u.length() - 3);
return u;
}
/**
* Resolve the embeddings path. Defaults to {@code /v1/embeddings}; when the
* base URL already ends with a version segment (Zhipu {@code /v4}, Volcano
* Ark {@code /v3}, …), drop the leading {@code /v1} so the request hits
* {@code <base>/embeddings} instead of a 404 at {@code <base>/v1/embeddings}.
*/
private String resolveEmbeddingsPath(String baseUrl) {
if (baseUrl != null && BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()) {
return "/embeddings";
}
return "/v1/embeddings";
private EmbeddingModel buildCompatible(ModelConfigEntity config, ModelProviderEntity provider,
Integer timeout, Integer dimension) {
if (!providerService.isProviderConfigured(provider.getProviderId())) throw new MateClawException(
"err.embedding.provider_not_configured", "Provider 未完成配置: " + provider.getProviderId());
String key = provider.getApiKey();
boolean usableKey = providerService.hasUsableApiKey(key);
if (!Boolean.FALSE.equals(provider.getRequireApiKey()) && !usableKey) throw new MateClawException(
"err.embedding.openai_key_invalid", "Provider API Key 未配置或无效: " + provider.getProviderId());
String url = provider.getBaseUrl();
if (!StringUtils.hasText(url)) throw new MateClawException("err.embedding.openai_baseurl_missing",
"Provider Base URL 未配置: " + provider.getProviderId());
url = url.trim();
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
if (!VERSION_SUFFIX.matcher(url).matches()) url += "/v1";
// The SDK's empty-string API-key mode omits Authorization for keyless local providers.
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder().model(config.getModelName())
.baseUrl(url).apiKey(usableKey ? key.trim() : "").dimensions(dimension)
.timeout(HttpTimeouts.resolveReadTimeout(timeout)).maxRetries(1).build();
return OpenAiEmbeddingModel.builder().metadataMode(MetadataMode.EMBED).options(options).build();
}
}

View File

@ -109,10 +109,11 @@ public class GeminiChatModel implements ChatModel {
}
@Override
public ChatOptions getDefaultOptions() {
return ChatOptions.builder()
public ChatOptions getOptions() {
return ToolCallingChatOptions.builder()
.model(modelName)
.temperature(temperature)
.maxTokens(maxTokens)
.build();
}

View File

@ -8,10 +8,13 @@ import org.springframework.transaction.annotation.Transactional;
import vip.mate.skill.model.SkillFileEntity;
import vip.mate.skill.repository.SkillFileMapper;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@ -77,11 +80,22 @@ public class SkillFileService {
*/
@Transactional
public ApplyResult applyBundleFiles(Long skillId, Map<String, String> newFiles, boolean force) {
Map<String, byte[]> bytes = new HashMap<>();
if (newFiles != null) {
newFiles.forEach((path, content) -> bytes.put(path,
(content == null ? "" : content).getBytes(StandardCharsets.UTF_8)));
}
return applyBundleBytes(skillId, bytes, force);
}
/** Apply a full DB-eligible bundle without decoding binary attachments as text. */
@Transactional
public ApplyResult applyBundleBytes(Long skillId, Map<String, byte[]> newFiles, boolean force) {
if (skillId == null) {
return new ApplyResult(0, 0, false, false);
}
Map<String, String> incoming = newFiles == null ? Map.of() : newFiles;
Map<String, byte[]> incoming = newFiles == null ? Map.of() : newFiles;
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
boolean newHasRefs = bucketHasEntries(incoming, "references/");
boolean newHasTemplates = bucketHasEntries(incoming, "templates/");
@ -126,9 +140,11 @@ public class SkillFileService {
LocalDateTime now = LocalDateTime.now();
for (var entry : incoming.entrySet()) {
String path = entry.getKey();
String content = entry.getValue() == null ? "" : entry.getValue();
String hash = sha256Hex(content);
int size = content.getBytes(StandardCharsets.UTF_8).length;
byte[] bytes = entry.getValue() == null ? new byte[0] : entry.getValue();
EncodedContent encoded = encode(bytes);
String content = encoded.content();
String hash = sha256Hex(bytes);
int size = bytes.length;
SkillFileEntity prior = existingByPath.get(path);
if (prior == null) {
@ -136,16 +152,17 @@ public class SkillFileService {
row.setSkillId(skillId);
row.setFilePath(path);
row.setContent(content);
row.setContentEncoding("utf8");
row.setContentEncoding(encoded.encoding());
row.setContentSize(size);
row.setSha256(hash);
row.setCreateTime(now);
row.setUpdateTime(now);
mapper.insert(row);
written++;
} else if (!hash.equals(prior.getSha256())) {
} else if (!hash.equals(prior.getSha256())
|| !encoded.encoding().equals(prior.isBinary() ? "base64" : "utf8")) {
prior.setContent(content);
prior.setContentEncoding("utf8");
prior.setContentEncoding(encoded.encoding());
prior.setContentSize(size);
prior.setSha256(hash);
prior.setUpdateTime(now);
@ -206,15 +223,9 @@ public class SkillFileService {
/** Preserve arbitrary bytes; only strictly valid UTF-8 without control bytes is editable text. */
@Transactional
public SkillFileEntity upsertBytes(Long skillId, String filePath, byte[] bytes) {
String text = null;
try {
text = StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes)).toString();
if (text.codePoints().anyMatch(c -> c < 32 && c != '\n' && c != '\r' && c != '\t')) text = null;
} catch (java.nio.charset.CharacterCodingException binary) {
// Keep the original bytes below.
}
String encoding = text == null ? "base64" : "utf8";
String content = text == null ? java.util.Base64.getEncoder().encodeToString(bytes) : text;
EncodedContent encoded = encode(bytes);
String content = encoded.content();
String encoding = encoded.encoding();
String hash = sha256Hex(bytes);
SkillFileEntity row = getFile(skillId, filePath);
boolean insert = row == null;
@ -245,7 +256,22 @@ public class SkillFileService {
return true;
}
private boolean bucketHasEntries(Map<String, String> files, String prefix) {
private static EncodedContent encode(byte[] bytes) {
String text = null;
try {
text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
if (text.codePoints().anyMatch(c -> c < 32 && c != '\n' && c != '\r' && c != '\t')) text = null;
} catch (CharacterCodingException binary) {
// Keep the original bytes below.
}
String encoding = text == null ? "base64" : "utf8";
String content = text == null ? Base64.getEncoder().encodeToString(bytes) : text;
return new EncodedContent(content, encoding);
}
private record EncodedContent(String content, String encoding) {}
private boolean bucketHasEntries(Map<String, ?> files, String prefix) {
for (String key : files.keySet()) {
if (key != null && key.startsWith(prefix)) return true;
}

View File

@ -20,9 +20,12 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -129,6 +132,7 @@ public class BundledSkillSyncer {
} else {
List<String> missing = missingBucketsOnDisk(source, targetDir);
if (missing.isEmpty()) {
repairMissingBundleRows(skillName, source, targetDir);
log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping",
skillName, bundledVersion, workspaceVersion);
return false;
@ -180,14 +184,42 @@ public class BundledSkillSyncer {
SkillEntity skill = skillService.findByName(skillName);
if (skill == null || skill.getId() == null) return;
try {
Map<String, String> bundleFiles = SkillBundleFiles.readDbEligible(source);
Map<String, byte[]> bundleFiles = SkillBundleFiles.readDbEligibleBytes(source);
if (bundleFiles.isEmpty()) return;
skillFileService.applyBundleFiles(skill.getId(), bundleFiles, false);
skillFileService.applyBundleBytes(skill.getId(), bundleFiles, false);
} catch (IOException e) {
log.warn("Failed to load bundle files from {}: {}", source.origin(), e.getMessage());
}
}
/** Fill only absent canonical rows from the builtin cache, retaining local versions and edits. */
private void repairMissingBundleRows(String skillName, SkillBundleSource source, Path targetDir) {
SkillEntity skill = skillService.findByName(skillName);
if (skill == null || skill.getId() == null || !Boolean.TRUE.equals(skill.getBuiltin())) return;
Set<String> storedPaths = skillFileService.listBySkillId(skill.getId()).stream()
.map(row -> row.getFilePath()).collect(Collectors.toSet());
try {
for (SkillBundleSource.BundleAsset asset : source.assets()) {
String relative = asset.relativePath();
if (!SkillBundleFiles.isDbEligible(relative) || storedPaths.contains(relative)) continue;
Path file = targetDir.resolve(relative).normalize();
if (!file.startsWith(targetDir.normalize())) continue;
boolean symbolic = false;
for (Path part = file; part != null && part.startsWith(targetDir); part = part.getParent()) {
if (Files.isSymbolicLink(part)) {
symbolic = true;
break;
}
}
if (symbolic || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) continue;
skillFileService.upsertBytes(skill.getId(), relative, Files.readAllBytes(file));
storedPaths.add(relative);
}
} catch (IOException e) {
log.warn("Failed to repair missing bundle rows for '{}': {}", skillName, e.getMessage());
}
}
private void copyBundle(SkillBundleSource source, Path targetDir) {
try {
bundleMaterializer.materialize(source, targetDir, MaterializeOptions.verbatim());

View File

@ -146,16 +146,16 @@ public class SkillFileSyncer {
SkillBundleSource source = new ClasspathBundleSource(resolver,
bundledPath + "/" + skill.getName());
Map<String, String> ingested;
Map<String, byte[]> ingested;
try {
ingested = SkillBundleFiles.readDbEligible(source);
ingested = SkillBundleFiles.readDbEligibleBytes(source);
} catch (IOException e) {
log.warn("Failed to backfill builtin skill '{}' from classpath: {}", skill.getName(), e.getMessage());
return 0;
}
if (ingested.isEmpty()) return 0;
skillFileService.applyBundleFiles(skill.getId(), ingested, false);
skillFileService.applyBundleBytes(skill.getId(), ingested, false);
log.info("Backfilled {} bundle file(s) from classpath into mate_skill_file for builtin skill '{}' (id={})",
ingested.size(), skill.getName(), skill.getId());
return ingested.size();
@ -225,7 +225,7 @@ public class SkillFileSyncer {
}
if (roots.isEmpty()) return 0;
Map<String, String> ingested = new LinkedHashMap<>();
Map<String, byte[]> ingested = new LinkedHashMap<>();
Set<String> seen = new HashSet<>();
for (Path root : roots) {
String prefix = workspaceDir.relativize(root).toString().replace('\\', '/') + "/";
@ -236,7 +236,7 @@ public class SkillFileSyncer {
if (!relative.startsWith(prefix)) continue;
if (!seen.add(relative)) continue;
try {
String content = Files.readString(f, StandardCharsets.UTF_8);
byte[] content = Files.readAllBytes(f);
ingested.put(relative, content);
} catch (IOException e) {
log.warn("Backfill skipped {} (read failed: {})", f, e.getMessage());
@ -248,7 +248,7 @@ public class SkillFileSyncer {
}
if (ingested.isEmpty()) return 0;
skillFileService.applyBundleFiles(skill.getId(), ingested, false);
skillFileService.applyBundleBytes(skill.getId(), ingested, false);
log.info("Backfilled {} bundle file(s) into mate_skill_file for skill '{}' (id={})",
ingested.size(), skill.getName(), skill.getId());

View File

@ -50,4 +50,17 @@ public final class SkillBundleFiles {
}
return files;
}
/** Read only DB-persisted buckets, preserving every source byte. */
public static Map<String, byte[]> readDbEligibleBytes(SkillBundleSource source) throws IOException {
Map<String, byte[]> files = new LinkedHashMap<>();
for (SkillBundleSource.BundleAsset asset : source.assets()) {
String path = asset.relativePath();
if (!isDbEligible(path)) continue;
try (InputStream is = asset.open().get()) {
files.put(path, is.readAllBytes());
}
}
return files;
}
}

View File

@ -1211,6 +1211,13 @@ public class DelegateAgentTool {
ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
.withAgent(target.getId())
.withConversationId(childConversationId);
// Preserve Goal attribution so incomplete fences are rejected at callback entry.
if (parentOrigin != null && parentOrigin.executionAttribution() != null) {
var attribution = parentOrigin.executionAttribution();
if (attribution.goalId() != null || attribution.goalAttemptId() != null) {
childOrigin = childOrigin.withExecutionAttribution(attribution);
}
}
// chatWithUsage runs the same StateGraph as chat() (so the child
// message is persisted identically) but also surfaces the child's
// token usage from the graph's _usage_final event, so the parent can

View File

@ -23,7 +23,7 @@ import java.util.Set;
* The model calls this after spotting a tool in the {@code ## Extension Tools}
* catalog. The tool only validates and returns a confirmation message — the
* activation is recorded into graph state by the action node (tools cannot
* mutate {@code OverAllState} directly), so the enabled tool's schema becomes
* mutate {@code RunState} directly), so the enabled tool's schema becomes
* visible on the next reasoning turn of the same ReAct loop.
*/
@Slf4j

View File

@ -59,6 +59,39 @@ public final class ToolExecutionContext {
public static void setOrigin(ChatOrigin origin) { ORIGIN.set(origin); }
public static ChatOrigin origin() { return ORIGIN.get(); }
public static Scope openScope(String conversationId, String username, String workspaceBasePath) {
Scope scope = new Scope();
set(conversationId, username, workspaceBasePath);
return scope;
}
public static final class Scope implements AutoCloseable {
private final Thread owner = Thread.currentThread();
private final ChatOrigin origin = ORIGIN.get();
private final String conversationId = CONVERSATION_ID.get();
private final String username = USERNAME.get();
private final String workspaceBasePath = WORKSPACE_BASE_PATH.get();
private boolean closed;
private Scope() {}
@Override
public void close() {
if (Thread.currentThread() != owner) throw new IllegalStateException("Tool context scope must close on its opening thread");
if (closed) return;
closed = true;
restore(ORIGIN, origin);
restore(CONVERSATION_ID, conversationId);
restore(USERNAME, username);
restore(WORKSPACE_BASE_PATH, workspaceBasePath);
}
private static <T> void restore(ThreadLocal<T> holder, T value) {
if (value == null) holder.remove();
else holder.set(value);
}
}
public static void clear() {
ORIGIN.remove();
CONVERSATION_ID.remove();

View File

@ -0,0 +1,79 @@
package vip.mate.tool.mcp.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Adapts discovered MCP tools without depending on Spring AI's MCP SDK version. */
final class ApplicationMcpToolCallback implements ToolCallback {
private static final TypeRef<Map<String, Object>> MAP_TYPE = new TypeRef<>() {};
private final McpSyncClient client;
private final McpJsonMapper protocolMapper;
private final ToolDefinition definition;
ApplicationMcpToolCallback(McpSyncClient client, McpSchema.Tool tool) {
this.client = Objects.requireNonNull(client, "MCP client must not be null");
// Protocol numbers must remain numeric independently of MVC entity-ID serializers.
this.protocolMapper = new JacksonMcpJsonMapper(new ObjectMapper());
Objects.requireNonNull(tool, "MCP tool must not be null");
Map<String, Object> schema = protocolMapper.convertValue(tool.inputSchema(), MAP_TYPE);
schema = schema != null ? new LinkedHashMap<>(schema) : new LinkedHashMap<>();
schema.putIfAbsent("type", "object");
if ("object".equals(schema.get("type"))) schema.putIfAbsent("properties", Map.of());
try {
this.definition = DefaultToolDefinition.builder()
.name(tool.name()).description(tool.description())
.inputSchema(protocolMapper.writeValueAsString(schema)).build();
} catch (IOException ex) {
throw new IllegalArgumentException("MCP tool schema cannot be serialized", ex);
}
}
@Override
public ToolDefinition getToolDefinition() {
return definition;
}
@Override
public String call(String toolInput) {
return call(toolInput, null);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
Map<String, Object> arguments;
try {
arguments = protocolMapper.readValue(toolInput == null || toolInput.isBlank() ? "{}" : toolInput, MAP_TYPE);
} catch (IOException ex) {
throw new IllegalArgumentException("MCP tool arguments must be a JSON object", ex);
}
if (arguments == null) {
throw new IllegalArgumentException("MCP tool arguments must be a JSON object");
}
Map<String, Object> metadata = toolContext != null ? McpWireMetadata.copy(toolContext) : null;
try {
McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder()
.name(definition.name()).arguments(arguments).meta(metadata).build());
if (Boolean.TRUE.equals(result.isError())) {
throw new IllegalStateException("Error calling tool: " + result.content());
}
return protocolMapper.writeValueAsString(result.content());
} catch (Exception ex) {
throw new ToolExecutionException(definition, ex);
}
}
}

View File

@ -8,11 +8,10 @@ import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@ -203,8 +202,10 @@ public class McpClientManager {
for (Map.Entry<Long, McpSyncClient> entry : clients.entrySet()) {
long serverId = entry.getKey();
try {
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(entry.getValue());
ToolCallback[] cbs = provider.getToolCallbacks();
ToolCallback[] cbs = entry.getValue().listTools().tools().stream()
.filter(tool -> tool.name() != null && !tool.name().isBlank())
.map(tool -> new ApplicationMcpToolCallback(entry.getValue(), tool))
.toArray(ToolCallback[]::new);
if (cbs != null && cbs.length > 0) {
String serverName = serverNames.get(serverId);
McpIdentityForwardService idSvc =
@ -446,7 +447,7 @@ public class McpClientManager {
// publish so McpProgressRelay can forward to the SSE stream.
spec.progressConsumer(progressNotification -> {
if (progressNotification == null || progressNotification.progressToken() == null) return;
McpProgressContext.ProgressEntry entry = progressContext.lookup(progressNotification.progressToken());
McpProgressContext.ProgressEntry entry = progressContext.lookup(String.valueOf(progressNotification.progressToken()));
if (entry == null) return;
try {
McpProgressEvent event = new McpProgressEvent(
@ -491,7 +492,7 @@ public class McpClientManager {
CwdAwareStdioClientTransport transport = new CwdAwareStdioClientTransport(
builder.build(),
McpJsonMapper.createDefault(),
new JacksonMcpJsonMapper(new ObjectMapper()),
expandEnvVars(server.getCwd()));
transport.setStdErrorHandler(line -> log.info("MCP stdio stderr [{}]: {}", server.getName(), line));
if (managed) {

View File

@ -15,6 +15,7 @@ public final class McpToolResultCapture {
private volatile Map<String, Object> structuredContent;
private volatile boolean error;
private volatile String errorText;
public ToolContext attach(ToolContext context) {
Map<String, Object> values = new HashMap<>(context.getContext());
@ -31,6 +32,14 @@ public final class McpToolResultCapture {
public void capture(McpSchema.CallToolResult result, ObjectMapper mapper) {
structuredContent = null;
error = result != null && Boolean.TRUE.equals(result.isError());
errorText = null;
if (error && result.content() != null) {
StringBuilder text = new StringBuilder();
for (var content : result.content()) {
if (content instanceof McpSchema.TextContent plain) text.append(plain.text());
}
errorText = text.toString();
}
if (result == null || error || !(result.structuredContent() instanceof Map<?, ?>)) return;
try {
byte[] json = mapper.writeValueAsBytes(result.structuredContent());
@ -45,4 +54,5 @@ public final class McpToolResultCapture {
public Map<String, Object> structuredContent() { return structuredContent; }
public boolean isError() { return error; }
public String errorText() { return errorText; }
}

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