mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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
|
||||
|
||||
# Claude Code local settings
|
||||
CLAUDE.md
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
|
||||
@ -67,6 +67,12 @@
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</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 ===== -->
|
||||
<!--
|
||||
1.1.2.2 需单独指定版本,不在 BOM 中
|
||||
|
||||
@ -670,6 +670,10 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var usage = chatResponse.getMetadata().getUsage();
|
||||
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.action.NodeAction;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
@ -52,7 +52,17 @@ public class PlanGenerationNode implements NodeAction {
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
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 = """
|
||||
你是任务分流器,不是聊天助手。根据用户目标把请求分到三类之一,并只输出一个 JSON 对象。
|
||||
@ -163,6 +173,11 @@ public class PlanGenerationNode implements NodeAction {
|
||||
|
||||
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);
|
||||
|
||||
// Broadcast a lightweight progress token so the frontend shows activity
|
||||
@ -203,14 +218,13 @@ public class PlanGenerationNode implements NodeAction {
|
||||
"completion_tokens", result.completionTokens()
|
||||
)));
|
||||
|
||||
String cleanedJson = cleanJsonResponse(llmResponse);
|
||||
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
|
||||
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
|
||||
TriageResult triage = converter.convert(llmResponse);
|
||||
boolean needsPlanning = triage != null && triage.needsPlanning();
|
||||
|
||||
if (!needsPlanning) {
|
||||
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
||||
String directAnswer = parsed.get("direct_answer") != null
|
||||
? parsed.get("direct_answer").toString() : llmResponse;
|
||||
String directAnswer = triage != null && triage.directAnswer() != null
|
||||
? triage.directAnswer() : llmResponse;
|
||||
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
||||
|
||||
streamingHelper.broadcastContent(conversationId, directAnswer);
|
||||
@ -227,8 +241,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
}
|
||||
|
||||
// Categories (B) single-step or (C) multi-step: extract steps.
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> steps = (List<String>) parsed.get("steps");
|
||||
List<String> steps = triage != null ? triage.steps() : null;
|
||||
if (steps == null || steps.isEmpty()) {
|
||||
// LLM asked for planning but produced no steps — fall back to a
|
||||
// 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;
|
||||
|
||||
import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.AsyncConfigurer;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
|
||||
import org.springframework.security.task.DelegatingSecurityContextTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 全局异步线程池配置,确保 SecurityContext 传播到 @Async 线程。
|
||||
* Global async executor config — ensures SecurityContext propagation to @Async threads.
|
||||
* <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
|
||||
*/
|
||||
@ -22,12 +23,13 @@ public class AsyncSecurityConfig implements AsyncConfigurer {
|
||||
|
||||
@Override
|
||||
public Executor getAsyncExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(16);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("async-sec-");
|
||||
executor.initialize();
|
||||
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
|
||||
// Keep DelegatingSecurityContextTaskExecutor so SecurityContext
|
||||
// is propagated to every @Async invocation (JWT, audit, permission checks).
|
||||
// Replace the inner platform-thread pool with a virtual-thread executor.
|
||||
var delegate = new SimpleAsyncTaskExecutorBuilder()
|
||||
.virtualThreads(true)
|
||||
.threadNamePrefix("async-vt-")
|
||||
.build();
|
||||
return new DelegatingSecurityContextTaskExecutor(delegate);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,9 @@ spring:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
max-request-size: 200MB
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
profiles:
|
||||
active: dev
|
||||
|
||||
@ -54,6 +57,9 @@ spring:
|
||||
max-tokens: 4096
|
||||
# Spring AI 1.1.x 会话记忆配置(使用内嵌 H2 时无需额外配置)
|
||||
chat:
|
||||
observations:
|
||||
log-prompt: false
|
||||
log-completion: false
|
||||
memory:
|
||||
repository:
|
||||
jdbc:
|
||||
@ -210,7 +216,7 @@ mate:
|
||||
# Phase 2: SOUL auto-evolution and provider decorators
|
||||
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-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
|
||||
fact:
|
||||
projection-enabled: true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user