mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
feat(runtime): migrate to Spring Boot 4, Spring AI 2 and AgentScope 2 (#660)
This commit is contained in:
parent
10aad3be6c
commit
93ba4258e5
22
README.md
22
README.md
@ -15,7 +15,7 @@
|
||||
[](https://claw-demo.mate.vip)
|
||||
[](https://claw.mate.vip)
|
||||
[](https://adoptium.net/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
22
README_zh.md
22
README_zh.md
@ -15,7 +15,7 @@
|
||||
[](https://claw-demo.mate.vip)
|
||||
[](https://claw.mate.vip)
|
||||
[](https://adoptium.net/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<GraphEvent>,最终写入 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();
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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. 迭代超限检查
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
// 检查停止标志
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) =====
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package vip.mate.agent.runtime.state;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EdgeAction {
|
||||
String apply(RunState state) throws Exception;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -1104,7 +1104,7 @@ public class ChatStreamTracker {
|
||||
/**
|
||||
* 直推事件(Object 自动序列化为 JSON)。
|
||||
* <p>
|
||||
* 用于在 Node 内部直接向前端推送 SSE 事件,绕过 NodeOutput 管道。
|
||||
* 用于在 Node 内部直接向前端推送 SSE 事件,绕过 RunStep 管道。
|
||||
* 典型场景:审批请求在 awaitDecision() 阻塞前必须先送达前端。
|
||||
*
|
||||
* @param conversationId 会话 ID
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/* ====================================================================== */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user