mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(agent): implement Lane E — JDK 21 virtual threads, Spring AI observability, BeanOutputConverter
This commit is contained in:
parent
320e13b975
commit
aed905efb7
1
.gitignore
vendored
1
.gitignore
vendored
@ -92,6 +92,5 @@ deploy/nginx/ssl/*.pem
|
|||||||
deploy/.env
|
deploy/.env
|
||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
CLAUDE.md
|
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/plans/
|
.claude/plans/
|
||||||
|
|||||||
@ -67,6 +67,12 @@
|
|||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== Actuator — exposes Spring AI observation metrics (gen_ai.*) ===== -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI Alibaba DashScope ===== -->
|
<!-- ===== Spring AI Alibaba DashScope ===== -->
|
||||||
<!--
|
<!--
|
||||||
1.1.2.2 需单独指定版本,不在 BOM 中
|
1.1.2.2 需单独指定版本,不在 BOM 中
|
||||||
|
|||||||
@ -670,6 +670,10 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
// 4. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
||||||
|
// E-4 probe: log metadata keys to check for x-ratelimit-* headers
|
||||||
|
if (chatResponse.getMetadata() != null && log.isDebugEnabled()) {
|
||||||
|
log.debug("[E4-probe] metadata keys: {}", chatResponse.getMetadata().keySet());
|
||||||
|
}
|
||||||
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
||||||
var usage = chatResponse.getMetadata().getUsage();
|
var usage = chatResponse.getMetadata().getUsage();
|
||||||
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
||||||
|
|||||||
@ -2,14 +2,14 @@ package vip.mate.agent.graph.plan.node;
|
|||||||
|
|
||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
import org.springframework.ai.chat.messages.SystemMessage;
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
import org.springframework.ai.chat.messages.UserMessage;
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.converter.BeanOutputConverter;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
@ -52,7 +52,17 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
private final NodeStreamingChatHelper streamingHelper;
|
private final NodeStreamingChatHelper streamingHelper;
|
||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
private final AgentToolSet toolSet;
|
private final AgentToolSet toolSet;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
/**
|
||||||
|
* Structured triage result — field names use @JsonProperty to match the
|
||||||
|
* snake_case keys the LLM is instructed to produce, so no prompt changes needed.
|
||||||
|
*/
|
||||||
|
record TriageResult(
|
||||||
|
@JsonProperty("needs_planning") boolean needsPlanning,
|
||||||
|
@JsonProperty("direct_answer") String directAnswer,
|
||||||
|
@JsonProperty("plan_type") String planType,
|
||||||
|
@JsonProperty("steps") List<String> steps
|
||||||
|
) {}
|
||||||
|
|
||||||
private static final String PLANNING_PROMPT = """
|
private static final String PLANNING_PROMPT = """
|
||||||
你是任务分流器,不是聊天助手。根据用户目标把请求分到三类之一,并只输出一个 JSON 对象。
|
你是任务分流器,不是聊天助手。根据用户目标把请求分到三类之一,并只输出一个 JSON 对象。
|
||||||
@ -163,6 +173,11 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
|
|
||||||
promptMessages.add(new UserMessage("用户目标:" + goal));
|
promptMessages.add(new UserMessage("用户目标:" + goal));
|
||||||
|
|
||||||
|
// Append JSON schema hint generated by BeanOutputConverter so the LLM
|
||||||
|
// knows the exact expected structure (replaces hand-written schema in PLANNING_PROMPT).
|
||||||
|
BeanOutputConverter<TriageResult> converter = new BeanOutputConverter<>(TriageResult.class);
|
||||||
|
promptMessages.add(new UserMessage(converter.getFormat()));
|
||||||
|
|
||||||
Prompt prompt = new Prompt(promptMessages);
|
Prompt prompt = new Prompt(promptMessages);
|
||||||
|
|
||||||
// Broadcast a lightweight progress token so the frontend shows activity
|
// Broadcast a lightweight progress token so the frontend shows activity
|
||||||
@ -203,14 +218,13 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
"completion_tokens", result.completionTokens()
|
"completion_tokens", result.completionTokens()
|
||||||
)));
|
)));
|
||||||
|
|
||||||
String cleanedJson = cleanJsonResponse(llmResponse);
|
TriageResult triage = converter.convert(llmResponse);
|
||||||
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
|
boolean needsPlanning = triage != null && triage.needsPlanning();
|
||||||
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
|
|
||||||
|
|
||||||
if (!needsPlanning) {
|
if (!needsPlanning) {
|
||||||
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
||||||
String directAnswer = parsed.get("direct_answer") != null
|
String directAnswer = triage != null && triage.directAnswer() != null
|
||||||
? parsed.get("direct_answer").toString() : llmResponse;
|
? triage.directAnswer() : llmResponse;
|
||||||
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
||||||
|
|
||||||
streamingHelper.broadcastContent(conversationId, directAnswer);
|
streamingHelper.broadcastContent(conversationId, directAnswer);
|
||||||
@ -227,8 +241,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Categories (B) single-step or (C) multi-step: extract steps.
|
// Categories (B) single-step or (C) multi-step: extract steps.
|
||||||
@SuppressWarnings("unchecked")
|
List<String> steps = triage != null ? triage.steps() : null;
|
||||||
List<String> steps = (List<String>) parsed.get("steps");
|
|
||||||
if (steps == null || steps.isEmpty()) {
|
if (steps == null || steps.isEmpty()) {
|
||||||
// LLM asked for planning but produced no steps — fall back to a
|
// LLM asked for planning but produced no steps — fall back to a
|
||||||
// synthetic 1-step plan using the user's goal so the executor
|
// synthetic 1-step plan using the user's goal so the executor
|
||||||
@ -287,25 +300,4 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip optional markdown code fences and isolate the first JSON object.
|
|
||||||
* Throws if no balanced JSON object is present; the caller treats that as
|
|
||||||
* a triage failure and falls back to a single-step plan.
|
|
||||||
*/
|
|
||||||
private String cleanJsonResponse(String response) {
|
|
||||||
if (response == null) {
|
|
||||||
throw new IllegalArgumentException("LLM returned null response");
|
|
||||||
}
|
|
||||||
String cleaned = response.trim();
|
|
||||||
if (cleaned.startsWith("```")) {
|
|
||||||
cleaned = cleaned.replaceAll("```json?\\n?", "").replaceAll("```", "").trim();
|
|
||||||
}
|
|
||||||
int start = cleaned.indexOf('{');
|
|
||||||
int end = cleaned.lastIndexOf('}');
|
|
||||||
if (start < 0 || end <= start) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"LLM response does not contain a valid JSON object: " + cleaned.substring(0, Math.min(80, cleaned.length())));
|
|
||||||
}
|
|
||||||
return cleaned.substring(start, end + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
package vip.mate.config;
|
package vip.mate.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.annotation.AsyncConfigurer;
|
import org.springframework.scheduling.annotation.AsyncConfigurer;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.security.task.DelegatingSecurityContextTaskExecutor;
|
||||||
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
|
|
||||||
|
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局异步线程池配置,确保 SecurityContext 传播到 @Async 线程。
|
* Global async executor config — ensures SecurityContext propagation to @Async threads.
|
||||||
* <p>
|
* <p>
|
||||||
* 使用 {@link DelegatingSecurityContextAsyncTaskExecutor} 包装线程池,
|
* The inner delegate uses virtual threads (JDK 21); the outer
|
||||||
* 使得审计、记忆摘要等异步任务能正确获取调用线程的用户身份和权限上下文。
|
* {@link DelegatingSecurityContextTaskExecutor} wrapper propagates the caller's
|
||||||
|
* SecurityContext (JWT identity, audit permissions) to every @Async invocation.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -22,12 +23,13 @@ public class AsyncSecurityConfig implements AsyncConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Executor getAsyncExecutor() {
|
public Executor getAsyncExecutor() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
// Keep DelegatingSecurityContextTaskExecutor so SecurityContext
|
||||||
executor.setCorePoolSize(4);
|
// is propagated to every @Async invocation (JWT, audit, permission checks).
|
||||||
executor.setMaxPoolSize(16);
|
// Replace the inner platform-thread pool with a virtual-thread executor.
|
||||||
executor.setQueueCapacity(100);
|
var delegate = new SimpleAsyncTaskExecutorBuilder()
|
||||||
executor.setThreadNamePrefix("async-sec-");
|
.virtualThreads(true)
|
||||||
executor.initialize();
|
.threadNamePrefix("async-vt-")
|
||||||
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
|
.build();
|
||||||
|
return new DelegatingSecurityContextTaskExecutor(delegate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,9 @@ spring:
|
|||||||
multipart:
|
multipart:
|
||||||
max-file-size: 100MB
|
max-file-size: 100MB
|
||||||
max-request-size: 200MB
|
max-request-size: 200MB
|
||||||
|
threads:
|
||||||
|
virtual:
|
||||||
|
enabled: true
|
||||||
profiles:
|
profiles:
|
||||||
active: dev
|
active: dev
|
||||||
|
|
||||||
@ -54,6 +57,9 @@ spring:
|
|||||||
max-tokens: 4096
|
max-tokens: 4096
|
||||||
# Spring AI 1.1.x 会话记忆配置(使用内嵌 H2 时无需额外配置)
|
# Spring AI 1.1.x 会话记忆配置(使用内嵌 H2 时无需额外配置)
|
||||||
chat:
|
chat:
|
||||||
|
observations:
|
||||||
|
log-prompt: false
|
||||||
|
log-completion: false
|
||||||
memory:
|
memory:
|
||||||
repository:
|
repository:
|
||||||
jdbc:
|
jdbc:
|
||||||
@ -210,7 +216,7 @@ mate:
|
|||||||
# Phase 2: SOUL auto-evolution and provider decorators
|
# Phase 2: SOUL auto-evolution and provider decorators
|
||||||
soul-update-interval: 20 # 20 writes trigger one SOUL.md LLM update (0 = off)
|
soul-update-interval: 20 # 20 writes trigger one SOUL.md LLM update (0 = off)
|
||||||
provider-retry-attempts: 1 # 1 = no retry (enable when external providers added)
|
provider-retry-attempts: 1 # 1 = no retry (enable when external providers added)
|
||||||
provider-metrics-enabled: false # requires spring-boot-starter-actuator dependency
|
provider-metrics-enabled: false # actuator dependency now present; enable when external providers added
|
||||||
# Phase 3: fact projection
|
# Phase 3: fact projection
|
||||||
fact:
|
fact:
|
||||||
projection-enabled: true
|
projection-enabled: true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user