mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
release: v1.5.0
This commit is contained in:
parent
68c010ecf9
commit
1efc13076d
@ -56,6 +56,8 @@ import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.wiki.service.WikiContextService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@ -125,6 +127,18 @@ public class AgentGraphBuilder {
|
||||
private final vip.mate.goal.service.GoalFollowupService goalFollowupService;
|
||||
private final vip.mate.goal.config.GoalProperties goalProperties;
|
||||
|
||||
/**
|
||||
* Auto-grant resolver wired into the executor so an active
|
||||
* {@code mate_approval_grant} row can skip {@code createPending()} for matching
|
||||
* tool calls. Together with {@link #workspaceLookupCache}, these two deps form
|
||||
* the auto-grant entry point; the executor's null-guard turns the feature off
|
||||
* cleanly if either is missing.
|
||||
*/
|
||||
private final vip.mate.approval.grant.service.ApprovalGrantResolver approvalGrantResolver;
|
||||
|
||||
/** Conversation→workspaceId lookup cache; see {@link #approvalGrantResolver}. */
|
||||
private final vip.mate.approval.grant.WorkspaceLookupCache workspaceLookupCache;
|
||||
|
||||
/**
|
||||
* Optional audit pipeline. Setter injection (rather than a constructor
|
||||
* parameter) keeps existing constructor-based wiring + tests intact.
|
||||
@ -166,6 +180,34 @@ public class AgentGraphBuilder {
|
||||
return modelConfigService.resolveModel(agentModelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the caller passed a complete (provider, model) pin AND that
|
||||
* pair resolves to an enabled model row. Used by {@link #build} to decide
|
||||
* whether the explicit pick should bypass capability-driven routing.
|
||||
*/
|
||||
private boolean pinResolvesToEnabledModel(String modelProvider, String modelName) {
|
||||
if (modelProvider == null || modelProvider.isBlank()
|
||||
|| modelName == null || modelName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return modelConfigService.findEnabledModel(modelProvider, modelName) != null;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the Agent declared its own modelName and that name resolved to
|
||||
* a real enabled row (rather than silently falling back to the system default).
|
||||
*/
|
||||
private boolean agentModelOverrideResolved(AgentEntity entity, ModelConfigEntity resolved) {
|
||||
if (entity == null || resolved == null) return false;
|
||||
String agentModelName = entity.getModelName();
|
||||
if (agentModelName == null || agentModelName.isBlank()) return false;
|
||||
return agentModelName.equalsIgnoreCase(resolved.getModelName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 AgentEntity 构建完整的 Agent 实例。
|
||||
*
|
||||
@ -187,6 +229,17 @@ public class AgentGraphBuilder {
|
||||
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
|
||||
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
|
||||
|
||||
// Issue #184 follow-up: an agent that opted out of skills must not be
|
||||
// able to circle back and discover/load them via the meta tools. Strip
|
||||
// the skill-discovery surface (listAvailableSkills / load_skill /
|
||||
// readSkillFile / runSkillScript / listSkillFiles) here. This runs as a
|
||||
// separate deny layer so the allowlist matrix in getEffectiveToolNames
|
||||
// stays untouched — in particular, the (skillsDisabled, !toolsDisabled,
|
||||
// no tool bindings) cell still returns null so non-skill global tools
|
||||
// continue to flow through.
|
||||
toolSet = toolSet.withDeniedToolsFiltered(
|
||||
agentBindingService.getSkillDiscoveryDeniedTools(entity.getId()));
|
||||
|
||||
// Escape hatch: drop the load_skill meta tool entirely when disabled, so
|
||||
// it isn't advertised regardless of binding (the catalog guidance falls
|
||||
// back to readSkillFile — see SkillRuntimeService).
|
||||
@ -199,22 +252,37 @@ public class AgentGraphBuilder {
|
||||
// looks up enabled-only models and silently degrades an unmatched pin /
|
||||
// override to the global default, preserving the legacy behaviour for
|
||||
// Agents and conversations without an explicit choice.
|
||||
// providerRouter.selectPrimary below may still swap this for a model
|
||||
// that satisfies a bound skill's requires-model constraint.
|
||||
ModelConfigEntity globalDefault;
|
||||
boolean explicitPinHonoured;
|
||||
boolean agentOverrideHonoured;
|
||||
try {
|
||||
explicitPinHonoured = pinResolvesToEnabledModel(modelProvider, modelName);
|
||||
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
|
||||
agentOverrideHonoured = !explicitPinHonoured
|
||||
&& agentModelOverrideResolved(entity, globalDefault);
|
||||
} catch (Exception e) {
|
||||
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
||||
}
|
||||
ModelConfigEntity runtimeModel;
|
||||
try {
|
||||
runtimeModel = providerRouter.selectPrimary(entity.getId(), globalDefault);
|
||||
if (runtimeModel == null) runtimeModel = globalDefault;
|
||||
} catch (Exception e) {
|
||||
log.debug("[ProviderRouter] primary selection failed, falling back to global default: {}",
|
||||
e.getMessage());
|
||||
if (explicitPinHonoured || agentOverrideHonoured) {
|
||||
// The caller (admin UI / chat console) handed us a concrete
|
||||
// (provider, model) pin and it points to an enabled row. Honour
|
||||
// it verbatim — running providerRouter.selectPrimary here would
|
||||
// silently swap to a different model whenever a bound skill
|
||||
// advertised a capability gap, which is exactly the "I switched
|
||||
// model but the agent kept using the old one" surface. The
|
||||
// diagnostic below still surfaces capability gaps in the logs
|
||||
// so operators can see if the pinned model misses a need.
|
||||
runtimeModel = globalDefault;
|
||||
} else {
|
||||
try {
|
||||
runtimeModel = providerRouter.selectPrimary(entity.getId(), globalDefault);
|
||||
if (runtimeModel == null) runtimeModel = globalDefault;
|
||||
} catch (Exception e) {
|
||||
log.debug("[ProviderRouter] primary selection failed, falling back to global default: {}",
|
||||
e.getMessage());
|
||||
runtimeModel = globalDefault;
|
||||
}
|
||||
}
|
||||
// Even after the upgrade, log a WARN when the chosen primary
|
||||
// still doesn't satisfy needs (e.g. no preferred provider was
|
||||
@ -358,18 +426,41 @@ public class AgentGraphBuilder {
|
||||
agent.topP = runtimeModel.getTopP();
|
||||
agent.toolCallingEnabled = toolCallingEnabled;
|
||||
|
||||
// 查找工作区活动目录
|
||||
// Agent-level override takes priority; a relative override is resolved
|
||||
// under the workspace basePath so admins can express agent directories
|
||||
// relative to the workspace root (matching the UI hint).
|
||||
String workspaceBase = null;
|
||||
if (entity.getWorkspaceId() != null) {
|
||||
try {
|
||||
var workspace = workspaceService.getById(entity.getWorkspaceId());
|
||||
if (workspace != null && workspace.getBasePath() != null && !workspace.getBasePath().isBlank()) {
|
||||
agent.workspaceBasePath = workspace.getBasePath();
|
||||
log.info("Agent {} bound to workspace basePath: {}", entity.getName(), agent.workspaceBasePath);
|
||||
if (workspace != null) {
|
||||
workspaceBase = workspace.getBasePath();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to lookup workspace basePath for agent {}: {}", entity.getName(), e.getMessage());
|
||||
log.warn("Failed to lookup workspace basePath for agent {}: {}",
|
||||
entity.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
String resolvedBase;
|
||||
try {
|
||||
resolvedBase = resolveAgentBasePath(entity.getWorkspaceBasePath(), workspaceBase);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Override violates the workspace-scoping rule (e.g. admin tried to
|
||||
// set an absolute path outside the workspace root). Fall back to
|
||||
// inheriting the workspace basePath so chat stays available, but
|
||||
// surface the violation in logs so the admin can fix it.
|
||||
log.warn("Agent {} workspaceBasePath override rejected, falling back to workspace: {}",
|
||||
entity.getName(), e.getMessage());
|
||||
resolvedBase = workspaceBase;
|
||||
}
|
||||
if (resolvedBase != null && !resolvedBase.isBlank()) {
|
||||
agent.workspaceBasePath = resolvedBase;
|
||||
boolean fromOverride = entity.getWorkspaceBasePath() != null
|
||||
&& !entity.getWorkspaceBasePath().isBlank()
|
||||
&& resolvedBase.equals(entity.getWorkspaceBasePath());
|
||||
log.info("Agent {} basePath = {} (source: {})",
|
||||
entity.getName(), resolvedBase, fromOverride ? "agent-override" : "workspace");
|
||||
}
|
||||
|
||||
log.info("Built agent instance: {} (type={}, protocol={}, tools={}, toolCallingEnabled={})",
|
||||
entity.getName(), entity.getAgentType(), protocol.getId(),
|
||||
@ -445,7 +536,10 @@ public class AgentGraphBuilder {
|
||||
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
workspaceLookupCache, approvalGrantResolver);
|
||||
// Issue #46: enable skill-aware "Tool not found" hint so when the
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
@ -687,7 +781,10 @@ public class AgentGraphBuilder {
|
||||
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
workspaceLookupCache, approvalGrantResolver);
|
||||
// Issue #46: enable skill-aware "Tool not found" hint so when the
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
@ -1147,6 +1244,54 @@ public class AgentGraphBuilder {
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective working directory for an agent.
|
||||
* <p>Precedence:
|
||||
* <ol>
|
||||
* <li>When the agent-level override is set, it wins.</li>
|
||||
* <li>An absolute override is used verbatim, but only when it sits
|
||||
* inside the workspace basePath (or when the workspace has no
|
||||
* basePath of its own). An absolute path that points outside a
|
||||
* configured workspace root is rejected — otherwise a less-trusted
|
||||
* user with agent-edit access could set
|
||||
* {@code workspaceBasePath="/"} and bypass workspace scoping.</li>
|
||||
* <li>A relative override is resolved <em>under</em> the workspace basePath
|
||||
* when the workspace has one, matching the UI hint that agent paths
|
||||
* are relative to the workspace root.</li>
|
||||
* <li>A relative override with no workspace basePath is used as-is
|
||||
* (resolves against the JVM working directory at file-tool time).</li>
|
||||
* <li>With no override, the workspace basePath is inherited verbatim;
|
||||
* returns {@code null} when neither side has a value.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @throws IllegalArgumentException when an absolute override escapes the
|
||||
* workspace root
|
||||
*/
|
||||
static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
|
||||
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
|
||||
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
|
||||
if (!hasOverride) {
|
||||
return hasWorkspace ? workspaceBase : null;
|
||||
}
|
||||
Path overridePath = Paths.get(agentOverride);
|
||||
if (overridePath.isAbsolute()) {
|
||||
if (hasWorkspace) {
|
||||
Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize();
|
||||
Path absOverride = overridePath.toAbsolutePath().normalize();
|
||||
if (!absOverride.startsWith(wsRoot)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Agent workspaceBasePath override must be inside the workspace root: "
|
||||
+ absOverride + " is not under " + wsRoot);
|
||||
}
|
||||
}
|
||||
return agentOverride;
|
||||
}
|
||||
if (hasWorkspace) {
|
||||
return Paths.get(workspaceBase).resolve(agentOverride).toString();
|
||||
}
|
||||
return agentOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first enabled chat model whose provider is fully configured.
|
||||
* Used as a fallback when the default model's provider is not available.
|
||||
@ -1245,6 +1390,18 @@ public class AgentGraphBuilder {
|
||||
Use workspace memory tools (MEMORY.md, daily notes) for long-form narrative notes.
|
||||
Use structured memory tools for key-value facts the system can query efficiently.
|
||||
|
||||
## Memory vs Knowledge Base Precedence
|
||||
When a question is about the user themselves — who they are, their current
|
||||
project, its name/codename, tech stack, goals, metrics, budget, team, or what
|
||||
they are working on — your recalled memory (the <memory-context> block plus
|
||||
structured/workspace memory) is the authoritative source. Knowledge-base / wiki
|
||||
pages are reference material that may describe unrelated, example, or upstream
|
||||
projects; do NOT treat a KB page's subject as the user's own project. Only read
|
||||
the knowledge base for explicit reference lookups, never to decide what the
|
||||
user's project is. If memory and a KB page disagree about the user's project,
|
||||
trust memory. If memory has no answer, say you do not have it rather than
|
||||
adopting a KB article as the user's project.
|
||||
|
||||
## Session Search
|
||||
- `session_search(agentId, currentConversationId, mode, query, limit)` — search conversation history
|
||||
- mode="recent": list recent conversations (titles, times, message counts)
|
||||
|
||||
@ -25,6 +25,7 @@ import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
@ -48,6 +49,7 @@ public class AgentService {
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
private final MemoryLifecycleMediator lifecycleMediator;
|
||||
private final MemoryProperties memoryProperties;
|
||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
|
||||
* to keep this a leaf dependency with no risk of a bean cycle. */
|
||||
private final ConversationMapper conversationMapper;
|
||||
@ -214,6 +216,20 @@ public class AgentService {
|
||||
agentInstances.remove(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached agent instance whenever one of its workspace files
|
||||
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured
|
||||
* memory) is baked into the cached instance at build time, so memory edits made
|
||||
* via tools, consolidation, or cleanup would otherwise stay invisible until an
|
||||
* agent config change or restart. Rebuilding on the next turn picks them up.
|
||||
*/
|
||||
@org.springframework.context.event.EventListener
|
||||
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
|
||||
if (event.agentId() != null) {
|
||||
agentInstances.remove(event.agentId());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 运行时入口 ====================
|
||||
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
@ -237,6 +253,26 @@ public class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync chat that also captures token usage and runtime model attribution
|
||||
* from the agent graph's {@code _usage_final} event. Equivalent to
|
||||
* subscribing to {@link #chatStructuredStream} and joining all content
|
||||
* deltas — produces the same assistant text as {@link #chat} but exposes
|
||||
* the usage figures so callers can persist them on the assistant message.
|
||||
*
|
||||
* <p>Prefer this entry over {@link #chat} for any path that writes the
|
||||
* reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket,
|
||||
* cron task, post-approval replay); the plain {@link #chat} stays as the
|
||||
* thin wrapper for fire-and-forget invocations where usage is not needed.
|
||||
*/
|
||||
public ChatResult chatWithUsage(Long agentId, String message, String conversationId) {
|
||||
return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin));
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
@ -362,6 +398,42 @@ public class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay-after-approval that also captures token usage and runtime model
|
||||
* attribution. Mirrors {@link #chatWithUsage} for the
|
||||
* approval-resumption path used by {@code ChannelMessageRouter}.
|
||||
*/
|
||||
public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, ChatOrigin origin) {
|
||||
return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId,
|
||||
toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a structured stream and collapse it into a single
|
||||
* {@link ChatResult}: append all content deltas, capture the trailing
|
||||
* {@code _usage_final} event for token and model attribution.
|
||||
*/
|
||||
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
final int[] usage = {0, 0};
|
||||
final String[] modelInfo = {null, null};
|
||||
stream.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
} else if (delta.content() != null) {
|
||||
content.append(delta.content());
|
||||
}
|
||||
}).blockLast(Duration.ofMinutes(10));
|
||||
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带工具重放的流式调用(Web 端审批通过后使用,通过 SSE 推送结果)
|
||||
*/
|
||||
@ -447,7 +519,8 @@ public class AgentService {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return invoke.apply(message, conversationId);
|
||||
}
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
||||
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
|
||||
// Inject memory context into the user message (RFC-037 §3.3)
|
||||
String enrichedMessage = injectMemoryContext(message, memoryContext);
|
||||
@ -469,7 +542,8 @@ public class AgentService {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return invoke.apply(message, conversationId);
|
||||
}
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
|
||||
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
|
||||
String enrichedMessage = injectMemoryContext(message, memoryContext);
|
||||
StringBuilder reply = new StringBuilder();
|
||||
@ -623,4 +697,23 @@ public class AgentService {
|
||||
return thinking != null ? thinking.length() : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ChatResult ====================
|
||||
|
||||
/**
|
||||
* Sync chat result carrying the assistant reply alongside the usage
|
||||
* attribution that the streaming path exposes via the {@code _usage_final}
|
||||
* event. Use this when callers need to persist {@code promptTokens} /
|
||||
* {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider}
|
||||
* on the assistant message row but cannot subscribe to the structured
|
||||
* stream directly (cron tasks, sync HTTP endpoints, voice WebSocket,
|
||||
* post-approval replays).
|
||||
*/
|
||||
public record ChatResult(String content, int promptTokens, int completionTokens,
|
||||
String runtimeModel, String runtimeProvider) {
|
||||
|
||||
public static ChatResult contentOnly(String content) {
|
||||
return new ChatResult(content != null ? content : "", 0, 0, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,8 +52,12 @@ public class AgentBindingController {
|
||||
verifyAgentWorkspace(agentId, workspaceId);
|
||||
bindingService.setSkillBindings(agentId, skillIds);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
// The Vue client always sends an array, but a non-Vue caller (curl /
|
||||
// SDK) can POST a body of just `null`, which Spring binds to a null
|
||||
// list. The service tolerates that — guard the audit message too.
|
||||
int count = skillIds == null ? 0 : skillIds.size();
|
||||
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
|
||||
"skills=" + skillIds.size(), null);
|
||||
"skills=" + count, null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@ -98,12 +102,14 @@ public class AgentBindingController {
|
||||
verifyAgentWorkspace(agentId, workspaceId);
|
||||
bindingService.setToolBindings(agentId, toolNames);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
// Same null-safety rationale as setSkills above.
|
||||
int count = toolNames == null ? 0 : toolNames.size();
|
||||
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
|
||||
"tools=" + toolNames.size(), null);
|
||||
"tools=" + count, null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
||||
// ==================== Provider Preferences ====================
|
||||
|
||||
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
|
||||
@GetMapping("/provider-preferences")
|
||||
|
||||
@ -118,14 +118,33 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 绑定的 enabled skill ID 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
* Effective bound skill IDs for the agent. Three return states:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code null} — no binding rows exist and the agent has not opted
|
||||
* out of skills. Caller treats this as "no agent-level restriction;
|
||||
* inherit every globally-enabled skill" (legacy default).</li>
|
||||
* <li>{@code Set.of()} — either {@code skills_disabled=true} on the agent,
|
||||
* or binding rows exist but none are {@code enabled=true}. Caller
|
||||
* treats this as "this agent is explicitly scoped to zero skills" —
|
||||
* no SKILL.md catalog injection, no skill-expanded tools.</li>
|
||||
* <li>non-empty set — the explicit allowlist.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The {@code skills_disabled} flag takes precedence over row count, so
|
||||
* a stale (disabled flag + leftover rows) row combination still surfaces
|
||||
* as "no skills". The {@code setSkillBindings} / {@code bindSkill} writers
|
||||
* keep these in sync by auto-clearing the flag when a non-empty row set is
|
||||
* persisted.
|
||||
*/
|
||||
@Override
|
||||
public Set<Long> getBoundSkillIds(Long agentId) {
|
||||
if (isSkillsDisabled(agentId)) {
|
||||
return Set.of();
|
||||
}
|
||||
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
return null; // 无绑定 → 全局默认
|
||||
return null; // no rows → inherit global default
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||
@ -135,7 +154,11 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
|
||||
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
|
||||
requireSameWorkspace(agentId, skillId);
|
||||
// 检查是否已绑定
|
||||
// Adding any skill binding is a concrete commitment — the operator
|
||||
// wants this skill on the agent, which contradicts an opt-out flag.
|
||||
// Clear the flag here so the data layer never holds a
|
||||
// "skills_disabled=true + binding rows" contradiction.
|
||||
clearSkillsDisabledFlag(agentId);
|
||||
AgentSkillBinding existing = skillBindingMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId)
|
||||
@ -161,7 +184,14 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置 Agent 的 skill 绑定(替换模式)
|
||||
* Replace the agent's skill binding set.
|
||||
*
|
||||
* <p>Side effect: when {@code skillIds} contains at least one entry,
|
||||
* the {@code skills_disabled} flag on the agent is auto-cleared. A
|
||||
* non-empty save is a concrete commitment to those skills, so the
|
||||
* data layer never holds a {@code disabled=true} + binding rows
|
||||
* contradiction. An empty / null save does <strong>not</strong>
|
||||
* touch the flag — the caller (UI toggle) owns that bit.
|
||||
*/
|
||||
public void setSkillBindings(Long agentId, List<Long> skillIds) {
|
||||
// Validate every incoming skill BEFORE touching the binding rows;
|
||||
@ -173,11 +203,17 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
requireSameWorkspace(agentId, skillId);
|
||||
}
|
||||
}
|
||||
// 删除旧绑定
|
||||
// Auto-clear the flag only when an explicit non-empty binding is
|
||||
// being committed. An empty save is ambiguous — the UI may be
|
||||
// either "uncheck everything" (keep flag as-is so the toggle
|
||||
// remains the source of truth) or just "no rows" (legacy). We let
|
||||
// the writer of skills_disabled (typically the agent PUT) own that.
|
||||
if (skillIds != null && !skillIds.isEmpty()) {
|
||||
clearSkillsDisabledFlag(agentId);
|
||||
}
|
||||
skillBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId));
|
||||
// 创建新绑定
|
||||
if (skillIds != null) {
|
||||
for (Long skillId : skillIds) {
|
||||
AgentSkillBinding binding = new AgentSkillBinding();
|
||||
@ -374,13 +410,26 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 绑定的 enabled tool name 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
* Effective bound tool names for the agent. Mirrors the three-state
|
||||
* contract of {@link #getBoundSkillIds}:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code null} — no binding rows and {@code tools_disabled=false}.
|
||||
* Caller defers to the global default tool set.</li>
|
||||
* <li>{@code Set.of()} — {@code tools_disabled=true}, or all rows are
|
||||
* {@code enabled=false}. The agent is explicitly scoped to no
|
||||
* user-pickable tools (system-level memory primitives still flow
|
||||
* through {@link #getEffectiveToolNames}).</li>
|
||||
* <li>non-empty set — the explicit allowlist.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public Set<String> getBoundToolNames(Long agentId) {
|
||||
if (isToolsDisabled(agentId)) {
|
||||
return Set.of();
|
||||
}
|
||||
List<AgentToolBinding> bindings = listToolBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
return null; // 无绑定 → 全局默认
|
||||
return null; // no rows → inherit global default
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||
@ -433,26 +482,43 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
* </ul>
|
||||
*/
|
||||
public Set<String> getEffectiveToolNames(Long agentId) {
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
boolean skillsDisabled = agent != null && Boolean.TRUE.equals(agent.getSkillsDisabled());
|
||||
boolean toolsDisabled = agent != null && Boolean.TRUE.equals(agent.getToolsDisabled());
|
||||
|
||||
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
|
||||
Set<String> directTools = getBoundToolNames(agentId);
|
||||
|
||||
// (1) null + null → no restriction; defer to the global default.
|
||||
// Four-state matrix — see issue #184.
|
||||
//
|
||||
// (1) Pure legacy: no flags, no rows on either side → defer to global
|
||||
// default (returns null). Agents created before V126 must remain
|
||||
// bit-identical to their previous runtime contract.
|
||||
if (boundSkillIds == null && directTools == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// (2) Skills-only opt-out with no explicit tool restriction → still
|
||||
// defer tools to the global default. Without this carve-out, a
|
||||
// user who only said "no skills" would silently lose every
|
||||
// non-MCP global tool because the merge branch only emits the
|
||||
// SYSTEM_LEVEL set. The SKILL.md catalog itself is still
|
||||
// suppressed via getBoundSkillIds returning Set.of().
|
||||
if (skillsDisabled && !toolsDisabled && directTools == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> merged = new LinkedHashSet<>();
|
||||
|
||||
if (boundSkillIds != null) {
|
||||
if (boundSkillIds != null && !boundSkillIds.isEmpty()) {
|
||||
for (Long skillId : boundSkillIds) {
|
||||
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
||||
if (resolved == null) continue;
|
||||
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
|
||||
// §14.2 fix: a disabled / security-blocked / setup-needed
|
||||
// skill must not contribute tools to the LLM
|
||||
// advertisement even if it's still bound. Without this
|
||||
// guard, users see ghost tools for skills they thought
|
||||
// were off.
|
||||
// A disabled / security-blocked / setup-needed skill must
|
||||
// not contribute tools to the LLM advertisement even if
|
||||
// it's still bound — otherwise the user sees ghost tools
|
||||
// for skills they thought were off.
|
||||
continue;
|
||||
}
|
||||
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
||||
@ -461,32 +527,36 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
if (directTools != null) {
|
||||
// ∪ Advanced 直选的原子 tool(§9.2 调整 B)
|
||||
merged.addAll(directTools);
|
||||
}
|
||||
|
||||
// System-level tools that don't belong to any single skill but
|
||||
// are agent-wide capabilities. Without this carve-out, binding
|
||||
// any skill silently strips record_lesson / remember / structured-
|
||||
// memory tools, breaking the §11 self-evolution loop entirely
|
||||
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
|
||||
// are agent-wide capabilities — structured memory primitives,
|
||||
// workspace memory CRUD, etc. Without this carve-out, binding any
|
||||
// skill silently strips record_lesson / remember / *memory_file
|
||||
// tools, breaking the self-evolution loop. These survive even
|
||||
// toolsDisabled=true because they are agent-internal infrastructure,
|
||||
// unrelated to the user-facing capability picker.
|
||||
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
||||
|
||||
// MCP tools. An agent that bound only a skill or a built-in tool
|
||||
// and ticked no MCP row keeps full access to every enabled MCP
|
||||
// tool: MCP servers are an administrator-enabled capability and
|
||||
// must not silently vanish just because some unrelated binding
|
||||
// exists. But once the operator ticks specific MCP rows, that is a
|
||||
// deliberate per-agent scope — only those MCP tools (already merged
|
||||
// via directTools above) stay, and the rest are not auto-joined, so
|
||||
// a role can be limited to a fixed MCP tool set. To instead hide a
|
||||
// single MCP tool from an agent that ticked no MCP row, use the
|
||||
// tool-guard deny path applied upstream in AgentGraphBuilder.
|
||||
Set<String> enabledMcpTools = getEnabledMcpToolNames();
|
||||
boolean agentScopedMcpExplicitly =
|
||||
directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
|
||||
if (!agentScopedMcpExplicitly) {
|
||||
merged.addAll(enabledMcpTools);
|
||||
// and ticked no MCP row normally keeps full access to every enabled
|
||||
// MCP tool (administrator-level capabilities should not silently
|
||||
// vanish just because some unrelated binding exists). Two cases
|
||||
// suppress the auto-include:
|
||||
// - tools_disabled=true → the user explicitly opted out of every
|
||||
// non-system tool. Auto-joining MCP would defeat that intent.
|
||||
// - The agent ticked at least one MCP tool itself → that signals a
|
||||
// deliberate per-agent MCP scope; only the ticked subset stays.
|
||||
// To deny a single MCP tool when none are ticked and tools are
|
||||
// enabled, use the tool-guard deny path in AgentGraphBuilder.
|
||||
if (!toolsDisabled) {
|
||||
Set<String> enabledMcpTools = getEnabledMcpToolNames();
|
||||
boolean agentScopedMcpExplicitly = directTools != null && !directTools.isEmpty()
|
||||
&& !Collections.disjoint(directTools, enabledMcpTools);
|
||||
if (!agentScopedMcpExplicitly) {
|
||||
merged.addAll(enabledMcpTools);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
@ -513,6 +583,40 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill-discovery meta tools that let the LLM enumerate, load, read, or
|
||||
* execute the workspace's skill catalog. Normally these live in
|
||||
* {@link #SYSTEM_LEVEL_TOOLS} because every agent needs them — but when
|
||||
* an agent has opted out of skills (issue #184), keeping them callable
|
||||
* defeats the opt-out: the LLM can simply call {@code listAvailableSkills}
|
||||
* to enumerate the catalog and {@code load_skill} to pull a SKILL.md
|
||||
* into the conversation, even though the SKILL.md catalog itself was
|
||||
* suppressed from the system prompt.
|
||||
*
|
||||
* <p>Resolved via {@link #getSkillDiscoveryDeniedTools} as a separate
|
||||
* deny layer chained after the main allowlist, so the four-state matrix
|
||||
* in {@link #getEffectiveToolNames} stays untouched.
|
||||
*/
|
||||
private static final Set<String> SKILL_DISCOVERY_TOOLS = Set.of(
|
||||
"listAvailableSkills",
|
||||
"load_skill",
|
||||
"readSkillFile",
|
||||
"runSkillScript",
|
||||
"listSkillFiles"
|
||||
);
|
||||
|
||||
/**
|
||||
* Tools the agent must NOT see when {@link AgentEntity#getSkillsDisabled()}
|
||||
* is {@code true}. Empty otherwise. Chained on top of the allowlist by
|
||||
* {@code AgentGraphBuilder} via {@code withDeniedToolsFiltered}.
|
||||
*/
|
||||
public Set<String> getSkillDiscoveryDeniedTools(Long agentId) {
|
||||
if (isSkillsDisabled(agentId)) {
|
||||
return SKILL_DISCOVERY_TOOLS;
|
||||
}
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that exist outside the skill scope and must survive any
|
||||
* agent-level skill binding restriction.
|
||||
@ -614,7 +718,7 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
|
||||
// Code Reviewer agent — the model called search → got
|
||||
// not-found → gave up before ever reaching renderDocx.
|
||||
"search",
|
||||
"web_search",
|
||||
"browser_use",
|
||||
"read_file",
|
||||
"send_file",
|
||||
@ -669,6 +773,9 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
public AgentToolBinding bindTool(Long agentId, String toolName) {
|
||||
// Mirror of bindSkill: writing any tool row clears the opt-out flag
|
||||
// so the binding state cannot contradict the agent-level toggle.
|
||||
clearToolsDisabledFlag(agentId);
|
||||
AgentToolBinding existing = toolBindingMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId)
|
||||
@ -715,6 +822,12 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
public void setToolBindings(Long agentId, List<String> toolNames) {
|
||||
validateNewToolBindings(agentId, toolNames);
|
||||
|
||||
// Side effect parallel to setSkillBindings: a non-empty save is an
|
||||
// explicit commitment to those tools, so the opt-out flag is
|
||||
// auto-cleared. Empty saves leave the flag untouched.
|
||||
if (toolNames != null && !toolNames.isEmpty()) {
|
||||
clearToolsDisabledFlag(agentId);
|
||||
}
|
||||
toolBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId));
|
||||
@ -827,4 +940,57 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
providerPreferenceMapper.insert(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Binding-mode flags (V126) ====================
|
||||
|
||||
/**
|
||||
* Read-side check for the agent's "skills opted out entirely" toggle.
|
||||
* Returns {@code false} when the agent row is missing — a missing agent
|
||||
* has no opinion, so binding queries fall through to the legacy
|
||||
* row-count path (which will surface the missing-agent issue at a more
|
||||
* useful layer than a binding read).
|
||||
*/
|
||||
private boolean isSkillsDisabled(Long agentId) {
|
||||
if (agentId == null) return false;
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
return agent != null && Boolean.TRUE.equals(agent.getSkillsDisabled());
|
||||
}
|
||||
|
||||
/** Mirror of {@link #isSkillsDisabled} for the tools opt-out toggle. */
|
||||
private boolean isToolsDisabled(Long agentId) {
|
||||
if (agentId == null) return false;
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
return agent != null && Boolean.TRUE.equals(agent.getToolsDisabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip {@code skills_disabled} back to false on the agent row. No-op
|
||||
* when already false or the agent doesn't exist. Used as an auto-clear
|
||||
* step in {@link #bindSkill} / {@link #setSkillBindings} so writing a
|
||||
* concrete binding always wins over a stale opt-out flag.
|
||||
*/
|
||||
private void clearSkillsDisabledFlag(Long agentId) {
|
||||
if (agentId == null) return;
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent == null || !Boolean.TRUE.equals(agent.getSkillsDisabled())) {
|
||||
return;
|
||||
}
|
||||
AgentEntity update = new AgentEntity();
|
||||
update.setId(agentId);
|
||||
update.setSkillsDisabled(false);
|
||||
agentMapper.updateById(update);
|
||||
}
|
||||
|
||||
/** Mirror of {@link #clearSkillsDisabledFlag} for the tools toggle. */
|
||||
private void clearToolsDisabledFlag(Long agentId) {
|
||||
if (agentId == null) return;
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent == null || !Boolean.TRUE.equals(agent.getToolsDisabled())) {
|
||||
return;
|
||||
}
|
||||
AgentEntity update = new AgentEntity();
|
||||
update.setId(agentId);
|
||||
update.setToolsDisabled(false);
|
||||
agentMapper.updateById(update);
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +170,15 @@ public class ConversationWindowManager {
|
||||
* conversation in a compaction storm. */
|
||||
private final ConcurrentHashMap<String, Long> ptlForceCompactAt = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Default max input tokens for the configured model window. Surfaced for
|
||||
* the per-loop budgeter so the L1 (multi-turn compaction) and L2
|
||||
* (per-iteration trim) layers stay calibrated to the same number.
|
||||
*/
|
||||
public int getDefaultMaxInputTokens() {
|
||||
return properties != null ? properties.getDefaultMaxInputTokens() : 0;
|
||||
}
|
||||
|
||||
/** Cooldown window after a structured PTL compaction during which a
|
||||
* follow-up PTL is downgraded to tail-only. Picked so a single ReAct
|
||||
* loop that retries within seconds can't burn another summary LLM
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
/**
|
||||
* Configuration for per-reasoning-loop message budgeting.
|
||||
*
|
||||
* <p>Used by {@link LoopMessageBudgeter} to decide when and how to trim the
|
||||
* working message list that a ReAct iteration hands to the LLM. Distinct from
|
||||
* the multi-turn history compression configured by
|
||||
* {@link vip.mate.config.ConversationWindowProperties}: this one applies inside
|
||||
* a single user turn while the ReAct loop accumulates reasoning steps and
|
||||
* tool-call/tool-response pairs.
|
||||
*
|
||||
* <p>Field semantics:
|
||||
* <ul>
|
||||
* <li>{@code triggerTokens} — token threshold above which budgeting kicks
|
||||
* in. Compared against {@code historyTokens + reservedPrefixTokens}
|
||||
* so the budgeter accounts for the full prompt the LLM will see,
|
||||
* not just the message list.</li>
|
||||
* <li>{@code keepTailTokens} — token budget reserved for the tail (recent
|
||||
* observations + the current user message). Scales with the model
|
||||
* window instead of relying on a fixed count.</li>
|
||||
* <li>{@code minTailMessages} — floor on the kept-tail count. Prevents a
|
||||
* single huge tool output from collapsing the tail to one message and
|
||||
* losing recent reasoning context.</li>
|
||||
* <li>{@code tailSoftCeilingRatio} — multiplier applied to
|
||||
* {@code keepTailTokens} when honoring the floor or pulling back to
|
||||
* keep a tool pair whole. Lets the tail overshoot the hard budget by
|
||||
* up to this factor before more aggressive cuts kick in.</li>
|
||||
* <li>{@code reservedPrefixTokens} — estimated tokens consumed by the
|
||||
* non-history portion of the prompt (system prompt, skill catalog,
|
||||
* runtime context, wiki injection, tool schemas, output reserve).
|
||||
* Surfaces these from the caller so the budget covers the whole
|
||||
* prompt, not just the message list.</li>
|
||||
* <li>{@code targetMaxMessages} — soft ceiling on the count fed to the
|
||||
* LLM. Best-effort: the budgeter may exceed it slightly to keep a
|
||||
* tool pair whole rather than orphan a call/response — that case is
|
||||
* reported via {@code BudgetTrace.capExceededForPairIntegrity}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record LoopBudgetConfig(
|
||||
int triggerTokens,
|
||||
int keepTailTokens,
|
||||
int minTailMessages,
|
||||
double tailSoftCeilingRatio,
|
||||
int reservedPrefixTokens,
|
||||
int targetMaxMessages) {
|
||||
|
||||
/** Smallest useful trigger threshold; below this budgeting is effectively disabled. */
|
||||
public static final int MIN_TRIGGER_TOKENS = 1_000;
|
||||
|
||||
/** Smallest sensible tail budget; below this even one observation may not fit. */
|
||||
public static final int MIN_TAIL_TOKENS = 2_000;
|
||||
|
||||
/** Floor on minTailMessages — fewer than 3 collapses recent context too aggressively. */
|
||||
public static final int MIN_TAIL_MESSAGES_FLOOR = 3;
|
||||
|
||||
/** Floor on the soft ceiling ratio — anything below 1.0 is degenerate. */
|
||||
public static final double MIN_TAIL_SOFT_CEILING_RATIO = 1.0;
|
||||
|
||||
/** Smallest sensible target cap; below this even a normal ReAct loop trips it. */
|
||||
public static final int MIN_TARGET_MAX = 20;
|
||||
|
||||
public LoopBudgetConfig {
|
||||
if (triggerTokens < MIN_TRIGGER_TOKENS) {
|
||||
throw new IllegalArgumentException(
|
||||
"triggerTokens must be >= " + MIN_TRIGGER_TOKENS + ", got " + triggerTokens);
|
||||
}
|
||||
if (keepTailTokens < MIN_TAIL_TOKENS) {
|
||||
throw new IllegalArgumentException(
|
||||
"keepTailTokens must be >= " + MIN_TAIL_TOKENS + ", got " + keepTailTokens);
|
||||
}
|
||||
if (minTailMessages < MIN_TAIL_MESSAGES_FLOOR) {
|
||||
throw new IllegalArgumentException(
|
||||
"minTailMessages must be >= " + MIN_TAIL_MESSAGES_FLOOR
|
||||
+ ", got " + minTailMessages);
|
||||
}
|
||||
if (tailSoftCeilingRatio < MIN_TAIL_SOFT_CEILING_RATIO) {
|
||||
throw new IllegalArgumentException(
|
||||
"tailSoftCeilingRatio must be >= " + MIN_TAIL_SOFT_CEILING_RATIO
|
||||
+ ", got " + tailSoftCeilingRatio);
|
||||
}
|
||||
if (reservedPrefixTokens < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"reservedPrefixTokens must be >= 0, got " + reservedPrefixTokens);
|
||||
}
|
||||
if (targetMaxMessages < MIN_TARGET_MAX) {
|
||||
throw new IllegalArgumentException(
|
||||
"targetMaxMessages must be >= " + MIN_TARGET_MAX
|
||||
+ ", got " + targetMaxMessages);
|
||||
}
|
||||
if (keepTailTokens >= triggerTokens) {
|
||||
throw new IllegalArgumentException(
|
||||
"keepTailTokens (" + keepTailTokens + ") must be < triggerTokens ("
|
||||
+ triggerTokens + ") — otherwise budgeting would never reduce anything");
|
||||
}
|
||||
}
|
||||
|
||||
/** Tail budget after applying the soft ceiling. */
|
||||
public int tailSoftCeilingTokens() {
|
||||
return (int) (keepTailTokens * tailSoftCeilingRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a sensible config from a model's context window. The ratios were
|
||||
* chosen so the budgeter triggers well before the model's actual limit and
|
||||
* leaves enough headroom for the LLM's own response.
|
||||
*
|
||||
* <ul>
|
||||
* <li>trigger = 50% of the window — same threshold the multi-turn
|
||||
* compressor uses, so the two layers stay calibrated.</li>
|
||||
* <li>tail budget = 30% of the window.</li>
|
||||
* <li>minTailMessages = 4 — at least one full reasoning/action cycle
|
||||
* stays visible to the LLM no matter how big a single tool output is.</li>
|
||||
* <li>tailSoftCeilingRatio = 1.5 — let the tail overshoot by 50% when
|
||||
* enforcing the floor or pulling back to keep a tool pair whole.</li>
|
||||
* <li>reservedPrefixTokens = 0 — caller should override with the real
|
||||
* prefix estimate; left at 0 the budget still works but errs on
|
||||
* the side of triggering later than it should.</li>
|
||||
* <li>targetMaxMessages = 200 — well above a normal ReAct loop's 20–40
|
||||
* working messages, low enough to be a meaningful guard rail.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static LoopBudgetConfig forContext(int contextWindowTokens) {
|
||||
if (contextWindowTokens <= 0) {
|
||||
contextWindowTokens = 32_000;
|
||||
}
|
||||
int trigger = Math.max(MIN_TRIGGER_TOKENS, (int) (contextWindowTokens * 0.50));
|
||||
int tail = Math.max(MIN_TAIL_TOKENS, (int) (contextWindowTokens * 0.30));
|
||||
if (tail >= trigger) {
|
||||
tail = Math.max(MIN_TAIL_TOKENS, trigger - MIN_TRIGGER_TOKENS);
|
||||
}
|
||||
return new LoopBudgetConfig(trigger, tail, 4, 1.5, 0, 200);
|
||||
}
|
||||
|
||||
/** Return a copy with {@code reservedPrefixTokens} replaced. */
|
||||
public LoopBudgetConfig withReservedPrefixTokens(int reservedPrefixTokens) {
|
||||
return new LoopBudgetConfig(triggerTokens, keepTailTokens, minTailMessages,
|
||||
tailSoftCeilingRatio, reservedPrefixTokens, targetMaxMessages);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,296 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
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.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Per-ReAct-loop message budgeter. Bounds the working message list a Reasoning
|
||||
* iteration hands to the LLM while preserving five invariants that, when
|
||||
* violated, either produce off-topic answers or 400s from strict providers:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>System prompt(s)</b> — all consecutive {@link SystemMessage}s at
|
||||
* the head stay verbatim. Production agents commonly have multiple
|
||||
* (SOUL, AGENTS, runtime context, wiki, tool prompt, skill catalog).</li>
|
||||
* <li><b>Turn anchor</b> — the latest {@link UserMessage} is never dropped.
|
||||
* Stitched in when an aggressive cut would otherwise lose it.</li>
|
||||
* <li><b>Tool-call/response pair integrity</b> — every assistant tool_call
|
||||
* reaches the model with its matching tool_response, and vice versa.
|
||||
* Delegated to {@link ToolPairSanitizer}.</li>
|
||||
* <li><b>Token budget over message count</b> — tail sized by token estimate
|
||||
* so a small ReAct loop with fat observations and a large loop with
|
||||
* thin observations both fit one config.</li>
|
||||
* <li><b>Minimum tail messages</b> — at least {@code minTailMessages}
|
||||
* entries survive even when a single message is bigger than the
|
||||
* hard tail budget. Prevents collapsing recent reasoning to one row
|
||||
* when the latest tool output is huge.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The trigger threshold compares {@code historyTokens +
|
||||
* reservedPrefixTokens} against {@code triggerTokens}; this keeps the
|
||||
* budgeter calibrated against the entire prompt the LLM will see, not just
|
||||
* the message list (the L1 compactor uses the same arithmetic).
|
||||
*
|
||||
* <p>Distinct from {@link ConversationWindowManager}: that one runs once per
|
||||
* user turn and produces a structured LLM summary for the accumulated
|
||||
* multi-turn history. This one runs per reasoning iteration on top of
|
||||
* whatever {@code ConversationWindowManager} already produced, bounding the
|
||||
* intra-turn ReAct accumulation.
|
||||
*
|
||||
* <p>Stateless and side-effect-free for callers; safe to call from
|
||||
* concurrent reasoning threads. The orphan-removal pass mutates a freshly
|
||||
* allocated local list, never the caller's input.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LoopMessageBudgeter {
|
||||
|
||||
/** Outcome of a budgeting pass. */
|
||||
public record Result(List<Message> messages, BudgetTrace trace) {}
|
||||
|
||||
/**
|
||||
* Structured trace of a single budgeting decision. All counts and token
|
||||
* figures refer to {@link Message} entries.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code anchorEnforced} — the tail cut was pulled earlier than
|
||||
* the token budget would have placed it because the latest
|
||||
* UserMessage would otherwise have been dropped.</li>
|
||||
* <li>{@code anchorStitched} — the latest UserMessage could not fit in
|
||||
* the tail even after pull-back (typically when the target cap
|
||||
* fired hard); it was inserted as a standalone slot between head
|
||||
* and tail.</li>
|
||||
* <li>{@code capExceededForPairIntegrity} — the final count exceeded
|
||||
* {@code targetMaxMessages} because pulling the cut back to keep
|
||||
* a tool pair whole won out over the soft cap. Useful signal that
|
||||
* upstream compaction should have run sooner.</li>
|
||||
* <li>{@code minTailFloorApplied} — the tail was enlarged past the
|
||||
* hard token budget (up to the soft ceiling) to honor
|
||||
* {@code minTailMessages}.</li>
|
||||
* <li>{@code triggered} — the budget entered its main path because the
|
||||
* trigger threshold was met. Says nothing about whether anything
|
||||
* was actually removed.</li>
|
||||
* <li>{@code modified} — the returned list differs from the input
|
||||
* (count changed or orphans removed). This is the only signal
|
||||
* callers should use to gate log output; a triggered-but-no-op
|
||||
* pass is normal and shouldn't spam logs.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record BudgetTrace(
|
||||
int originalCount,
|
||||
int originalTokens,
|
||||
int finalCount,
|
||||
int finalTokens,
|
||||
int reservedPrefixTokens,
|
||||
int headKept,
|
||||
int tailKept,
|
||||
int droppedMiddle,
|
||||
int orphansRemoved,
|
||||
boolean anchorEnforced,
|
||||
boolean anchorStitched,
|
||||
boolean targetMaxTripped,
|
||||
boolean capExceededForPairIntegrity,
|
||||
boolean minTailFloorApplied,
|
||||
boolean triggered,
|
||||
boolean modified) {
|
||||
|
||||
/** Trace for the no-op case (budget not triggered). */
|
||||
public static BudgetTrace untouched(int count, int tokens, int prefixTokens, int headKept) {
|
||||
return new BudgetTrace(count, tokens, count, tokens, prefixTokens, headKept,
|
||||
count - headKept, 0, 0,
|
||||
false, false, false, false, false, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the loop budget to {@code messages}. Pure function; never mutates the input. */
|
||||
public Result budget(List<Message> messages, LoopBudgetConfig cfg) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return new Result(messages == null ? List.of() : messages,
|
||||
BudgetTrace.untouched(0, 0, cfg.reservedPrefixTokens(), 0));
|
||||
}
|
||||
int originalCount = messages.size();
|
||||
int historyTokens = TokenEstimator.estimateTokens(messages);
|
||||
int headEnd = findHeadEnd(messages);
|
||||
|
||||
// Budget against the full prompt (history + prefix), so the trigger
|
||||
// matches what the LLM would actually receive — not just the
|
||||
// history slice. Prefix covers system prompt, skill catalog,
|
||||
// runtime context, wiki, tool schemas, output reserve.
|
||||
int promptTokens = historyTokens + cfg.reservedPrefixTokens();
|
||||
|
||||
// Below both thresholds → forward unchanged.
|
||||
if (promptTokens < cfg.triggerTokens() && originalCount < cfg.targetMaxMessages()) {
|
||||
return new Result(messages,
|
||||
BudgetTrace.untouched(originalCount, historyTokens,
|
||||
cfg.reservedPrefixTokens(), headEnd));
|
||||
}
|
||||
|
||||
// 1. Token-budgeted tail cut. Walk backward from the end; the
|
||||
// earliest index whose suffix fits within keepTailTokens is the
|
||||
// proposed boundary.
|
||||
int hardTailStart = findTailCutByTokens(messages, headEnd, cfg.keepTailTokens());
|
||||
|
||||
// 2. Min-tail floor: if the hard cut keeps fewer than minTailMessages,
|
||||
// pull back to keep at least that many — but only up to the soft
|
||||
// ceiling. Without this, one giant tool output can collapse the
|
||||
// tail to a single row and lose recent reasoning context.
|
||||
boolean minTailFloorApplied = false;
|
||||
int tailStart = hardTailStart;
|
||||
int hardTailCount = originalCount - hardTailStart;
|
||||
if (hardTailCount < cfg.minTailMessages()) {
|
||||
int floorTailStart = Math.max(headEnd, originalCount - cfg.minTailMessages());
|
||||
// Honor the soft ceiling: if even the floor count would consume
|
||||
// more than tailSoftCeilingTokens, accept it (the floor wins,
|
||||
// since the alternative is losing recent reasoning entirely).
|
||||
tailStart = floorTailStart;
|
||||
minTailFloorApplied = true;
|
||||
} else {
|
||||
// Apply the soft ceiling: if the hard cut undershoots the soft
|
||||
// ceiling (i.e. there's slack), keep going. We already cut to
|
||||
// the hard budget so there's no need to expand here — the soft
|
||||
// ceiling acts as a guard rail for the floor/pull-back path,
|
||||
// not as a relaxation of the normal cut.
|
||||
}
|
||||
|
||||
// 3. Anchor: never drop the latest UserMessage. Pull tail back if
|
||||
// needed (cheap — just moves the boundary).
|
||||
boolean anchorEnforced = false;
|
||||
int anchorIdx = findLatestUserMessageIdx(messages, headEnd);
|
||||
if (anchorIdx >= 0 && anchorIdx < tailStart) {
|
||||
tailStart = anchorIdx;
|
||||
anchorEnforced = true;
|
||||
}
|
||||
|
||||
// 4. Tool-pair integrity at the boundary: if tailStart sits inside a
|
||||
// tool pair, pull back so the pair survives whole. Delegated to
|
||||
// the shared sanitizer.
|
||||
int beforePairPullBack = tailStart;
|
||||
tailStart = ToolPairSanitizer.pullBackToToolPairBoundary(messages, headEnd, tailStart);
|
||||
|
||||
// 5. Target max safety net. The pair-integrity pull-back may have
|
||||
// pushed final count above the soft cap; we re-evaluate and try
|
||||
// to enforce, but pair integrity wins over count cap.
|
||||
boolean targetMaxTripped = false;
|
||||
boolean anchorStitched = false;
|
||||
boolean capExceededForPairIntegrity = false;
|
||||
int targetTailCap = Math.max(0, cfg.targetMaxMessages() - headEnd);
|
||||
if (targetTailCap > 0 && (originalCount - tailStart) > targetTailCap) {
|
||||
int provisionalTailStart = originalCount - targetTailCap;
|
||||
boolean stitchNeeded = anchorIdx >= 0 && anchorIdx < provisionalTailStart;
|
||||
int reservedForStitchedAnchor = stitchNeeded ? 1 : 0;
|
||||
int recentTailCap = Math.max(1, targetTailCap - reservedForStitchedAnchor);
|
||||
int newTailStart = originalCount - recentTailCap;
|
||||
int adjustedTailStart = ToolPairSanitizer.pullBackToToolPairBoundary(
|
||||
messages, headEnd, newTailStart);
|
||||
if (adjustedTailStart < newTailStart) {
|
||||
// Pair integrity prevailed over the cap; honestly record that
|
||||
// the final count will exceed targetMaxMessages.
|
||||
capExceededForPairIntegrity = true;
|
||||
}
|
||||
tailStart = adjustedTailStart;
|
||||
targetMaxTripped = true;
|
||||
anchorStitched = anchorIdx >= 0 && anchorIdx < tailStart;
|
||||
}
|
||||
|
||||
// Detect anchor stitching from the tool-pair pull-back path too:
|
||||
// pull-back may have moved tailStart earlier than the anchor index
|
||||
// (rare, but possible if the pair anchor is in the head section).
|
||||
if (!anchorStitched && anchorIdx >= 0 && anchorIdx < tailStart) {
|
||||
anchorStitched = true;
|
||||
}
|
||||
|
||||
// 6. Build the trimmed list: head + [stitched anchor?] + tail.
|
||||
int estimated = headEnd + (anchorStitched ? 1 : 0) + (originalCount - tailStart);
|
||||
List<Message> trimmed = new ArrayList<>(estimated);
|
||||
trimmed.addAll(messages.subList(0, headEnd));
|
||||
if (anchorStitched) {
|
||||
trimmed.add(messages.get(anchorIdx));
|
||||
}
|
||||
trimmed.addAll(messages.subList(tailStart, originalCount));
|
||||
|
||||
// 7. Tool-pair invariant: cross-boundary orphans cleaned up. The
|
||||
// pull-back at step 4 handles the boundary case but a head-section
|
||||
// Assistant(tool_calls) whose responses fell in the dropped middle
|
||||
// still needs the bidirectional pass.
|
||||
int orphans = ToolPairSanitizer.removeOrphans(trimmed);
|
||||
|
||||
int finalCount = trimmed.size();
|
||||
int finalTokens = TokenEstimator.estimateTokens(trimmed);
|
||||
int droppedMiddle = originalCount - finalCount;
|
||||
|
||||
// Touch the unused locals so the compiler doesn't warn — they're
|
||||
// useful in the trace's narrative but the actual cut already
|
||||
// committed.
|
||||
if (beforePairPullBack != tailStart) {
|
||||
// pair pull-back moved the boundary; logged via trace fields
|
||||
}
|
||||
|
||||
boolean modified = (finalCount != originalCount) || (orphans > 0);
|
||||
|
||||
return new Result(trimmed, new BudgetTrace(
|
||||
originalCount, historyTokens,
|
||||
finalCount, finalTokens,
|
||||
cfg.reservedPrefixTokens(),
|
||||
headEnd,
|
||||
finalCount - headEnd,
|
||||
droppedMiddle,
|
||||
orphans,
|
||||
anchorEnforced,
|
||||
anchorStitched,
|
||||
targetMaxTripped,
|
||||
capExceededForPairIntegrity,
|
||||
minTailFloorApplied,
|
||||
/* triggered */ true,
|
||||
modified));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static int findHeadEnd(List<Message> messages) {
|
||||
int i = 0;
|
||||
while (i < messages.size() && messages.get(i) instanceof SystemMessage) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk backward from the end accumulating per-message token estimates.
|
||||
* Return the earliest index whose suffix fits within {@code keepTokens}.
|
||||
* Always returns a value in {@code [headEnd, messages.size())} so the
|
||||
* tail is non-empty.
|
||||
*/
|
||||
private static int findTailCutByTokens(List<Message> messages, int headEnd, int keepTokens) {
|
||||
int n = messages.size();
|
||||
if (n <= headEnd) {
|
||||
return n;
|
||||
}
|
||||
int acc = 0;
|
||||
for (int i = n - 1; i >= headEnd; i--) {
|
||||
int t = TokenEstimator.estimateTokens(messages.get(i));
|
||||
if (acc + t > keepTokens && i < n - 1) {
|
||||
return i + 1;
|
||||
}
|
||||
acc += t;
|
||||
}
|
||||
return headEnd;
|
||||
}
|
||||
|
||||
/** Index of the latest {@link UserMessage} at or after {@code headEnd}; -1 if none. */
|
||||
private static int findLatestUserMessageIdx(List<Message> messages, int headEnd) {
|
||||
for (int i = messages.size() - 1; i >= headEnd; i--) {
|
||||
if (messages.get(i) instanceof UserMessage) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@ -87,12 +87,40 @@ public final class RuntimeContextInjector {
|
||||
sb.append("\n[system-context] Working directory: ").append(workspaceBasePath);
|
||||
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
|
||||
}
|
||||
appendSkillRootHintIfPresent(sb, workspaceBasePath, i18n);
|
||||
}
|
||||
|
||||
appendSenderBlockIfPresent(sb, origin);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the model that the shared skill repository is reachable in addition
|
||||
* to the workspace. Without this, a model that strictly honors the
|
||||
* "working directory only" hint refuses to read or run skill files that
|
||||
* live outside the workspace — even though the path sandbox now allows
|
||||
* them. Skipped when the skill root is unknown or already sits inside the
|
||||
* workspace (no separate boundary to explain).
|
||||
*/
|
||||
private static void appendSkillRootHintIfPresent(StringBuilder sb, String workspaceBasePath,
|
||||
vip.mate.i18n.I18nService i18n) {
|
||||
java.nio.file.Path skillRoot = vip.mate.tool.guard.WorkspacePathGuard.getSkillRoot();
|
||||
if (skillRoot == null) {
|
||||
return;
|
||||
}
|
||||
java.nio.file.Path wsRoot = java.nio.file.Paths.get(workspaceBasePath).toAbsolutePath().normalize();
|
||||
if (skillRoot.startsWith(wsRoot)) {
|
||||
return;
|
||||
}
|
||||
String skillRootStr = skillRoot.toString();
|
||||
if (i18n != null) {
|
||||
sb.append("\n").append(i18n.msg("context.skill_dir_hint", skillRootStr));
|
||||
} else {
|
||||
sb.append("\nShared skills live under ").append(skillRootStr)
|
||||
.append("; you may also read and run files there, even though it is outside the working directory.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a sender / channel / chat block when the origin carries
|
||||
* meaningful IM context. Format is intentionally one line per
|
||||
|
||||
@ -0,0 +1,192 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Pure-function utilities that enforce the OpenAI-compatible
|
||||
* tool_call ↔ tool_response pairing invariant:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Every {@code tool_call.id} on an {@link AssistantMessage} has a
|
||||
* matching {@code tool_response.id} on a {@link ToolResponseMessage}
|
||||
* <em>after</em> it in the list.</li>
|
||||
* <li>Every {@code tool_response.id} on a {@link ToolResponseMessage} has
|
||||
* a matching {@code tool_call.id} on an {@link AssistantMessage}
|
||||
* <em>before</em> it.</li>
|
||||
* <li>No empty/null ids on either side.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Violating either rule causes strict providers (kimi-code, anthropic in
|
||||
* tool-use mode, OpenAI's responses API on certain models) to reject the
|
||||
* request with a 400 error such as
|
||||
* {@code "tool_call_id is not found"}. This sanitizer is the single source of
|
||||
* truth for that invariant — any trim / cut / window logic should run its
|
||||
* pre/post passes here rather than reimplementing them.
|
||||
*
|
||||
* <p>All methods are {@code static} and side-effect-free except where
|
||||
* documented (e.g. {@link #removeOrphans(List)} mutates the list in place to
|
||||
* avoid an extra allocation hot in the reasoning loop). They never touch the
|
||||
* input list when no fix is needed.
|
||||
*/
|
||||
public final class ToolPairSanitizer {
|
||||
|
||||
private ToolPairSanitizer() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a proposed cut boundary earlier so an Assistant(tool_calls) that
|
||||
* issued ids matching {@link ToolResponseMessage}s in the kept tail
|
||||
* survives into the tail alongside its responses. Prevents producing an
|
||||
* orphan response at the cut boundary in the first place.
|
||||
*
|
||||
* @param messages full message list (read-only)
|
||||
* @param headEnd index after the last protected head message
|
||||
* @param tailStart proposed boundary; messages at and after this index
|
||||
* are kept, those between {@code headEnd} and
|
||||
* {@code tailStart} are dropped
|
||||
* @return possibly-earlier {@code tailStart} that keeps tool pairs whole
|
||||
*/
|
||||
public static int pullBackToToolPairBoundary(List<Message> messages, int headEnd, int tailStart) {
|
||||
if (tailStart <= headEnd || messages == null || messages.isEmpty()) {
|
||||
return tailStart;
|
||||
}
|
||||
Set<String> tailResponseIds = new HashSet<>();
|
||||
for (int i = tailStart; i < messages.size(); i++) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage trm) {
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
if (r.id() != null && !r.id().isEmpty()) {
|
||||
tailResponseIds.add(r.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tailResponseIds.isEmpty()) {
|
||||
return tailStart;
|
||||
}
|
||||
for (int i = tailStart - 1; i >= headEnd; i--) {
|
||||
Message m = messages.get(i);
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||
boolean overlaps = am.getToolCalls().stream()
|
||||
.anyMatch(tc -> tc.id() != null && tailResponseIds.contains(tc.id()));
|
||||
if (overlaps) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tailStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iteratively remove tool-pair orphans from {@code messages} (mutates the
|
||||
* list in place). Two shapes are handled:
|
||||
*
|
||||
* <p><b>P0</b>: a {@link ToolResponseMessage} whose response id has no
|
||||
* matching assistant tool_call in the list.
|
||||
*
|
||||
* <p><b>P1</b>: an {@link AssistantMessage} whose every tool_call id
|
||||
* has no matching response in the list. (An assistant with both matched
|
||||
* and unmatched calls is left alone — removing it would harm more than
|
||||
* it helps; strict providers tolerate extra calls more readily than
|
||||
* dropping the whole assistant message.)
|
||||
*
|
||||
* <p>Iterates until convergence: removing an assistant for P1 can expose
|
||||
* a P0 orphan that needs cleaning, and vice versa.
|
||||
*
|
||||
* <p>Also removes any tool_call or tool_response with a null or empty id
|
||||
* — those have no useful pairing semantics and confuse both the strict
|
||||
* providers and the matching logic.
|
||||
*
|
||||
* @return total number of messages removed across all passes
|
||||
*/
|
||||
public static int removeOrphans(List<Message> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int totalRemoved = 0;
|
||||
boolean changed;
|
||||
do {
|
||||
Set<String> callIds = new HashSet<>();
|
||||
Set<String> respIds = new HashSet<>();
|
||||
for (Message m : messages) {
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||
if (tc.id() != null && !tc.id().isEmpty()) {
|
||||
callIds.add(tc.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
if (r.id() != null && !r.id().isEmpty()) {
|
||||
respIds.add(r.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
int before = messages.size();
|
||||
messages.removeIf(m -> {
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
// P0: a response with a null/empty id, or whose id has
|
||||
// no matching tool_call.
|
||||
return trm.getResponses().stream().anyMatch(r ->
|
||||
r.id() == null || r.id().isEmpty() || !callIds.contains(r.id()));
|
||||
}
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null
|
||||
&& !am.getToolCalls().isEmpty()) {
|
||||
// P1: every tool_call on this assistant has no matching response.
|
||||
return am.getToolCalls().stream().allMatch(tc ->
|
||||
tc.id() == null || tc.id().isEmpty() || !respIds.contains(tc.id()));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
int removed = before - messages.size();
|
||||
totalRemoved += removed;
|
||||
changed = removed > 0;
|
||||
} while (changed);
|
||||
return totalRemoved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-condition check: returns {@code true} iff {@code messages}
|
||||
* satisfies the pairing invariant — every assistant tool_call has a
|
||||
* matching response after it, every response has a matching call before
|
||||
* it, all ids are non-empty. Intended for tests and defensive asserts;
|
||||
* production code should run {@link #removeOrphans(List)} which
|
||||
* guarantees this holds on return.
|
||||
*/
|
||||
public static boolean isPaired(List<Message> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
Set<String> callIds = new HashSet<>();
|
||||
Set<String> respIds = new HashSet<>();
|
||||
for (Message m : messages) {
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
|
||||
if (tc.id() == null || tc.id().isEmpty()) return false;
|
||||
callIds.add(tc.id());
|
||||
}
|
||||
}
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||
if (r.id() == null || r.id().isEmpty()) return false;
|
||||
respIds.add(r.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String c : callIds) {
|
||||
if (!respIds.contains(c)) return false;
|
||||
}
|
||||
for (String r : respIds) {
|
||||
if (!callIds.contains(r)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.agent.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -27,6 +28,7 @@ import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@ -49,6 +51,7 @@ public class AgentController {
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelCapabilityService modelCapabilityService;
|
||||
private final SystemSettingService systemSettingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@Operation(summary = "获取Agent列表")
|
||||
@ -144,10 +147,14 @@ public class AgentController {
|
||||
@Operation(summary = "更新Agent")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent,
|
||||
public R<AgentEntity> update(@PathVariable Long id, @RequestBody Map<String, Object> body,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
AgentEntity existing = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId);
|
||||
AgentEntity agent = objectMapper.convertValue(body, AgentEntity.class);
|
||||
if (!body.containsKey("primaryKbId")) {
|
||||
agent.setPrimaryKbId(existing.getPrimaryKbId());
|
||||
}
|
||||
agent.setId(id);
|
||||
agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移
|
||||
AgentEntity updated = agentService.updateAgent(agent);
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pre-egress message-list normalizer.
|
||||
*
|
||||
* <p>Some OpenAI-compatible providers (notably LM Studio's built-in server,
|
||||
* and certain strict-mode vLLM / SGLang deployments) enforce that exactly
|
||||
* one {@link SystemMessage} must appear at index 0 of the messages array.
|
||||
* Multiple consecutive SystemMessages, or any SystemMessage following a
|
||||
* user / assistant / tool message, returns {@code 400 BAD_REQUEST:
|
||||
* "System message must be at the beginning."}.
|
||||
*
|
||||
* <p>Permissive providers (OpenAI, DashScope, Ollama, DeepSeek, Kimi, Doubao,
|
||||
* GLM) accept the relaxed shape, so the runtime historically composed
|
||||
* prompts with multiple SystemMessages sprinkled through the non-history
|
||||
* prefix (main system prompt + skill catalog + progress-ledger snapshot,
|
||||
* each as its own SystemMessage). To stay portable across both strict and
|
||||
* permissive backends, this normalizer collects every SystemMessage found
|
||||
* anywhere in the input list, concatenates their text with a blank-line
|
||||
* separator, and emits the result as a single SystemMessage at index 0.
|
||||
* The relative order of non-system messages (user / assistant /
|
||||
* tool_response) is preserved verbatim so {@code tool_call_id} pairings
|
||||
* are unaffected.
|
||||
*
|
||||
* <p>Blank / whitespace-only SystemMessages are dropped from the merge. If
|
||||
* every SystemMessage in the input is blank, the result is the same list
|
||||
* with all SystemMessages removed (no synthetic empty SystemMessage is
|
||||
* emitted). If the input contains zero SystemMessages, the input list
|
||||
* reference is returned unchanged.
|
||||
*
|
||||
* <p>The transformation is semantically equivalent on permissive providers
|
||||
* — the merged SystemMessage produces the same token sequence the model
|
||||
* would have seen across N separate SystemMessages — and converts the
|
||||
* strict-provider 400 into a success. It is also safe for non-OpenAI
|
||||
* protocols: the Spring AI Anthropic and Vertex / Gemini adapters already
|
||||
* extract SystemMessages out of the messages list into a top-level
|
||||
* {@code system} / {@code systemInstruction} request field, so they receive
|
||||
* an identical outbound payload whether handed one merged SystemMessage
|
||||
* or several.
|
||||
*
|
||||
* <p>A kill switch is exposed via the JVM system property
|
||||
* {@code mateclaw.llm.message-normalizer.enabled=false}, which makes
|
||||
* {@link #normalize} a no-op for emergency rollback without code changes.
|
||||
*/
|
||||
public final class MessageNormalizer {
|
||||
|
||||
/** Separator inserted between merged SystemMessage segments. */
|
||||
static final String SEPARATOR = "\n\n";
|
||||
|
||||
/**
|
||||
* Kill-switch property name. Set to {@code false} (case-insensitive) on
|
||||
* the JVM command line to disable normalization without a code change.
|
||||
*/
|
||||
public static final String ENABLED_PROPERTY = "mateclaw.llm.message-normalizer.enabled";
|
||||
|
||||
private static volatile boolean enabled = !"false".equalsIgnoreCase(
|
||||
System.getProperty(ENABLED_PROPERTY, "true"));
|
||||
|
||||
private MessageNormalizer() {
|
||||
}
|
||||
|
||||
/** Read the current kill-switch state. */
|
||||
public static boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the kill-switch at runtime (primarily for tests). Production
|
||||
* code should not need to call this — set the JVM property at startup
|
||||
* instead.
|
||||
*/
|
||||
public static void setEnabledForTesting(boolean value) {
|
||||
enabled = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of {@code prompt} with every SystemMessage merged into a
|
||||
* single SystemMessage at index 0. Returns the input prompt reference
|
||||
* unchanged when no normalization is necessary (kill switch off, zero
|
||||
* SystemMessages, or already a single non-blank SystemMessage at index 0).
|
||||
*/
|
||||
public static Prompt normalize(Prompt prompt) {
|
||||
if (prompt == null || !enabled) {
|
||||
return prompt;
|
||||
}
|
||||
List<Message> in = prompt.getInstructions();
|
||||
List<Message> out = normalize(in);
|
||||
if (out == in) {
|
||||
return prompt;
|
||||
}
|
||||
return new Prompt(out, prompt.getOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* List-level normalization, used by {@link #normalize(Prompt)} and by
|
||||
* unit tests that want to assert on the raw message shape without
|
||||
* constructing a {@link Prompt}. Returns the input list reference
|
||||
* unchanged when no normalization is necessary.
|
||||
*/
|
||||
public static List<Message> normalize(List<Message> messages) {
|
||||
if (!enabled || messages == null || messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
int systemCount = 0;
|
||||
int firstSystemIdx = -1;
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
if (messages.get(i) instanceof SystemMessage) {
|
||||
if (firstSystemIdx < 0) firstSystemIdx = i;
|
||||
systemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fast-path 1: no SystemMessages — nothing to do.
|
||||
if (systemCount == 0) {
|
||||
return messages;
|
||||
}
|
||||
// Fast-path 2: exactly one SystemMessage and it sits at index 0 with
|
||||
// non-blank text. Already canonical — skip the rebuild.
|
||||
if (systemCount == 1 && firstSystemIdx == 0) {
|
||||
SystemMessage sm = (SystemMessage) messages.get(0);
|
||||
String text = sm.getText();
|
||||
if (text != null && !text.isBlank()) {
|
||||
return messages;
|
||||
}
|
||||
// Single blank SystemMessage at [0] — fall through to the rebuild,
|
||||
// which will drop it.
|
||||
}
|
||||
|
||||
StringBuilder merged = new StringBuilder();
|
||||
List<Message> rest = new ArrayList<>(messages.size());
|
||||
for (Message m : messages) {
|
||||
if (m instanceof SystemMessage sm) {
|
||||
String text = sm.getText();
|
||||
if (text == null || text.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (merged.length() > 0) {
|
||||
merged.append(SEPARATOR);
|
||||
}
|
||||
merged.append(text);
|
||||
} else {
|
||||
rest.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.length() == 0) {
|
||||
// Every SystemMessage in the input was blank — return just the
|
||||
// non-system tail. No synthetic empty SystemMessage.
|
||||
return rest;
|
||||
}
|
||||
|
||||
List<Message> out = new ArrayList<>(rest.size() + 1);
|
||||
out.add(new SystemMessage(merged.toString()));
|
||||
out.addAll(rest);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -412,18 +412,22 @@ public class NodeStreamingChatHelper {
|
||||
return ErrorType.BILLING;
|
||||
}
|
||||
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
|
||||
// Includes DashScope's "[InvalidParameter] url error, please check url"
|
||||
// (https://help.aliyun.com/zh/model-studio/error-code#error-url) which despite
|
||||
// the wording is the provider rejecting an unknown/unsupported model id on
|
||||
// the native protocol. Splitting this out from CLIENT_ERROR lets us hand off
|
||||
// to the fallback chain instead of terminating — a different provider may
|
||||
// recognize the model name (or have an equivalent default).
|
||||
// DashScope signals an unknown/unsupported model id specifically as
|
||||
// "[InvalidParameter] url error, please check url"
|
||||
// (https://help.aliyun.com/zh/model-studio/error-code#error-url). Splitting this
|
||||
// out from CLIENT_ERROR lets us hand off to the fallback chain instead of
|
||||
// terminating — a different provider may recognize the model name (or have an
|
||||
// equivalent default).
|
||||
//
|
||||
// Note: we match on the specific "url error" wording rather than a bare
|
||||
// "InvalidParameter", because DashScope reuses the InvalidParameter code for
|
||||
// request-shape problems that have nothing to do with the model id (an illegal
|
||||
// tool name, or an unsupported parameter) — those are handled as CLIENT_ERROR
|
||||
// below so a healthy model is not evicted from the failover pool.
|
||||
if (msg.contains("Model not exist")
|
||||
|| msg.contains("model_not_found")
|
||||
|| msg.contains("Model not found")
|
||||
|| msg.contains("does not exist")
|
||||
|| msg.contains("[InvalidParameter]")
|
||||
|| msg.contains("InvalidParameter")
|
||||
|| msg.contains("url error")
|
||||
// Volcano Ark: model exists but the user's account hasn't opened it,
|
||||
// or the id isn't valid for this region. Both are hard failures —
|
||||
@ -432,9 +436,16 @@ public class NodeStreamingChatHelper {
|
||||
|| msg.contains("InvalidEndpointOrModel")) {
|
||||
return ErrorType.MODEL_NOT_FOUND;
|
||||
}
|
||||
// Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable
|
||||
// Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable.
|
||||
// DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved
|
||||
// or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported
|
||||
// parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR
|
||||
// (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real
|
||||
// cause instead of a misleading "model not available" message.
|
||||
if (msg.contains("400") || msg.contains("Bad Request")
|
||||
|| msg.contains("invalid_request_error") || msg.contains("unsupported")) {
|
||||
|| msg.contains("invalid_request_error") || msg.contains("unsupported")
|
||||
|| msg.contains("Tool names are not allowed")
|
||||
|| msg.contains("InvalidParameter")) {
|
||||
return ErrorType.CLIENT_ERROR;
|
||||
}
|
||||
// Server errors and transient TLS / socket-level network hiccups.
|
||||
@ -716,11 +727,25 @@ public class NodeStreamingChatHelper {
|
||||
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
|
||||
String conversationId, String phase,
|
||||
boolean broadcast, int attempt) {
|
||||
// Collapse every SystemMessage in the prompt into a single SystemMessage
|
||||
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
|
||||
// server, certain strict vLLM / SGLang deployments) reject 400
|
||||
// "System message must be at the beginning" when SystemMessages appear
|
||||
// after user / assistant / tool messages — the runtime composes the
|
||||
// non-history prefix from several SystemMessage segments (main prompt,
|
||||
// skill catalog, progress-ledger snapshot) and some of them land mid-
|
||||
// list. Permissive providers see an equivalent token sequence either
|
||||
// way; non-OpenAI protocols (Anthropic, Vertex) extract the merged
|
||||
// system into their top-level system field exactly as before.
|
||||
// Preserves the input's options reference so downstream relay logic
|
||||
// (options.user = relay token) keeps working.
|
||||
Prompt outbound = MessageNormalizer.normalize(prompt);
|
||||
|
||||
// PR-2 L4 (RFC-049 §2.4.2): normalize as a pre-egress step (not only on retry).
|
||||
// Strip reasoning_content from prior-turn AssistantMessages (i <= lastUserIdx),
|
||||
// preserving in-turn thinking (i > lastUserIdx) so DeepSeek's contract holds.
|
||||
// The returned Prompt shares `options` by reference with the input prompt.
|
||||
Prompt outbound = stripThinkingFromPrompt(prompt);
|
||||
outbound = stripThinkingFromPrompt(outbound);
|
||||
|
||||
// RFC-049 follow-up (2026-04-27): trim trailing AssistantMessage from the
|
||||
// outbound prompt. Triggered in practice by the summarizing→reasoning
|
||||
|
||||
@ -14,6 +14,9 @@ import vip.mate.agent.context.StructuredTruncator;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.grant.AutoApproveResult;
|
||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
@ -226,6 +229,38 @@ public class ToolExecutionExecutor {
|
||||
this.auditEventService = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-grant lookup cache. Optional — legacy constructors leave it
|
||||
* {@code null} and {@code evaluateGuard()} falls back to the original
|
||||
* human-approval path. Both this and {@link #approvalGrantResolver} must be
|
||||
* non-null for auto-grant to engage; either being null disables the resolver
|
||||
* branch entirely (see {@code autoGrantWired} in {@code evaluateGuard}).
|
||||
* Not {@code final} so existing constructors that don't take these
|
||||
* dependencies stay source-compatible without restructuring.
|
||||
*/
|
||||
private WorkspaceLookupCache workspaceLookupCache;
|
||||
|
||||
/** Auto-grant resolver. Optional; see {@link #workspaceLookupCache} note. */
|
||||
private ApprovalGrantResolver approvalGrantResolver;
|
||||
|
||||
/**
|
||||
* Constructor used by {@code AgentGraphBuilder} after PR-1: takes the auto-grant
|
||||
* dependencies on top of the standard 7 params. Legacy constructors continue
|
||||
* to work unchanged (they simply leave the two new fields {@code null}).
|
||||
*/
|
||||
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker,
|
||||
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties,
|
||||
ToolResultStorage resultStorage,
|
||||
vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry,
|
||||
WorkspaceLookupCache workspaceLookupCache,
|
||||
ApprovalGrantResolver approvalGrantResolver) {
|
||||
this(toolSet, toolGuardService, null, approvalService, streamTracker,
|
||||
toolTimeoutProperties, resultStorage, concurrencyRegistry);
|
||||
this.workspaceLookupCache = workspaceLookupCache;
|
||||
this.approvalGrantResolver = approvalGrantResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn deduplication key set for child-agent denial audit. Without
|
||||
* this, a child that retries the same denied tool many times in one
|
||||
@ -458,7 +493,7 @@ public class ToolExecutionExecutor {
|
||||
// 2. ToolGuard 安全检查(replay 模式跳过)
|
||||
if (!isReplay) {
|
||||
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
|
||||
conversationId, agentId, toolCalls, i, events, requesterId);
|
||||
conversationId, agentId, toolCalls, i, events, requesterId, safeOrigin);
|
||||
|
||||
if (decision.blocked) {
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
@ -898,8 +933,20 @@ public class ToolExecutionExecutor {
|
||||
private GuardDecision evaluateGuard(AssistantMessage.ToolCall toolCall, String toolName, String arguments,
|
||||
String conversationId, String agentId,
|
||||
List<AssistantMessage.ToolCall> allToolCalls, int currentIndex,
|
||||
List<GraphEventPublisher.GraphEvent> events, String requesterId) {
|
||||
ToolInvocationContext guardCtx = ToolInvocationContext.of(toolName, arguments, conversationId, agentId);
|
||||
List<GraphEventPublisher.GraphEvent> events, String requesterId,
|
||||
ChatOrigin origin) {
|
||||
// Auto-grant requires BOTH the lookup cache and the resolver to be wired.
|
||||
// Legacy constructors leave them null; in that case we skip workspace
|
||||
// resolution and skip the resolver block, falling back to the original
|
||||
// human-approval path.
|
||||
boolean autoGrantWired = approvalGrantResolver != null && workspaceLookupCache != null;
|
||||
Long workspaceId = autoGrantWired
|
||||
? workspaceLookupCache.resolveByConversation(conversationId)
|
||||
: null;
|
||||
ToolInvocationContext guardCtx = ToolInvocationContext.of(
|
||||
toolName, java.util.Map.of(), arguments,
|
||||
conversationId, agentId,
|
||||
/*channelType*/ null, requesterId, workspaceId);
|
||||
|
||||
if (toolGuardService != null) {
|
||||
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
|
||||
@ -912,6 +959,34 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
|
||||
if (evaluation.shouldRequireApproval()) {
|
||||
// Auto-grant decision layer: only engages when both deps are wired.
|
||||
// HARD_BLOCK short-circuits to a blocked decision (no approval banner).
|
||||
// APPROVED skips createPending() and lets the tool run as normal.
|
||||
// REQUIRES_HUMAN falls through to the existing manual approval path.
|
||||
if (autoGrantWired) {
|
||||
AutoApproveResult auto = approvalGrantResolver.tryAutoApprove(guardCtx, evaluation);
|
||||
if (auto.isHardBlocked()) {
|
||||
String msg = "[安全拦截] safety floor matched: " + auto.reason()
|
||||
+ " — this command cannot be executed even with approval. "
|
||||
+ "Please use a safer alternative.";
|
||||
log.warn("[ToolExecutor] Auto-grant HARD_BLOCK: tool={}, reason={}", toolName, auto.reason());
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
return GuardDecision.blocked(msg);
|
||||
}
|
||||
if (auto.isApproved()) {
|
||||
log.info("[ToolExecutor] Auto-grant APPROVED: tool={}, grantId={}", toolName, auto.grantId());
|
||||
return GuardDecision.allowed();
|
||||
}
|
||||
// requiresHuman → fall through to legacy human-approval path below.
|
||||
}
|
||||
|
||||
// No human can resolve an approval in a non-interactive (scheduled-job)
|
||||
// run, so a pending request would hang the turn until it times out with
|
||||
// no answer. Deny immediately with an actionable message instead.
|
||||
if (origin != null && origin.cronOrigin()) {
|
||||
return denyNonInteractiveApproval(toolCall, toolName, events);
|
||||
}
|
||||
|
||||
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
|
||||
String approvalResponse = ToolExecutionGuardHelper.handleToolApproval(
|
||||
toolCall, toolName, arguments, evaluation,
|
||||
@ -931,6 +1006,9 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
|
||||
if (guardResult.needsApproval()) {
|
||||
if (origin != null && origin.cronOrigin()) {
|
||||
return denyNonInteractiveApproval(toolCall, toolName, events);
|
||||
}
|
||||
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
|
||||
String approvalResponse = ToolExecutionGuardHelper.handleToolApprovalLegacy(
|
||||
toolCall, toolName, arguments, guardResult,
|
||||
@ -943,6 +1021,22 @@ public class ToolExecutionExecutor {
|
||||
return GuardDecision.allowed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny an approval-required tool when the run is non-interactive (no human can
|
||||
* approve), returning an actionable message so the agent falls back to a
|
||||
* non-gated built-in tool instead of stalling on a pending nobody resolves.
|
||||
*/
|
||||
private GuardDecision denyNonInteractiveApproval(AssistantMessage.ToolCall toolCall, String toolName,
|
||||
List<GraphEventPublisher.GraphEvent> events) {
|
||||
String msg = "[审批不可用] 该工具需要人工审批,但当前为非交互(定时任务)运行,无人可批准,"
|
||||
+ "因此无法执行。请改用无需审批的内置工具完成本步骤(例如 PDF / XLSX / 文档技能、文件读写工具),"
|
||||
+ "或跳过该步骤并说明原因,不要反复重试同一命令。";
|
||||
log.info("[ToolExecutor] NON_INTERACTIVE_DENY: tool={} needs approval but origin is non-interactive (cron); "
|
||||
+ "denying to avoid an unresolvable pending", toolName);
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
return GuardDecision.blocked(msg);
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -25,9 +25,8 @@ import java.util.Optional;
|
||||
/**
|
||||
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
|
||||
*
|
||||
* <p>Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so
|
||||
* upstream finishReason / evidence checks are already authoritative. The
|
||||
* node:
|
||||
* <p>Evaluation runs on a settled terminal answer so upstream finishReason /
|
||||
* evidence checks are already authoritative. The node:
|
||||
* <ol>
|
||||
* <li>Bails out for the "this turn shouldn't count" finishReasons
|
||||
* (evidence_insufficient, stopped, error_fallback, return_direct,
|
||||
@ -50,8 +49,8 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
private final GoalFollowupService followupService;
|
||||
private final GoalService goalService;
|
||||
private final GoalProperties properties;
|
||||
private final ConversationWindowManager windowManager; // unused PR2, kept for PR5
|
||||
private final ConversationService conversationService; // unused PR2, kept for PR5
|
||||
private final ConversationWindowManager windowManager; // reserved for evaluator context windowing
|
||||
private final ConversationService conversationService; // reserved for evaluator context lookups
|
||||
private final GraphFlavor flavor;
|
||||
|
||||
public GoalEvaluationNode(GoalEvaluationService evaluationService,
|
||||
@ -72,7 +71,7 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
|
||||
@Override
|
||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||
// Master kill switch — node stays inert until PR5 flips this.
|
||||
// Master kill switch — when disabled the node stays inert.
|
||||
if (!properties.isEnabled()) {
|
||||
return Map.of();
|
||||
}
|
||||
@ -186,30 +185,34 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
// failure on completion) does not propagate into the chat graph
|
||||
// and abort the streamed answer the user already sees.
|
||||
try {
|
||||
if (result.completed() || result.score() >= 0.95) {
|
||||
goalService.markCompleted(refreshed.getId(), result);
|
||||
// Completion is the deterministic "all criteria passed" signal the
|
||||
// evaluator already folded into result.completed() — no score gate.
|
||||
if (result.completed()) {
|
||||
GoalEntity completed = goalService.markCompleted(refreshed.getId(), result);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score()))))
|
||||
"goalId", String.valueOf(completed.getId()),
|
||||
"score", result.score(),
|
||||
"goal", goalService.toResponse(completed)))))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (goalService.isBudgetExhausted(refreshed)) {
|
||||
String reason = goalService.exhaustionReason(refreshed);
|
||||
goalService.markExhausted(refreshed.getId(), reason);
|
||||
GoalEntity exhausted = goalService.markExhausted(refreshed.getId(), reason);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"turnsUsed", refreshed.getTurnsUsed(),
|
||||
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
|
||||
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
|
||||
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
|
||||
"reason", reason))))
|
||||
"goalId", String.valueOf(exhausted.getId()),
|
||||
"turnsUsed", exhausted.getTurnsUsed(),
|
||||
"agentLlmCallsUsed", exhausted.getAgentLlmCallsUsed(),
|
||||
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
|
||||
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
|
||||
"reason", reason,
|
||||
"goal", goalService.toResponse(exhausted)))))
|
||||
.build();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
@ -270,7 +273,8 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.needsToolCall(false)
|
||||
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"prompt", followup.get()))));
|
||||
"prompt", followup.get(),
|
||||
"goal", goalService.toResponse(refreshed)))));
|
||||
|
||||
if (flavor == GraphFlavor.REACT) {
|
||||
// ReAct: append the followup as a fresh user message via the
|
||||
@ -309,7 +313,8 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
.events(List.of(goalEvent("goal_evaluated", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score(),
|
||||
"gap", result.gap() == null ? "" : result.gap()))))
|
||||
"gap", result.gap() == null ? "" : result.gap(),
|
||||
"goal", goalService.toResponse(refreshed)))))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,10 @@ import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.context.ConversationWindowManager;
|
||||
import vip.mate.agent.context.LoopBudgetConfig;
|
||||
import vip.mate.agent.context.LoopMessageBudgeter;
|
||||
import vip.mate.agent.context.RuntimeContextInjector;
|
||||
import vip.mate.agent.context.TokenEstimator;
|
||||
import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
@ -71,6 +74,36 @@ public class ReasoningNode implements NodeAction {
|
||||
*/
|
||||
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
||||
|
||||
/**
|
||||
* Stateless singleton used to budget the per-iteration working message
|
||||
* list. Static-final because the budgeter holds no mutable state — the
|
||||
* choice keeps the existing ReasoningNode constructor surface unchanged
|
||||
* (it already carries 13 parameters across 5 overloads) and makes the
|
||||
* dependency obvious to anyone reading the class.
|
||||
*/
|
||||
private static final LoopMessageBudgeter LOOP_BUDGETER = new LoopMessageBudgeter();
|
||||
|
||||
/**
|
||||
* Fallback context window used when no provider-level value is wired in.
|
||||
* Calibrated to the same default {@code ConversationWindowProperties}
|
||||
* uses for its multi-turn budget so the two layers stay in sync. Models
|
||||
* with smaller windows still benefit — the budgeter triggers earlier on
|
||||
* raw message volume via {@code absoluteMaxMessages}.
|
||||
*/
|
||||
private static final int DEFAULT_LOOP_CONTEXT_WINDOW_TOKENS = 128_000;
|
||||
|
||||
/**
|
||||
* Conservative buffer added to the per-loop budget's reservedPrefixTokens
|
||||
* to cover non-history prompt segments that are appended <em>after</em>
|
||||
* the budget runs: the runtime-rendered skill catalog, runtime-context
|
||||
* snapshot, wiki injection, progress ledger snapshot, and assorted
|
||||
* marker SystemMessages. Underestimating here only delays the trigger
|
||||
* slightly; loop invariants (anchor preservation, tool-pair integrity)
|
||||
* are unaffected. Sized for a typical agent with 20–30 skills and
|
||||
* moderate wiki content.
|
||||
*/
|
||||
private static final int LOOP_PREFIX_AUXILIARY_RESERVE_TOKENS = 4_000;
|
||||
|
||||
/**
|
||||
* DashScope's native chat API caps {@code max_tokens} at 8192 and returns a
|
||||
* 400 {@code InvalidParameter} ("Range of max_tokens should be [1, 8192]")
|
||||
@ -319,6 +352,20 @@ public class ReasoningNode implements NodeAction {
|
||||
this.progressLedgerService = progressLedgerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context window used by the per-loop budgeter. Returns the
|
||||
* conversation-window manager's effective max input tokens when one is
|
||||
* wired in (so L1 and L2 stay calibrated to the same model window),
|
||||
* otherwise the documented fallback.
|
||||
*/
|
||||
private int loopContextWindowTokens() {
|
||||
if (conversationWindowManager != null) {
|
||||
int v = conversationWindowManager.getDefaultMaxInputTokens();
|
||||
if (v > 0) return v;
|
||||
}
|
||||
return DEFAULT_LOOP_CONTEXT_WINDOW_TOKENS;
|
||||
}
|
||||
|
||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||
NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager) {
|
||||
@ -414,81 +461,50 @@ public class ReasoningNode implements NodeAction {
|
||||
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
|
||||
List<Message> messages = accessor.messages();
|
||||
|
||||
// Guard against runaway message list growth.
|
||||
// Per-loop budget: bound the working message list a single Reasoning
|
||||
// iteration hands to the LLM. The previous fixed head=4 + tail=36 cut
|
||||
// could lose the latest UserMessage once the ReAct loop accumulated
|
||||
// tool calls/observations past ~70 messages — the user's question
|
||||
// fell into the dropped middle, the LLM lost it, and the agent
|
||||
// answered off-topic. LoopMessageBudgeter anchors the latest
|
||||
// UserMessage as undroppable, sizes the tail by token budget instead
|
||||
// of message count, and keeps the same bidirectional tool-pair
|
||||
// integrity guard the old block already had. The L2 trim here is
|
||||
// distinct from ConversationWindowManager (L1): L1 runs once per
|
||||
// user turn and produces an LLM summary for multi-turn history; L2
|
||||
// runs per reasoning iteration on what L1 already produced plus
|
||||
// intra-turn tool-call growth.
|
||||
//
|
||||
// CRITICAL: a naive head+tail cut can break the OpenAI-compatible protocol invariant
|
||||
// that requires tool_call / tool_response pairs to be complete:
|
||||
//
|
||||
// P0 (originally observed): AssistantMessage(tool_calls) falls into the dropped gap,
|
||||
// its ToolResponseMessage lands in the kept tail → provider sees an orphaned
|
||||
// ToolResponseMessage → kimi-code 400 "tool_call_id is not found".
|
||||
//
|
||||
// P1 (symmetric): AssistantMessage(tool_calls) is kept in the head at the boundary,
|
||||
// its ToolResponseMessage falls into the dropped gap → provider sees an assistant
|
||||
// tool_call with no matching response → also a 400 on strict providers.
|
||||
//
|
||||
// Fix: perform the normal cut, then run an iterative bidirectional integrity pass until
|
||||
// the list is stable:
|
||||
// • Remove any ToolResponseMessage whose parent AssistantMessage.tool_calls id was
|
||||
// dropped (P0).
|
||||
// • Remove any AssistantMessage whose tool_calls have no matching ToolResponseMessage
|
||||
// (P1).
|
||||
// Iterate because a P1 removal could expose a new P0 orphan (and vice versa, though that
|
||||
// is pathological in practice). With ≤40 messages convergence is always fast.
|
||||
// Dropping incomplete pairs is safe — prior iterations already processed those
|
||||
// observations; the LLM needs the summary context, not the raw tool I/O.
|
||||
final int MAX_LOOP_MESSAGES = 40;
|
||||
if (messages.size() > MAX_LOOP_MESSAGES) {
|
||||
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
|
||||
messages.size(), MAX_LOOP_MESSAGES, conversationId);
|
||||
int headKeep = Math.min(4, messages.size());
|
||||
int tailKeep = MAX_LOOP_MESSAGES - headKeep;
|
||||
int tailStart = messages.size() - tailKeep;
|
||||
|
||||
List<Message> trimmed = new ArrayList<>(MAX_LOOP_MESSAGES);
|
||||
trimmed.addAll(messages.subList(0, headKeep));
|
||||
trimmed.addAll(messages.subList(tailStart, messages.size()));
|
||||
|
||||
// Iterative bidirectional integrity pass.
|
||||
int totalRemoved = 0;
|
||||
boolean changed;
|
||||
do {
|
||||
// Snapshot current tool_call ids and response ids.
|
||||
Set<String> callIds = new java.util.HashSet<>();
|
||||
Set<String> respIds = new java.util.HashSet<>();
|
||||
for (Message m : trimmed) {
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : am.getToolCalls()) callIds.add(tc.id());
|
||||
}
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) respIds.add(r.id());
|
||||
}
|
||||
}
|
||||
int before = trimmed.size();
|
||||
trimmed.removeIf(m -> {
|
||||
// P0: ToolResponseMessage whose parent tool_call was dropped
|
||||
if (m instanceof ToolResponseMessage trm) {
|
||||
return trm.getResponses().stream().anyMatch(r -> !callIds.contains(r.id()));
|
||||
}
|
||||
// P1: AssistantMessage whose tool_call has no ToolResponseMessage
|
||||
if (m instanceof AssistantMessage am && am.getToolCalls() != null
|
||||
&& !am.getToolCalls().isEmpty()) {
|
||||
return am.getToolCalls().stream().anyMatch(tc -> !respIds.contains(tc.id()));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
int removed = before - trimmed.size();
|
||||
totalRemoved += removed;
|
||||
changed = removed > 0;
|
||||
} while (changed);
|
||||
|
||||
if (totalRemoved > 0) {
|
||||
log.warn("[ReasoningNode] Removed {} message(s) with broken tool_call/response pairs "
|
||||
+ "after trim (bidirectional integrity guard), conv={}", totalRemoved, conversationId);
|
||||
}
|
||||
|
||||
messages = trimmed;
|
||||
// Reserved prefix tokens cover the non-history portion of the
|
||||
// prompt the LLM will receive: system prompt (with tool-use
|
||||
// enforcement already appended), tool schemas, output reserve,
|
||||
// and a buffer for skill catalog + runtime context + wiki
|
||||
// injections that are added downstream. Underestimating here only
|
||||
// delays the trigger slightly — invariants (anchor, pair integrity)
|
||||
// still hold once budget fires.
|
||||
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
|
||||
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
|
||||
int loopReservedPrefixTokens = systemTokens + toolsTokens
|
||||
+ maxOutputTokens + LOOP_PREFIX_AUXILIARY_RESERVE_TOKENS;
|
||||
LoopBudgetConfig loopCfg = LoopBudgetConfig.forContext(loopContextWindowTokens())
|
||||
.withReservedPrefixTokens(loopReservedPrefixTokens);
|
||||
LoopMessageBudgeter.Result budgeted = LOOP_BUDGETER.budget(messages, loopCfg);
|
||||
// Only log when the budget actually modified the list — a triggered-
|
||||
// but-no-op pass is normal (history fits comfortably under the tail
|
||||
// budget) and would otherwise spam logs every iteration.
|
||||
if (budgeted.trace().modified()) {
|
||||
LoopMessageBudgeter.BudgetTrace t = budgeted.trace();
|
||||
log.warn("[ReasoningNode] Loop budget trim: {} -> {} msgs (history {} -> {} tokens, "
|
||||
+ "prefix~{}), head={}, tail={}, droppedMiddle={}, orphans={}, "
|
||||
+ "anchorEnforced={}, anchorStitched={}, targetMaxTripped={}, "
|
||||
+ "capExceededForPairIntegrity={}, minTailFloorApplied={}, conv={}",
|
||||
t.originalCount(), t.finalCount(), t.originalTokens(), t.finalTokens(),
|
||||
t.reservedPrefixTokens(),
|
||||
t.headKept(), t.tailKept(), t.droppedMiddle(), t.orphansRemoved(),
|
||||
t.anchorEnforced(), t.anchorStitched(), t.targetMaxTripped(),
|
||||
t.capExceededForPairIntegrity(), t.minTailFloorApplied(), conversationId);
|
||||
}
|
||||
messages = budgeted.messages();
|
||||
|
||||
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||
@ -955,7 +971,17 @@ public class ReasoningNode implements NodeAction {
|
||||
List<Message> prefix = new ArrayList<>();
|
||||
prefix.add(new SystemMessage(systemPrompt));
|
||||
prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin)));
|
||||
if (wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
|
||||
// When this turn already recalled the user's own current project from
|
||||
// structured memory, skip auto-injecting knowledge-base reference context.
|
||||
// Otherwise the KB pages (reference material, possibly about unrelated
|
||||
// projects) compete with — and tend to override — the user's actual
|
||||
// project identity. The agent can still query the wiki on demand.
|
||||
boolean projectRecalled = userMsg != null
|
||||
&& userMsg.contains(vip.mate.memory.service.StructuredMemoryService.PROJECT_RECALLED_MARKER);
|
||||
if (projectRecalled) {
|
||||
log.debug("[ReasoningNode] Skipping wiki-relevant injection: user's project was recalled from memory this turn");
|
||||
}
|
||||
if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
|
||||
try {
|
||||
Long parsedAgentId = Long.parseLong(agentIdStr);
|
||||
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
|
||||
|
||||
@ -70,26 +70,30 @@ public class PlanGenerationNode implements NodeAction {
|
||||
硬性规则:
|
||||
1. 只返回一个 JSON 对象;不允许 markdown 代码块、不允许任何 JSON 以外的文字。
|
||||
2. 不要解释,不要寒暄,不要说"我来...""我先..."。
|
||||
3. 不确定时优先选择"单步",而不是拆成多步。
|
||||
3. 判断依据是"目标是否由多个明显独立的子任务/交付物组成",而不是难度高低:
|
||||
单个连贯动作不要拆,但目标确实分成多个部分时也不要硬压成一步。
|
||||
|
||||
三类分流:
|
||||
|
||||
(A) 直接回答 — 纯知识问答,模型凭自身知识即可回答,不需要任何工具、不需要读文件、不需要查询当前状态。
|
||||
(A) 直接回答 — 简单的纯知识问答:凭自身知识用一两段话即可答完,不需要任何工具、不需要读文件、
|
||||
不需要查询当前状态,且目标本身不包含多个需要分别完成的子任务。
|
||||
(注意:成段的分析、对比、方案、规划、教程等通常不属于此类,应走 B 或 C。)
|
||||
输出:{"needs_planning": false, "direct_answer": "<你的回答>"}
|
||||
|
||||
(B) 单步任务 — 需要工具,但本质是一个连贯动作(一次文件读取 / 一次搜索 / 一次命令 / 一次记忆读写 / 一次计算)。
|
||||
执行器会在这一步内部迭代调用多次工具,你**不要**提前拆分。
|
||||
(B) 单步任务 — 本质是一个连贯动作(一次文件读取 / 一次搜索 / 一次命令 / 一次记忆读写 / 一次计算 /
|
||||
一段集中产出)。执行器会在这一步内部迭代调用多次工具,你**不要**提前拆分。
|
||||
输出:{"needs_planning": true, "steps": ["<将用户目标复述为一句清晰可执行的指令>"]}
|
||||
|
||||
(C) 多步任务 — 用户目标包含 2 个及以上明显独立、必须先后完成的子任务(例如"先调研 A 再调研 B 然后对比"、
|
||||
"读配置、迁移数据、验证结果")。子任务之间如果可以合并,应当合并。
|
||||
(C) 多步任务 — 用户目标包含 2 个及以上明显独立、需要先后完成的子任务或交付物(例如"先调研 A 再调研 B
|
||||
然后对比"、"读配置、迁移数据、验证结果"、"分阶段制定计划"、"产出由若干独立部分组成的方案")。
|
||||
这是规划型智能体的主路径——当目标确实由多个部分组成时就走这里。
|
||||
输出:{"needs_planning": true, "steps": ["步骤1", "步骤2", ...]}(2 到 6 个步骤)
|
||||
|
||||
关键原则:
|
||||
- 单工具调用绝对不拆成多步。例:"读 A 文件并总结" 是单步(B),不是两步。
|
||||
- 默认不要把 MEMORY.md / PROFILE.md / 技能文件读取当成独立步骤;仅当用户明确询问偏好、历史决策或长期约束时才加入。
|
||||
- 每个步骤必须是可执行动作,不写"思考一下""确认一下"之类的空话。
|
||||
- 解析不出来时,视作(B) 单步;宁愿单步也不要无脑拆分。
|
||||
- 多部分、多阶段、需要逐步推进的目标走(C);真正单一原子动作走(B);只有简单一问一答才用(A)。
|
||||
""";
|
||||
|
||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||
|
||||
@ -78,6 +78,59 @@ public class AgentEntity {
|
||||
/** 默认思考深度:off / low / medium / high / max,null 表示跟随模型默认 */
|
||||
private String defaultThinkingLevel;
|
||||
|
||||
/**
|
||||
* Agent-level working directory override. When non-blank, takes priority
|
||||
* over the workspace's basePath; relative values are resolved under the
|
||||
* workspace basePath. Null/blank means inherit the workspace value.
|
||||
*/
|
||||
@TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String workspaceBasePath;
|
||||
|
||||
/**
|
||||
* Agent's primary wiki knowledge base. This is a per-agent default target
|
||||
* for wiki tools; it does not affect KB visibility or ownership.
|
||||
* Null means no explicit primary KB, so wiki resolution falls back to the
|
||||
* workspace's most recently updated KB.
|
||||
*/
|
||||
@TableField(value = "primary_kb_id", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Long primaryKbId;
|
||||
|
||||
/**
|
||||
* Explicit opt-out from every skill. When {@code true}, the binding service
|
||||
* returns {@link java.util.Collections#emptySet()} from
|
||||
* {@code getBoundSkillIds}, which (a) suppresses every {@code SKILL.md}
|
||||
* catalog entry from the system prompt and (b) drops skill-expanded tools
|
||||
* out of the effective tool set.
|
||||
*
|
||||
* <p>Default {@code false} preserves the legacy "zero rows = inherit global
|
||||
* default" behaviour for every legacy agent. The flag is auto-cleared when
|
||||
* a non-empty skill binding is written, so the data layer never holds a
|
||||
* "{@code disabled=true} + binding rows" contradiction.
|
||||
*
|
||||
* <p>Default {@code NOT_NULL} update strategy is deliberate: a frontend
|
||||
* PUT that explicitly carries {@code true} or {@code false} writes through
|
||||
* (both are non-null Boolean), while a sparse partial update (e.g. the
|
||||
* auto-clear helper that constructs a one-field entity) won't emit the
|
||||
* other flag's column as a stray {@code SET ... = NULL} that would
|
||||
* collide with the {@code NOT NULL} DDL.
|
||||
*/
|
||||
@TableField(value = "skills_disabled")
|
||||
private Boolean skillsDisabled;
|
||||
|
||||
/**
|
||||
* Explicit opt-out from every non-system-level tool. When {@code true},
|
||||
* {@code getBoundToolNames} returns {@link java.util.Collections#emptySet()}
|
||||
* and the MCP auto-include in {@code getEffectiveToolNames} is suppressed;
|
||||
* the structured-memory primitives (record_lesson / remember / workspace
|
||||
* memory CRUD) still pass through because they are agent-internal
|
||||
* capabilities unrelated to the user-facing capability picker.
|
||||
*
|
||||
* <p>Same defaulting / auto-clear / update strategy contract as
|
||||
* {@link #skillsDisabled}.
|
||||
*/
|
||||
@TableField(value = "tools_disabled")
|
||||
private Boolean toolsDisabled;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.approval.event.ApprovalResolutionEvent;
|
||||
import vip.mate.approval.event.WorkflowApprovalResolvedEvent;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
@ -707,6 +708,41 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5 — generic resolution event so the auto-grant resolution log
|
||||
// can record this final decision. Distinct from the workflow-bridge
|
||||
// event above: this fires for EVERY resolved approval (not just wf-*),
|
||||
// and its consumer writes one mate_approval_resolution_log row.
|
||||
// SUPERSEDED is not a user decision — the replacement pending will fire
|
||||
// its own event when it resolves, so we skip the event here.
|
||||
if (events != null && !"SUPERSEDED".equals(dbStatus)) {
|
||||
String decisionSource = "TIMEOUT".equals(dbStatus)
|
||||
? "TIMEOUT"
|
||||
: "USER_MANUAL";
|
||||
String note = "USER_MANUAL".equals(decisionSource) && "DENIED".equals(dbStatus)
|
||||
? "denied"
|
||||
: null;
|
||||
ApprovalResolutionEvent resolutionEvent = new ApprovalResolutionEvent(
|
||||
snapshot.getPendingId(),
|
||||
snapshot.getConversationId(),
|
||||
snapshot.getAgentId(),
|
||||
/* userId resolves to actor or original requester */
|
||||
userId != null ? userId : snapshot.getUserId(),
|
||||
snapshot.getToolName(),
|
||||
snapshot.getToolArguments(),
|
||||
snapshot.getMaxSeverity(),
|
||||
snapshot.getFindingsJson(),
|
||||
decisionSource,
|
||||
note);
|
||||
afterCommit(() -> {
|
||||
try {
|
||||
events.publishEvent(resolutionEvent);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] failed to publish ApprovalResolutionEvent for {}: {}",
|
||||
snapshot.getPendingId(), e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
boolean consumed = "consumed".equals(snapshotStatus);
|
||||
ResolveOutcome outcome = consumed
|
||||
? ResolveOutcome.consumed(snapshot, true, rewritten)
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
package vip.mate.approval.event;
|
||||
|
||||
/**
|
||||
* Generic application event fired AFTER an approval row reaches a final
|
||||
* decision through the human-approval path (approved / denied / consumed) or
|
||||
* through the timeout sweep.
|
||||
* <p>
|
||||
* Distinct from {@code WorkflowApprovalResolvedEvent}, which is workflow-bridge
|
||||
* specific (only published for {@code pendingId} starting with {@code "wf-"}).
|
||||
* This event is published for every tool-call approval row so the auto-grant
|
||||
* resolution-log subsystem can record exactly one row per final decision.
|
||||
*
|
||||
* <p><b>Decision source mapping</b> (see {@code ApprovalResolutionLog.DecisionSource}):
|
||||
* <ul>
|
||||
* <li>{@code APPROVED} / {@code DENIED} / {@code CONSUMED} → {@code USER_MANUAL}</li>
|
||||
* <li>{@code TIMEOUT} → {@code TIMEOUT}</li>
|
||||
* <li>{@code SUPERSEDED} → no event (not a final user decision; the replacement
|
||||
* approval will fire its own event when it resolves)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code findingsJson} is the original JSON serialization of the
|
||||
* {@code GuardEvaluation.findings} captured at {@code createPending} time.
|
||||
* The listener extracts {@code ruleId}s from it for {@code resolution_log.rule_ids}.
|
||||
*
|
||||
* <p>All fields are nullable: a row created via the legacy command-injection
|
||||
* path may not carry every snapshot field. The listener treats missing fields
|
||||
* as empty rather than skipping the row, so the resolution-log audit stays
|
||||
* complete even when upstream context is partial.
|
||||
*/
|
||||
public record ApprovalResolutionEvent(
|
||||
String pendingId,
|
||||
String conversationId,
|
||||
String agentId,
|
||||
String userId,
|
||||
String toolName,
|
||||
String toolArguments,
|
||||
String maxSeverity,
|
||||
String findingsJson,
|
||||
String decisionSource,
|
||||
String resolutionNote
|
||||
) {}
|
||||
@ -0,0 +1,76 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
import vip.mate.tool.guard.model.ToolInvocationContext;
|
||||
|
||||
/**
|
||||
* WARN-level audit logger for the three resolver outcomes that need operator
|
||||
* visibility: AUTO_GRANT (a stored grant let a tool through), HARD_BLOCK (the
|
||||
* safety floor blocked a disaster) and FORCE_HUMAN (a dangerous pattern was
|
||||
* downgraded back to manual approval).
|
||||
* <p>
|
||||
* The logger is intentionally a separate bean with a fixed name
|
||||
* ({@code vip.mate.approval.grant.AutoApproveAuditLogger}) so operations can
|
||||
* filter / route just this signal without grepping through generic guard logs.
|
||||
* Arguments are truncated to 200 characters to keep each entry to one line; the
|
||||
* DB column {@code mate_approval_resolution_log.args_preview} stores up to 500
|
||||
* for the detail page.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AutoApproveAuditLogger {
|
||||
|
||||
private static final int LOG_ARGS_MAX = 200;
|
||||
|
||||
public void logAutoGrant(ApprovalGrant grant, ToolInvocationContext ctx, GuardEvaluation evaluation) {
|
||||
log.warn("[APPROVAL] AUTO_GRANT grantId={} tool={} severity={} (ceiling={}) "
|
||||
+ "scope={}/{} workspaceId={} args({})={} userId={} conversationId={} ruleId={}",
|
||||
grant.getId(),
|
||||
ctx.toolName(),
|
||||
severityName(evaluation),
|
||||
grant.getMaxSeverity(),
|
||||
grant.getScopeType(), grant.getScopeId(),
|
||||
ctx.workspaceId(),
|
||||
LOG_ARGS_MAX, truncate(ctx.rawArguments()),
|
||||
ctx.userId(), ctx.conversationId(),
|
||||
primaryRuleId(evaluation));
|
||||
}
|
||||
|
||||
public void logHardBlock(ToolInvocationContext ctx, GuardEvaluation evaluation, String patternName) {
|
||||
log.warn("[APPROVAL] HARD_BLOCK pattern={} tool={} args({})={} userId={} conversationId={}",
|
||||
patternName,
|
||||
ctx.toolName(),
|
||||
LOG_ARGS_MAX, truncate(ctx.rawArguments()),
|
||||
ctx.userId(), ctx.conversationId());
|
||||
}
|
||||
|
||||
public void logForceHuman(ToolInvocationContext ctx, GuardEvaluation evaluation, String patternName) {
|
||||
log.warn("[APPROVAL] FORCE_HUMAN pattern={} tool={} args({})={} userId={} conversationId={} "
|
||||
+ "— falling back to existing approval flow",
|
||||
patternName,
|
||||
ctx.toolName(),
|
||||
LOG_ARGS_MAX, truncate(ctx.rawArguments()),
|
||||
ctx.userId(), ctx.conversationId());
|
||||
}
|
||||
|
||||
private static String truncate(String s) {
|
||||
if (s == null) return "";
|
||||
return s.length() <= LOG_ARGS_MAX ? s : s.substring(0, LOG_ARGS_MAX) + "…";
|
||||
}
|
||||
|
||||
private static String severityName(GuardEvaluation evaluation) {
|
||||
return evaluation == null || evaluation.maxSeverity() == null
|
||||
? "UNKNOWN"
|
||||
: evaluation.maxSeverity().name();
|
||||
}
|
||||
|
||||
private static String primaryRuleId(GuardEvaluation evaluation) {
|
||||
if (evaluation == null || evaluation.findings() == null || evaluation.findings().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return evaluation.findings().get(0).ruleId();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
/**
|
||||
* Tri-state outcome of {@code ApprovalGrantResolver.tryAutoApprove(...)}.
|
||||
* <p>
|
||||
* The resolver never throws on a missing grant or a fallback condition; it
|
||||
* returns one of these three states and lets the caller
|
||||
* ({@code ToolExecutionExecutor.evaluateGuard()}) map them to the right
|
||||
* {@code GuardDecision}.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #approved(Long)} — caller skips {@code createPending(...)} and
|
||||
* runs the tool directly. Carries the matched grant id.</li>
|
||||
* <li>{@link #hardBlocked(String)} — caller returns
|
||||
* {@code GuardDecision.blocked(...)}. No approval banner. Carries the
|
||||
* hard-floor pattern name for log/audit context.</li>
|
||||
* <li>{@link #requiresHuman(String)} — caller falls back to the existing
|
||||
* human approval flow. The {@code reason} is a short tag (e.g.
|
||||
* {@code "FORCE_HUMAN:pipe_shell"}, {@code "SEVERITY_CRITICAL"},
|
||||
* {@code "UNKNOWN_WORKSPACE"}, {@code "NO_GRANT"}) for logging.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class AutoApproveResult {
|
||||
|
||||
private enum State { APPROVED, HARD_BLOCKED, REQUIRES_HUMAN }
|
||||
|
||||
private final State state;
|
||||
private final Long grantId;
|
||||
private final String reason;
|
||||
|
||||
private AutoApproveResult(State state, Long grantId, String reason) {
|
||||
this.state = state;
|
||||
this.grantId = grantId;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public static AutoApproveResult approved(Long grantId) {
|
||||
return new AutoApproveResult(State.APPROVED, grantId, null);
|
||||
}
|
||||
|
||||
public static AutoApproveResult hardBlocked(String reason) {
|
||||
return new AutoApproveResult(State.HARD_BLOCKED, null, reason);
|
||||
}
|
||||
|
||||
public static AutoApproveResult requiresHuman(String reason) {
|
||||
return new AutoApproveResult(State.REQUIRES_HUMAN, null, reason);
|
||||
}
|
||||
|
||||
public boolean isApproved() { return state == State.APPROVED; }
|
||||
public boolean isHardBlocked() { return state == State.HARD_BLOCKED; }
|
||||
public boolean isRequiresHuman() { return state == State.REQUIRES_HUMAN; }
|
||||
|
||||
/** Non-null only when {@link #isApproved()} is true. */
|
||||
public Long grantId() { return grantId; }
|
||||
|
||||
/** Non-null when {@link #isHardBlocked()} or {@link #isRequiresHuman()}. */
|
||||
public String reason() { return reason; }
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,77 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Caffeine-backed cache for {@code conversationId → workspaceId} lookups on the
|
||||
* tool-call hot path.
|
||||
* <p>
|
||||
* The conversation→workspace mapping is immutable once a conversation is created,
|
||||
* so a 5-minute TTL is purely a bound on cache size, not a correctness guard.
|
||||
* Cache misses query MyBatis with a {@code LambdaQueryWrapper} on the
|
||||
* {@code conversation_id} business column — calling
|
||||
* {@code conversationMapper.selectById(stringConversationId)} would interpret the
|
||||
* string as the {@code Long} {@code @TableId} primary key and silently miss every
|
||||
* row, which would route every tool call to {@code UNKNOWN_WORKSPACE} and disable
|
||||
* auto-grant entirely.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class WorkspaceLookupCache {
|
||||
|
||||
private final ConversationMapper conversationMapper;
|
||||
|
||||
private final Cache<String, Long> cache = Caffeine.newBuilder()
|
||||
.maximumSize(5_000)
|
||||
.expireAfterWrite(Duration.ofMinutes(5))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Returns the workspaceId for the given business conversation id, or {@code null}
|
||||
* if the conversation does not exist (or was soft-deleted).
|
||||
*/
|
||||
public Long resolveByConversation(String conversationId) {
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return cache.get(conversationId, id -> {
|
||||
ConversationEntity conv = conversationMapper.selectOne(
|
||||
Wrappers.<ConversationEntity>lambdaQuery()
|
||||
.eq(ConversationEntity::getConversationId, id)
|
||||
.eq(ConversationEntity::getDeleted, 0)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
if (conv == null) {
|
||||
log.debug("[APPROVAL] WorkspaceLookupCache: conversation {} not found, returning null", id);
|
||||
return null;
|
||||
}
|
||||
return conv.getWorkspaceId();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a single mapping. Called by the lifecycle listener on
|
||||
* {@code ConversationDeletedEvent} so a re-created conversation with the same id
|
||||
* does not inherit a stale workspace.
|
||||
*/
|
||||
public void invalidate(String conversationId) {
|
||||
if (conversationId != null) {
|
||||
cache.invalidate(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
long estimatedSize() {
|
||||
return cache.estimatedSize();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,399 @@
|
||||
package vip.mate.approval.grant.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantService;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.repository.UserMapper;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST surface for the auto-grant subsystem.
|
||||
* <p>
|
||||
* The header {@code @RequireWorkspaceRole("member")} is the minimum gate; the
|
||||
* §2.4.5 6-cell authorization matrix is enforced inside each handler:
|
||||
* <ul>
|
||||
* <li>{@code CONVERSATION} scope — any workspace member.</li>
|
||||
* <li>{@code USER} scope — only the actor can create a grant for themselves.</li>
|
||||
* <li>{@code AGENT} scope with explicit {@code toolName} — agent owner or admin.</li>
|
||||
* <li>{@code AGENT} scope with {@code toolName=null} — admin only, plus password.</li>
|
||||
* <li>{@code WORKSPACE} scope with explicit {@code toolName} — admin only.</li>
|
||||
* <li>{@code WORKSPACE} scope with {@code toolName=null} — admin only, plus password.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Snowflake id fields ({@code id} / {@code grantedBy} / {@code revokedBy} /
|
||||
* {@code scopeId}) are serialized as strings by the global Jackson config so the
|
||||
* frontend keeps them as strings end-to-end (see CLAUDE.md precision convention).
|
||||
*/
|
||||
@Tag(name = "自动批准策略")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/approval")
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalGrantController {
|
||||
|
||||
private static final long DEFAULT_WORKSPACE_ID = 1L;
|
||||
|
||||
private final ApprovalGrantService grantService;
|
||||
private final ApprovalGrantMapper grantMapper;
|
||||
private final ApprovalResolutionLogMapper resolutionMapper;
|
||||
private final AuthService authService;
|
||||
private final UserMapper userMapper;
|
||||
private final WorkspaceService workspaceService;
|
||||
|
||||
// ─── Create ─────────────────────────────────────────────────────────
|
||||
|
||||
@Operation(summary = "创建自动批准策略")
|
||||
@PostMapping("/grants")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<ApprovalGrant> create(@RequestBody CreateGrantRequest body,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
Long actorId = resolveUserId(auth);
|
||||
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||
|
||||
validate(body);
|
||||
enforceCreationAuthorization(body, actorId, ws);
|
||||
|
||||
ApprovalGrant grant = new ApprovalGrant();
|
||||
grant.setWorkspaceId(ws);
|
||||
grant.setScopeType(body.scopeType);
|
||||
grant.setScopeId(body.scopeId);
|
||||
grant.setToolName(emptyToNull(body.toolName));
|
||||
grant.setRuleId(emptyToNull(body.ruleId));
|
||||
grant.setMaxSeverity(body.maxSeverity);
|
||||
grant.setGrantKind(body.grantKind);
|
||||
grant.setExpireAt(body.expireAt);
|
||||
grant.setGrantedBy(actorId);
|
||||
grant.setGrantedAt(LocalDateTime.now());
|
||||
grant.setRevoked(0);
|
||||
grant.setDeleted(0);
|
||||
grant.setNote(body.note);
|
||||
|
||||
grantMapper.insert(grant);
|
||||
log.info("[APPROVAL] Grant created: id={} scope={}/{} tool={} rule={} ceiling={} kind={} by user={}",
|
||||
grant.getId(), grant.getScopeType(), grant.getScopeId(),
|
||||
grant.getToolName(), grant.getRuleId(), grant.getMaxSeverity(),
|
||||
grant.getGrantKind(), actorId);
|
||||
return R.ok(grant);
|
||||
}
|
||||
|
||||
// ─── List ───────────────────────────────────────────────────────────
|
||||
|
||||
@Operation(summary = "列出当前 workspace 的自动批准策略(分页)")
|
||||
@GetMapping("/grants")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<IPage<ApprovalGrant>> list(
|
||||
@RequestParam(required = false) String scopeType,
|
||||
@RequestParam(required = false) String toolName,
|
||||
@RequestParam(required = false) Integer revoked,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean mine,
|
||||
@RequestParam(required = false, defaultValue = "1") long page,
|
||||
@RequestParam(required = false, defaultValue = "20") long size,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
Long actorId = resolveUserId(auth);
|
||||
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||
|
||||
// mine=false (看全部) 需要 admin;mine=true 任意 member 可以看自己的
|
||||
if (!mine) {
|
||||
workspaceService.requirePermission(ws, actorId, "admin");
|
||||
}
|
||||
|
||||
// Bound page size so a malformed client can't blow up the UI / mapper.
|
||||
long boundedSize = Math.min(Math.max(size, 1), 200);
|
||||
long boundedPage = Math.max(page, 1);
|
||||
|
||||
var wrapper = Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, ws)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
.orderByDesc(ApprovalGrant::getGrantedAt);
|
||||
if (scopeType != null && !scopeType.isEmpty()) {
|
||||
wrapper.eq(ApprovalGrant::getScopeType, scopeType);
|
||||
}
|
||||
if (toolName != null && !toolName.isEmpty()) {
|
||||
wrapper.eq(ApprovalGrant::getToolName, toolName);
|
||||
}
|
||||
if (revoked != null) {
|
||||
wrapper.eq(ApprovalGrant::getRevoked, revoked);
|
||||
}
|
||||
if (mine) {
|
||||
wrapper.eq(ApprovalGrant::getGrantedBy, actorId);
|
||||
}
|
||||
Page<ApprovalGrant> pageObj = new Page<>(boundedPage, boundedSize);
|
||||
IPage<ApprovalGrant> result = grantMapper.selectPage(pageObj, wrapper);
|
||||
fillGranterNames(result.getRecords());
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-loads the display name (nickname → username fallback) for every
|
||||
* unique {@code grantedBy} id on the page and writes it into the entity's
|
||||
* transient {@code grantedByName} field. One round-trip via
|
||||
* {@code selectBatchIds} rather than N queries; the field stays null when
|
||||
* the source user has since been deleted.
|
||||
*/
|
||||
private void fillGranterNames(java.util.List<ApprovalGrant> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
java.util.Set<Long> userIds = new java.util.HashSet<>();
|
||||
for (ApprovalGrant g : records) {
|
||||
if (g.getGrantedBy() != null) userIds.add(g.getGrantedBy());
|
||||
}
|
||||
if (userIds.isEmpty()) return;
|
||||
java.util.Map<Long, String> idToName = userMapper.selectBatchIds(userIds).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(
|
||||
UserEntity::getId,
|
||||
u -> u.getNickname() != null && !u.getNickname().isEmpty()
|
||||
? u.getNickname()
|
||||
: u.getUsername(),
|
||||
(a, b) -> a));
|
||||
for (ApprovalGrant g : records) {
|
||||
if (g.getGrantedBy() != null) {
|
||||
g.setGrantedByName(idToName.get(g.getGrantedBy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Active summary (chip "(N)") ────────────────────────────────────
|
||||
|
||||
@Operation(summary = "当前 workspace 的活跃策略数量摘要")
|
||||
@GetMapping("/grants/active")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Map<String, Object>> activeSummary(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||
// Cast to int: this is a per-workspace grant count, never bigger than a
|
||||
// few hundred. Returning Long here would be serialized as a JSON string
|
||||
// by the global Long→String serializer (CLAUDE.md precision convention
|
||||
// for snowflake ids), but count is not a snowflake — the frontend wants
|
||||
// a real number for the chip badge and `count > 0` checks.
|
||||
int count = (int) Math.min(grantService.countActiveInWorkspace(ws), Integer.MAX_VALUE);
|
||||
// hasWorkspaceWide: workspace + tool_name IS NULL — the dangerous one.
|
||||
Long workspaceWide = grantMapper.selectCount(
|
||||
Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, ws)
|
||||
.eq(ApprovalGrant::getScopeType, ApprovalGrant.ScopeType.WORKSPACE)
|
||||
.isNull(ApprovalGrant::getToolName)
|
||||
.eq(ApprovalGrant::getRevoked, 0)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
.and(w -> w.isNull(ApprovalGrant::getExpireAt)
|
||||
.or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now())));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("count", count);
|
||||
out.put("hasWorkspaceWide", workspaceWide != null && workspaceWide > 0);
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
// ─── Revoke ─────────────────────────────────────────────────────────
|
||||
|
||||
@Operation(summary = "撤销自动批准策略")
|
||||
@DeleteMapping("/grants/{id}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> revoke(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
Long actorId = resolveUserId(auth);
|
||||
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||
|
||||
ApprovalGrant existing = grantMapper.selectById(id);
|
||||
if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)) {
|
||||
throw new MateClawException("err.approval.grant_not_found", 404, "grant not found");
|
||||
}
|
||||
if (!existing.getWorkspaceId().equals(ws)) {
|
||||
// Cross-workspace lookup is treated as not-found to avoid leaking existence.
|
||||
throw new MateClawException("err.approval.grant_not_found", 404, "grant not found");
|
||||
}
|
||||
boolean isOwner = existing.getGrantedBy() != null && existing.getGrantedBy().equals(actorId);
|
||||
boolean isAdmin = workspaceService.hasPermission(ws, actorId, "admin");
|
||||
if (!isOwner && !isAdmin) {
|
||||
throw new MateClawException("err.approval.revoke_forbidden", 403,
|
||||
"only the grant owner or a workspace admin can revoke");
|
||||
}
|
||||
grantService.revoke(id, actorId);
|
||||
log.info("[APPROVAL] Grant revoked: id={} by user={} (owner={}, admin={})",
|
||||
id, actorId, isOwner, isAdmin);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// ─── Resolutions read surface ───────────────────────────────────────
|
||||
|
||||
@Operation(summary = "查询审批最终决策日志(按 grantId 或 conversationId 过滤)")
|
||||
@GetMapping("/resolutions")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<ApprovalResolutionLog>> listResolutions(
|
||||
@RequestParam(required = false) Long grantId,
|
||||
@RequestParam(required = false) String conversationId,
|
||||
@RequestParam(required = false, defaultValue = "100") int limit,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
Authentication auth) {
|
||||
Long actorId = resolveUserId(auth);
|
||||
Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
|
||||
int cappedLimit = Math.min(Math.max(limit, 1), 500);
|
||||
|
||||
// grantId queries are admin-only; conversationId queries (member view) just
|
||||
// filter by membership of the workspace.
|
||||
if (grantId != null) {
|
||||
workspaceService.requirePermission(ws, actorId, "admin");
|
||||
}
|
||||
|
||||
var wrapper = Wrappers.<ApprovalResolutionLog>lambdaQuery()
|
||||
.eq(ApprovalResolutionLog::getDeleted, 0)
|
||||
.eq(ApprovalResolutionLog::getWorkspaceId, ws)
|
||||
.orderByDesc(ApprovalResolutionLog::getCreateTime)
|
||||
.last("LIMIT " + cappedLimit);
|
||||
if (grantId != null) {
|
||||
wrapper.eq(ApprovalResolutionLog::getGrantId, grantId);
|
||||
}
|
||||
if (conversationId != null && !conversationId.isEmpty()) {
|
||||
wrapper.eq(ApprovalResolutionLog::getConversationId, conversationId);
|
||||
}
|
||||
return R.ok(resolutionMapper.selectList(wrapper));
|
||||
}
|
||||
|
||||
// ─── Authorization matrix ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enforces the §2.4.5 6-cell matrix. Throws {@link MateClawException} with
|
||||
* an HTTP 403 status when the actor is not permitted to create this scope.
|
||||
* Password second-factor is checked for the two cells that require it.
|
||||
*/
|
||||
private void enforceCreationAuthorization(CreateGrantRequest body, Long actorId, Long workspaceId) {
|
||||
String scope = body.scopeType;
|
||||
boolean toolNull = body.toolName == null || body.toolName.isEmpty();
|
||||
boolean isAdmin = workspaceService.hasPermission(workspaceId, actorId, "admin");
|
||||
|
||||
switch (scope) {
|
||||
case ApprovalGrant.ScopeType.CONVERSATION -> {
|
||||
// Any member; nothing extra.
|
||||
}
|
||||
case ApprovalGrant.ScopeType.USER -> {
|
||||
if (body.scopeId == null || !body.scopeId.equals(String.valueOf(actorId))) {
|
||||
throw new MateClawException("err.approval.user_scope_self_only", 403,
|
||||
"USER-scope grants can only target the requesting user");
|
||||
}
|
||||
}
|
||||
case ApprovalGrant.ScopeType.AGENT -> {
|
||||
if (toolNull) {
|
||||
requireAdminPlusPassword(isAdmin, body.password, actorId);
|
||||
} else if (!isAdmin) {
|
||||
// We don't currently model an "agent owner" surface here, so admin is the safe default.
|
||||
// (Refining this to support agent owner is a v1.1 follow-up.)
|
||||
workspaceService.requirePermission(workspaceId, actorId, "admin");
|
||||
}
|
||||
}
|
||||
case ApprovalGrant.ScopeType.WORKSPACE -> {
|
||||
workspaceService.requirePermission(workspaceId, actorId, "admin");
|
||||
if (toolNull) {
|
||||
requireAdminPlusPassword(true, body.password, actorId);
|
||||
}
|
||||
}
|
||||
default -> throw new MateClawException("err.approval.invalid_scope", 400,
|
||||
"unknown scope_type: " + scope);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminPlusPassword(boolean isAdmin, String rawPassword, Long actorId) {
|
||||
if (!isAdmin) {
|
||||
throw new MateClawException("err.approval.admin_required", 403, "admin role required");
|
||||
}
|
||||
if (rawPassword == null || rawPassword.isEmpty()) {
|
||||
throw new MateClawException("err.approval.password_required", 403,
|
||||
"this scope requires password re-confirmation");
|
||||
}
|
||||
authService.verifyCurrentUserPassword(actorId, rawPassword);
|
||||
}
|
||||
|
||||
// ─── Validation ─────────────────────────────────────────────────────
|
||||
|
||||
private static void validate(CreateGrantRequest body) {
|
||||
if (body.scopeType == null || body.scopeType.isEmpty()) {
|
||||
throw new MateClawException("err.approval.scope_type_required", 400, "scope_type is required");
|
||||
}
|
||||
if (body.scopeId == null || body.scopeId.isEmpty()) {
|
||||
throw new MateClawException("err.approval.scope_id_required", 400, "scope_id is required");
|
||||
}
|
||||
if (body.maxSeverity == null
|
||||
|| !(body.maxSeverity.equals("LOW") || body.maxSeverity.equals("MEDIUM") || body.maxSeverity.equals("HIGH"))) {
|
||||
// CRITICAL is explicitly rejected so it can never be auto-approvable; the resolver
|
||||
// enforces the same gate at runtime as a defense in depth.
|
||||
throw new MateClawException("err.approval.invalid_severity", 400,
|
||||
"max_severity must be LOW | MEDIUM | HIGH (CRITICAL is not auto-approvable)");
|
||||
}
|
||||
if (body.grantKind == null
|
||||
|| !(body.grantKind.equals("ALWAYS")
|
||||
|| body.grantKind.equals("UNTIL_TIMESTAMP")
|
||||
|| body.grantKind.equals("UNTIL_CONVERSATION_END"))) {
|
||||
throw new MateClawException("err.approval.invalid_grant_kind", 400,
|
||||
"grant_kind must be ALWAYS | UNTIL_TIMESTAMP | UNTIL_CONVERSATION_END");
|
||||
}
|
||||
if ("UNTIL_TIMESTAMP".equals(body.grantKind) && body.expireAt == null) {
|
||||
throw new MateClawException("err.approval.expire_at_required", 400,
|
||||
"expire_at is required when grant_kind = UNTIL_TIMESTAMP");
|
||||
}
|
||||
if ("UNTIL_CONVERSATION_END".equals(body.grantKind)
|
||||
&& !ApprovalGrant.ScopeType.CONVERSATION.equals(body.scopeType)) {
|
||||
throw new MateClawException("err.approval.kind_scope_mismatch", 400,
|
||||
"UNTIL_CONVERSATION_END requires scope_type = CONVERSATION");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private Long resolveUserId(Authentication auth) {
|
||||
if (auth == null || auth.getName() == null) {
|
||||
throw new MateClawException("err.auth.unauthenticated", 401, "未登录");
|
||||
}
|
||||
UserEntity user = authService.findByUsername(auth.getName());
|
||||
if (user == null) {
|
||||
throw new MateClawException("err.auth.user_not_found", 404, "用户不存在");
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private static String emptyToNull(String s) {
|
||||
return s == null || s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
// ─── DTO ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Request body for {@link #create}. Snowflake ids ({@code scopeId}) are
|
||||
* received as strings to preserve precision through JS; the global Jackson
|
||||
* coercion accepts numeric JSON too, so existing tools that send numbers
|
||||
* still work.
|
||||
*/
|
||||
public static class CreateGrantRequest {
|
||||
public String scopeType;
|
||||
public String scopeId;
|
||||
public String toolName;
|
||||
public String ruleId;
|
||||
public String maxSeverity;
|
||||
public String grantKind;
|
||||
public LocalDateTime expireAt;
|
||||
public String note;
|
||||
/** Required only for {@code WORKSPACE + tool_name=null} and {@code AGENT + tool_name=null}. */
|
||||
public String password;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package vip.mate.approval.grant.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Auto-approve grant entity.
|
||||
* <p>
|
||||
* Each row authorizes {@code ApprovalGrantResolver} to skip the manual approval
|
||||
* step for tool calls matching {@code (scope_type, scope_id, tool_name?, rule_id?)}
|
||||
* up to a {@code max_severity} ceiling. Hard-floor patterns still block irrespective
|
||||
* of any grant.
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_approval_grant")
|
||||
public class ApprovalGrant {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long workspaceId;
|
||||
|
||||
/** USER | AGENT | CONVERSATION | WORKSPACE — see {@link ScopeType}. */
|
||||
private String scopeType;
|
||||
|
||||
/** Snowflake string per CLAUDE.md precision convention. */
|
||||
private String scopeId;
|
||||
|
||||
/** Null = any tool (only valid when granted by workspace admin with password confirmation). */
|
||||
private String toolName;
|
||||
|
||||
/**
|
||||
* Matches the {@code String ruleId} on {@code GuardFinding}. Null = any rule
|
||||
* (grant applies to all findings under the severity ceiling).
|
||||
*/
|
||||
private String ruleId;
|
||||
|
||||
/** LOW | MEDIUM | HIGH. CRITICAL is rejected at API/UI; resolver never reaches a grant for it. */
|
||||
private String maxSeverity;
|
||||
|
||||
/** ALWAYS | UNTIL_TIMESTAMP | UNTIL_CONVERSATION_END — see {@link GrantKind}. */
|
||||
private String grantKind;
|
||||
|
||||
/** Only meaningful when {@code grantKind = UNTIL_TIMESTAMP}. */
|
||||
private LocalDateTime expireAt;
|
||||
|
||||
private Long grantedBy;
|
||||
|
||||
/**
|
||||
* Display name of the granter (nickname → username). Not persisted; the
|
||||
* controller fills it in after {@code selectPage} by batch-loading the
|
||||
* touched user ids so the UI doesn't need a separate /users call for a
|
||||
* snowflake → name lookup. Null when the user no longer exists.
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String grantedByName;
|
||||
|
||||
private LocalDateTime grantedAt;
|
||||
|
||||
private Integer revoked;
|
||||
|
||||
private Long revokedBy;
|
||||
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
private String note;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
|
||||
/** Allowed values for {@link #scopeType}; kept as constants to avoid string typos. */
|
||||
public static final class ScopeType {
|
||||
public static final String USER = "USER";
|
||||
public static final String AGENT = "AGENT";
|
||||
public static final String CONVERSATION = "CONVERSATION";
|
||||
public static final String WORKSPACE = "WORKSPACE";
|
||||
private ScopeType() {}
|
||||
}
|
||||
|
||||
/** Allowed values for {@link #grantKind}. */
|
||||
public static final class GrantKind {
|
||||
public static final String ALWAYS = "ALWAYS";
|
||||
public static final String UNTIL_TIMESTAMP = "UNTIL_TIMESTAMP";
|
||||
public static final String UNTIL_CONVERSATION_END = "UNTIL_CONVERSATION_END";
|
||||
private GrantKind() {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package vip.mate.approval.grant.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Final decision log written by the approval layer (one row per resolved invocation).
|
||||
* <p>
|
||||
* Decoupled from {@code mate_tool_guard_audit_log} (which records guard evaluation
|
||||
* facts). Dashboard decision-source percentages read from this table only, so the
|
||||
* counts stay clean even when an invocation produces both an evaluation row and a
|
||||
* resolution row.
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_approval_resolution_log")
|
||||
public class ApprovalResolutionLog {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Nullable: a {@code HARD_BLOCK} event can be recorded before the workspace
|
||||
* has been resolved (missing/deleted conversation, malformed context). Other
|
||||
* decision sources ({@code USER_MANUAL}, {@code AUTO_GRANT}, {@code TIMEOUT})
|
||||
* always have a known workspace by the time they reach this table.
|
||||
*/
|
||||
private Long workspaceId;
|
||||
|
||||
private String conversationId;
|
||||
|
||||
private String agentId;
|
||||
|
||||
private String userId;
|
||||
|
||||
/** Correlates to {@code AssistantMessage.ToolCall.id} when available; nullable. */
|
||||
private String toolCallId;
|
||||
|
||||
private String toolName;
|
||||
|
||||
private String maxSeverity;
|
||||
|
||||
/** Comma-joined list of GuardFinding ruleIds present at decision time. */
|
||||
private String ruleIds;
|
||||
|
||||
/** USER_MANUAL | AUTO_GRANT | HARD_BLOCK | TIMEOUT — see {@link DecisionSource}. */
|
||||
private String decisionSource;
|
||||
|
||||
/** Non-null when {@code decisionSource = AUTO_GRANT}. */
|
||||
private Long grantId;
|
||||
|
||||
/** Non-null when the path went through {@code ApprovalWorkflowService.createPending()}. */
|
||||
private String pendingId;
|
||||
|
||||
/** First 500 chars of rawArguments. WARN log prints 200; this stores more for the detail page. */
|
||||
private String argsPreview;
|
||||
|
||||
private String note;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private Integer deleted;
|
||||
|
||||
/** Allowed values for {@link #decisionSource}. */
|
||||
public static final class DecisionSource {
|
||||
public static final String USER_MANUAL = "USER_MANUAL";
|
||||
public static final String AUTO_GRANT = "AUTO_GRANT";
|
||||
public static final String HARD_BLOCK = "HARD_BLOCK";
|
||||
public static final String TIMEOUT = "TIMEOUT";
|
||||
private DecisionSource() {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package vip.mate.approval.grant.listener;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.event.ApprovalResolutionEvent;
|
||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Records one row in {@code mate_approval_resolution_log} per final
|
||||
* human-approval decision (USER_MANUAL / TIMEOUT), complementing the rows
|
||||
* written directly by {@code ApprovalGrantResolver} for HARD_BLOCK and
|
||||
* AUTO_GRANT.
|
||||
* <p>
|
||||
* The listener runs out-of-tx (the publisher fires events from an
|
||||
* {@code afterCommit} hook), so a DB write failure here cannot roll back the
|
||||
* already-committed approval state. We log the failure and continue — losing
|
||||
* one resolution-log row is far less harmful than re-opening the approval row
|
||||
* for double-resolve.
|
||||
*
|
||||
* <p>Workspace resolution goes through {@link WorkspaceLookupCache} so we get
|
||||
* the same conversation→workspace mapping the resolver uses on the hot path,
|
||||
* with the same null-fallback behavior: a deleted conversation produces a row
|
||||
* with {@code workspace_id = null}, which is allowed by the V128 schema.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalResolutionLogListener {
|
||||
|
||||
private static final int ARGS_PREVIEW_MAX = 500;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ApprovalResolutionLogMapper resolutionMapper;
|
||||
private final WorkspaceLookupCache workspaceLookupCache;
|
||||
|
||||
@EventListener
|
||||
public void onApprovalResolved(ApprovalResolutionEvent event) {
|
||||
try {
|
||||
ApprovalResolutionLog row = new ApprovalResolutionLog();
|
||||
row.setWorkspaceId(workspaceLookupCache.resolveByConversation(event.conversationId()));
|
||||
row.setConversationId(event.conversationId());
|
||||
row.setAgentId(event.agentId());
|
||||
row.setUserId(event.userId());
|
||||
row.setToolName(event.toolName());
|
||||
row.setMaxSeverity(event.maxSeverity());
|
||||
row.setRuleIds(extractRuleIds(event.findingsJson()));
|
||||
row.setDecisionSource(event.decisionSource());
|
||||
row.setGrantId(null);
|
||||
row.setPendingId(event.pendingId());
|
||||
row.setArgsPreview(previewArgs(event.toolArguments()));
|
||||
row.setNote(event.resolutionNote());
|
||||
|
||||
resolutionMapper.insert(row);
|
||||
} catch (Exception e) {
|
||||
log.warn("[APPROVAL] ApprovalResolutionLogListener failed to record {} for pending {}: {}",
|
||||
event.decisionSource(), event.pendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls {@code ruleId}s out of the serialized findings JSON captured at
|
||||
* {@code createPending} time. The JSON is the standard
|
||||
* {@code GuardFinding.toMap()} array form (a {@code List<Map<String, Object>>}),
|
||||
* so we read the list and pick the {@code "ruleId"} key out of each map.
|
||||
* Returns {@code null} on missing or unparseable input — the row still gets
|
||||
* written, just without rule-id provenance.
|
||||
*/
|
||||
private static String extractRuleIds(String findingsJson) {
|
||||
if (findingsJson == null || findingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> findings = OBJECT_MAPPER.readValue(
|
||||
findingsJson, new TypeReference<>() {});
|
||||
String joined = findings.stream()
|
||||
.map(m -> m.get("ruleId"))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Object::toString)
|
||||
.filter(s -> !s.isBlank())
|
||||
.distinct()
|
||||
.collect(Collectors.joining(","));
|
||||
return joined.isEmpty() ? null : joined;
|
||||
} catch (Exception e) {
|
||||
log.debug("[APPROVAL] Failed to parse findingsJson for rule_ids extraction: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String previewArgs(String raw) {
|
||||
if (raw == null) return null;
|
||||
return raw.length() <= ARGS_PREVIEW_MAX ? raw : raw.substring(0, ARGS_PREVIEW_MAX);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package vip.mate.approval.grant.listener;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantService;
|
||||
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
|
||||
|
||||
/**
|
||||
* Tears down conversation-scoped state in the auto-grant subsystem when a
|
||||
* conversation is deleted.
|
||||
* <p>
|
||||
* Two actions, run in order:
|
||||
* <ol>
|
||||
* <li>Soft-revoke every {@code UNTIL_CONVERSATION_END} grant whose
|
||||
* {@code scope_type = CONVERSATION} and {@code scope_id = conversationId}.
|
||||
* Without this, the grant would linger as an apparently-active row that
|
||||
* can never match again (its scope no longer exists), but still shows up
|
||||
* in the management page and the chip {@code (N)} counter.</li>
|
||||
* <li>Drop the {@code conversationId → workspaceId} entry from
|
||||
* {@link WorkspaceLookupCache}. A re-created conversation with the same
|
||||
* id (rare but possible across a backup restore) would otherwise inherit
|
||||
* the stale mapping for up to five minutes.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>{@link ConversationDeletedEvent} is published <i>after</i> the delete tx
|
||||
* commits, so this listener runs in a clean tx and the soft-revoke either
|
||||
* succeeds or fails in isolation — it cannot poison the delete itself.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ConversationLifecycleListener {
|
||||
|
||||
private final ApprovalGrantService grantService;
|
||||
private final WorkspaceLookupCache workspaceLookupCache;
|
||||
|
||||
@EventListener
|
||||
public void onConversationDeleted(ConversationDeletedEvent event) {
|
||||
String conversationId = event.conversationId();
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int revoked = grantService.revokeConversationScopedGrants(conversationId);
|
||||
if (revoked > 0) {
|
||||
log.info("[APPROVAL] ConversationLifecycleListener: revoked {} UNTIL_CONVERSATION_END grant(s) for {}",
|
||||
revoked, conversationId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[APPROVAL] ConversationLifecycleListener: failed to revoke grants for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
} finally {
|
||||
// Always invalidate the cache, even if grant revocation threw: a stale
|
||||
// workspace mapping is more dangerous than a missed revoke (the grant
|
||||
// can no longer match its conversation anyway).
|
||||
workspaceLookupCache.invalidate(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package vip.mate.approval.grant.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Mapper for {@link ApprovalGrant}.
|
||||
* <p>
|
||||
* BaseMapper covers ordinary CRUD; {@link #findFirstMatching} is a custom query
|
||||
* defined in {@code ApprovalGrantMapper.xml} that returns the best-matching active
|
||||
* grant for a tool invocation, ordered by scope priority and specificity.
|
||||
*/
|
||||
@Mapper
|
||||
public interface ApprovalGrantMapper extends BaseMapper<ApprovalGrant> {
|
||||
|
||||
/**
|
||||
* Returns the single best grant that authorizes the given tool invocation, or
|
||||
* {@code null} if none applies. Matching rules (see {@code ApprovalGrantMapper.xml}):
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code workspace_id} must equal {@code workspaceId} (tenant isolation, mandatory).</li>
|
||||
* <li>Not revoked, not deleted, not expired.</li>
|
||||
* <li>{@code max_severity} must be at least as high as {@code evalSeverity}.</li>
|
||||
* <li>{@code tool_name} is NULL or equals {@code toolName}.</li>
|
||||
* <li>{@code rule_id} is NULL or is in {@code candidateRuleIds} (when the list is non-empty).</li>
|
||||
* <li>One of the scope clauses must match: CONVERSATION+conversationId / AGENT+agentId /
|
||||
* USER+userId / WORKSPACE+workspaceScopeId.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Order: scope priority CONVERSATION > AGENT > USER > WORKSPACE,
|
||||
* then rule-id-specific over rule-id-null, then tool-name-specific over null. {@code LIMIT 1}.
|
||||
*
|
||||
* @param workspaceScopeId {@code String.valueOf(workspaceId)} — pre-converted to avoid
|
||||
* dialect-specific CAST in SQL (H2 vs MySQL).
|
||||
* @param candidateRuleIds list of GuardFinding ruleIds for the current invocation; may be empty
|
||||
* or null, in which case only {@code rule_id IS NULL} grants match.
|
||||
*/
|
||||
ApprovalGrant findFirstMatching(
|
||||
@Param("workspaceId") Long workspaceId,
|
||||
@Param("userId") String userId,
|
||||
@Param("agentId") String agentId,
|
||||
@Param("conversationId") String conversationId,
|
||||
@Param("workspaceScopeId") String workspaceScopeId,
|
||||
@Param("toolName") String toolName,
|
||||
@Param("candidateRuleIds") List<String> candidateRuleIds,
|
||||
@Param("evalSeverity") String evalSeverity);
|
||||
|
||||
/**
|
||||
* Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the given
|
||||
* conversation. Called by {@code ConversationLifecycleListener} on
|
||||
* {@code ConversationDeletedEvent} (PR-2).
|
||||
*/
|
||||
int revokeUntilConversationEnd(@Param("conversationId") String conversationId);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.approval.grant.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
|
||||
@Mapper
|
||||
public interface ApprovalResolutionLogMapper extends BaseMapper<ApprovalResolutionLog> {
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package vip.mate.approval.grant.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.approval.grant.AutoApproveAuditLogger;
|
||||
import vip.mate.approval.grant.AutoApproveResult;
|
||||
import vip.mate.approval.grant.AutoGrantSafetyFloor;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
import vip.mate.tool.guard.model.GuardFinding;
|
||||
import vip.mate.tool.guard.model.GuardSeverity;
|
||||
import vip.mate.tool.guard.model.ToolInvocationContext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Decides whether a tool invocation that {@code ToolGuardService.evaluate(...)}
|
||||
* already flagged as {@code NEEDS_APPROVAL} can be auto-approved by a stored
|
||||
* {@link ApprovalGrant}, or must fall back to the existing human-approval flow,
|
||||
* or must be hard-blocked.
|
||||
* <p>
|
||||
* Decision order:
|
||||
* <ol>
|
||||
* <li>Safety floor — {@link AutoGrantSafetyFloor#evaluate(String)} short-circuits
|
||||
* on disasters ({@code HARD_BLOCK}) and downgrades dangerous-but-occasionally-
|
||||
* legitimate patterns to {@code FORCE_HUMAN} (skip grant lookup, fall back
|
||||
* to manual approval).</li>
|
||||
* <li>Severity ceiling — {@code CRITICAL} is never auto-approvable.</li>
|
||||
* <li>Tenant gate — when {@code workspaceId} is unknown the resolver
|
||||
* conservatively returns {@code requiresHuman("UNKNOWN_WORKSPACE")} rather
|
||||
* than letting a malformed context match the wrong workspace.</li>
|
||||
* <li>Grant lookup — every {@code ruleId} present on the findings is sent to
|
||||
* the mapper as a candidate; the mapper's SQL handles scope priority and
|
||||
* severity ceiling.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Whenever the resolver itself reaches a final decision (HARD_BLOCK or
|
||||
* AUTO_GRANT), it writes one row to {@code mate_approval_resolution_log}. For
|
||||
* {@code FORCE_HUMAN} / {@code SEVERITY_CRITICAL} / {@code UNKNOWN_WORKSPACE} /
|
||||
* {@code NO_GRANT} no row is written here — the row is added later by
|
||||
* {@code ApprovalWorkflowService.resolve*()} / {@code garbageCollect()} (PR-2)
|
||||
* once the human path actually completes.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalGrantResolver {
|
||||
|
||||
private static final int ARGS_PREVIEW_MAX = 500;
|
||||
|
||||
private final ApprovalGrantMapper grantMapper;
|
||||
private final ApprovalResolutionLogMapper resolutionMapper;
|
||||
private final AutoGrantSafetyFloor safetyFloor;
|
||||
private final AutoApproveAuditLogger auditLogger;
|
||||
|
||||
public AutoApproveResult tryAutoApprove(ToolInvocationContext ctx, GuardEvaluation evaluation) {
|
||||
// 1) Safety floor — hard block or force the existing human path.
|
||||
AutoGrantSafetyFloor.SafetyFloorMatch sf = safetyFloor.evaluate(ctx.rawArguments());
|
||||
if (sf.action() == AutoGrantSafetyFloor.Action.HARD_BLOCK) {
|
||||
auditLogger.logHardBlock(ctx, evaluation, sf.patternName());
|
||||
resolutionMapper.insert(
|
||||
buildResolutionLog(ctx, evaluation,
|
||||
ApprovalResolutionLog.DecisionSource.HARD_BLOCK,
|
||||
null, null,
|
||||
"matched safety floor pattern: " + sf.patternName()));
|
||||
return AutoApproveResult.hardBlocked(sf.patternName());
|
||||
}
|
||||
if (sf.action() == AutoGrantSafetyFloor.Action.FORCE_HUMAN) {
|
||||
auditLogger.logForceHuman(ctx, evaluation, sf.patternName());
|
||||
return AutoApproveResult.requiresHuman("FORCE_HUMAN:" + sf.patternName());
|
||||
}
|
||||
|
||||
// 2) Severity ceiling — CRITICAL is never auto-approvable.
|
||||
if (evaluation != null && evaluation.maxSeverity() == GuardSeverity.CRITICAL) {
|
||||
return AutoApproveResult.requiresHuman("SEVERITY_CRITICAL");
|
||||
}
|
||||
|
||||
// 3) workspaceId required for tenant isolation; null → conservative human path.
|
||||
if (ctx.workspaceId() == null) {
|
||||
log.warn("[APPROVAL] workspaceId=null for conversation={} agent={} tool={} — "
|
||||
+ "falling back to human approval. Check WorkspaceLookupCache wiring.",
|
||||
ctx.conversationId(), ctx.agentId(), ctx.toolName());
|
||||
return AutoApproveResult.requiresHuman("UNKNOWN_WORKSPACE");
|
||||
}
|
||||
|
||||
// 4) Collect all candidate ruleIds from findings (for IN-clause matching).
|
||||
List<String> candidateRuleIds = (evaluation == null || evaluation.findings() == null)
|
||||
? List.of()
|
||||
: evaluation.findings().stream()
|
||||
.map(GuardFinding::ruleId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// 5) Mapper finds first grant ordered by scope priority + specificity.
|
||||
// workspaceId is also passed as a string for the WORKSPACE-scope match,
|
||||
// so the mapper SQL stays dialect-clean (no CAST). See ApprovalGrantMapper.xml.
|
||||
String workspaceScopeId = String.valueOf(ctx.workspaceId());
|
||||
String evalSeverity = evaluation == null || evaluation.maxSeverity() == null
|
||||
? GuardSeverity.LOW.name()
|
||||
: evaluation.maxSeverity().name();
|
||||
|
||||
ApprovalGrant matched = grantMapper.findFirstMatching(
|
||||
ctx.workspaceId(),
|
||||
ctx.userId(), ctx.agentId(), ctx.conversationId(),
|
||||
workspaceScopeId,
|
||||
ctx.toolName(),
|
||||
candidateRuleIds,
|
||||
evalSeverity);
|
||||
|
||||
if (matched == null) {
|
||||
return AutoApproveResult.requiresHuman("NO_GRANT");
|
||||
}
|
||||
|
||||
// 6) Grant matched — log, audit, and approve.
|
||||
auditLogger.logAutoGrant(matched, ctx, evaluation);
|
||||
resolutionMapper.insert(
|
||||
buildResolutionLog(ctx, evaluation,
|
||||
ApprovalResolutionLog.DecisionSource.AUTO_GRANT,
|
||||
matched.getId(), null, matched.getNote()));
|
||||
return AutoApproveResult.approved(matched.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link ApprovalResolutionLog} row for the given final decision.
|
||||
* {@code grantId} is set only for AUTO_GRANT; {@code pendingId} is set only when
|
||||
* the row is later written by the human-path hooks in
|
||||
* {@code ApprovalWorkflowService} (PR-2).
|
||||
*/
|
||||
private ApprovalResolutionLog buildResolutionLog(ToolInvocationContext ctx,
|
||||
GuardEvaluation evaluation,
|
||||
String decisionSource,
|
||||
Long grantId,
|
||||
String pendingId,
|
||||
String note) {
|
||||
ApprovalResolutionLog row = new ApprovalResolutionLog();
|
||||
row.setWorkspaceId(ctx.workspaceId());
|
||||
row.setConversationId(ctx.conversationId());
|
||||
row.setAgentId(ctx.agentId());
|
||||
row.setUserId(ctx.userId());
|
||||
row.setToolName(ctx.toolName());
|
||||
row.setMaxSeverity(evaluation == null || evaluation.maxSeverity() == null
|
||||
? null : evaluation.maxSeverity().name());
|
||||
row.setRuleIds(joinRuleIds(evaluation));
|
||||
row.setDecisionSource(decisionSource);
|
||||
row.setGrantId(grantId);
|
||||
row.setPendingId(pendingId);
|
||||
row.setArgsPreview(previewArgs(ctx.rawArguments()));
|
||||
row.setNote(note);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String joinRuleIds(GuardEvaluation evaluation) {
|
||||
if (evaluation == null || evaluation.findings() == null || evaluation.findings().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return evaluation.findings().stream()
|
||||
.map(GuardFinding::ruleId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.reduce((a, b) -> a + "," + b)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String previewArgs(String raw) {
|
||||
if (raw == null) return null;
|
||||
return raw.length() <= ARGS_PREVIEW_MAX ? raw : raw.substring(0, ARGS_PREVIEW_MAX);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package vip.mate.approval.grant.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Service-layer operations on {@link ApprovalGrant}.
|
||||
* <p>
|
||||
* CRUD is handled via the {@link ApprovalGrantMapper} BaseMapper; this service
|
||||
* adds the small number of approval-domain operations that callers from outside
|
||||
* the controller need:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #revokeConversationScopedGrants(String)} — used by the lifecycle
|
||||
* listener (PR-2) on {@code ConversationDeletedEvent} to soft-revoke every
|
||||
* {@code UNTIL_CONVERSATION_END} grant attached to that conversation.</li>
|
||||
* <li>{@link #countActiveInWorkspace(Long)} — used by the {@code /api/v1/approval/grants/active}
|
||||
* endpoint and the front-end pill / chip so they can show {@code (N)} without
|
||||
* fetching every row.</li>
|
||||
* <li>{@link #listActiveByScope(Long, String)} — generic listing for the
|
||||
* management page, with the standard "not deleted, not revoked, not expired"
|
||||
* filter applied uniformly.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>CRUD validation (e.g. rejecting {@code max_severity=CRITICAL} or enforcing the
|
||||
* scope/tool_name authorization matrix in §2.4.5) lives in {@code ApprovalGrantController}
|
||||
* (PR-4), not here — the service stays low-policy so {@code ApprovalGrantResolver}
|
||||
* can call it without dragging REST concerns in.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalGrantService {
|
||||
|
||||
private final ApprovalGrantMapper grantMapper;
|
||||
|
||||
/**
|
||||
* Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the
|
||||
* conversation. Idempotent.
|
||||
*/
|
||||
@Transactional
|
||||
public int revokeConversationScopedGrants(String conversationId) {
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int revoked = grantMapper.revokeUntilConversationEnd(conversationId);
|
||||
if (revoked > 0) {
|
||||
log.info("[APPROVAL] Revoked {} UNTIL_CONVERSATION_END grant(s) on conversation delete: {}",
|
||||
revoked, conversationId);
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/** Counts active grants visible in the given workspace (drives the chip "(N)"). */
|
||||
public long countActiveInWorkspace(Long workspaceId) {
|
||||
if (workspaceId == null) return 0;
|
||||
return grantMapper.selectCount(
|
||||
Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, workspaceId)
|
||||
.eq(ApprovalGrant::getRevoked, 0)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
.and(w -> w.isNull(ApprovalGrant::getExpireAt)
|
||||
.or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now()))
|
||||
);
|
||||
}
|
||||
|
||||
/** Lists active grants in a workspace, optionally restricted to a scope type. */
|
||||
public List<ApprovalGrant> listActiveByScope(Long workspaceId, String scopeType) {
|
||||
if (workspaceId == null) return List.of();
|
||||
var wrapper = Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, workspaceId)
|
||||
.eq(ApprovalGrant::getRevoked, 0)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
.and(w -> w.isNull(ApprovalGrant::getExpireAt)
|
||||
.or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now()))
|
||||
.orderByDesc(ApprovalGrant::getGrantedAt);
|
||||
if (scopeType != null && !scopeType.isEmpty()) {
|
||||
wrapper.eq(ApprovalGrant::getScopeType, scopeType);
|
||||
}
|
||||
return grantMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/** Soft-revokes a single grant. Caller must enforce ownership / admin (PR-4). */
|
||||
@Transactional
|
||||
public boolean revoke(Long grantId, Long revokedBy) {
|
||||
ApprovalGrant g = grantMapper.selectById(grantId);
|
||||
if (g == null || g.getRevoked() != null && g.getRevoked() == 1) {
|
||||
return false;
|
||||
}
|
||||
g.setRevoked(1);
|
||||
g.setRevokedBy(revokedBy);
|
||||
g.setRevokedAt(LocalDateTime.now());
|
||||
return grantMapper.updateById(g) > 0;
|
||||
}
|
||||
}
|
||||
@ -109,17 +109,41 @@ public class AuthService {
|
||||
* 修改密码
|
||||
*/
|
||||
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
||||
verifyCurrentUserPassword(userId, oldPassword);
|
||||
UserEntity user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new MateClawException("err.auth.user_not_found", "用户不存在");
|
||||
}
|
||||
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
|
||||
throw new MateClawException("err.auth.wrong_password", "原密码错误");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up authentication: confirms that {@code rawPassword} matches the
|
||||
* user's currently stored password without changing anything.
|
||||
* <p>
|
||||
* Used by sensitive operations that require re-confirmation of identity
|
||||
* (e.g. creating a workspace-wide all-tool auto-approve grant). Throws
|
||||
* the same {@link MateClawException} keys as {@link #changePassword} so
|
||||
* the user-facing error message stays consistent.
|
||||
*
|
||||
* @throws MateClawException {@code err.auth.user_not_found} when the user
|
||||
* doesn't exist, or {@code err.auth.wrong_password} when the
|
||||
* password doesn't match.
|
||||
*/
|
||||
public void verifyCurrentUserPassword(Long userId, String rawPassword) {
|
||||
UserEntity user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
// 404: target user no longer exists; surfacing as 401 would mask the cause.
|
||||
throw new MateClawException("err.auth.user_not_found", 404, "用户不存在");
|
||||
}
|
||||
if (rawPassword == null || !passwordEncoder.matches(rawPassword, user.getPassword())) {
|
||||
// 403, not 401: 401 would trigger the global http interceptor's
|
||||
// handleAuthFailure() and log the user out, but this is a step-up
|
||||
// re-confirmation (token is still valid). Falling through to the
|
||||
// default 500 looks like a server fault on the client; 403 cleanly
|
||||
// communicates "valid session, wrong second-factor".
|
||||
throw new MateClawException("err.auth.wrong_password", 403, "原密码错误");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token 获取用户名
|
||||
*/
|
||||
|
||||
@ -16,6 +16,7 @@ import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.service.ChannelService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.tts.TtsService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
@ -237,6 +238,31 @@ public class ChannelMessageRouter {
|
||||
* @param channelEntity 渠道配置(含关联 agentId)
|
||||
*/
|
||||
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
|
||||
// The adapter caches the ChannelEntity it was constructed with, so a
|
||||
// long-lived adapter (e.g. Feishu WS) keeps handing us a snapshot
|
||||
// that may be stale by the time the message arrives. Refresh from
|
||||
// the DB so a freshly-rebound agent (or any other routing-metadata
|
||||
// change applied without a restart) is honoured immediately.
|
||||
ChannelEntity fresh = freshChannelEntity(channelEntity);
|
||||
if (fresh == null) {
|
||||
// Channel deleted between adapter start and message arrival.
|
||||
// Skip everything — even the trigger publish, since the channel
|
||||
// no longer exists for downstream consumers to reference.
|
||||
return;
|
||||
}
|
||||
// Only drop on an EXPLICIT enabled=false. A null enabled (which the
|
||||
// production DB never returns but tests / hand-constructed entities
|
||||
// do) means "not declared", and treating it as disabled would
|
||||
// collapse every downstream behaviour into a silent drop — which is
|
||||
// exactly how the previous !Boolean.TRUE.equals(...) form regressed
|
||||
// mock-driven tests that don't bother seeding the flag.
|
||||
if (Boolean.FALSE.equals(fresh.getEnabled())) {
|
||||
log.warn("[{}] Channel {} (id={}) is disabled; dropping message from {}",
|
||||
adapter.getChannelType(), fresh.getName(), fresh.getId(), message.getSenderId());
|
||||
return;
|
||||
}
|
||||
channelEntity = fresh;
|
||||
|
||||
// Fan out to the trigger pipeline FIRST — channel_message and
|
||||
// content_match triggers fire on every received message regardless
|
||||
// of whether the channel has an agent attached. If we returned
|
||||
@ -538,7 +564,31 @@ public class ChannelMessageRouter {
|
||||
*/
|
||||
private void processMessage(ChannelMessage message, ChannelAdapter adapter,
|
||||
ChannelEntity channelEntity, String conversationId) {
|
||||
// The snapshot captured at enqueue time can be stale: an admin may
|
||||
// have rebound, deleted, or disabled the channel between debounce-
|
||||
// queue and flush. Re-read here so the rest of this method sees the
|
||||
// current state, and fail closed on deletion / disable so we don't
|
||||
// process traffic for a channel the admin has shut down.
|
||||
ChannelEntity fresh = freshChannelEntity(channelEntity);
|
||||
if (fresh == null) {
|
||||
log.warn("[{}] Channel id={} not found at processing time; dropping message from {}",
|
||||
adapter.getChannelType(),
|
||||
channelEntity != null ? channelEntity.getId() : null,
|
||||
message.getSenderId());
|
||||
return;
|
||||
}
|
||||
if (Boolean.FALSE.equals(fresh.getEnabled())) {
|
||||
log.warn("[{}] Channel {} (id={}) is disabled at processing time; dropping message from {}",
|
||||
adapter.getChannelType(), fresh.getName(), fresh.getId(), message.getSenderId());
|
||||
return;
|
||||
}
|
||||
channelEntity = fresh;
|
||||
Long agentId = channelEntity.getAgentId();
|
||||
if (agentId == null) {
|
||||
log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}",
|
||||
adapter.getChannelType(), channelEntity.getName(), message.getSenderId());
|
||||
return;
|
||||
}
|
||||
log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}",
|
||||
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
|
||||
|
||||
@ -732,10 +782,22 @@ public class ChannelMessageRouter {
|
||||
// for any Web SSE viewer of the same conversationId.
|
||||
StringBuilder replyAccumulator = new StringBuilder();
|
||||
final String channelType = adapter.getChannelType();
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
agentService.chatStructuredStream(agentId, promptText, conversationId,
|
||||
message.getSenderId(), chatOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (delta.isEvent()) {
|
||||
if ("_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
}
|
||||
mirrorPlanEventToTracker(conversationId, delta, channelType);
|
||||
} else if (delta.content() != null) {
|
||||
// Match the legacy agentService.chat() behavior: include
|
||||
@ -770,10 +832,11 @@ public class ChannelMessageRouter {
|
||||
boolean isError = errorClassifier.isErrorReply(reply);
|
||||
String status = isError ? "error" : "completed";
|
||||
MessageEntity saved = conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, null, status);
|
||||
conversationId, "assistant", reply, null, status,
|
||||
usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
savedAssistantId = saved != null ? saved.getId() : null;
|
||||
if (!isError) {
|
||||
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
|
||||
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
|
||||
}
|
||||
adapter.renderAndSend(replyTarget, reply);
|
||||
log.info("[{}] Reply sent to {}: {}chars",
|
||||
@ -884,8 +947,21 @@ public class ChannelMessageRouter {
|
||||
// only reads `delta.content()` and would otherwise eat plan_created /
|
||||
// plan_step_* events, leaving the Web Console mirror with no
|
||||
// PlanStepsPanel for IM-routed conversations.
|
||||
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta ->
|
||||
mirrorPlanEventToTracker(conversationId, delta, channelType));
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
}
|
||||
mirrorPlanEventToTracker(conversationId, delta, channelType);
|
||||
});
|
||||
|
||||
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
|
||||
String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId);
|
||||
@ -905,9 +981,10 @@ public class ChannelMessageRouter {
|
||||
boolean isError = errorClassifier.isErrorReply(finalContent);
|
||||
String status = isError ? "error" : "completed";
|
||||
MessageEntity saved = conversationService.saveMessage(
|
||||
conversationId, "assistant", finalContent, null, status);
|
||||
conversationId, "assistant", finalContent, null, status,
|
||||
usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
if (!isError) {
|
||||
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
|
||||
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
|
||||
}
|
||||
log.info("[{}] Streaming completed: contentLen={}, isError={}",
|
||||
channelType, finalContent.length(), isError);
|
||||
@ -990,8 +1067,9 @@ public class ChannelMessageRouter {
|
||||
replayOrigin = chatOriginFactory.from(
|
||||
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
||||
}
|
||||
String reply = agentService.chatWithReplay(
|
||||
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
|
||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||
String reply = replayResult.content();
|
||||
|
||||
// Persist the replay result. If the LLM 400'd during replay,
|
||||
// the error reply must also get status='error' — otherwise the
|
||||
@ -999,7 +1077,9 @@ public class ChannelMessageRouter {
|
||||
// into the prompt and re-trigger the same failure.
|
||||
boolean isError = errorClassifier.isErrorReply(reply);
|
||||
conversationService.saveMessage(conversationId, "assistant", reply, null,
|
||||
isError ? "error" : "completed");
|
||||
isError ? "error" : "completed",
|
||||
replayResult.promptTokens(), replayResult.completionTokens(),
|
||||
replayResult.runtimeModel(), replayResult.runtimeProvider());
|
||||
|
||||
// 发送回复
|
||||
adapter.renderAndSend(replyTarget, reply);
|
||||
@ -1080,8 +1160,13 @@ public class ChannelMessageRouter {
|
||||
* messageCount lookup no longer live here.
|
||||
*/
|
||||
private void publishConversationCompletedEvent(Long agentId, String conversationId,
|
||||
String userMessage, String assistantReply) {
|
||||
completionPublisher.publish(agentId, conversationId, userMessage, assistantReply, "channel");
|
||||
String userMessage, String assistantReply,
|
||||
ChatOrigin origin) {
|
||||
// Attribute the memory write to the same external sender the read path
|
||||
// recalled for, so per-sender IM memory is both written and recalled
|
||||
// under the same owner key.
|
||||
completionPublisher.publishForOrigin(agentId, conversationId, userMessage, assistantReply,
|
||||
"channel", origin);
|
||||
}
|
||||
|
||||
// ==================== 流式处理(Web 渠道专用,不走队列) ====================
|
||||
@ -1090,6 +1175,14 @@ public class ChannelMessageRouter {
|
||||
* 路由消息并使用流式处理(用于支持流式的渠道,如 Web)
|
||||
*/
|
||||
public Flux<String> routeStream(ChannelMessage message, ChannelEntity channelEntity) {
|
||||
ChannelEntity fresh = freshChannelEntity(channelEntity);
|
||||
if (fresh == null) {
|
||||
return Flux.error(new IllegalStateException("Channel no longer exists"));
|
||||
}
|
||||
if (Boolean.FALSE.equals(fresh.getEnabled())) {
|
||||
return Flux.error(new IllegalStateException("Channel is disabled"));
|
||||
}
|
||||
channelEntity = fresh;
|
||||
Long agentId = channelEntity.getAgentId();
|
||||
if (agentId == null) {
|
||||
return Flux.error(new IllegalStateException("Channel has no associated agent"));
|
||||
@ -1164,6 +1257,49 @@ public class ChannelMessageRouter {
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
/**
|
||||
* Re-read the channel row from the database so the rest of the message
|
||||
* pipeline sees current routing metadata (agentId, workspaceId, identityJson)
|
||||
* rather than the snapshot captured when the adapter was constructed.
|
||||
*
|
||||
* <p>Failure semantics:
|
||||
* <ul>
|
||||
* <li><b>Channel deleted</b> — {@link ChannelService#getChannel} throws
|
||||
* a {@link MateClawException} with {@code msgKey="err.channel.not_found"}.
|
||||
* We return {@code null} so the caller drops the message: the channel
|
||||
* no longer exists, routing the message would land it against a row
|
||||
* that's been removed.</li>
|
||||
* <li><b>Transient lookup failure</b> — any other exception (DB blip,
|
||||
* NPE in mapper, …). We fall back to the snapshot so an isolated
|
||||
* infrastructure hiccup doesn't black-hole live traffic.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code enabled=false} is NOT handled here — that's an admin decision
|
||||
* the callers check separately, with channel-type-specific logging.
|
||||
*/
|
||||
private ChannelEntity freshChannelEntity(ChannelEntity snapshot) {
|
||||
if (snapshot == null || snapshot.getId() == null) {
|
||||
return snapshot;
|
||||
}
|
||||
try {
|
||||
ChannelEntity latest = channelService.getChannel(snapshot.getId());
|
||||
return latest != null ? latest : snapshot;
|
||||
} catch (MateClawException biz) {
|
||||
if ("err.channel.not_found".equals(biz.getMsgKey())) {
|
||||
log.warn("Channel id={} no longer exists; dropping incoming message",
|
||||
snapshot.getId());
|
||||
return null;
|
||||
}
|
||||
log.debug("Transient channel lookup failure id={}, using snapshot: {}",
|
||||
snapshot.getId(), biz.getMessage());
|
||||
return snapshot;
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to refresh ChannelEntity id={}, using snapshot: {}",
|
||||
snapshot.getId(), e.getMessage());
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建会话 ID
|
||||
* 格式:{channelType}:{chatId 或 senderId}
|
||||
|
||||
@ -97,11 +97,53 @@ public class ChannelController {
|
||||
channel.setId(id);
|
||||
channel.setWorkspaceId(existing.getWorkspaceId());
|
||||
ChannelEntity updated = channelService.updateChannel(channel);
|
||||
channelManager.restartChannel(id);
|
||||
// Restart only when a field the adapter consumes BEFORE the router
|
||||
// takes over has changed — channel type, enabled toggle, configJson
|
||||
// (app credentials, connection_mode, domain, …), and botPrefix
|
||||
// (consumed by AbstractChannelAdapter.shouldProcess / cleanBotPrefix
|
||||
// before enqueue, so a per-message DB refresh in the router can't
|
||||
// catch it). Pure router-visible metadata (bound agent, display
|
||||
// name, description, identityJson) is re-read on every message via
|
||||
// ChannelMessageRouter.freshChannelEntity, so it doesn't justify
|
||||
// dropping the live connection — for Feishu WS that would mean a
|
||||
// multi-second blackout where inbound messages never reach the bot.
|
||||
if (transportConfigChanged(existing, updated)) {
|
||||
channelManager.restartChannel(id);
|
||||
}
|
||||
auditEventService.record("UPDATE", "CHANNEL", String.valueOf(id), updated.getName(), null);
|
||||
return R.ok(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff a field the adapter consumes BEFORE the router takes over (or
|
||||
* that gates the adapter lifecycle entirely) has changed.
|
||||
*
|
||||
* <p>{@code agentId}, {@code name}, {@code description}, {@code identityJson}
|
||||
* stay excluded — those are routing metadata read on every message via
|
||||
* {@code ChannelMessageRouter.freshChannelEntity()}.
|
||||
*
|
||||
* <p>{@code botPrefix} IS included even though it's "just routing metadata"
|
||||
* conceptually: {@code AbstractChannelAdapter.shouldProcess()} and
|
||||
* {@code cleanBotPrefix()} run inside the adapter before the message
|
||||
* reaches the router, and they read from the adapter's cached
|
||||
* {@code channelEntity}. A prefix edit without restart would still filter
|
||||
* and strip with the old prefix until the adapter is recreated.
|
||||
*/
|
||||
private boolean transportConfigChanged(ChannelEntity oldRow, ChannelEntity newRow) {
|
||||
if (!java.util.Objects.equals(oldRow.getChannelType(), newRow.getChannelType())) {
|
||||
return true;
|
||||
}
|
||||
if (!java.util.Objects.equals(oldRow.getEnabled(), newRow.getEnabled())) {
|
||||
return true;
|
||||
}
|
||||
if (!java.util.Objects.equals(oldRow.getBotPrefix(), newRow.getBotPrefix())) {
|
||||
return true;
|
||||
}
|
||||
String oldCfg = oldRow.getConfigJson() == null ? "" : oldRow.getConfigJson();
|
||||
String newCfg = newRow.getConfigJson() == null ? "" : newRow.getConfigJson();
|
||||
return !oldCfg.equals(newCfg);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "删除渠道")
|
||||
@DeleteMapping("/{id}")
|
||||
|
||||
@ -20,6 +20,9 @@ import vip.mate.channel.media.MediaUploadResult;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@ -170,6 +173,25 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
*/
|
||||
private final vip.mate.stt.SttService sttService;
|
||||
|
||||
// ==================== Per-chat recent file cache ====================
|
||||
|
||||
/**
|
||||
* Per-chat cache of recently downloaded file messages. When a file is
|
||||
* sent in a Feishu chat (even without @mention), it is downloaded and
|
||||
* cached here. When a follow-up text message arrives in the same chat,
|
||||
* the cached files are injected as content parts so the agent can see
|
||||
* and process them.
|
||||
*/
|
||||
private static final long RECENT_FILE_TTL_MINUTES = 60;
|
||||
private static final int RECENT_FILE_MAX_PER_CHAT = 5;
|
||||
|
||||
record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {}
|
||||
|
||||
private final Cache<String, List<RecentFileEntry>> recentFileCache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(RECENT_FILE_TTL_MINUTES, TimeUnit.MINUTES)
|
||||
.maximumSize(200)
|
||||
.build();
|
||||
|
||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper) {
|
||||
@ -548,8 +570,15 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 WebSocket 连接
|
||||
* SDK 的 start() 在线程中阻塞运行,通过中断线程来触发停止
|
||||
* 关闭 WebSocket 连接。
|
||||
* <p>
|
||||
* 必须主动关闭底层 WebSocket 连接并触发 SDK 的内部清理(停止 pingLoop、
|
||||
* 释放 ExecutorService)。仅置空引用会导致旧连接的 pingLoop 和线程池
|
||||
* 持续运行,造成文件描述符和线程泄漏,最终使新连接无法建立。
|
||||
* <p>
|
||||
* SDK 2.7.0 起暴露了 public {@code close()} 入口,内部调用 protected
|
||||
* {@code disconnect()} 完成 {@code conn.close(1000) → executor.shutdown() →
|
||||
* 字段清零} 的全套清理。直接调用即可,无需反射。
|
||||
*/
|
||||
private void stopWebSocket() {
|
||||
cancelSilentDisconnectWatchdog();
|
||||
@ -558,6 +587,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
wsThread.interrupt();
|
||||
wsThread = null;
|
||||
}
|
||||
if (wsClient != null) {
|
||||
try {
|
||||
wsClient.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("[feishu] WebSocket close failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
wsClient = null;
|
||||
}
|
||||
|
||||
@ -889,10 +925,30 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
private void handleFeishuMessage(String messageId, String messageType, String contentStr,
|
||||
String chatId, String chatType, String senderOpenId,
|
||||
String parentId, boolean isBotMentioned, Object rawPayload) {
|
||||
// Per-chat recent file cache: always download file messages (even
|
||||
// without @mention) so they can be auto-associated with follow-up
|
||||
// text messages in the same chat.
|
||||
boolean isGroup = "group".equals(chatType);
|
||||
boolean isFileMessage = "file".equals(messageType) || "image".equals(messageType)
|
||||
|| "audio".equals(messageType) || "media".equals(messageType);
|
||||
// Compute conversationId once — used as cache key for both write (cacheRecentFile)
|
||||
// and read (injectRecentFiles), and as the directory name under data/chat-uploads/.
|
||||
// It MUST equal the id ChannelMessageRouter derives for this chat: the routed
|
||||
// ChannelMessage carries chatId = (isGroup ? shortSuffix : null), so the router
|
||||
// resolves it to feishu:{shortSuffix} for groups and feishu:{senderId} for DMs.
|
||||
// ChatUploadResolver locates attachments under data/chat-uploads/{that id}/, and the
|
||||
// prompt only exposes the file name (not its path) to the model — so if this id does
|
||||
// not match, ReadFileTool / DocumentExtractTool cannot find the cached file.
|
||||
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
||||
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
|
||||
|
||||
if (isFileMessage) {
|
||||
cacheRecentFile(messageId, messageType, contentStr, conversationId);
|
||||
}
|
||||
|
||||
// require_mention 群聊过滤:群聊中必须 @机器人才响应。
|
||||
// 当 botOpenId 为 null 时(API 抖动 / 尚未拉取成功),失败回退到放行 —
|
||||
// 避免飞书 /open-apis/bot/v3/info 短暂不可用时整个群机器人变哑巴。
|
||||
boolean isGroup = "group".equals(chatType);
|
||||
boolean requireMention = getConfigBoolean("require_mention", false);
|
||||
if (isGroupNonMentionDrop(isGroup, requireMention, isBotMentioned, botOpenId)) {
|
||||
log.debug("[feishu] require_mention=true but bot not mentioned, dropping messageId={}", messageId);
|
||||
@ -942,9 +998,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
}
|
||||
}
|
||||
|
||||
// 生成短会话后缀
|
||||
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
||||
// Auto-associate recent files: inject file/image parts from the
|
||||
// per-chat cache so the agent can see files sent earlier in the
|
||||
// same conversation (user sends file → asks about it in text).
|
||||
if (!isFileMessage && conversationId != null) {
|
||||
textContent = injectRecentFiles(conversationId, contentParts, textContent);
|
||||
}
|
||||
|
||||
// shortSuffix already computed above (kept consistent with conversationId).
|
||||
ChannelMessage channelMessage = ChannelMessage.builder()
|
||||
.messageId(messageId)
|
||||
.channelType(CHANNEL_TYPE)
|
||||
@ -1341,6 +1402,150 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the conversationId that {@link ChannelMessageRouter} would
|
||||
* derive for this chat, so we can save inbound files to the matching
|
||||
* {@code data/chat-uploads/} directory.
|
||||
*
|
||||
* <p>The router derives the id from the routed {@link ChannelMessage},
|
||||
* whose {@code chatId} is {@code (isGroup ? shortSuffix : null)} and whose
|
||||
* {@code senderId} is the full open id. Mirror that exactly:
|
||||
* {@code groups → feishu:{shortSuffix}}, {@code DMs → feishu:{senderOpenId}}.
|
||||
*/
|
||||
static String buildConversationId(String shortSuffix, String senderOpenId, boolean isGroup) {
|
||||
// The routed ChannelMessage carries chatId = (isGroup ? shortSuffix : null);
|
||||
// the router then falls back to senderId when that chatId is null. Mirror both
|
||||
// steps so the storage id matches the runtime id in every case (including the
|
||||
// degenerate group-with-no-suffix path).
|
||||
String routedChatId = isGroup ? shortSuffix : null;
|
||||
String identifier = routedChatId != null ? routedChatId : senderOpenId;
|
||||
return identifier != null ? CHANNEL_TYPE + ":" + identifier : null;
|
||||
}
|
||||
|
||||
// ==================== Per-chat recent file cache ====================
|
||||
|
||||
/**
|
||||
* Download an inbound file message and cache its metadata in the
|
||||
* per-chat recent-file cache. The file is saved to
|
||||
* {@code data/chat-uploads/{conversationId}/} so existing tools
|
||||
* ({@code ReadFileTool}, {@code DocumentExtractTool}) can find it
|
||||
* via {@code ChatUploadResolver}, and it gets cleaned up when the
|
||||
* conversation is deleted.
|
||||
*/
|
||||
private void cacheRecentFile(String messageId, String messageType, String contentStr,
|
||||
String conversationId) {
|
||||
try {
|
||||
Map<String, Object> contentObj = objectMapper.readValue(contentStr, Map.class);
|
||||
|
||||
String fileKey = null;
|
||||
String fileName = null;
|
||||
String type; // SDK type: "image" or "file"
|
||||
|
||||
switch (messageType) {
|
||||
case "image" -> {
|
||||
fileKey = (String) contentObj.get("image_key");
|
||||
type = "image";
|
||||
}
|
||||
case "file" -> {
|
||||
fileKey = (String) contentObj.get("file_key");
|
||||
fileName = (String) contentObj.get("file_name");
|
||||
type = "file";
|
||||
}
|
||||
case "audio" -> {
|
||||
fileKey = (String) contentObj.get("file_key");
|
||||
type = "file";
|
||||
}
|
||||
case "media" -> {
|
||||
fileKey = (String) contentObj.get("file_key");
|
||||
fileName = (String) contentObj.get("file_name");
|
||||
type = "file";
|
||||
}
|
||||
default -> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileKey == null) return;
|
||||
|
||||
// Download file bytes
|
||||
DownloadedResource dl = "image".equals(messageType)
|
||||
? maybeDownloadImage(messageId, fileKey)
|
||||
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
||||
if (dl == null) return;
|
||||
|
||||
// Save to data/chat-uploads/{conversationId}/
|
||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
||||
Files.createDirectories(uploadDir);
|
||||
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
||||
? dl.fileName() : fileKey;
|
||||
String safeName = Path.of(rawName).getFileName().toString()
|
||||
.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
if (safeName.isBlank()) safeName = "file";
|
||||
String storedName = System.currentTimeMillis() + "_" + safeName;
|
||||
Path dest = uploadDir.resolve(storedName);
|
||||
Files.copy(Path.of(dl.path()), dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
String contentType = dl.contentType() != null ? dl.contentType() : "application/octet-stream";
|
||||
RecentFileEntry entry = new RecentFileEntry(safeName, dest.toAbsolutePath().toString(),
|
||||
dl.fileUrl(), contentType);
|
||||
|
||||
// Append to per-conversation cache (cap at RECENT_FILE_MAX_PER_CHAT)
|
||||
recentFileCache.asMap().compute(conversationId, (k, existing) -> {
|
||||
List<RecentFileEntry> list = existing != null ? new ArrayList<>(existing) : new ArrayList<>();
|
||||
list.add(entry);
|
||||
if (list.size() > RECENT_FILE_MAX_PER_CHAT) {
|
||||
list = list.subList(list.size() - RECENT_FILE_MAX_PER_CHAT, list.size());
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
log.info("[feishu] Cached recent file for conversation={}: {} ({} bytes, {})",
|
||||
conversationId, entry.fileName(), Files.size(dest), contentType);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("[feishu] Failed to cache recent file: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject recent files from the per-chat cache into the current
|
||||
* message's content parts, so the agent can see files that were
|
||||
* sent earlier in the same conversation.
|
||||
*
|
||||
* @return updated textContent with file descriptions appended
|
||||
*/
|
||||
private String injectRecentFiles(String conversationId, List<MessageContentPart> parts, String textContent) {
|
||||
List<RecentFileEntry> recent = recentFileCache.getIfPresent(conversationId);
|
||||
if (recent == null || recent.isEmpty()) return textContent;
|
||||
|
||||
// Collect paths already in parts to avoid duplicates
|
||||
Set<String> existingPaths = new java.util.HashSet<>();
|
||||
for (MessageContentPart p : parts) {
|
||||
if (p != null && p.getPath() != null) existingPaths.add(p.getPath());
|
||||
}
|
||||
|
||||
StringBuilder text = new StringBuilder(textContent != null ? textContent : "");
|
||||
for (RecentFileEntry entry : recent) {
|
||||
if (existingPaths.contains(entry.path())) continue;
|
||||
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
if (entry.contentType() != null && entry.contentType().startsWith("image/")) {
|
||||
part.setType("image");
|
||||
} else {
|
||||
part.setType("file");
|
||||
}
|
||||
part.setFileName(entry.fileName());
|
||||
part.setPath(entry.path());
|
||||
if (entry.fileUrl() != null) part.setFileUrl(entry.fileUrl());
|
||||
part.setContentType(entry.contentType());
|
||||
parts.add(part);
|
||||
|
||||
if (!text.isEmpty()) text.append('\n');
|
||||
text.append("[用户发送了文件: ").append(entry.fileName()).append("]");
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
// ==================== 消息内容解析 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -23,8 +23,9 @@ import java.util.regex.Matcher;
|
||||
* a render tool. {@link GeneratedFileCache#put} logs every real
|
||||
* put, so its absence here is proof the file was never generated
|
||||
* this turn.</li>
|
||||
* <li>The 10-min cache entry expired before the IM client got around
|
||||
* to clicking, or was wiped on JVM restart.</li>
|
||||
* <li>The persisted entry was swept after its retention window
|
||||
* ({@link GeneratedFileCache#TTL}) elapsed before the IM client
|
||||
* got around to clicking.</li>
|
||||
* </ol>
|
||||
* Without this rewrite, IM clients tap a markdown link that returns
|
||||
* 404, save the HTML 404 body as the requested file extension, then
|
||||
|
||||
@ -0,0 +1,286 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Shared inbound-media pipeline for IM channels.
|
||||
*
|
||||
* <p>Every channel that receives images / files / audio / video from users
|
||||
* needs the same three steps after it knows how to fetch the raw bytes:
|
||||
* <ol>
|
||||
* <li>fetch with retry + backoff (mobile uploads over flaky networks fail
|
||||
* transiently — a single attempt drops the attachment);</li>
|
||||
* <li>sniff the real type from magic bytes so the stored file and the
|
||||
* {@code MessageContentPart} carry an accurate MIME (a screenshot saved
|
||||
* as {@code image.jpg} but actually PNG/WEBP/HEIC otherwise gets a wrong
|
||||
* Content-Type that some multimodal gateways reject);</li>
|
||||
* <li>write to disk under a collision-resistant, URL-safe name.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The channel-specific protocol (AES decryption, API auth, CDN URL shape)
|
||||
* stays in the adapter and is supplied as a {@link ByteSource}. This class owns
|
||||
* only the cross-channel concerns above.
|
||||
*/
|
||||
public final class InboundMediaDownloader {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InboundMediaDownloader.class);
|
||||
|
||||
private InboundMediaDownloader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the raw (already-decrypted) bytes for a piece of media. May throw;
|
||||
* the downloader retries a throwing source before giving up.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ByteSource {
|
||||
byte[] fetch() throws Exception;
|
||||
}
|
||||
|
||||
/** A successfully downloaded and typed file on local disk. */
|
||||
public record DownloadedMedia(
|
||||
Path localPath,
|
||||
String storedName,
|
||||
String fileName,
|
||||
String contentType,
|
||||
long fileSize,
|
||||
String fileUrl) {
|
||||
|
||||
public boolean isImage() {
|
||||
return contentType != null && contentType.startsWith("image/");
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return contentType != null && contentType.startsWith("video/");
|
||||
}
|
||||
|
||||
public boolean isAudio() {
|
||||
return contentType != null && contentType.startsWith("audio/");
|
||||
}
|
||||
}
|
||||
|
||||
/** Total fetch attempts (1 initial + retries) before giving up. */
|
||||
private static final int DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private static final long RETRY_INITIAL_DELAY_MS = 300;
|
||||
private static final long RETRY_MAX_DELAY_MS = 3000;
|
||||
|
||||
/**
|
||||
* Download with the default retry policy and no servable URL. See
|
||||
* {@link #download(ByteSource, String, Path, String, String, int, Function)}.
|
||||
*/
|
||||
public static Optional<DownloadedMedia> download(ByteSource source,
|
||||
String filenameHint,
|
||||
Path targetDir,
|
||||
String storedNamePrefix,
|
||||
String dedupSeed) {
|
||||
return download(source, filenameHint, targetDir, storedNamePrefix, dedupSeed,
|
||||
DEFAULT_MAX_ATTEMPTS, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download with a custom attempt count and no servable URL. See
|
||||
* {@link #download(ByteSource, String, Path, String, String, int, Function)}.
|
||||
*/
|
||||
public static Optional<DownloadedMedia> download(ByteSource source,
|
||||
String filenameHint,
|
||||
Path targetDir,
|
||||
String storedNamePrefix,
|
||||
String dedupSeed,
|
||||
int maxAttempts) {
|
||||
return download(source, filenameHint, targetDir, storedNamePrefix, dedupSeed, maxAttempts, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download with the default retry policy and a servable-URL builder. See
|
||||
* {@link #download(ByteSource, String, Path, String, String, int, Function)}.
|
||||
*/
|
||||
public static Optional<DownloadedMedia> download(ByteSource source,
|
||||
String filenameHint,
|
||||
Path targetDir,
|
||||
String storedNamePrefix,
|
||||
String dedupSeed,
|
||||
Function<String, String> fileUrlBuilder) {
|
||||
return download(source, filenameHint, targetDir, storedNamePrefix, dedupSeed,
|
||||
DEFAULT_MAX_ATTEMPTS, fileUrlBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the bytes (with retry), detect the real type, and persist the file.
|
||||
*
|
||||
* @param source fetches the decrypted bytes; retried on failure
|
||||
* @param filenameHint the real user-supplied filename when known, else
|
||||
* {@code null}/blank. A name with a meaningful
|
||||
* extension is kept; a blank, extension-less, or
|
||||
* {@code .bin} hint is replaced with the sniffed
|
||||
* extension
|
||||
* @param targetDir directory to write into (created if absent)
|
||||
* @param storedNamePrefix short channel tag prefixed to the stored file
|
||||
* name (e.g. {@code "weixin"})
|
||||
* @param dedupSeed stable string (e.g. the source URL / media key)
|
||||
* hashed into the stored name so the same media maps
|
||||
* to the same file
|
||||
* @param maxAttempts total fetch attempts (>= 1)
|
||||
* @param fileUrlBuilder optional mapping from the stored filename to a
|
||||
* browser-servable URL (e.g.
|
||||
* {@code name -> "/api/v1/chat/files/" + convId + "/" + name});
|
||||
* {@code null} leaves {@link DownloadedMedia#fileUrl()}
|
||||
* null for channels with no serve path
|
||||
* @return the stored file, or empty when every attempt failed
|
||||
*/
|
||||
public static Optional<DownloadedMedia> download(ByteSource source,
|
||||
String filenameHint,
|
||||
Path targetDir,
|
||||
String storedNamePrefix,
|
||||
String dedupSeed,
|
||||
int maxAttempts,
|
||||
Function<String, String> fileUrlBuilder) {
|
||||
byte[] data = fetchWithRetry(source, Math.max(1, maxAttempts), filenameHint);
|
||||
if (data == null || data.length == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
MediaTypeSniffer.Sniffed sniff = MediaTypeSniffer.sniff(data);
|
||||
|
||||
// Derive a display name. The hint is authoritative only when the
|
||||
// caller passed a real user-supplied filename with a meaningful
|
||||
// extension; for media the caller passes null/blank and we
|
||||
// synthesize a name from the sniffed type. ".bin" is treated as
|
||||
// "no real extension" since it is the universal unknown-binary
|
||||
// placeholder. This keeps the contract channel-agnostic — no
|
||||
// per-channel sentinel names leak into this shared layer.
|
||||
String safeName = sanitize(filenameHint);
|
||||
boolean hintIsGeneric = "media".equals(safeName)
|
||||
|| !safeName.contains(".")
|
||||
|| safeName.toLowerCase().endsWith(".bin");
|
||||
String fileName = safeName;
|
||||
if (hintIsGeneric && sniff.isKnown()) {
|
||||
fileName = stripExtension(safeName) + sniff.extension();
|
||||
}
|
||||
|
||||
String seed = (dedupSeed == null || dedupSeed.isBlank()) ? fileName : dedupSeed;
|
||||
String hash = md5Short(seed);
|
||||
String prefix = (storedNamePrefix == null || storedNamePrefix.isBlank())
|
||||
? "media" : sanitize(storedNamePrefix);
|
||||
String storedName = prefix + "_" + hash + "_" + fileName;
|
||||
|
||||
Path filePath = targetDir.resolve(storedName);
|
||||
Files.write(filePath, data);
|
||||
|
||||
// Prefer the sniffed MIME; fall back to extension-based guess only
|
||||
// when sniffing was inconclusive.
|
||||
String contentType = sniff.isKnown() ? sniff.contentType() : mimeFromExtension(fileName);
|
||||
|
||||
String fileUrl = null;
|
||||
if (fileUrlBuilder != null) {
|
||||
try {
|
||||
fileUrl = fileUrlBuilder.apply(storedName);
|
||||
} catch (Exception e) {
|
||||
log.warn("[media] fileUrl builder failed for {}: {}", storedName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[media] Inbound media saved: {} ({} bytes, type={}, sniffed={})",
|
||||
filePath, data.length, contentType, sniff.isKnown());
|
||||
return Optional.of(new DownloadedMedia(
|
||||
filePath.toAbsolutePath(),
|
||||
storedName,
|
||||
fileName,
|
||||
contentType,
|
||||
data.length,
|
||||
fileUrl));
|
||||
} catch (Exception e) {
|
||||
log.error("[media] Failed to persist inbound media (hint={}): {}", filenameHint, e.getMessage(), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] fetchWithRetry(ByteSource source, int maxAttempts, String hint) {
|
||||
ExponentialBackoff backoff = new ExponentialBackoff(
|
||||
RETRY_INITIAL_DELAY_MS, RETRY_MAX_DELAY_MS, 2.0, maxAttempts, 0.2);
|
||||
Exception last = null;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
byte[] data = source.fetch();
|
||||
if (data != null && data.length > 0) {
|
||||
return data;
|
||||
}
|
||||
log.warn("[media] Download attempt {}/{} returned empty (hint={})", attempt, maxAttempts, hint);
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
log.warn("[media] Download attempt {}/{} failed (hint={}): {}",
|
||||
attempt, maxAttempts, hint, e.getMessage());
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
try {
|
||||
Thread.sleep(backoff.nextDelayMs());
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (last != null) {
|
||||
log.error("[media] Download exhausted after {} attempts (hint={}): {}",
|
||||
maxAttempts, hint, last.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strip everything except a safe, file-system-friendly character set. */
|
||||
private static String sanitize(String name) {
|
||||
String raw = (name == null) ? "" : name.trim();
|
||||
String safe = raw.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
return safe.isBlank() ? "media" : safe;
|
||||
}
|
||||
|
||||
private static String stripExtension(String name) {
|
||||
int dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
|
||||
private static String mimeFromExtension(String fileName) {
|
||||
String lower = fileName.toLowerCase();
|
||||
int dot = lower.lastIndexOf('.');
|
||||
String ext = dot >= 0 ? lower.substring(dot + 1) : "";
|
||||
return switch (ext) {
|
||||
case "jpg", "jpeg" -> "image/jpeg";
|
||||
case "png" -> "image/png";
|
||||
case "gif" -> "image/gif";
|
||||
case "webp" -> "image/webp";
|
||||
case "heic", "heif" -> "image/heic";
|
||||
case "bmp" -> "image/bmp";
|
||||
case "mp4" -> "video/mp4";
|
||||
case "mov" -> "video/quicktime";
|
||||
case "mp3" -> "audio/mpeg";
|
||||
case "amr" -> "audio/amr";
|
||||
case "wav" -> "audio/wav";
|
||||
case "pdf" -> "application/pdf";
|
||||
default -> "application/octet-stream";
|
||||
};
|
||||
}
|
||||
|
||||
private static String md5Short(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
sb.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return Integer.toHexString(input.hashCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Best-effort MIME / extension detection from a file's leading bytes.
|
||||
*
|
||||
* <p>IM channels frequently deliver media without a reliable filename or
|
||||
* Content-Type: forwarded files arrive nameless, and personal-WeChat images
|
||||
* are saved with a fixed {@code image.jpg} hint regardless of the real format.
|
||||
* Labelling a PNG / WEBP / HEIC photo as {@code image/jpeg} makes some
|
||||
* multimodal model gateways reject the request, and a nameless PDF saved as
|
||||
* {@code file.bin} stops PDF tools from firing. Sniffing the magic bytes
|
||||
* recovers an accurate type so downstream routing and vision models work.
|
||||
*
|
||||
* <p>Covers the formats users routinely send to bots: common raster images
|
||||
* (incl. HEIC from iPhones and WEBP from screenshots), documents, archives,
|
||||
* and audio / video containers. ZIP-based containers (DOCX/XLSX/PPTX/ODF/EPUB/
|
||||
* JAR) share one magic number, so a successful ZIP match is refined by peeking
|
||||
* at the first archive entries.
|
||||
*/
|
||||
public final class MediaTypeSniffer {
|
||||
|
||||
private MediaTypeSniffer() {
|
||||
}
|
||||
|
||||
/** Sniff result: a leading-dot extension plus the matching MIME type. */
|
||||
public record Sniffed(String extension, String contentType) {
|
||||
/** Fallback when no signature matches. */
|
||||
public static final Sniffed UNKNOWN = new Sniffed(".bin", "application/octet-stream");
|
||||
|
||||
public boolean isKnown() {
|
||||
return !UNKNOWN.equals(this);
|
||||
}
|
||||
|
||||
public boolean isImage() {
|
||||
return contentType.startsWith("image/");
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return contentType.startsWith("video/");
|
||||
}
|
||||
|
||||
public boolean isAudio() {
|
||||
return contentType.startsWith("audio/");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the type of {@code data} from its leading bytes.
|
||||
*
|
||||
* @param data the full file bytes (may be null/empty — returns
|
||||
* {@link Sniffed#UNKNOWN}); only the first bytes are inspected,
|
||||
* except for ZIP containers which are scanned a little deeper.
|
||||
* @return the detected type, never null.
|
||||
*/
|
||||
public static Sniffed sniff(byte[] data) {
|
||||
if (data == null || data.length < 4) {
|
||||
return Sniffed.UNKNOWN;
|
||||
}
|
||||
|
||||
Sniffed basic = sniffHead(data);
|
||||
// ZIP container needs a deeper look — DOCX/XLSX/PPTX/ODF/EPUB/JAR all
|
||||
// share the PK\x03\x04 magic. Peek inside the first few entries.
|
||||
if (".zip".equals(basic.extension())) {
|
||||
return refineZipKind(data, basic);
|
||||
}
|
||||
return basic;
|
||||
}
|
||||
|
||||
/** Signature match against the leading bytes only. */
|
||||
private static Sniffed sniffHead(byte[] h) {
|
||||
// PDF: %PDF
|
||||
if (match(h, 0x25, 0x50, 0x44, 0x46)) {
|
||||
return new Sniffed(".pdf", "application/pdf");
|
||||
}
|
||||
// PNG: 89 50 4E 47
|
||||
if (match(h, 0x89, 0x50, 0x4E, 0x47)) {
|
||||
return new Sniffed(".png", "image/png");
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if (match(h, 0xFF, 0xD8, 0xFF)) {
|
||||
return new Sniffed(".jpg", "image/jpeg");
|
||||
}
|
||||
// GIF: "GIF8"
|
||||
if (match(h, 0x47, 0x49, 0x46, 0x38)) {
|
||||
return new Sniffed(".gif", "image/gif");
|
||||
}
|
||||
// BMP: "BM"
|
||||
if (match(h, 0x42, 0x4D)) {
|
||||
return new Sniffed(".bmp", "image/bmp");
|
||||
}
|
||||
// TIFF: little-endian "II*\0" or big-endian "MM\0*"
|
||||
if (match(h, 0x49, 0x49, 0x2A, 0x00) || match(h, 0x4D, 0x4D, 0x00, 0x2A)) {
|
||||
return new Sniffed(".tiff", "image/tiff");
|
||||
}
|
||||
// RIFF container: bytes 0..3 = "RIFF", bytes 8..11 identify the payload.
|
||||
// WEBP is the one users send (screenshots / phone photos); WAV is audio.
|
||||
if (h.length >= 12 && match(h, 0x52, 0x49, 0x46, 0x46)) {
|
||||
if (matchAt(h, 8, 0x57, 0x45, 0x42, 0x50)) { // "WEBP"
|
||||
return new Sniffed(".webp", "image/webp");
|
||||
}
|
||||
if (matchAt(h, 8, 0x57, 0x41, 0x56, 0x45)) { // "WAVE"
|
||||
return new Sniffed(".wav", "audio/wav");
|
||||
}
|
||||
}
|
||||
// ISO Base Media (ftyp at bytes 4..7). The brand at bytes 8..11
|
||||
// distinguishes HEIC photos / M4A audio / QuickTime from plain MP4 —
|
||||
// critical because iPhone photos are HEIC, not video.
|
||||
if (h.length >= 12 && matchAt(h, 4, 0x66, 0x74, 0x79, 0x70)) {
|
||||
return classifyFtyp(brandAt(h, 8));
|
||||
}
|
||||
// ZIP-based container: PK\x03\x04 (refined by the caller).
|
||||
if (match(h, 0x50, 0x4B, 0x03, 0x04)) {
|
||||
return new Sniffed(".zip", "application/zip");
|
||||
}
|
||||
// Legacy Office (DOC/XLS/PPT): D0 CF 11 E0 A1 B1 1A E1
|
||||
if (h.length >= 8 && match(h, 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1)) {
|
||||
return new Sniffed(".doc", "application/msword");
|
||||
}
|
||||
// RTF: "{\rtf"
|
||||
if (h.length >= 5 && match(h, 0x7B, 0x5C, 0x72, 0x74, 0x66)) {
|
||||
return new Sniffed(".rtf", "application/rtf");
|
||||
}
|
||||
// 7z: 37 7A BC AF 27 1C
|
||||
if (h.length >= 6 && match(h, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C)) {
|
||||
return new Sniffed(".7z", "application/x-7z-compressed");
|
||||
}
|
||||
// RAR: "Rar!\x1A\x07"
|
||||
if (h.length >= 6 && match(h, 0x52, 0x61, 0x72, 0x21, 0x1A, 0x07)) {
|
||||
return new Sniffed(".rar", "application/x-rar-compressed");
|
||||
}
|
||||
// MP3: ID3v2 tag "ID3"
|
||||
if (match(h, 0x49, 0x44, 0x33)) {
|
||||
return new Sniffed(".mp3", "audio/mpeg");
|
||||
}
|
||||
// MP3: MPEG audio frame sync (0xFFFB / 0xFFF3 / 0xFFF2)
|
||||
if ((h[0] & 0xFF) == 0xFF && (h[1] & 0xE0) == 0xE0) {
|
||||
return new Sniffed(".mp3", "audio/mpeg");
|
||||
}
|
||||
// OGG: "OggS"
|
||||
if (match(h, 0x4F, 0x67, 0x67, 0x53)) {
|
||||
return new Sniffed(".ogg", "audio/ogg");
|
||||
}
|
||||
// AMR (WeChat / WeCom voice): "#!AMR"
|
||||
if (h.length >= 5 && match(h, 0x23, 0x21, 0x41, 0x4D, 0x52)) {
|
||||
return new Sniffed(".amr", "audio/amr");
|
||||
}
|
||||
// SILK (WeChat voice): "#!SILK"
|
||||
if (h.length >= 6 && match(h, 0x23, 0x21, 0x53, 0x49, 0x4C, 0x4B)) {
|
||||
return new Sniffed(".silk", "audio/silk");
|
||||
}
|
||||
return Sniffed.UNKNOWN;
|
||||
}
|
||||
|
||||
/** Map an ISO-BMFF major brand to a concrete type. */
|
||||
private static Sniffed classifyFtyp(String brand) {
|
||||
if (brand == null) {
|
||||
return new Sniffed(".mp4", "video/mp4");
|
||||
}
|
||||
// HEIF / HEIC still images (iPhone camera default).
|
||||
switch (brand) {
|
||||
case "heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs",
|
||||
"mif1", "msf1" -> {
|
||||
return new Sniffed(".heic", "image/heic");
|
||||
}
|
||||
case "avif", "avis" -> {
|
||||
return new Sniffed(".avif", "image/avif");
|
||||
}
|
||||
case "qt " -> {
|
||||
return new Sniffed(".mov", "video/quicktime");
|
||||
}
|
||||
case "M4A ", "M4B " -> {
|
||||
return new Sniffed(".m4a", "audio/mp4");
|
||||
}
|
||||
case "M4V " -> {
|
||||
return new Sniffed(".m4v", "video/x-m4v");
|
||||
}
|
||||
default -> {
|
||||
return new Sniffed(".mp4", "video/mp4");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek inside a ZIP container to distinguish OOXML (DOCX/XLSX/PPTX), ODF
|
||||
* (ODT/ODS/ODP), JAR, and EPUB from a plain ZIP. Reads local file headers
|
||||
* in order; the discriminator entry is almost always within the first few
|
||||
* entries, so iteration is capped at 16 to bound CPU. Returns the supplied
|
||||
* {@code zipDefault} when nothing specific is detected.
|
||||
*/
|
||||
private static Sniffed refineZipKind(byte[] data, Sniffed zipDefault) {
|
||||
if (data == null || data.length < 30) {
|
||||
return zipDefault;
|
||||
}
|
||||
String mimetypeContent = null;
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(data))) {
|
||||
ZipEntry entry;
|
||||
int seen = 0;
|
||||
while ((entry = zis.getNextEntry()) != null && seen < 16) {
|
||||
seen++;
|
||||
String name = entry.getName();
|
||||
if (name.startsWith("word/")) {
|
||||
return new Sniffed(".docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
}
|
||||
if (name.startsWith("xl/")) {
|
||||
return new Sniffed(".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
if (name.startsWith("ppt/")) {
|
||||
return new Sniffed(".pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
}
|
||||
if (name.startsWith("visio/")) {
|
||||
return new Sniffed(".vsdx", "application/vnd.ms-visio.drawing");
|
||||
}
|
||||
if ("META-INF/MANIFEST.MF".equals(name)) {
|
||||
return new Sniffed(".jar", "application/java-archive");
|
||||
}
|
||||
// EPUB always carries META-INF/container.xml.
|
||||
if ("META-INF/container.xml".equals(name)) {
|
||||
return new Sniffed(".epub", "application/epub+zip");
|
||||
}
|
||||
// ODF / EPUB also declare the type in a leading "mimetype" entry.
|
||||
if ("mimetype".equals(name)) {
|
||||
byte[] body = zis.readAllBytes();
|
||||
mimetypeContent = new String(body, StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return zipDefault;
|
||||
}
|
||||
// Match leniently (contains) — the mimetype body occasionally carries a
|
||||
// trailing newline or charset noise.
|
||||
if (mimetypeContent != null) {
|
||||
if (mimetypeContent.contains("opendocument.text")) {
|
||||
return new Sniffed(".odt", "application/vnd.oasis.opendocument.text");
|
||||
}
|
||||
if (mimetypeContent.contains("opendocument.spreadsheet")) {
|
||||
return new Sniffed(".ods", "application/vnd.oasis.opendocument.spreadsheet");
|
||||
}
|
||||
if (mimetypeContent.contains("opendocument.presentation")) {
|
||||
return new Sniffed(".odp", "application/vnd.oasis.opendocument.presentation");
|
||||
}
|
||||
if (mimetypeContent.contains("epub")) {
|
||||
return new Sniffed(".epub", "application/epub+zip");
|
||||
}
|
||||
}
|
||||
return zipDefault;
|
||||
}
|
||||
|
||||
/** True when the leading bytes equal the given unsigned-byte signature. */
|
||||
private static boolean match(byte[] data, int... signature) {
|
||||
return matchAt(data, 0, signature);
|
||||
}
|
||||
|
||||
/** True when bytes starting at {@code offset} equal the signature. */
|
||||
private static boolean matchAt(byte[] data, int offset, int... signature) {
|
||||
if (data.length < offset + signature.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < signature.length; i++) {
|
||||
if ((data[offset + i] & 0xFF) != (signature[i] & 0xFF)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Read a 4-character ASCII brand at the given offset, or null. */
|
||||
private static String brandAt(byte[] data, int offset) {
|
||||
if (data.length < offset + 4) {
|
||||
return null;
|
||||
}
|
||||
return new String(data, offset, 4, StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,7 @@ public class ChatController {
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||
private final Path uploadRoot = Paths.get("data", "chat-uploads");
|
||||
|
||||
// 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor())
|
||||
@ -543,7 +544,7 @@ public class ChatController {
|
||||
// tools that need a workspace path read it from the agent (origin
|
||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
|
||||
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId());
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
@ -630,7 +631,12 @@ public class ChatController {
|
||||
// garbage like "[错误] Bad request..." as the assistant reply,
|
||||
// which would pollute the memory extraction pipeline if propagated.
|
||||
if (!wasStopped && !isError) {
|
||||
completionPublisher.publish(agentId, conversationId, message, assistantText, "web");
|
||||
// Attribute the memory write to the same owner the read
|
||||
// path recalled this turn — the publish runs in a reactive
|
||||
// completion callback after the origin holder is cleared,
|
||||
// so resolve from the captured webOrigin explicitly.
|
||||
completionPublisher.publish(agentId, conversationId, message, assistantText, "web",
|
||||
memoryOwnerResolver.resolve(webOrigin));
|
||||
}
|
||||
|
||||
if (isInterruptFollowup) {
|
||||
@ -1035,9 +1041,17 @@ public class ChatController {
|
||||
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts());
|
||||
|
||||
String promptText = buildPromptText(request.getMessage(), request.getContentParts());
|
||||
String response = agentService.chat(agentId, promptText, request.getConversationId());
|
||||
conversationService.saveMessage(request.getConversationId(), "assistant", response);
|
||||
completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web");
|
||||
// Carry the web origin so per-owner memory recall (read) and the
|
||||
// post-conversation memory write below agree on the same owner key.
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId());
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
|
||||
String response = result.content();
|
||||
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
||||
result.promptTokens(), result.completionTokens(),
|
||||
result.runtimeModel(), result.runtimeProvider());
|
||||
completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web",
|
||||
memoryOwnerResolver.resolve(webOrigin));
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
@ -1120,11 +1134,36 @@ public class ChatController {
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner
|
||||
* memory isolation for a web request. When {@code endUserId} is supplied
|
||||
* (third-party single-account integration) the origin is attributed to that
|
||||
* external end-user ({@code api:<endUserId>}); otherwise to the logged-in
|
||||
* MateClaw user ({@code user:<username>}).
|
||||
*/
|
||||
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
|
||||
Long workspaceId, String endUserId) {
|
||||
if (endUserId != null && !endUserId.isBlank()) {
|
||||
return vip.mate.agent.context.ChatOrigin
|
||||
.web(conversationId, endUserId.trim(), workspaceId, null)
|
||||
.withSender(null, "api", null);
|
||||
}
|
||||
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
public static class ChatRequest {
|
||||
private String message;
|
||||
private String conversationId = "default";
|
||||
private List<MessageContentPart> contentParts;
|
||||
/**
|
||||
* Optional third-party end-user identifier. When a single MateClaw
|
||||
* account (e.g. one PAT) fronts many of an external system's users,
|
||||
* pass that system's user id here so memory and recall are isolated
|
||||
* per end-user. Kept as a string (never coerced to a number) to
|
||||
* preserve precision of large external ids.
|
||||
*/
|
||||
private String endUserId;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@ -1164,6 +1203,12 @@ public class ChatController {
|
||||
private String modelProvider;
|
||||
/** Model id the user picked for this conversation. See {@link #modelProvider}. */
|
||||
private String modelName;
|
||||
/**
|
||||
* Optional third-party end-user identifier — see
|
||||
* {@link ChatRequest#getEndUserId()}. Isolates memory per external
|
||||
* end-user when one MateClaw account fronts many of them.
|
||||
*/
|
||||
private String endUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -144,18 +144,26 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
|
||||
talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId);
|
||||
conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of());
|
||||
|
||||
// 5. Agent 对话(同步)
|
||||
String reply = agentService.chat(talkSession.agentId, transcript, talkSession.conversationId);
|
||||
// 5. Agent 对话(同步)。Carry the voice user's identity so per-owner
|
||||
// memory recall (read) and the post-turn memory write (below) agree
|
||||
// on the same owner key.
|
||||
vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web(
|
||||
talkSession.conversationId, talkSession.username, talkWsId, null);
|
||||
AgentService.ChatResult chatResult = agentService.chatWithUsage(
|
||||
talkSession.agentId, transcript, talkSession.conversationId, talkOrigin);
|
||||
String reply = chatResult.content();
|
||||
if (reply == null || reply.isBlank()) {
|
||||
reply = "Sorry, I couldn't generate a response.";
|
||||
}
|
||||
|
||||
// 6. 保存助手回复
|
||||
conversationService.saveMessage(talkSession.conversationId, "assistant", reply, List.of());
|
||||
// 6. 保存助手回复(携带 token usage + runtime model 归属)
|
||||
conversationService.saveMessage(talkSession.conversationId, "assistant", reply, List.of(),
|
||||
"completed", chatResult.promptTokens(), chatResult.completionTokens(),
|
||||
chatResult.runtimeModel(), chatResult.runtimeProvider());
|
||||
|
||||
// Publish conversation-completed event so memory extraction runs for voice turns too.
|
||||
completionPublisher.publish(talkSession.agentId, talkSession.conversationId,
|
||||
transcript, reply, "talk");
|
||||
completionPublisher.publishForOrigin(talkSession.agentId, talkSession.conversationId,
|
||||
transcript, reply, "talk", talkOrigin);
|
||||
|
||||
// 7. 推送文字回复
|
||||
sendJson(session, Map.of("type", "reply", "text", reply));
|
||||
|
||||
@ -50,6 +50,7 @@ public class WebChatController {
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@ -118,9 +119,30 @@ public class WebChatController {
|
||||
// Pattern mirrors ChatController: always accumulate, only broadcast when the
|
||||
// delta is not a persistence-only echo of content already streamed by inner nodes.
|
||||
StringBuilder assistantReply = new StringBuilder();
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
|
||||
agentService.chatStructuredStream(agentId, message, conversationId, visitorId)
|
||||
// Attribute memory to this external visitor so each end-user
|
||||
// behind the shared webchat account is isolated. The same origin
|
||||
// resolves the owner key for both the read (recall) and write
|
||||
// (publish) paths below.
|
||||
vip.mate.agent.context.ChatOrigin webchatOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null)
|
||||
.withSender(null, "api", null);
|
||||
String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin);
|
||||
|
||||
agentService.chatStructuredStream(agentId, message, conversationId, visitorId, null, webchatOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
}
|
||||
if (delta.content() != null && !delta.content().isEmpty()) {
|
||||
assistantReply.append(delta.content());
|
||||
if (!delta.persistenceOnly()) {
|
||||
@ -139,10 +161,11 @@ public class WebChatController {
|
||||
try {
|
||||
if (!reply.isBlank()) {
|
||||
conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, List.of());
|
||||
conversationId, "assistant", reply, List.of(),
|
||||
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
}
|
||||
completionPublisher.publish(
|
||||
agentId, conversationId, message, reply, "webchat");
|
||||
agentId, conversationId, message, reply, "webchat", webchatOwnerKey);
|
||||
} catch (Exception persistErr) {
|
||||
log.warn("[WebChat] Failed to persist assistant reply / publish event: {}",
|
||||
persistErr.getMessage());
|
||||
|
||||
@ -6,13 +6,13 @@ import vip.mate.channel.AbstractChannelAdapter;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
import vip.mate.channel.media.InboundMediaDownloader;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@ -27,8 +27,6 @@ import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@ -2847,13 +2845,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
private MessageContentPart buildInboundImagePart(String url, String aesKey, String msgId,
|
||||
String fileNameHint, String conversationId) {
|
||||
if (getConfigBoolean("media_download_enabled", true)) {
|
||||
InboundMediaResult r = downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
|
||||
InboundMediaDownloader.DownloadedMedia r =
|
||||
downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
|
||||
if (r != null) {
|
||||
String localPath = r.localPath().toString();
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("image");
|
||||
part.setFileName(r.fileName());
|
||||
part.setStoredName(r.storedName());
|
||||
part.setPath(r.localPath());
|
||||
part.setPath(localPath);
|
||||
part.setFileUrl(r.fileUrl());
|
||||
part.setFileSize(r.fileSize());
|
||||
// Prefer the sniffed contentType (could be image/png) over a
|
||||
@ -2863,7 +2863,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
part.setContentType((ct != null && ct.startsWith("image/")) ? ct : "image/jpeg");
|
||||
// mediaId mirrors path so callers that prefer it still resolve
|
||||
// to the same on-disk file (matches Web upload's behaviour).
|
||||
part.setMediaId(r.localPath());
|
||||
part.setMediaId(localPath);
|
||||
return part;
|
||||
}
|
||||
}
|
||||
@ -2891,17 +2891,19 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
private MessageContentPart buildInboundFilePart(String url, String aesKey, String msgId,
|
||||
String fileNameHint, String conversationId) {
|
||||
if (getConfigBoolean("media_download_enabled", true)) {
|
||||
InboundMediaResult r = downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
|
||||
InboundMediaDownloader.DownloadedMedia r =
|
||||
downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
|
||||
if (r != null) {
|
||||
String localPath = r.localPath().toString();
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("file");
|
||||
part.setFileName(r.fileName());
|
||||
part.setStoredName(r.storedName());
|
||||
part.setPath(r.localPath());
|
||||
part.setPath(localPath);
|
||||
part.setFileUrl(r.fileUrl());
|
||||
part.setFileSize(r.fileSize());
|
||||
part.setContentType(r.contentType());
|
||||
part.setMediaId(r.localPath());
|
||||
part.setMediaId(localPath);
|
||||
return part;
|
||||
}
|
||||
}
|
||||
@ -2916,285 +2918,43 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound-media download result. Carries every field the bubble renderer
|
||||
* and the multimodal sidecar need so callers don't have to re-derive
|
||||
* storedName / fileUrl from scratch.
|
||||
*
|
||||
* @param localPath absolute filesystem path of the saved file
|
||||
* @param storedName the on-disk filename (matches the last segment of localPath)
|
||||
* @param fileUrl browser-servable URL: {@code /api/v1/chat/files/{convId}/{storedName}}
|
||||
* @param fileSize byte length after decryption
|
||||
* @param fileName human-readable display name (extension corrected by magic-byte sniff)
|
||||
* @param contentType MIME type derived from magic bytes (or {@code application/octet-stream})
|
||||
* Download + decrypt an inbound media attachment via the shared media
|
||||
* pipeline, stored under {@code data/chat-uploads/{conversationId}/} so the
|
||||
* existing {@code /api/v1/chat/files/...} endpoint can serve it back to the
|
||||
* chat bubble. Returns the persisted, type-detected file, or {@code null}
|
||||
* on download/decrypt failure (callers fall back to URL-only).
|
||||
*/
|
||||
record InboundMediaResult(String localPath, String storedName,
|
||||
String fileUrl, long fileSize, String fileName,
|
||||
String contentType) {}
|
||||
|
||||
/** Magic-byte sniff result. */
|
||||
private record MagicSniff(String extension, String contentType) {
|
||||
static final MagicSniff UNKNOWN = new MagicSniff(".bin", "application/octet-stream");
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort MIME sniff from the first 12 bytes of a file. Covers the
|
||||
* formats users routinely forward to bots (PDF, Office, archives, common
|
||||
* image / audio / video). When nothing matches, returns
|
||||
* {@link MagicSniff#UNKNOWN} so the caller falls back to {@code .bin}.
|
||||
* <p>
|
||||
* This exists because WeCom's {@code aibot_msg_callback} {@code file}
|
||||
* body sometimes omits {@code filename} entirely (forwarded files in
|
||||
* particular), and shipping the agent a part labelled {@code file.bin}
|
||||
* makes downstream tools mis-route the content. Sniffing recovers a
|
||||
* useful extension so PDF tools fire on PDFs.
|
||||
*/
|
||||
private static MagicSniff sniffMagic(byte[] head) {
|
||||
if (head == null || head.length < 4) return MagicSniff.UNKNOWN;
|
||||
// PDF: %PDF
|
||||
if (head[0] == 0x25 && head[1] == 0x50 && head[2] == 0x44 && head[3] == 0x46) {
|
||||
return new MagicSniff(".pdf", "application/pdf");
|
||||
}
|
||||
// PNG: 89 50 4E 47
|
||||
if (head[0] == (byte) 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47) {
|
||||
return new MagicSniff(".png", "image/png");
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if (head[0] == (byte) 0xFF && head[1] == (byte) 0xD8 && head[2] == (byte) 0xFF) {
|
||||
return new MagicSniff(".jpg", "image/jpeg");
|
||||
}
|
||||
// GIF: "GIF8"
|
||||
if (head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x38) {
|
||||
return new MagicSniff(".gif", "image/gif");
|
||||
}
|
||||
// ZIP-based container: PK\x03\x04. Could be a plain ZIP, a JAR,
|
||||
// an OOXML document (DOCX/XLSX/PPTX), an ODF document (ODT/ODS/ODP),
|
||||
// or an EPUB. Magic-byte alone can't tell them apart — caller is
|
||||
// expected to follow up with refineZipKind(fullBytes) to pick a
|
||||
// specific type.
|
||||
if (head[0] == 0x50 && head[1] == 0x4B && head[2] == 0x03 && head[3] == 0x04) {
|
||||
return new MagicSniff(".zip", "application/zip");
|
||||
}
|
||||
// Legacy Office (DOC/XLS/PPT): D0 CF 11 E0 A1 B1 1A E1
|
||||
if (head.length >= 8
|
||||
&& head[0] == (byte) 0xD0 && head[1] == (byte) 0xCF
|
||||
&& head[2] == 0x11 && head[3] == (byte) 0xE0
|
||||
&& head[4] == (byte) 0xA1 && head[5] == (byte) 0xB1
|
||||
&& head[6] == 0x1A && head[7] == (byte) 0xE1) {
|
||||
return new MagicSniff(".doc", "application/msword");
|
||||
}
|
||||
// RTF: "{\rtf"
|
||||
if (head.length >= 5
|
||||
&& head[0] == 0x7B && head[1] == 0x5C
|
||||
&& head[2] == 0x72 && head[3] == 0x74 && head[4] == 0x66) {
|
||||
return new MagicSniff(".rtf", "application/rtf");
|
||||
}
|
||||
// 7z: 37 7A BC AF 27 1C
|
||||
if (head.length >= 6
|
||||
&& head[0] == 0x37 && head[1] == 0x7A && head[2] == (byte) 0xBC
|
||||
&& head[3] == (byte) 0xAF && head[4] == 0x27 && head[5] == 0x1C) {
|
||||
return new MagicSniff(".7z", "application/x-7z-compressed");
|
||||
}
|
||||
// RAR: "Rar!\x1A\x07"
|
||||
if (head.length >= 6
|
||||
&& head[0] == 0x52 && head[1] == 0x61 && head[2] == 0x72
|
||||
&& head[3] == 0x21 && head[4] == 0x1A && head[5] == 0x07) {
|
||||
return new MagicSniff(".rar", "application/x-rar-compressed");
|
||||
}
|
||||
// MP3: ID3v2 ("ID3") or MPEG sync 0xFFFB / 0xFFF3 / 0xFFF2
|
||||
if (head[0] == 0x49 && head[1] == 0x44 && head[2] == 0x33) {
|
||||
return new MagicSniff(".mp3", "audio/mpeg");
|
||||
}
|
||||
// MP4: "....ftyp" — bytes 4..7 == "ftyp"
|
||||
if (head.length >= 8
|
||||
&& head[4] == 0x66 && head[5] == 0x74 && head[6] == 0x79 && head[7] == 0x70) {
|
||||
return new MagicSniff(".mp4", "video/mp4");
|
||||
}
|
||||
// OGG: "OggS"
|
||||
if (head[0] == 0x4F && head[1] == 0x67 && head[2] == 0x67 && head[3] == 0x53) {
|
||||
return new MagicSniff(".ogg", "audio/ogg");
|
||||
}
|
||||
return MagicSniff.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek inside a ZIP container to distinguish OOXML (DOCX/XLSX/PPTX),
|
||||
* ODF (ODT/ODS/ODP), JAR, and EPUB from a plain ZIP. Reads the local
|
||||
* file headers in order via {@link ZipInputStream}; the discriminator
|
||||
* entry is almost always within the first few entries (OOXML places
|
||||
* {@code [Content_Types].xml} first, ODF places {@code mimetype} first),
|
||||
* so we cap iteration at 16 entries to bound CPU.
|
||||
* <p>
|
||||
* Returns the original {@code zipDefault} sniff (plain
|
||||
* {@code application/zip}) when no specific kind is detected — that's
|
||||
* the right answer for actual ZIPs and unknown archive formats.
|
||||
*/
|
||||
private static MagicSniff refineZipKind(byte[] fileData, MagicSniff zipDefault) {
|
||||
if (fileData == null || fileData.length < 30) return zipDefault;
|
||||
String mimetypeContent = null;
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileData))) {
|
||||
ZipEntry entry;
|
||||
int seen = 0;
|
||||
while ((entry = zis.getNextEntry()) != null && seen < 16) {
|
||||
String name = entry.getName();
|
||||
// OOXML — Office Open XML (Word/Excel/PowerPoint). Each format
|
||||
// has a distinct top-level directory; we match on prefix
|
||||
// because the entry order isn't guaranteed.
|
||||
if (name.startsWith("word/")) {
|
||||
return new MagicSniff(".docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
}
|
||||
if (name.startsWith("xl/")) {
|
||||
return new MagicSniff(".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
if (name.startsWith("ppt/")) {
|
||||
return new MagicSniff(".pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
}
|
||||
// Visio (rare but worth catching)
|
||||
if (name.startsWith("visio/")) {
|
||||
return new MagicSniff(".vsdx",
|
||||
"application/vnd.ms-visio.drawing");
|
||||
}
|
||||
// ODF marker: a {@code mimetype} entry that contains the full
|
||||
// application/vnd.oasis.opendocument.* string — read its body
|
||||
// and decide once we have it.
|
||||
if ("mimetype".equals(name)) {
|
||||
byte[] buf = zis.readAllBytes();
|
||||
mimetypeContent = new String(buf, java.nio.charset.StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
// JAR
|
||||
if ("META-INF/MANIFEST.MF".equals(name)) {
|
||||
return new MagicSniff(".jar", "application/java-archive");
|
||||
}
|
||||
// EPUB always has META-INF/container.xml
|
||||
if ("META-INF/container.xml".equals(name)) {
|
||||
return new MagicSniff(".epub", "application/epub+zip");
|
||||
}
|
||||
seen++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[wecom] refineZipKind failed (treating as plain zip): {}", e.getMessage());
|
||||
return zipDefault;
|
||||
}
|
||||
if (mimetypeContent != null) {
|
||||
if (mimetypeContent.contains("opendocument.text")) {
|
||||
return new MagicSniff(".odt", "application/vnd.oasis.opendocument.text");
|
||||
}
|
||||
if (mimetypeContent.contains("opendocument.spreadsheet")) {
|
||||
return new MagicSniff(".ods", "application/vnd.oasis.opendocument.spreadsheet");
|
||||
}
|
||||
if (mimetypeContent.contains("opendocument.presentation")) {
|
||||
return new MagicSniff(".odp", "application/vnd.oasis.opendocument.presentation");
|
||||
}
|
||||
if (mimetypeContent.contains("epub")) {
|
||||
return new MagicSniff(".epub", "application/epub+zip");
|
||||
}
|
||||
}
|
||||
return zipDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing extension from a filename. {@code "image.jpg" → "image"};
|
||||
* {@code "no_ext" → "no_ext"}; {@code "" → ""}.
|
||||
*/
|
||||
private static String stripExtension(String name) {
|
||||
if (name == null || name.isBlank()) return "";
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot <= 0) return name;
|
||||
return name.substring(0, dot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download + decrypt an inbound media attachment and stash it under
|
||||
* {@code data/chat-uploads/{conversationId}/} so the existing
|
||||
* {@code /api/v1/chat/files/...} endpoint can serve it back to the chat
|
||||
* bubble. Returns a fully-populated {@link InboundMediaResult} on success
|
||||
* or null on download/decrypt failure (callers fall back to URL-only).
|
||||
* <p>
|
||||
* Storing under chat-uploads rather than {@code data/media} means
|
||||
* {@link MessageContentPart#getPath()} resolves to a real file for the
|
||||
* vision sidecar AND {@code fileUrl} renders as a thumbnail in the Web
|
||||
* mirror — instead of the WeCom-signed CDN URL whose 5-minute query-string
|
||||
* signature expires before the browser can fetch it.
|
||||
*/
|
||||
private InboundMediaResult downloadInboundMedia(String url, String aesKey, String msgId,
|
||||
private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId,
|
||||
String fileNameHint, String conversationId) {
|
||||
try {
|
||||
// Mirror ChatController.uploadRoot ("data/chat-uploads") so the
|
||||
// serve endpoint at /api/v1/chat/files/{convId}/{storedName} works
|
||||
// without any extra wiring. The conversationId may contain ':'
|
||||
// (e.g. "wecom:XuZhanFu" or "wecom:group:abc"); Path resolution
|
||||
// tolerates this on macOS/Linux but Windows would reject the
|
||||
// colon — for now we keep parity with the existing chat-uploads
|
||||
// layout and revisit if Windows support comes up.
|
||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
||||
Files.createDirectories(uploadDir);
|
||||
|
||||
// 1. HTTP GET 下载文件
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
byte[] encryptedData = response.body().readAllBytes();
|
||||
|
||||
byte[] fileData;
|
||||
// 2. AES 解密(如果提供了 aesKey)
|
||||
if (aesKey != null && !aesKey.isBlank()) {
|
||||
fileData = decryptAes256Cbc(encryptedData, aesKey);
|
||||
} else {
|
||||
fileData = encryptedData;
|
||||
}
|
||||
|
||||
// 3. Magic-byte sniff to recover a real extension when WeCom
|
||||
// didn't include filename in the body (forwarded files often
|
||||
// arrive nameless — saving them as "file.bin" misroutes the
|
||||
// agent because every PDF tool keys off the .pdf extension).
|
||||
byte[] head = new byte[Math.min(12, fileData.length)];
|
||||
System.arraycopy(fileData, 0, head, 0, head.length);
|
||||
MagicSniff sniff = sniffMagic(head);
|
||||
// ZIP container needs a deeper look — DOCX/XLSX/PPTX/ODF/EPUB/JAR
|
||||
// all share the PK\x03\x04 magic. Peek inside the first few
|
||||
// entries to pick the specific kind.
|
||||
if (".zip".equals(sniff.extension())) {
|
||||
sniff = refineZipKind(fileData, sniff);
|
||||
}
|
||||
|
||||
// 4. Compose a URL-safe storedName. If the hint is generic
|
||||
// (e.g. "file.bin"), prefer the sniffed extension.
|
||||
String urlHash = md5Hex(url).substring(0, 8);
|
||||
String hintRaw = (fileNameHint == null ? "media" : fileNameHint).trim();
|
||||
String safeName = hintRaw.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
if (safeName.isBlank()) safeName = "media";
|
||||
// "file.bin" is the WeCom-no-filename sentinel; if magic gave us
|
||||
// something better, replace the extension. Same when hint had no
|
||||
// extension at all.
|
||||
boolean hintIsGeneric = safeName.equals("file.bin") || safeName.equals("media")
|
||||
|| !safeName.contains(".");
|
||||
if (hintIsGeneric && !".bin".equals(sniff.extension())) {
|
||||
safeName = stripExtension(safeName) + sniff.extension();
|
||||
}
|
||||
String storedName = "wecom_" + urlHash + "_" + safeName;
|
||||
Path filePath = uploadDir.resolve(storedName);
|
||||
Files.write(filePath, fileData);
|
||||
|
||||
String fileUrl = "/api/v1/chat/files/" + conversationId + "/" + storedName;
|
||||
log.info("[wecom] Inbound media saved: {} ({} bytes, sniffed={}), serve URL={}",
|
||||
filePath, fileData.length, sniff.contentType(), fileUrl);
|
||||
return new InboundMediaResult(
|
||||
filePath.toAbsolutePath().toString(),
|
||||
storedName,
|
||||
fileUrl,
|
||||
fileData.length,
|
||||
safeName,
|
||||
sniff.contentType());
|
||||
} catch (Exception e) {
|
||||
log.error("[wecom] Failed to download inbound media: {}", e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
// Store under data/chat-uploads/{conversationId} so the existing
|
||||
// /api/v1/chat/files/{convId}/{storedName} endpoint serves the file
|
||||
// back to the chat bubble — the WeCom CDN URL carries a short-lived
|
||||
// signature that expires before a browser can fetch it. The shared
|
||||
// pipeline owns retry/backoff, magic-byte type detection, and the
|
||||
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
|
||||
// inside the byte source so a fetch + decrypt is retried as one unit.
|
||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
||||
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
|
||||
return InboundMediaDownloader.download(
|
||||
() -> {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<InputStream> response =
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
byte[] encrypted = response.body().readAllBytes();
|
||||
return (aesKey != null && !aesKey.isBlank())
|
||||
? decryptAes256Cbc(encrypted, aesKey)
|
||||
: encrypted;
|
||||
},
|
||||
hint,
|
||||
uploadDir,
|
||||
"wecom",
|
||||
url,
|
||||
storedName -> "/api/v1/chat/files/" + conversationId + "/" + storedName)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,6 +6,7 @@ import vip.mate.channel.AbstractChannelAdapter;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
import vip.mate.channel.media.InboundMediaDownloader;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.weixin.error.TokenExpiredException;
|
||||
import vip.mate.common.security.SecretEquals;
|
||||
@ -18,7 +19,6 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
@ -406,7 +406,13 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
|
||||
List<Map<String, Object>> itemList = (List<Map<String, Object>>) msg.getOrDefault("item_list", List.of());
|
||||
boolean mediaDownloadEnabled = getConfigBoolean("media_download_enabled", true);
|
||||
String mediaDir = getConfigString("media_dir", "data/media");
|
||||
// Inbound conversation id (see class doc): private "weixin:{user}",
|
||||
// group "weixin:group:{group}". Downloaded media is stored under
|
||||
// data/chat-uploads/{convId} so the /api/v1/chat/files endpoint can
|
||||
// serve it back to the chat bubble / Web mirror.
|
||||
String inboundConvId = !groupId.isBlank()
|
||||
? "weixin:group:" + groupId
|
||||
: "weixin:" + fromUserId;
|
||||
|
||||
for (Map<String, Object> item : itemList) {
|
||||
int itemType = item.get("type") instanceof Number n ? n.intValue() : 0;
|
||||
@ -423,12 +429,21 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
case 2 -> {
|
||||
// Image
|
||||
if (mediaDownloadEnabled) {
|
||||
String path = downloadMediaItem(item, "image_item", "image.jpg", mediaDir);
|
||||
if (path != null) {
|
||||
InboundMediaDownloader.DownloadedMedia dl =
|
||||
downloadMediaItem(item, "image_item", null, inboundConvId);
|
||||
if (dl != null) {
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("image");
|
||||
part.setPath(path);
|
||||
part.setContentType("image/*");
|
||||
part.setPath(dl.localPath().toString());
|
||||
part.setStoredName(dl.storedName());
|
||||
part.setFileUrl(dl.fileUrl());
|
||||
part.setMediaId(dl.localPath().toString());
|
||||
part.setFileName(dl.fileName());
|
||||
// Use the sniffed MIME (image/png, image/webp, image/heic, …)
|
||||
// so vision gateways get an accurate Content-Type. Fall back
|
||||
// to a concrete jpeg only when sniffing was inconclusive.
|
||||
part.setContentType(dl.isImage() ? dl.contentType() : "image/jpeg");
|
||||
part.setFileSize(dl.fileSize());
|
||||
contentParts.add(part);
|
||||
} else {
|
||||
// 下载失败,尝试构建 CDN URL
|
||||
@ -484,15 +499,21 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
// ASR 为空:可能是语音过短、噪音、或 iLink API 字段变更
|
||||
// 尝试下载语音文件保存到本地(供后续调试 / 自有 STT 使用)
|
||||
if (mediaDownloadEnabled) {
|
||||
String voicePath = downloadMediaItem(item, "voice_item", "voice.amr", mediaDir);
|
||||
if (voicePath != null) {
|
||||
// 保存为 audio content part,即使无 ASR 文本
|
||||
InboundMediaDownloader.DownloadedMedia dl =
|
||||
downloadMediaItem(item, "voice_item", null, inboundConvId);
|
||||
if (dl != null) {
|
||||
// Persist as an audio content part even without ASR text
|
||||
MessageContentPart audioPart = new MessageContentPart();
|
||||
audioPart.setType("audio");
|
||||
audioPart.setPath(voicePath);
|
||||
audioPart.setFileName("voice.amr");
|
||||
audioPart.setPath(dl.localPath().toString());
|
||||
audioPart.setStoredName(dl.storedName());
|
||||
audioPart.setFileUrl(dl.fileUrl());
|
||||
audioPart.setMediaId(dl.localPath().toString());
|
||||
audioPart.setFileName(dl.fileName());
|
||||
audioPart.setContentType(dl.contentType());
|
||||
audioPart.setFileSize(dl.fileSize());
|
||||
contentParts.add(audioPart);
|
||||
log.info("[weixin] Voice audio downloaded (no ASR): {}", voicePath);
|
||||
log.info("[weixin] Voice audio downloaded (no ASR): {}", dl.localPath());
|
||||
}
|
||||
}
|
||||
textParts.add("[语音消息]");
|
||||
@ -506,12 +527,18 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
String fileName = getStr(fileItemMap, "file_name");
|
||||
if (fileName.isBlank()) fileName = "file.bin";
|
||||
if (mediaDownloadEnabled) {
|
||||
String path = downloadMediaItem(item, "file_item", fileName, mediaDir);
|
||||
if (path != null) {
|
||||
InboundMediaDownloader.DownloadedMedia dl =
|
||||
downloadMediaItem(item, "file_item", fileName, inboundConvId);
|
||||
if (dl != null) {
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("file");
|
||||
part.setPath(path);
|
||||
part.setFileName(fileName);
|
||||
part.setPath(dl.localPath().toString());
|
||||
part.setStoredName(dl.storedName());
|
||||
part.setFileUrl(dl.fileUrl());
|
||||
part.setMediaId(dl.localPath().toString());
|
||||
part.setFileName(dl.fileName());
|
||||
part.setContentType(dl.contentType());
|
||||
part.setFileSize(dl.fileSize());
|
||||
contentParts.add(part);
|
||||
} else {
|
||||
textParts.add("[文件: " + fileName + " 下载失败]");
|
||||
@ -523,12 +550,18 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
case 5 -> {
|
||||
// Video
|
||||
if (mediaDownloadEnabled) {
|
||||
String path = downloadMediaItem(item, "video_item", "video.mp4", mediaDir);
|
||||
if (path != null) {
|
||||
InboundMediaDownloader.DownloadedMedia dl =
|
||||
downloadMediaItem(item, "video_item", null, inboundConvId);
|
||||
if (dl != null) {
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("video");
|
||||
part.setPath(path);
|
||||
part.setContentType("video/*");
|
||||
part.setPath(dl.localPath().toString());
|
||||
part.setStoredName(dl.storedName());
|
||||
part.setFileUrl(dl.fileUrl());
|
||||
part.setMediaId(dl.localPath().toString());
|
||||
part.setFileName(dl.fileName());
|
||||
part.setContentType(dl.isVideo() ? dl.contentType() : "video/mp4");
|
||||
part.setFileSize(dl.fileSize());
|
||||
contentParts.add(part);
|
||||
} else {
|
||||
// 尝试构建 CDN URL
|
||||
@ -604,42 +637,44 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
|
||||
// ==================== 媒体下载 ====================
|
||||
|
||||
/**
|
||||
* Download an inbound media item via the shared media pipeline (retry +
|
||||
* backoff, magic-byte type detection, dedup-named persistence). The iLink
|
||||
* AES key extraction stays here because it is protocol-specific; the
|
||||
* decrypted bytes are handed to {@link InboundMediaDownloader}.
|
||||
*
|
||||
* @return the stored, type-detected file, or {@code null} on failure
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private String downloadMediaItem(Map<String, Object> item, String itemKey, String filenameHint, String mediaDir) {
|
||||
try {
|
||||
Map<String, Object> mediaItem = (Map<String, Object>) item.getOrDefault(itemKey, Map.of());
|
||||
Map<String, Object> media = (Map<String, Object>) mediaItem.getOrDefault("media", Map.of());
|
||||
String encryptQueryParam = getStr(media, "encrypt_query_param");
|
||||
String aesKey;
|
||||
private InboundMediaDownloader.DownloadedMedia downloadMediaItem(
|
||||
Map<String, Object> item, String itemKey, String filenameHint, String conversationId) {
|
||||
Map<String, Object> mediaItem = (Map<String, Object>) item.getOrDefault(itemKey, Map.of());
|
||||
Map<String, Object> media = (Map<String, Object>) mediaItem.getOrDefault("media", Map.of());
|
||||
String encryptQueryParam = getStr(media, "encrypt_query_param");
|
||||
|
||||
// image_item 有顶级 aeskey (hex)
|
||||
String aeskeyHex = getStr(mediaItem, "aeskey");
|
||||
if (!aeskeyHex.isBlank()) {
|
||||
aesKey = Base64.getEncoder().encodeToString(hexToBytes(aeskeyHex));
|
||||
} else {
|
||||
aesKey = getStr(media, "aes_key");
|
||||
}
|
||||
// image_item carries a top-level hex aeskey; other items use media.aes_key
|
||||
final String aesKey;
|
||||
String aeskeyHex = getStr(mediaItem, "aeskey");
|
||||
if (!aeskeyHex.isBlank()) {
|
||||
aesKey = Base64.getEncoder().encodeToString(hexToBytes(aeskeyHex));
|
||||
} else {
|
||||
aesKey = getStr(media, "aes_key");
|
||||
}
|
||||
|
||||
if (encryptQueryParam.isBlank()) {
|
||||
log.warn("[weixin] No encrypt_query_param for media download");
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] data = client.downloadMedia("", aesKey, encryptQueryParam);
|
||||
|
||||
// 保存到本地
|
||||
Path dir = Path.of(mediaDir);
|
||||
Files.createDirectories(dir);
|
||||
String safeFilename = filenameHint.replaceAll("[^a-zA-Z0-9._-]", "");
|
||||
if (safeFilename.isBlank()) safeFilename = "media";
|
||||
String urlHash = md5Short(encryptQueryParam);
|
||||
Path filePath = dir.resolve("weixin_" + urlHash + "_" + safeFilename);
|
||||
Files.write(filePath, data);
|
||||
return filePath.toString();
|
||||
} catch (Exception e) {
|
||||
log.error("[weixin] Media download failed: {}", e.getMessage(), e);
|
||||
if (encryptQueryParam.isBlank()) {
|
||||
log.warn("[weixin] No encrypt_query_param for media download");
|
||||
return null;
|
||||
}
|
||||
|
||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
||||
return InboundMediaDownloader.download(
|
||||
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
||||
filenameHint,
|
||||
uploadDir,
|
||||
"weixin",
|
||||
encryptQueryParam,
|
||||
storedName -> "/api/v1/chat/files/" + conversationId + "/" + storedName)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// ==================== 发送消息 ====================
|
||||
@ -1001,20 +1036,6 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
return val != null ? val.toString() : "";
|
||||
}
|
||||
|
||||
private static String md5Short(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(input.getBytes());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
sb.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(input.hashCode());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] hexToBytes(String hex) {
|
||||
int len = hex.length();
|
||||
byte[] data = new byte[len / 2];
|
||||
|
||||
@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.cron.delivery.CronJobCompletedEvent;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
@ -178,6 +179,20 @@ public class CronJobLifecycleService {
|
||||
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
|
||||
String userMessage, AssistantMessage result,
|
||||
String conversationId, boolean silent) {
|
||||
finishRunAndPublish(job, run, userMessage, result, conversationId, silent, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param chatResult optional usage attribution from the LLM path; pass
|
||||
* {@code null} for non-LLM paths (e.g. reminder
|
||||
* direct-push) so the assistant row is persisted with
|
||||
* zero token counts and null runtime model attribution.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
|
||||
String userMessage, AssistantMessage result,
|
||||
String conversationId, boolean silent,
|
||||
AgentService.ChatResult chatResult) {
|
||||
String convId = conversationId != null ? conversationId : run.getConversationId();
|
||||
String text = result != null && result.getText() != null ? result.getText() : "";
|
||||
|
||||
@ -197,7 +212,13 @@ public class CronJobLifecycleService {
|
||||
return;
|
||||
}
|
||||
|
||||
conversationService.saveMessage(convId, "assistant", text);
|
||||
if (chatResult != null) {
|
||||
conversationService.saveMessage(convId, "assistant", text, null, "completed",
|
||||
chatResult.promptTokens(), chatResult.completionTokens(),
|
||||
chatResult.runtimeModel(), chatResult.runtimeProvider());
|
||||
} else {
|
||||
conversationService.saveMessage(convId, "assistant", text);
|
||||
}
|
||||
|
||||
// Memory pipeline (existing behavior preserved — was inline in the
|
||||
// old executeJob; now lives behind the same publisher used by the
|
||||
|
||||
@ -138,10 +138,12 @@ public class CronJobRunner {
|
||||
|
||||
// No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold
|
||||
// any DB connection during this call.
|
||||
AgentService.ChatResult chatResult;
|
||||
AssistantMessage result;
|
||||
try {
|
||||
ChatOrigin origin = originFactory.from(job, conversationId);
|
||||
result = runAgent(job, userMessage, origin, conversationId);
|
||||
chatResult = runAgent(job, userMessage, origin, conversationId);
|
||||
result = new AssistantMessage(chatResult.content());
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
try {
|
||||
@ -156,12 +158,12 @@ public class CronJobRunner {
|
||||
|
||||
// Explicit no-op: the agent answered with the silent sentinel,
|
||||
// meaning there is nothing to deliver or report for this run.
|
||||
boolean silent = result != null && result.getText() != null
|
||||
boolean silent = result.getText() != null
|
||||
&& CRON_SILENT_MARKER.equals(result.getText().trim());
|
||||
|
||||
// T2 — short tx
|
||||
try {
|
||||
lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId, silent);
|
||||
lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId, silent, chatResult);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
try {
|
||||
@ -261,13 +263,14 @@ public class CronJobRunner {
|
||||
* Runs the agent with the scheduled-job {@link ChatOrigin} and the
|
||||
* execution-context prompt assembled by {@link #buildCronPrompt}.
|
||||
*/
|
||||
private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin,
|
||||
String conversationId) {
|
||||
private AgentService.ChatResult runAgent(CronJobEntity job, String userMessage, ChatOrigin origin,
|
||||
String conversationId) {
|
||||
String prompt = buildCronPrompt(userMessage, origin);
|
||||
String text = "agent".equals(job.getTaskType())
|
||||
? agentService.execute(job.getAgentId(), prompt, conversationId, origin)
|
||||
: agentService.chat(job.getAgentId(), prompt, conversationId, origin);
|
||||
return new AssistantMessage(text != null ? text : "");
|
||||
// execute() and chat() both ultimately route through the agent's
|
||||
// StateGraph; chatWithUsage captures token + runtime model attribution
|
||||
// for either path. Plan-Execute agents stream via the same
|
||||
// chatStructuredStream the helper consumes.
|
||||
return agentService.chatWithUsage(job.getAgentId(), prompt, conversationId, origin);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,10 +7,9 @@ import org.springframework.stereotype.Component;
|
||||
/**
|
||||
* Configuration knobs for the persistent-goal subsystem.
|
||||
*
|
||||
* <p>{@link #enabled} is the master gate: when {@code false} (PR1-4 default)
|
||||
* the StateGraph wiring stays inactive and {@code findActiveByConversation}
|
||||
* still works for tests, but no graph node touches the table. PR5 flips it
|
||||
* to {@code true}.
|
||||
* <p>{@link #enabled} is the master gate: when {@code false} the StateGraph
|
||||
* wiring stays inactive (no graph node touches the table), while
|
||||
* {@code findActiveByConversation} still works for tests.
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ -20,12 +19,26 @@ public class GoalProperties {
|
||||
/**
|
||||
* Master switch — when off, the graph never invokes GoalEvaluationNode
|
||||
* (the conditional edge sees no active goal, so the node is unreachable).
|
||||
* Defaults to true now that the full PR1-5 chain is in place; operators
|
||||
* who want to disable goal evaluation can override via
|
||||
* Operators who want to disable goal evaluation entirely can override via
|
||||
* {@code mateclaw.goal.enabled=false} in application.yml.
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Create-time default for a goal's {@code autoFollowupEnabled} when the
|
||||
* caller leaves it unspecified (null). Explicit true/false in the request
|
||||
* is never overridden by this.
|
||||
*/
|
||||
private boolean defaultAutoFollowup = true;
|
||||
|
||||
/**
|
||||
* Runtime hard gate for auto-followup. When false, no goal injects a
|
||||
* follow-up regardless of its per-goal {@code autoFollowupEnabled} flag —
|
||||
* the operator's kill switch for the self-continuation loop that takes
|
||||
* effect immediately, even for goals created with the flag on.
|
||||
*/
|
||||
private boolean allowAutoFollowup = true;
|
||||
|
||||
/** Default turn budget when the user doesn't override. */
|
||||
private int defaultTurnBudget = 20;
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import vip.mate.exception.MateClawException;
|
||||
import vip.mate.goal.model.GoalCreateRequest;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEventEntity;
|
||||
import vip.mate.goal.model.GoalResponse;
|
||||
import vip.mate.goal.model.GoalUpdateRequest;
|
||||
import vip.mate.goal.service.GoalService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
@ -51,7 +52,7 @@ public class GoalController {
|
||||
|
||||
@Operation(summary = "Create a persistent goal for a conversation")
|
||||
@PostMapping
|
||||
public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) {
|
||||
public R<GoalResponse> create(@RequestBody GoalCreateRequest req, Authentication auth) {
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(req.getConversationId(), username);
|
||||
// Derive agentId/workspaceId from the conversation itself so the
|
||||
@ -71,22 +72,22 @@ public class GoalController {
|
||||
req.setAgentId(conv.getAgentId());
|
||||
req.setWorkspaceId(conv.getWorkspaceId() != null ? conv.getWorkspaceId() : 1L);
|
||||
GoalEntity g = goalService.create(req, username);
|
||||
return R.ok(g);
|
||||
return R.ok(goalService.toResponse(g));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get the active goal bound to a conversation (or null)")
|
||||
@GetMapping("/by-conversation/{conversationId}")
|
||||
public R<GoalEntity> findActive(@PathVariable String conversationId, Authentication auth) {
|
||||
public R<GoalResponse> findActive(@PathVariable String conversationId, Authentication auth) {
|
||||
requireOwner(conversationId, currentUsername(auth));
|
||||
return R.ok(goalService.findActiveByConversation(conversationId));
|
||||
return R.ok(goalService.toResponse(goalService.findActiveByConversation(conversationId)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get goal detail by id")
|
||||
@GetMapping("/{id}")
|
||||
public R<GoalEntity> get(@PathVariable Long id, Authentication auth) {
|
||||
public R<GoalResponse> get(@PathVariable Long id, Authentication auth) {
|
||||
GoalEntity g = goalService.getById(id);
|
||||
requireOwner(g.getConversationId(), currentUsername(auth));
|
||||
return R.ok(g);
|
||||
return R.ok(goalService.toResponse(g));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get the event timeline for a goal")
|
||||
@ -101,61 +102,61 @@ public class GoalController {
|
||||
|
||||
@Operation(summary = "List goals (optionally filtered by status)")
|
||||
@GetMapping
|
||||
public R<List<GoalEntity>> list(@RequestParam(required = false) String status,
|
||||
public R<List<GoalResponse>> list(@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
Authentication auth) {
|
||||
// List is owner-scoped — only your own goals are visible.
|
||||
return R.ok(goalService.list(status, currentUsername(auth), limit));
|
||||
return R.ok(goalService.toResponseList(goalService.list(status, currentUsername(auth), limit)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Sparse update of a non-terminal goal")
|
||||
@PatchMapping("/{id}")
|
||||
public R<GoalEntity> update(@PathVariable Long id,
|
||||
public R<GoalResponse> update(@PathVariable Long id,
|
||||
@RequestBody GoalUpdateRequest req,
|
||||
Authentication auth) {
|
||||
GoalEntity g = goalService.getById(id);
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(g.getConversationId(), username);
|
||||
return R.ok(goalService.update(id, req, username));
|
||||
return R.ok(goalService.toResponse(goalService.update(id, req, username)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Pause an active goal")
|
||||
@PostMapping("/{id}/pause")
|
||||
public R<GoalEntity> pause(@PathVariable Long id, Authentication auth) {
|
||||
public R<GoalResponse> pause(@PathVariable Long id, Authentication auth) {
|
||||
GoalEntity g = goalService.getById(id);
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(g.getConversationId(), username);
|
||||
return R.ok(goalService.pause(id, username));
|
||||
return R.ok(goalService.toResponse(goalService.pause(id, username)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Resume a paused goal")
|
||||
@PostMapping("/{id}/resume")
|
||||
public R<GoalEntity> resume(@PathVariable Long id, Authentication auth) {
|
||||
public R<GoalResponse> resume(@PathVariable Long id, Authentication auth) {
|
||||
GoalEntity g = goalService.getById(id);
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(g.getConversationId(), username);
|
||||
return R.ok(goalService.resume(id, username));
|
||||
return R.ok(goalService.toResponse(goalService.resume(id, username)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Abandon a goal (terminal)")
|
||||
@PostMapping("/{id}/abandon")
|
||||
public R<GoalEntity> abandon(@PathVariable Long id, Authentication auth) {
|
||||
public R<GoalResponse> abandon(@PathVariable Long id, Authentication auth) {
|
||||
GoalEntity g = goalService.getById(id);
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(g.getConversationId(), username);
|
||||
return R.ok(goalService.abandon(id, username));
|
||||
return R.ok(goalService.toResponse(goalService.abandon(id, username)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Append a sub-criterion to an active goal")
|
||||
@PostMapping("/{id}/criteria")
|
||||
public R<GoalEntity> addCriterion(@PathVariable Long id,
|
||||
public R<GoalResponse> addCriterion(@PathVariable Long id,
|
||||
@RequestBody Map<String, String> body,
|
||||
Authentication auth) {
|
||||
GoalEntity g = goalService.getById(id);
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(g.getConversationId(), username);
|
||||
String criterion = body != null ? body.get("criterion") : null;
|
||||
return R.ok(goalService.appendCriterion(id, criterion, username));
|
||||
return R.ok(goalService.toResponse(goalService.appendCriterion(id, criterion, username)));
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Evaluator output for the <b>verdict</b> round — applied once a goal's
|
||||
* checklist already exists.
|
||||
*
|
||||
* <p>The evaluator only takes a position on existing criteria by id; it does
|
||||
* not re-emit the criterion text. The service merges each {@link CriterionVerdict}
|
||||
* into the persistent {@code List<GoalCriterion>} by id (text and untouched
|
||||
* criteria are preserved), then derives completion from "all passed".
|
||||
*
|
||||
* <p>This is a per-round delta — never the full outward-facing checklist.
|
||||
* Outward payloads always carry the full {@code GoalResponse.criteria} array.
|
||||
*/
|
||||
public record GoalChecklistVerdict(
|
||||
List<CriterionVerdict> criterionVerdicts,
|
||||
String summary) {
|
||||
|
||||
/** Per-criterion delta: latest passed state + evidence, keyed by id. */
|
||||
public record CriterionVerdict(String id, boolean passed, String evidence) {
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@ package vip.mate.goal.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/v1/goals}.
|
||||
*
|
||||
@ -30,4 +32,12 @@ public class GoalCreateRequest {
|
||||
private Integer llmCallBudget;
|
||||
private Boolean autoFollowupEnabled;
|
||||
private Integer followupCooldownSeconds;
|
||||
|
||||
/**
|
||||
* Optional initial checklist. Callers supply only {@code text} per item;
|
||||
* the service normalizes ids ({@code C1..Cn}), forces {@code passed=false}
|
||||
* and clears {@code evidence} on create. An empty/omitted list defers to
|
||||
* first-evaluation bootstrap.
|
||||
*/
|
||||
private List<GoalCriterion> criteria;
|
||||
}
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared (de)serialization and merge helpers for a goal's checklist stored
|
||||
* as JSON text in {@code mate_agent_goal.criteria}.
|
||||
*
|
||||
* <p>Centralizes the String JSON ↔ {@code List<GoalCriterion>} boundary so the
|
||||
* evaluator, service and node never reimplement parsing. Parse failures fail
|
||||
* soft to an empty list (logged) rather than throwing — a corrupt column must
|
||||
* never break a chat turn or an API response.
|
||||
*/
|
||||
public final class GoalCriteriaCodec {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GoalCriteriaCodec.class);
|
||||
private static final TypeReference<List<GoalCriterion>> LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private GoalCriteriaCodec() {
|
||||
}
|
||||
|
||||
/** Parse the JSON column into a mutable list; empty list on null/blank/corrupt. */
|
||||
public static List<GoalCriterion> parse(String json, ObjectMapper mapper) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
List<GoalCriterion> parsed = mapper.readValue(json, LIST_TYPE);
|
||||
return parsed != null ? parsed : new ArrayList<>();
|
||||
} catch (Exception e) {
|
||||
log.warn("[GoalCriteria] failed to parse criteria JSON, treating as empty: {}", e.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a checklist to JSON text; {@code null} for a null list. */
|
||||
public static String serialize(List<GoalCriterion> criteria, ObjectMapper mapper) {
|
||||
if (criteria == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return mapper.writeValueAsString(criteria);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[GoalCriteria] failed to serialize criteria, storing null: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a per-round verdict delta into the full checklist by id. Criteria
|
||||
* absent from the delta are preserved unchanged; the criterion text is
|
||||
* always kept from the existing item (the verdict never carries text).
|
||||
*/
|
||||
public static List<GoalCriterion> merge(List<GoalCriterion> existing,
|
||||
List<GoalChecklistVerdict.CriterionVerdict> verdicts) {
|
||||
if (existing == null || existing.isEmpty()) {
|
||||
return existing == null ? new ArrayList<>() : existing;
|
||||
}
|
||||
Map<String, GoalChecklistVerdict.CriterionVerdict> byId = new LinkedHashMap<>();
|
||||
if (verdicts != null) {
|
||||
for (GoalChecklistVerdict.CriterionVerdict v : verdicts) {
|
||||
if (v != null && v.id() != null) {
|
||||
byId.put(v.id(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<GoalCriterion> merged = new ArrayList<>(existing.size());
|
||||
for (GoalCriterion c : existing) {
|
||||
GoalChecklistVerdict.CriterionVerdict v = byId.get(c.id());
|
||||
merged.add(v == null
|
||||
? c
|
||||
: new GoalCriterion(c.id(), c.text(), v.passed(),
|
||||
v.evidence() != null ? v.evidence() : ""));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** True only when the list is non-empty and every criterion is passed. */
|
||||
public static boolean allPassed(List<GoalCriterion> criteria) {
|
||||
return criteria != null && !criteria.isEmpty()
|
||||
&& criteria.stream().allMatch(GoalCriterion::passed);
|
||||
}
|
||||
|
||||
/** Criteria not yet passed (used for the continuation prompt + gap text). */
|
||||
public static List<GoalCriterion> remaining(List<GoalCriterion> criteria) {
|
||||
if (criteria == null) {
|
||||
return List.of();
|
||||
}
|
||||
return criteria.stream().filter(c -> !c.passed()).toList();
|
||||
}
|
||||
|
||||
/** Reassign stable ids {@code C1..Cn} in list order. */
|
||||
public static List<GoalCriterion> reindex(List<GoalCriterion> criteria) {
|
||||
List<GoalCriterion> out = new ArrayList<>(criteria.size());
|
||||
int n = 1;
|
||||
for (GoalCriterion c : criteria) {
|
||||
out.add(new GoalCriterion("C" + n, c.text(), c.passed(), c.evidence() == null ? "" : c.evidence()));
|
||||
n++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Evaluator output for the <b>bootstrap</b> round — the first evaluation of a
|
||||
* goal that has no criteria yet.
|
||||
*
|
||||
* <p>When {@code mate_agent_goal.criteria} is empty there is nothing to score
|
||||
* by id, so the evaluator instead decomposes the goal (title / description /
|
||||
* exit criteria) into a full checklist with text. The service persists this
|
||||
* as the goal's initial criteria (all {@code passed=false}); completion is
|
||||
* not judged on the bootstrap round.
|
||||
*
|
||||
* <p>Distinct from {@link GoalChecklistVerdict}, which is the per-round delta
|
||||
* used once the checklist already exists.
|
||||
*/
|
||||
public record GoalCriteriaDraft(List<GoalCriterion> criteria) {
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
/**
|
||||
* One checkable item of a goal's exit checklist — the persistent unit.
|
||||
*
|
||||
* <p>Stored as part of the JSON array in {@code mate_agent_goal.criteria}
|
||||
* and surfaced to clients as an element of {@code GoalResponse.criteria}.
|
||||
* Completion of a goal is derived from "every criterion passed" rather than
|
||||
* a fuzzy completion score.
|
||||
*
|
||||
* @param id stable identifier ({@code C1}, {@code C2}, ...), assigned
|
||||
* by the service on create/append; callers never mint ids
|
||||
* @param text the criterion statement (human + LLM readable)
|
||||
* @param passed whether the evaluator has judged this criterion satisfied
|
||||
* @param evidence concrete justification for {@code passed} (an output line,
|
||||
* a file excerpt, a command result); empty until evaluated
|
||||
*/
|
||||
public record GoalCriterion(String id, String text, boolean passed, String evidence) {
|
||||
}
|
||||
@ -90,6 +90,17 @@ public class GoalEntity {
|
||||
@TableField(value = "completion_score", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Double completionScore;
|
||||
|
||||
/**
|
||||
* Checkable exit checklist as a JSON array of {@link GoalCriterion}.
|
||||
* Completion is derived from "all criteria passed". Nullable: a missing
|
||||
* list bootstraps on first evaluation. ALWAYS strategy so clearing /
|
||||
* empty-list writes are persisted. Serialized as text; the service layer
|
||||
* maps to/from {@code List<GoalCriterion>} and exposes the parsed array
|
||||
* to clients via {@code GoalResponse.criteria}.
|
||||
*/
|
||||
@TableField(value = "criteria", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String criteria;
|
||||
|
||||
@TableField(value = "last_evaluation_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime lastEvaluationAt;
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -8,15 +9,24 @@ import java.util.Map;
|
||||
* {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to
|
||||
* {@code GoalService.recordEvaluation}.
|
||||
*
|
||||
* <p>Defined in PR1 so the service-layer signature is stable; the actual
|
||||
* evaluator implementation lands in PR2.
|
||||
*
|
||||
* <p>{@link #completed} means "evaluator judged this turn satisfies all
|
||||
* exit criteria". It does not mean "graph FINISH_REASON should change" —
|
||||
* goal status and graph FinishReason are independent (RFC 48 §3.1 v2).
|
||||
* <p>{@link #completed} means "evaluator judged this turn satisfies every
|
||||
* exit criterion". It does not mean "graph FINISH_REASON should change" —
|
||||
* goal status and graph FinishReason are independent.
|
||||
*
|
||||
* <p>{@link #llmCallsConsumed} is the evaluator-side delta only; the
|
||||
* agent-side delta is read from graph state by the node itself.
|
||||
*
|
||||
* <p>{@link #criterionVerdicts} and {@link #bootstrapCriteria} are mutually
|
||||
* exclusive carriers for the checklist:
|
||||
* <ul>
|
||||
* <li><b>verdict round</b> (checklist already exists): {@code criterionVerdicts}
|
||||
* holds the per-criterion delta (by id), {@code bootstrapCriteria} is null.</li>
|
||||
* <li><b>bootstrap round</b> (no criteria yet): {@code bootstrapCriteria}
|
||||
* holds the freshly decomposed full checklist, {@code criterionVerdicts}
|
||||
* is empty.</li>
|
||||
* </ul>
|
||||
* Neither is the outward-facing full list — clients always receive the merged
|
||||
* checklist via {@code GoalResponse.criteria}.
|
||||
*/
|
||||
public record GoalEvaluationResult(
|
||||
double score,
|
||||
@ -25,19 +35,34 @@ public record GoalEvaluationResult(
|
||||
boolean completed,
|
||||
String evaluatorModel,
|
||||
int llmCallsConsumed,
|
||||
long latencyMs) {
|
||||
long latencyMs,
|
||||
List<GoalChecklistVerdict.CriterionVerdict> criterionVerdicts,
|
||||
List<GoalCriterion> bootstrapCriteria) {
|
||||
|
||||
public static final String DECISION_COMPLETED = "completed";
|
||||
public static final String DECISION_CONTINUE = "continue";
|
||||
public static final String DECISION_FALLBACK = "fallback";
|
||||
|
||||
/** Failure fallback used when the evaluator LLM call errors out.
|
||||
* Does NOT charge eval_llm_calls_used. */
|
||||
/** Failure fallback for the "no call was made" cases (no goal, empty
|
||||
* answer, no model). Does NOT charge eval_llm_calls_used. */
|
||||
public static GoalEvaluationResult fallback(String reason) {
|
||||
return new GoalEvaluationResult(
|
||||
0.0, "evaluator unavailable: " + reason,
|
||||
DECISION_FALLBACK, false,
|
||||
"", 0, 0L);
|
||||
"", 0, 0L,
|
||||
List.of(), null);
|
||||
}
|
||||
|
||||
/** Failure fallback for cases where the evaluator LLM call already
|
||||
* succeeded but its output was unusable (empty / unparseable). The call
|
||||
* was really spent, so it charges {@code llmCallsConsumed = 1} and
|
||||
* records the model + latency for accurate budget accounting. */
|
||||
public static GoalEvaluationResult fallbackAfterCall(String reason, String model, long latencyMs) {
|
||||
return new GoalEvaluationResult(
|
||||
0.0, "evaluator unavailable: " + reason,
|
||||
DECISION_FALLBACK, false,
|
||||
model == null ? "" : model, 1, latencyMs,
|
||||
List.of(), null);
|
||||
}
|
||||
|
||||
public Map<String, Object> toMap() {
|
||||
@ -49,6 +74,9 @@ public record GoalEvaluationResult(
|
||||
m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel);
|
||||
m.put("llmCallsConsumed", llmCallsConsumed);
|
||||
m.put("latencyMs", latencyMs);
|
||||
// Per-round delta, for debugging/detail only. UI progress is driven by
|
||||
// the full GoalResponse.criteria array, never reconstructed from this.
|
||||
m.put("criterionVerdicts", criterionVerdicts == null ? List.of() : criterionVerdicts);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Outward-facing shape of a goal. Identical to {@link GoalEntity} except the
|
||||
* checklist is the parsed {@code List<GoalCriterion>} array rather than the
|
||||
* raw JSON String stored in the column — so REST responses and SSE payloads
|
||||
* always carry {@code criteria} as an array, never a string.
|
||||
*
|
||||
* <p>{@code criteria} is never null on the wire: a missing / unparseable
|
||||
* column maps to an empty list.
|
||||
*/
|
||||
@Data
|
||||
public class GoalResponse {
|
||||
|
||||
private Long id;
|
||||
private String conversationId;
|
||||
private Long agentId;
|
||||
private Long workspaceId;
|
||||
private String createdBy;
|
||||
|
||||
private String title;
|
||||
private String description;
|
||||
private String exitCriteria;
|
||||
private String successCheckPrompt;
|
||||
|
||||
private GoalStatus status;
|
||||
|
||||
private Integer turnBudget;
|
||||
private Integer turnsUsed;
|
||||
private Integer llmCallBudget;
|
||||
private Integer agentLlmCallsUsed;
|
||||
private Integer evalLlmCallsUsed;
|
||||
private int totalLlmCallsUsed;
|
||||
|
||||
private String progressSummary;
|
||||
private Double completionScore;
|
||||
private LocalDateTime lastEvaluationAt;
|
||||
|
||||
private Boolean autoFollowupEnabled;
|
||||
private Integer followupCooldownSeconds;
|
||||
private LocalDateTime lastFollowupAt;
|
||||
|
||||
private Integer version;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** Parsed checklist; empty (never null) when the column is null/unparseable. */
|
||||
private List<GoalCriterion> criteria;
|
||||
}
|
||||
@ -42,7 +42,7 @@ public enum GoalStatus {
|
||||
* the DB stores via {@link EnumValue}. Without this, Jackson defaults to
|
||||
* {@link #name()} (uppercase) and the frontend's
|
||||
* {@code status: 'active' | 'paused' | ...} TS literal types reject
|
||||
* every payload — UI bug observed during PR4 manual QA.
|
||||
* every payload.
|
||||
*/
|
||||
@JsonValue
|
||||
public String getValue() {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
@ -10,9 +9,17 @@ 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.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.evaluation.EvaluationRequest;
|
||||
import org.springframework.ai.evaluation.EvaluationResponse;
|
||||
import org.springframework.ai.evaluation.Evaluator;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalChecklistVerdict;
|
||||
import vip.mate.goal.model.GoalCriteriaCodec;
|
||||
import vip.mate.goal.model.GoalCriteriaDraft;
|
||||
import vip.mate.goal.model.GoalCriterion;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.llm.chatmodel.ProviderChatModelFactory;
|
||||
@ -20,50 +27,52 @@ import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Evaluates whether the assistant's latest reply satisfies a goal's exit
|
||||
* criteria. Drives the persistent-goal completion path (the
|
||||
* "auto-followup until score hits 1.0" loop), so it sits on the hot path
|
||||
* of every chat turn that has an active goal.
|
||||
* checklist, and bootstraps that checklist on first run. Sits on the hot
|
||||
* path of every chat turn that has an active goal.
|
||||
*
|
||||
* <p>Returns a deterministic {@link GoalEvaluationResult#fallback fallback}
|
||||
* when the LLM call is unavailable, errors out, or returns un-parseable
|
||||
* JSON — the {@code GoalEvaluationNode} treats fallback as "skip
|
||||
* bookkeeping deltas, no event log, stay safe". This degrades cleanly
|
||||
* when the evaluator provider is misconfigured or transiently down.
|
||||
* <p>Two evaluation modes, chosen by whether the goal already has criteria:
|
||||
* <ul>
|
||||
* <li><b>Bootstrap</b> (no criteria yet): decompose the goal into a set of
|
||||
* verifiable criteria and return them as
|
||||
* {@link GoalEvaluationResult#bootstrapCriteria()}. Completion is not
|
||||
* judged on this round.</li>
|
||||
* <li><b>Verdict</b> (criteria exist): take a position on each existing
|
||||
* criterion by id (passed + concrete evidence) and return the delta as
|
||||
* {@link GoalEvaluationResult#criterionVerdicts()}. Completion is
|
||||
* derived from "all criteria passed" after the merge.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Model selection:
|
||||
* <ol>
|
||||
* <li>If {@code mateclaw.goal.evaluator-model} names an enabled model,
|
||||
* use it.</li>
|
||||
* <li>Otherwise fall back to {@link ModelConfigService#getDefaultModel()}
|
||||
* — convenient for dev, but operators are encouraged to pin a cheap
|
||||
* evaluator-only model in production since this fires on every turn.
|
||||
* </li>
|
||||
* </ol>
|
||||
* <p>Output is shaped by {@link BeanOutputConverter}, which injects a JSON
|
||||
* format instruction and parses the reply. On any failure (no model, empty
|
||||
* reply, unparseable output, provider error) a deterministic
|
||||
* {@link GoalEvaluationResult#fallback fallback} is returned so the node can
|
||||
* degrade cleanly.
|
||||
*
|
||||
* <p>Prompt is short and JSON-only: the evaluator returns one object with
|
||||
* {@code score} (0.0–1.0 fraction of criteria satisfied), {@code gap}
|
||||
* (plain-text description of what's missing), and {@code completed} (bool).
|
||||
* <p>Implements Spring AI's {@link Evaluator} for interface uniformity and
|
||||
* testability; the goal-aware overloads carry the context the generic SPI
|
||||
* request cannot.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class GoalEvaluationService {
|
||||
public class GoalEvaluationService implements Evaluator {
|
||||
|
||||
/**
|
||||
* Token budget for the evaluator response. Reasoning-mode models
|
||||
* (DeepSeek V4 Pro, Kimi for Coding, GLM-Z1, …) consume a chunk of
|
||||
* this budget on internal {@code <think>} content before emitting
|
||||
* the JSON answer; 400 was empirically too tight and produced
|
||||
* empty responses on every reasoning provider. 2000 leaves comfort
|
||||
* for ~1500 tokens of reasoning + the small JSON object we need.
|
||||
* Token budget for the evaluator response. Reasoning-mode models consume
|
||||
* a chunk of this on internal thinking before emitting JSON; 2000 leaves
|
||||
* comfort for the reasoning trace plus the small object we need.
|
||||
*/
|
||||
private static final int MAX_OUTPUT_TOKENS = 2000;
|
||||
private static final int MAX_CONVERSATION_CHARS = 6_000;
|
||||
private static final int MAX_TERMINAL_ANSWER_CHARS = 4_000;
|
||||
/** Skip-retry template — the goal node has its own try/catch, no need to double-retry. */
|
||||
private static final int MIN_BOOTSTRAP_CRITERIA = 1;
|
||||
private static final int MAX_BOOTSTRAP_CRITERIA = 8;
|
||||
/** Skip-retry template — the goal node has its own try/catch. */
|
||||
private static final RetryTemplate ONESHOT = RetryTemplate.builder().maxAttempts(1).build();
|
||||
|
||||
private final GoalProperties properties;
|
||||
@ -71,6 +80,11 @@ public class GoalEvaluationService {
|
||||
private final ProviderChatModelFactory chatModelFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final BeanOutputConverter<GoalCriteriaDraft> draftConverter =
|
||||
new BeanOutputConverter<>(GoalCriteriaDraft.class);
|
||||
private final BeanOutputConverter<GoalChecklistVerdict> verdictConverter =
|
||||
new BeanOutputConverter<>(GoalChecklistVerdict.class);
|
||||
|
||||
public GoalEvaluationService(GoalProperties properties,
|
||||
ModelConfigService modelConfigService,
|
||||
ProviderChatModelFactory chatModelFactory,
|
||||
@ -82,19 +96,12 @@ public class GoalEvaluationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one terminal answer against the goal's exit criteria.
|
||||
* Evaluate one terminal answer against the goal's checklist (or bootstrap
|
||||
* the checklist when none exists yet).
|
||||
*
|
||||
* <p>Returns a {@link GoalEvaluationResult} carrying the score, gap
|
||||
* description, decision, model id, and elapsed latency. The
|
||||
* {@code llmCallsConsumed} field is 1 on success (one evaluator
|
||||
* call) and 0 on fallback paths so the per-goal LLM-call budget
|
||||
* stays accurate.
|
||||
*
|
||||
* @param goal the active goal under evaluation; never {@code null}
|
||||
* @param recentMessages most-recent N messages from the parent conversation
|
||||
* for context; the node already trims by
|
||||
* {@link GoalProperties#getEvaluatorContextMessages()}
|
||||
* @param terminalAnswer the assistant's just-emitted final answer text
|
||||
* @param goal the active goal under evaluation; never {@code null}
|
||||
* @param recentMessages most-recent N messages for context (already trimmed)
|
||||
* @param terminalAnswer the assistant's just-emitted final answer text
|
||||
*/
|
||||
public GoalEvaluationResult evaluate(GoalEntity goal,
|
||||
List<? extends Message> recentMessages,
|
||||
@ -108,19 +115,24 @@ public class GoalEvaluationService {
|
||||
|
||||
ModelConfigEntity model = resolveEvaluatorModel();
|
||||
if (model == null) {
|
||||
log.warn("[GoalEvaluation] no evaluator model available (configured={}, default lookup empty)",
|
||||
log.warn("[GoalEvaluation] no evaluator model available (configured={})",
|
||||
properties.getEvaluatorModel());
|
||||
return GoalEvaluationResult.fallback("no_model");
|
||||
}
|
||||
|
||||
List<GoalCriterion> existing = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper);
|
||||
boolean bootstrap = existing.isEmpty();
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
ChatModel chatModel = chatModelFactory.buildFor(model, ONESHOT);
|
||||
String prompt = buildUserPrompt(goal, recentMessages, terminalAnswer);
|
||||
String format = bootstrap ? draftConverter.getFormat() : verdictConverter.getFormat();
|
||||
String userPrompt = buildUserPrompt(goal, existing, recentMessages, terminalAnswer, bootstrap)
|
||||
+ "\n\n" + format;
|
||||
|
||||
List<Message> messages = new ArrayList<>(2);
|
||||
messages.add(new SystemMessage(SYSTEM_PROMPT));
|
||||
messages.add(new UserMessage(prompt));
|
||||
messages.add(new SystemMessage(bootstrap ? BOOTSTRAP_SYSTEM_PROMPT : VERDICT_SYSTEM_PROMPT));
|
||||
messages.add(new UserMessage(userPrompt));
|
||||
|
||||
ChatOptions options = ChatOptions.builder()
|
||||
.temperature(0.1)
|
||||
@ -133,10 +145,13 @@ public class GoalEvaluationService {
|
||||
String body = extractText(response);
|
||||
if (body == null || body.isBlank()) {
|
||||
log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName());
|
||||
return GoalEvaluationResult.fallback("empty_response");
|
||||
// The call was really spent — bill it.
|
||||
return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed);
|
||||
}
|
||||
|
||||
return parseJson(body, model.getModelName(), elapsed);
|
||||
return bootstrap
|
||||
? parseBootstrap(body, model.getModelName(), elapsed)
|
||||
: parseVerdict(body, existing, model.getModelName(), elapsed);
|
||||
} catch (Throwable t) {
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
log.warn("[GoalEvaluation] evaluator call failed after {}ms: {}", elapsed, t.toString());
|
||||
@ -144,38 +159,89 @@ public class GoalEvaluationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Evaluator SPI ====================
|
||||
|
||||
/**
|
||||
* Generic SPI surface: judge whether {@code request.getResponseContent()}
|
||||
* satisfies the objective stated in {@code request.getUserText()}.
|
||||
*
|
||||
* <p>The objective is wrapped as a single checklist criterion so the call
|
||||
* runs in <b>verdict</b> mode (a real pass/fail judgement of the response),
|
||||
* not bootstrap mode. {@code isPass()} is true only when that criterion is
|
||||
* satisfied; detail rides in {@code metadata.criterionVerdicts}.
|
||||
*
|
||||
* <p>Goal-aware callers use the {@link #evaluate(GoalEntity, List, String)}
|
||||
* overload, which carries the full multi-criterion checklist context the
|
||||
* generic request cannot.
|
||||
*/
|
||||
@Override
|
||||
public EvaluationResponse evaluate(EvaluationRequest request) {
|
||||
String objective = request.getUserText() != null ? request.getUserText() : "";
|
||||
GoalEntity probe = new GoalEntity();
|
||||
probe.setTitle("Does the response satisfy the objective?");
|
||||
probe.setDescription(objective);
|
||||
// One criterion = the objective -> non-empty criteria -> verdict mode.
|
||||
probe.setCriteria(GoalCriteriaCodec.serialize(
|
||||
List.of(new GoalCriterion("C1", objective, false, "")), objectMapper));
|
||||
|
||||
GoalEvaluationResult r = evaluate(probe, List.of(), request.getResponseContent());
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("decision", r.decision());
|
||||
metadata.put("criterionVerdicts", r.criterionVerdicts());
|
||||
return new EvaluationResponse(r.completed(), (float) r.score(),
|
||||
r.gap() == null ? "" : r.gap(), metadata);
|
||||
}
|
||||
|
||||
// ==================== Internals ====================
|
||||
|
||||
private ModelConfigEntity resolveEvaluatorModel() {
|
||||
String name = properties.getEvaluatorModel();
|
||||
if (name != null && !name.isBlank()) {
|
||||
// resolveModel returns the default model when the named one
|
||||
// can't be found, which is exactly the desired "graceful
|
||||
// degradation" semantics for a misconfigured evaluator id.
|
||||
// resolveModel returns the default model when the named one can't
|
||||
// be found — the desired graceful-degradation semantics.
|
||||
return modelConfigService.resolveModel(name);
|
||||
}
|
||||
return modelConfigService.getDefaultModel();
|
||||
}
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"You are a goal-completion evaluator. You judge whether an AI "
|
||||
+ "assistant's latest reply satisfies a user's stated goal. "
|
||||
+ "Output exactly ONE JSON object with the keys score, gap, "
|
||||
+ "completed. No markdown, no commentary, no extra prose.";
|
||||
private static final String BOOTSTRAP_SYSTEM_PROMPT =
|
||||
"You decompose a user's goal into a short checklist of concrete, "
|
||||
+ "independently verifiable acceptance criteria. Each criterion "
|
||||
+ "must be checkable from observable evidence (an output, a file, "
|
||||
+ "a command result), not a vague aspiration. Output only the "
|
||||
+ "requested JSON.";
|
||||
|
||||
private static final String VERDICT_SYSTEM_PROMPT =
|
||||
"You judge, criterion by criterion, whether an AI assistant's latest "
|
||||
+ "reply satisfies a goal's checklist. For each criterion you MUST "
|
||||
+ "cite concrete evidence from the reply (an output line, a file "
|
||||
+ "excerpt, a command result). Do NOT accept generic phrases like "
|
||||
+ "'all requirements met'. If a criterion lacks specific evidence, "
|
||||
+ "mark it not passed. Output only the requested JSON.";
|
||||
|
||||
private String buildUserPrompt(GoalEntity goal,
|
||||
List<GoalCriterion> existing,
|
||||
List<? extends Message> recentMessages,
|
||||
String terminalAnswer) {
|
||||
String terminalAnswer,
|
||||
boolean bootstrap) {
|
||||
StringBuilder sb = new StringBuilder(2048);
|
||||
sb.append("Goal title: ").append(safe(goal.getTitle())).append('\n');
|
||||
if (goal.getDescription() != null && !goal.getDescription().isBlank()) {
|
||||
sb.append("Goal description: ").append(safe(goal.getDescription())).append('\n');
|
||||
}
|
||||
if (goal.getExitCriteria() != null && !goal.getExitCriteria().isBlank()) {
|
||||
sb.append("Exit criteria:\n").append(safe(goal.getExitCriteria())).append('\n');
|
||||
sb.append("Exit criteria (free text):\n").append(safe(goal.getExitCriteria())).append('\n');
|
||||
}
|
||||
sb.append('\n');
|
||||
|
||||
if (!bootstrap) {
|
||||
sb.append("Current checklist (judge each by id):\n");
|
||||
for (GoalCriterion c : existing) {
|
||||
sb.append("- ").append(c.id()).append(": ").append(c.text()).append('\n');
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
|
||||
if (recentMessages != null && !recentMessages.isEmpty()) {
|
||||
sb.append("Recent conversation (oldest first):\n");
|
||||
String convo = serializeMessages(recentMessages);
|
||||
@ -191,22 +257,117 @@ public class GoalEvaluationService {
|
||||
}
|
||||
sb.append("\nAssistant's latest final answer to evaluate:\n").append(answer).append('\n');
|
||||
|
||||
sb.append('\n')
|
||||
.append("Return exactly:\n")
|
||||
.append("{\n")
|
||||
.append(" \"score\": <number 0.0 to 1.0 — fraction of exit criteria satisfied>,\n")
|
||||
.append(" \"gap\": \"<short plain-text description of what's still missing; empty when score=1.0>\",\n")
|
||||
.append(" \"completed\": <true if every exit criterion is fully satisfied, else false>\n")
|
||||
.append("}");
|
||||
sb.append('\n');
|
||||
if (bootstrap) {
|
||||
sb.append("Produce between ").append(MIN_BOOTSTRAP_CRITERIA).append(" and ")
|
||||
.append(MAX_BOOTSTRAP_CRITERIA)
|
||||
.append(" criteria. Leave every 'passed' false and 'evidence' empty — "
|
||||
+ "this round only defines the checklist.");
|
||||
} else {
|
||||
sb.append("For every criterion above, return its id with passed=true ONLY when "
|
||||
+ "the reply shows concrete evidence; otherwise passed=false with a short "
|
||||
+ "note of what is missing.");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private GoalEvaluationResult parseBootstrap(String body, String modelName, long latencyMs) {
|
||||
try {
|
||||
GoalCriteriaDraft dto = draftConverter.convert(stripFences(body));
|
||||
if (dto == null || dto.criteria() == null || dto.criteria().isEmpty()) {
|
||||
return GoalEvaluationResult.fallbackAfterCall("bootstrap_empty", modelName, latencyMs);
|
||||
}
|
||||
List<GoalCriterion> normalized = new ArrayList<>();
|
||||
for (GoalCriterion c : dto.criteria()) {
|
||||
if (c != null && c.text() != null && !c.text().isBlank()) {
|
||||
normalized.add(new GoalCriterion("", c.text().trim(), false, ""));
|
||||
}
|
||||
// Hard cap regardless of what the model returned — the prompt
|
||||
// asks for <= MAX but a verbose model could exceed it.
|
||||
if (normalized.size() >= MAX_BOOTSTRAP_CRITERIA) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (normalized.isEmpty()) {
|
||||
return GoalEvaluationResult.fallbackAfterCall("bootstrap_empty", modelName, latencyMs);
|
||||
}
|
||||
normalized = GoalCriteriaCodec.reindex(normalized);
|
||||
// Bootstrap never judges completion: the checklist is freshly created.
|
||||
return new GoalEvaluationResult(
|
||||
0.0, "checklist created", GoalEvaluationResult.DECISION_CONTINUE, false,
|
||||
modelName != null ? modelName : "", 1, latencyMs,
|
||||
List.of(), normalized);
|
||||
} catch (Exception e) {
|
||||
log.warn("[GoalEvaluation] bootstrap parse failed: {}", e.getMessage());
|
||||
return GoalEvaluationResult.fallbackAfterCall("parse_failed", modelName, latencyMs);
|
||||
}
|
||||
}
|
||||
|
||||
private GoalEvaluationResult parseVerdict(String body,
|
||||
List<GoalCriterion> existing,
|
||||
String modelName,
|
||||
long latencyMs) {
|
||||
try {
|
||||
GoalChecklistVerdict verdict = verdictConverter.convert(stripFences(body));
|
||||
List<GoalChecklistVerdict.CriterionVerdict> deltas =
|
||||
verdict != null && verdict.criterionVerdicts() != null
|
||||
? verdict.criterionVerdicts() : List.of();
|
||||
List<GoalCriterion> merged = GoalCriteriaCodec.merge(existing, deltas);
|
||||
boolean completed = GoalCriteriaCodec.allPassed(merged);
|
||||
int total = merged.size();
|
||||
int passed = (int) merged.stream().filter(GoalCriterion::passed).count();
|
||||
double score = total == 0 ? 0.0 : (double) passed / total;
|
||||
String gap = completed ? "" : buildGap(GoalCriteriaCodec.remaining(merged));
|
||||
String decision = completed
|
||||
? GoalEvaluationResult.DECISION_COMPLETED
|
||||
: GoalEvaluationResult.DECISION_CONTINUE;
|
||||
return new GoalEvaluationResult(
|
||||
score, gap, decision, completed,
|
||||
modelName != null ? modelName : "", 1, latencyMs,
|
||||
deltas, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[GoalEvaluation] verdict parse failed: {}", e.getMessage());
|
||||
return GoalEvaluationResult.fallbackAfterCall("parse_failed", modelName, latencyMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildGap(List<GoalCriterion> remaining) {
|
||||
if (remaining.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("Still missing: ");
|
||||
for (int i = 0; i < remaining.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append("; ");
|
||||
}
|
||||
sb.append(remaining.get(i).text());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Strip ```json fences the model may add despite instructions. */
|
||||
private static String stripFences(String body) {
|
||||
String t = body.strip();
|
||||
if (t.startsWith("```")) {
|
||||
int nl = t.indexOf('\n');
|
||||
if (nl > 0) {
|
||||
t = t.substring(nl + 1);
|
||||
}
|
||||
if (t.endsWith("```")) {
|
||||
t = t.substring(0, t.length() - 3);
|
||||
}
|
||||
}
|
||||
return t.strip();
|
||||
}
|
||||
|
||||
private String serializeMessages(List<? extends Message> messages) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Message m : messages) {
|
||||
String role = m.getMessageType() != null ? m.getMessageType().getValue() : "msg";
|
||||
String text = m.getText();
|
||||
if (text == null) text = "";
|
||||
if (text == null) {
|
||||
text = "";
|
||||
}
|
||||
sb.append(role).append(": ").append(text.strip()).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
@ -222,11 +383,8 @@ public class GoalEvaluationService {
|
||||
if (text != null && !text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
// Fallback for reasoning models: some providers (DeepSeek-style
|
||||
// OpenAI-compatible streaming, MiMo) emit the entire output as
|
||||
// `reasoning_content` and leave the regular content field empty
|
||||
// when the token budget gets eaten by thinking. The JSON object
|
||||
// we want often appears at the tail of the reasoning trace.
|
||||
// Fallback for reasoning models that emit everything as reasoningContent
|
||||
// and leave the regular content empty; the JSON often tails the trace.
|
||||
var metadata = output.getMetadata();
|
||||
if (metadata != null) {
|
||||
Object rc = metadata.get("reasoningContent");
|
||||
@ -237,56 +395,6 @@ public class GoalEvaluationService {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the evaluator's JSON output. The model may wrap the object in
|
||||
* ```json fences despite the system prompt telling it not to, so we
|
||||
* locate the first {@code {...}} substring and parse that. Anything
|
||||
* else (non-numeric score, missing fields, malformed JSON) downgrades
|
||||
* to a fallback result rather than throwing.
|
||||
*/
|
||||
private GoalEvaluationResult parseJson(String body, String modelName, long latencyMs) {
|
||||
String trimmed = body.strip();
|
||||
int braceStart = trimmed.indexOf('{');
|
||||
int braceEnd = trimmed.lastIndexOf('}');
|
||||
if (braceStart < 0 || braceEnd <= braceStart) {
|
||||
log.warn("[GoalEvaluation] no JSON object in evaluator output: {}",
|
||||
trimmed.length() > 200 ? trimmed.substring(0, 200) + "..." : trimmed);
|
||||
return GoalEvaluationResult.fallback("parse_no_object");
|
||||
}
|
||||
String json = trimmed.substring(braceStart, braceEnd + 1);
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
JsonNode scoreNode = node.get("score");
|
||||
if (scoreNode == null || !scoreNode.isNumber()) {
|
||||
return GoalEvaluationResult.fallback("parse_missing_score");
|
||||
}
|
||||
double score = clamp01(scoreNode.asDouble());
|
||||
String gap = node.hasNonNull("gap") ? node.get("gap").asText("") : "";
|
||||
boolean completed = node.hasNonNull("completed") && node.get("completed").asBoolean(false);
|
||||
// Belt-and-braces: a perfect score implies completion; let the
|
||||
// node's >= 0.95 threshold handle the gray zone.
|
||||
if (score >= 1.0 - 1e-9) completed = true;
|
||||
String decision = completed
|
||||
? GoalEvaluationResult.DECISION_COMPLETED
|
||||
: GoalEvaluationResult.DECISION_CONTINUE;
|
||||
return new GoalEvaluationResult(
|
||||
score, gap, decision, completed,
|
||||
modelName != null ? modelName : "", 1, latencyMs);
|
||||
} catch (Exception e) {
|
||||
log.warn("[GoalEvaluation] JSON parse failed: {} — body={}",
|
||||
e.getMessage(),
|
||||
json.length() > 200 ? json.substring(0, 200) + "..." : json);
|
||||
return GoalEvaluationResult.fallback("parse_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static double clamp01(double v) {
|
||||
if (Double.isNaN(v)) return 0.0;
|
||||
if (v < 0.0) return 0.0;
|
||||
if (v > 1.0) return 1.0;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
@ -1,43 +1,61 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalCriteriaCodec;
|
||||
import vip.mate.goal.model.GoalCriterion;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decides whether to inject a follow-up user prompt for the next graph
|
||||
* pass. PR2 wires the plumbing; the actual "yes, continue" path defaults
|
||||
* to off until PR5 flips {@code mateclaw.goal.enabled=true} and operators
|
||||
* opt their goals in via {@code auto_followup_enabled}.
|
||||
* Decides whether to inject a follow-up user prompt for the next graph pass,
|
||||
* driving the autonomous "continue until the checklist is complete" loop.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class GoalFollowupService {
|
||||
|
||||
private final GoalProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GoalFollowupService(GoalProperties properties, ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the follow-up prompt to inject, or empty when no follow-up
|
||||
* should fire this turn. Conditions follow RFC 48 §3.10:
|
||||
* Build the follow-up prompt to inject, or empty when no follow-up should
|
||||
* fire this turn. Gating order:
|
||||
* <ol>
|
||||
* <li>{@code autoFollowupEnabled} is true.</li>
|
||||
* <li>Evaluator decision is "continue" with score < 0.95.</li>
|
||||
* <li>{@code allow-auto-followup} runtime hard gate (operator kill
|
||||
* switch; overrides per-goal flag).</li>
|
||||
* <li>Per-goal {@code autoFollowupEnabled}.</li>
|
||||
* <li>Evaluator decision is "continue" (not all criteria passed).</li>
|
||||
* <li>Cooldown since the last follow-up has elapsed.</li>
|
||||
* <li>turn_budget has at least one slot left after this turn.</li>
|
||||
* <li>(agent + eval) LLM calls below 90 % of llm_call_budget.</li>
|
||||
* <li>(agent + eval) LLM calls below 90% of llm_call_budget.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public Optional<String> maybeBuildFollowup(GoalEntity goal,
|
||||
GoalEvaluationResult result) {
|
||||
GoalEvaluationResult result) {
|
||||
if (goal == null || result == null) return Optional.empty();
|
||||
// Runtime hard gate first — overrides any per-goal flag.
|
||||
if (!properties.isAllowAutoFollowup()) return Optional.empty();
|
||||
if (!Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) return Optional.empty();
|
||||
// Completion is deterministic now: the evaluator sets decision=completed
|
||||
// only when every checklist criterion passed. Anything still "continue"
|
||||
// has remaining work regardless of the numeric score, so there is no
|
||||
// score threshold here — a 20/21 goal (score 0.95) must still follow up.
|
||||
if (!GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (result.score() >= 0.95) return Optional.empty();
|
||||
|
||||
// Cooldown — last_followup_at recorded by recordFollowupInjected().
|
||||
Integer cooldownSec = goal.getFollowupCooldownSeconds();
|
||||
@ -51,17 +69,39 @@ public class GoalFollowupService {
|
||||
|
||||
int turnsUsed = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
|
||||
int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE;
|
||||
// Leave at least one turn slot for the real user — refuse to burn
|
||||
// the final slot on an auto-followup that the user can't watch.
|
||||
// Leave at least one turn slot for the real user — refuse to burn the
|
||||
// final slot on an auto-followup the user can't watch.
|
||||
if (turnsUsed >= turnBudget - 1) return Optional.empty();
|
||||
|
||||
int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE;
|
||||
if (goal.totalLlmCallsUsed() >= (int) (callBudget * 0.9)) return Optional.empty();
|
||||
|
||||
return Optional.of(buildPrompt(goal, result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a concrete remaining-criteria list when the goal has a checklist;
|
||||
* fall back to the free-text gap otherwise. Both end with the same "take
|
||||
* the next concrete step" instruction.
|
||||
*/
|
||||
private String buildPrompt(GoalEntity goal, GoalEvaluationResult result) {
|
||||
List<GoalCriterion> all = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper);
|
||||
List<GoalCriterion> remaining = GoalCriteriaCodec.remaining(all);
|
||||
if (!remaining.isEmpty()) {
|
||||
int total = all.size();
|
||||
int passed = total - remaining.size();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Continue working toward the goal. ")
|
||||
.append(passed).append('/').append(total).append(" criteria passed. Remaining:\n");
|
||||
for (GoalCriterion c : remaining) {
|
||||
sb.append(" - ").append(c.text()).append('\n');
|
||||
}
|
||||
sb.append("Take the next concrete step on the remaining criteria.");
|
||||
return sb.toString();
|
||||
}
|
||||
String gap = result.gap();
|
||||
if (gap == null || gap.isBlank()) gap = "the goal is not yet complete.";
|
||||
String prompt = "Continue working on the goal. Still missing: " + gap
|
||||
return "Continue working on the goal. Still missing: " + gap
|
||||
+ "\nTake the next concrete step.";
|
||||
return Optional.of(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,13 +4,14 @@ import vip.mate.goal.model.GoalCreateRequest;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.goal.model.GoalEventEntity;
|
||||
import vip.mate.goal.model.GoalResponse;
|
||||
import vip.mate.goal.model.GoalUpdateRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Persistent goal service — CRUD, status transitions, and bookkeeping
|
||||
* called from {@code GoalEvaluationNode} (PR2).
|
||||
* called from {@code GoalEvaluationNode}.
|
||||
*
|
||||
* <p>Concurrency model: writes use a per-row {@code WHERE version=?}
|
||||
* compare-and-set. On conflict the service retries up to 3 times before
|
||||
@ -78,4 +79,17 @@ public interface GoalService {
|
||||
|
||||
/** Append a sub-criterion without restarting the goal. */
|
||||
GoalEntity appendCriterion(Long id, String criterion, String username);
|
||||
|
||||
// ==================== Response mapping ====================
|
||||
|
||||
/**
|
||||
* Map an entity to its outward-facing form: {@code criteria} becomes a
|
||||
* parsed {@code List<GoalCriterion>} array (empty when null/unparseable),
|
||||
* never the raw JSON String. Use at every REST return point and SSE
|
||||
* payload so clients never see the string form.
|
||||
*/
|
||||
GoalResponse toResponse(GoalEntity entity);
|
||||
|
||||
/** Convenience: {@link #toResponse} over a list. */
|
||||
List<GoalResponse> toResponseList(List<GoalEntity> entities);
|
||||
}
|
||||
|
||||
@ -14,7 +14,10 @@ import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalCreateRequest;
|
||||
import vip.mate.goal.model.GoalCriteriaCodec;
|
||||
import vip.mate.goal.model.GoalCriterion;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalResponse;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.goal.model.GoalEventEntity;
|
||||
import vip.mate.goal.model.GoalEventType;
|
||||
@ -112,9 +115,17 @@ public class GoalServiceImpl implements GoalService {
|
||||
? req.getLlmCallBudget() : properties.getDefaultLlmCallBudget());
|
||||
entity.setAgentLlmCallsUsed(0);
|
||||
entity.setEvalLlmCallsUsed(0);
|
||||
entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled()));
|
||||
// Three-state default: explicit true/false is honored; null falls
|
||||
// back to the configured create-time default.
|
||||
entity.setAutoFollowupEnabled(req.getAutoFollowupEnabled() != null
|
||||
? req.getAutoFollowupEnabled()
|
||||
: properties.isDefaultAutoFollowup());
|
||||
entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null
|
||||
? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds());
|
||||
// Normalize any caller-supplied checklist: assign C1..Cn, force
|
||||
// passed=false, clear evidence. Empty/omitted -> null column so the
|
||||
// first evaluation bootstraps the list.
|
||||
entity.setCriteria(serializeCriteria(normalizeInitialCriteria(req.getCriteria())));
|
||||
entity.setVersion(0);
|
||||
entity.setDeleted(0);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@ -278,6 +289,18 @@ public class GoalServiceImpl implements GoalService {
|
||||
w.set(GoalEntity::getCompletionScore, result.score())
|
||||
.set(GoalEntity::getProgressSummary, result.gap());
|
||||
}
|
||||
// Snapshot the checklist as fully satisfied. Idempotent for the
|
||||
// auto path (recordEvaluation already merged all-passed); required
|
||||
// for manual completion, which has no preceding verdict.
|
||||
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
|
||||
if (!existing.isEmpty()) {
|
||||
List<GoalCriterion> allPassed = existing.stream()
|
||||
.map(c -> c.passed() ? c : new GoalCriterion(c.id(), c.text(), true,
|
||||
c.evidence() == null || c.evidence().isBlank()
|
||||
? "marked complete" : c.evidence()))
|
||||
.toList();
|
||||
w.set(GoalEntity::getCriteria, GoalCriteriaCodec.serialize(allPassed, objectMapper));
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
@ -285,6 +308,7 @@ public class GoalServiceImpl implements GoalService {
|
||||
detail.put("finalScore", result != null ? result.score() : null);
|
||||
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
|
||||
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
|
||||
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
|
||||
writeEvent(id, GoalEventType.COMPLETED, null, detail);
|
||||
recordAudit("goal.completed", g, detail);
|
||||
|
||||
@ -344,7 +368,7 @@ public class GoalServiceImpl implements GoalService {
|
||||
int agentDelta = Math.max(0, agentLlmCallsDelta);
|
||||
int evalDelta = Math.max(0, evalLlmCallsDelta);
|
||||
|
||||
retryOptimistic(id, "recordEvaluation", fresh -> {
|
||||
GoalEntity g = retryOptimistic(id, "recordEvaluation", fresh -> {
|
||||
if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.setSql("turns_used = turns_used + 1")
|
||||
@ -354,6 +378,13 @@ public class GoalServiceImpl implements GoalService {
|
||||
if (result != null) {
|
||||
w.set(GoalEntity::getCompletionScore, result.score())
|
||||
.set(GoalEntity::getProgressSummary, result.gap());
|
||||
// Persist the checklist by carrier: bootstrap writes the fresh
|
||||
// draft; verdict merges the per-criterion delta into the
|
||||
// current list (re-read on the locked `fresh` to avoid races).
|
||||
String criteriaJson = nextCriteriaJson(fresh, result);
|
||||
if (criteriaJson != null) {
|
||||
w.set(GoalEntity::getCriteria, criteriaJson);
|
||||
}
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
@ -369,9 +400,33 @@ public class GoalServiceImpl implements GoalService {
|
||||
}
|
||||
detail.put("agentLlmCallsDelta", agentDelta);
|
||||
detail.put("evalLlmCallsDelta", evalDelta);
|
||||
// Full checklist (array) so the timeline / SSE consumer never sees the
|
||||
// raw String column or has to reconstruct from the per-round delta.
|
||||
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
|
||||
writeEvent(id, GoalEventType.EVALUATED, null, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next criteria JSON for a record-evaluation write, or
|
||||
* {@code null} when the result carries no checklist change. Bootstrap
|
||||
* results replace the list with the freshly derived draft; verdict
|
||||
* results merge their per-criterion delta into the locked-row list.
|
||||
*/
|
||||
private String nextCriteriaJson(GoalEntity fresh, GoalEvaluationResult result) {
|
||||
if (result.bootstrapCriteria() != null && !result.bootstrapCriteria().isEmpty()) {
|
||||
return GoalCriteriaCodec.serialize(result.bootstrapCriteria(), objectMapper);
|
||||
}
|
||||
if (result.criterionVerdicts() != null && !result.criterionVerdicts().isEmpty()) {
|
||||
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
|
||||
if (existing.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return GoalCriteriaCodec.serialize(
|
||||
GoalCriteriaCodec.merge(existing, result.criterionVerdicts()), objectMapper);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBudgetExhausted(GoalEntity goal) {
|
||||
int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
|
||||
@ -416,25 +471,107 @@ public class GoalServiceImpl implements GoalService {
|
||||
throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty");
|
||||
}
|
||||
String trimmed = criterion.trim();
|
||||
// Merge against the freshly refetched criteria so a concurrent
|
||||
// addCriterion never silently overwrites a sibling's append.
|
||||
// Double-write: append a structured criterion (authoritative) and
|
||||
// mirror the text into exit_criteria for backward compatibility /
|
||||
// human readability. New id is the current max ordinal + 1. Merge
|
||||
// against the freshly refetched row so concurrent appends don't clobber.
|
||||
GoalEntity g = retryOptimistic(id, "appendCriterion", fresh -> {
|
||||
ensureNotTerminal(fresh, "appendCriterion");
|
||||
|
||||
List<GoalCriterion> list = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
|
||||
list.add(new GoalCriterion("C" + (list.size() + 1), trimmed, false, ""));
|
||||
String criteriaJson = GoalCriteriaCodec.serialize(GoalCriteriaCodec.reindex(list), objectMapper);
|
||||
|
||||
String existing = fresh.getExitCriteria() != null ? fresh.getExitCriteria() : "";
|
||||
String merged = existing.isEmpty() ? trimmed : existing + "\n+ " + trimmed;
|
||||
String mergedText = existing.isEmpty() ? trimmed : existing + "\n+ " + trimmed;
|
||||
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getExitCriteria, merged);
|
||||
.set(GoalEntity::getCriteria, criteriaJson)
|
||||
.set(GoalEntity::getExitCriteria, mergedText);
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
List<GoalCriterion> full = GoalCriteriaCodec.parse(g.getCriteria(), objectMapper);
|
||||
String criterionId = full.isEmpty() ? "" : full.get(full.size() - 1).id();
|
||||
writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of(
|
||||
"criterion", trimmed,
|
||||
"criterionId", criterionId,
|
||||
"criteria", full,
|
||||
"by", username));
|
||||
return g;
|
||||
}
|
||||
|
||||
// ==================== Internals ====================
|
||||
|
||||
/**
|
||||
* Normalize a caller-supplied initial checklist: keep only non-blank
|
||||
* {@code text}, assign stable ids {@code C1..Cn} (ignore any caller ids),
|
||||
* force {@code passed=false} and clear evidence. Returns {@code null} for
|
||||
* an empty/null result so the column stays NULL and the first evaluation
|
||||
* bootstraps the list.
|
||||
*/
|
||||
private List<GoalCriterion> normalizeInitialCriteria(List<GoalCriterion> raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<GoalCriterion> kept = new java.util.ArrayList<>(raw.size());
|
||||
for (GoalCriterion c : raw) {
|
||||
if (c != null && c.text() != null && !c.text().isBlank()) {
|
||||
kept.add(new GoalCriterion("", c.text().trim(), false, ""));
|
||||
}
|
||||
}
|
||||
return kept.isEmpty() ? null : GoalCriteriaCodec.reindex(kept);
|
||||
}
|
||||
|
||||
/** Serialize a checklist to JSON text, or {@code null} for a null list. */
|
||||
private String serializeCriteria(List<GoalCriterion> criteria) {
|
||||
return GoalCriteriaCodec.serialize(criteria, objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GoalResponse toResponse(GoalEntity e) {
|
||||
if (e == null) {
|
||||
return null;
|
||||
}
|
||||
GoalResponse r = new GoalResponse();
|
||||
r.setId(e.getId());
|
||||
r.setConversationId(e.getConversationId());
|
||||
r.setAgentId(e.getAgentId());
|
||||
r.setWorkspaceId(e.getWorkspaceId());
|
||||
r.setCreatedBy(e.getCreatedBy());
|
||||
r.setTitle(e.getTitle());
|
||||
r.setDescription(e.getDescription());
|
||||
r.setExitCriteria(e.getExitCriteria());
|
||||
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
|
||||
r.setStatus(e.getStatus());
|
||||
r.setTurnBudget(e.getTurnBudget());
|
||||
r.setTurnsUsed(e.getTurnsUsed());
|
||||
r.setLlmCallBudget(e.getLlmCallBudget());
|
||||
r.setAgentLlmCallsUsed(e.getAgentLlmCallsUsed());
|
||||
r.setEvalLlmCallsUsed(e.getEvalLlmCallsUsed());
|
||||
r.setTotalLlmCallsUsed(e.totalLlmCallsUsed());
|
||||
r.setProgressSummary(e.getProgressSummary());
|
||||
r.setCompletionScore(e.getCompletionScore());
|
||||
r.setLastEvaluationAt(e.getLastEvaluationAt());
|
||||
r.setAutoFollowupEnabled(e.getAutoFollowupEnabled());
|
||||
r.setFollowupCooldownSeconds(e.getFollowupCooldownSeconds());
|
||||
r.setLastFollowupAt(e.getLastFollowupAt());
|
||||
r.setVersion(e.getVersion());
|
||||
r.setCreateTime(e.getCreateTime());
|
||||
r.setUpdateTime(e.getUpdateTime());
|
||||
// Always an array; empty when the column is null/unparseable.
|
||||
r.setCriteria(GoalCriteriaCodec.parse(e.getCriteria(), objectMapper));
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GoalResponse> toResponseList(List<GoalEntity> entities) {
|
||||
if (entities == null) {
|
||||
return List.of();
|
||||
}
|
||||
return entities.stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
private void validateCreate(GoalCreateRequest req) {
|
||||
if (req == null) {
|
||||
throw new MateClawException("err.goal.bad_request", 400, "Request body required");
|
||||
|
||||
@ -121,13 +121,36 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
return lower.contains("4-7") || lower.contains("4.7");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude 4.8 model variants (including the {@code -fast} sibling).
|
||||
* Claude 4.8 inherits 4.7's strict API contract: temperature / top_p /
|
||||
* top_k must be unset, and the "xhigh" thinking tier is available.
|
||||
*/
|
||||
static boolean isClaude48(String modelName) {
|
||||
if (modelName == null) return false;
|
||||
String lower = modelName.toLowerCase();
|
||||
if (!lower.contains("claude")) return false;
|
||||
// Matches claude-opus-4-8, claude-opus-4.8, the "-fast" sibling
|
||||
// (claude-opus-4-8-fast / claude-opus-4.8-fast), and OpenRouter
|
||||
// prefixed forms (anthropic/claude-opus-4-8...).
|
||||
return lower.contains("4-8") || lower.contains("4.8");
|
||||
}
|
||||
|
||||
/**
|
||||
* True for any Claude 4.7+ model — the family that drops temperature /
|
||||
* top_p / top_k and exposes the "xhigh" thinking tier between high and max.
|
||||
*/
|
||||
static boolean isClaude47OrLater(String modelName) {
|
||||
return isClaude47(modelName) || isClaude48(modelName);
|
||||
}
|
||||
|
||||
AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
|
||||
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
|
||||
String modelName = runtimeModel.getModelName();
|
||||
if (StringUtils.hasText(modelName)) {
|
||||
builder.model(modelName);
|
||||
}
|
||||
boolean isClaude47 = isClaude47(modelName);
|
||||
boolean strictSamplingContract = isClaude47OrLater(modelName);
|
||||
|
||||
// Extended thinking — request-level depth from ThinkingLevelHolder
|
||||
String thinkingLevel = ThinkingLevelHolder.get();
|
||||
@ -136,22 +159,22 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
if (thinkingEnabled) {
|
||||
// Anthropic thinking-mode constraints (pre-4.7): temperature MUST be 1,
|
||||
// top_p forbidden, max_tokens must accommodate budget_tokens + buffer.
|
||||
// Claude 4.7 forbids temperature/top_p/top_k entirely (any non-null value
|
||||
// Claude 4.7+ forbids temperature/top_p/top_k entirely (any non-null value
|
||||
// → HTTP 400) and adds an "xhigh" budget tier between high and max.
|
||||
int budgetTokens = switch (thinkingLevel.toLowerCase()) {
|
||||
case "low" -> 4096;
|
||||
case "medium" -> 8192;
|
||||
case "high" -> 16384;
|
||||
case "xhigh" -> 24576; // 4.7 only — between high (16k) and max (32k)
|
||||
case "xhigh" -> 24576; // 4.7+ only — between high (16k) and max (32k)
|
||||
case "max" -> 32768;
|
||||
default -> 16384;
|
||||
};
|
||||
builder.thinking(AnthropicApi.ThinkingType.ENABLED, 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
|
||||
// Claude 4.7+: omit temperature entirely. Pre-4.7 thinking mode requires
|
||||
// temperature=1 (Anthropic-mandated default for thinking).
|
||||
if (!isClaude47) {
|
||||
if (!strictSamplingContract) {
|
||||
builder.temperature(1.0);
|
||||
}
|
||||
} else {
|
||||
@ -159,14 +182,14 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
// - Pre-4.7: Anthropic accepts EITHER temperature OR top_p (not both).
|
||||
// - 4.7+: rejects all of temperature/top_p/top_k unless null/default. We
|
||||
// omit them entirely so operators with legacy configs don't 400.
|
||||
if (!isClaude47) {
|
||||
if (!strictSamplingContract) {
|
||||
if (runtimeModel.getTemperature() != null) {
|
||||
builder.temperature(runtimeModel.getTemperature());
|
||||
} else if (runtimeModel.getTopP() != null) {
|
||||
builder.topP(runtimeModel.getTopP());
|
||||
}
|
||||
} else if (runtimeModel.getTemperature() != null || runtimeModel.getTopP() != null) {
|
||||
log.debug("Ignoring temperature/top_p for Claude 4.7 model {} (API rejects sampling params)",
|
||||
log.debug("Ignoring temperature/top_p for Claude 4.7+ model {} (API rejects sampling params)",
|
||||
modelName);
|
||||
}
|
||||
// Anthropic rejects non-positive maxTokens — clamp here so a bad config
|
||||
|
||||
@ -7,6 +7,7 @@ import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
@ -45,6 +46,7 @@ public class ProviderRouter {
|
||||
private final AgentBindingResolver bindingService;
|
||||
private final ModelCapabilityService capabilityService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
|
||||
/**
|
||||
* Compute the union of capability requirements declared by the
|
||||
@ -176,57 +178,88 @@ public class ProviderRouter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a primary {@link ModelConfigEntity} that satisfies as many
|
||||
* required modalities as possible. Falls back to the global default
|
||||
* when nothing better is configured.
|
||||
* Pick a primary model using a two-pass strategy.
|
||||
*
|
||||
* <p>Logic: try each preferred provider in turn; for each, ask
|
||||
* {@link ModelProviderService#getDefaultModelByProvider} for its
|
||||
* default chat model and check capability resolution. First match
|
||||
* wins. If nothing matches, return the global default unchanged.
|
||||
* <p>Pass 1 (capability-gated): preferred providers → global default.
|
||||
* <p>Pass 2 (unconstrained fallback): preferred providers → global default.
|
||||
*
|
||||
* <p>When no preferred providers are configured the preferred branches
|
||||
* are skipped, preserving the legacy behaviour.
|
||||
*/
|
||||
public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) {
|
||||
if (agentId == null) return globalDefault;
|
||||
|
||||
List<String> preferred = bindingService.getPreferredProviderIds(agentId);
|
||||
Set<Modality> requiredModalities = resolveRequiredModalities(agentId);
|
||||
|
||||
// Pass 1: capability-satisfying providers (preferred first, global fallback)
|
||||
if (requiredModalities != null) {
|
||||
// 1a. preferred providers satisfying capabilities
|
||||
for (String providerId : preferred) {
|
||||
ModelConfigEntity candidate = pickProviderDefault(providerId);
|
||||
if (candidate == null) continue;
|
||||
if (satisfies(candidate, requiredModalities)) {
|
||||
log.info("[ProviderRouter] agent={} primary={}/{} (preferred, satisfies {})",
|
||||
agentId, candidate.getProvider(), candidate.getModelName(), requiredModalities);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// 1b. global default satisfying capabilities
|
||||
if (globalDefault != null && satisfies(globalDefault, requiredModalities)) {
|
||||
log.info("[ProviderRouter] agent={} primary={}/{} (global, satisfies {})",
|
||||
agentId, globalDefault.getProvider(), globalDefault.getModelName(), requiredModalities);
|
||||
return globalDefault;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: unconstrained (capability ignored — last resort)
|
||||
// 2a. any available preferred provider
|
||||
for (String providerId : preferred) {
|
||||
ModelConfigEntity candidate = pickProviderDefault(providerId);
|
||||
if (candidate == null) continue;
|
||||
log.info("[ProviderRouter] agent={} primary={}/{} (preferred, unconstrained)",
|
||||
agentId, candidate.getProvider(), candidate.getModelName());
|
||||
return candidate;
|
||||
}
|
||||
// 2b. global default (ultimate fallback)
|
||||
if (globalDefault != null) {
|
||||
log.info("[ProviderRouter] agent={} primary={}/{} (global default)",
|
||||
agentId, globalDefault.getProvider(), globalDefault.getModelName());
|
||||
return globalDefault;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns null when no capabilities are required (skips Pass 1). */
|
||||
private Set<Modality> resolveRequiredModalities(Long agentId) {
|
||||
Set<String> needs = aggregateModelNeeds(agentId);
|
||||
if (needs.isEmpty()) return globalDefault;
|
||||
Set<Modality> requiredModalities = needs.stream()
|
||||
if (needs == null || needs.isEmpty()) return null;
|
||||
Set<Modality> mods = needs.stream()
|
||||
.map(this::mapToModality)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(java.util.stream.Collectors.toCollection(
|
||||
() -> EnumSet.noneOf(Modality.class)));
|
||||
if (requiredModalities.isEmpty()) return globalDefault;
|
||||
return mods.isEmpty() ? null : mods;
|
||||
}
|
||||
|
||||
// Already satisfies? Skip the search.
|
||||
if (globalDefault != null) {
|
||||
EnumSet<Modality> resolved = capabilityService.resolve(
|
||||
globalDefault.getModelName(), globalDefault.getModalities());
|
||||
if (resolved.containsAll(requiredModalities)) return globalDefault;
|
||||
}
|
||||
|
||||
List<String> preferred = bindingService.getPreferredProviderIds(agentId);
|
||||
for (String providerId : preferred) {
|
||||
ModelConfigEntity candidate = pickProviderDefault(providerId);
|
||||
if (candidate == null) continue;
|
||||
EnumSet<Modality> resolved = capabilityService.resolve(
|
||||
candidate.getModelName(), candidate.getModalities());
|
||||
if (resolved.containsAll(requiredModalities)) {
|
||||
log.info("[ProviderRouter] agent={} switched primary to {}/{} for needs={}",
|
||||
agentId, candidate.getProvider(), candidate.getModelName(),
|
||||
requiredModalities);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// No preferred provider satisfied; keep the diagnostic warning
|
||||
// path on the original default so the user sees the gap in logs.
|
||||
return globalDefault;
|
||||
private boolean satisfies(ModelConfigEntity model, Set<Modality> required) {
|
||||
return capabilityService.resolve(model.getModelName(), model.getModalities())
|
||||
.containsAll(required);
|
||||
}
|
||||
|
||||
private ModelConfigEntity pickProviderDefault(String providerId) {
|
||||
if (providerId == null || providerId.isBlank()) return null;
|
||||
try {
|
||||
return modelConfigService.getDefaultModelByProvider(providerId);
|
||||
// A provider without usable credentials can't serve as the primary
|
||||
// model: selecting it would only be rejected downstream and fall
|
||||
// back to the global default, silently skipping the remaining
|
||||
// preferred providers. Skip it here so preference resolution
|
||||
// continues to the next entry instead.
|
||||
if (!modelProviderService.isProviderConfigured(providerId)) return null;
|
||||
return modelConfigService.getPrimaryChatModelByProvider(providerId);
|
||||
} catch (Exception e) {
|
||||
// getDefaultModelByProvider can return null or throw when
|
||||
// getPrimaryChatModelByProvider can return null or throw when
|
||||
// the provider has no enabled chat model; treat both as
|
||||
// "no candidate from this provider".
|
||||
return null;
|
||||
|
||||
@ -183,6 +183,32 @@ public class ModelConfigService {
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider's primary chat model for routing.
|
||||
*
|
||||
* <p>Prefers the row carrying the system default flag when it happens to
|
||||
* belong to this provider; otherwise falls back to the provider's
|
||||
* earliest-configured enabled chat model. The {@code is_default} flag is a
|
||||
* single system-wide marker (see {@link #clearDefaultFlag}), so a provider
|
||||
* that does not own it has no row matching {@link #getDefaultModelByProvider}.
|
||||
* Without this fallback a preferred provider could never contribute a
|
||||
* primary model unless it already held the global default.
|
||||
*
|
||||
* @return the provider's primary chat model, or {@code null} when the
|
||||
* provider has no enabled chat model configured
|
||||
*/
|
||||
public ModelConfigEntity getPrimaryChatModelByProvider(String providerId) {
|
||||
if (providerId == null || providerId.isBlank()) return null;
|
||||
ModelConfigEntity def = getDefaultModelByProvider(providerId);
|
||||
if (def != null) return def;
|
||||
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getProvider, providerId)
|
||||
.eq(ModelConfigEntity::getEnabled, true)
|
||||
.eq(ModelConfigEntity::getModelType, "chat")
|
||||
.orderByAsc(ModelConfigEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public ModelConfigEntity createModel(ModelConfigEntity entity) {
|
||||
validateModel(entity, null);
|
||||
if (Boolean.TRUE.equals(entity.getIsDefault())) {
|
||||
|
||||
@ -11,6 +11,9 @@ package vip.mate.memory.event;
|
||||
* @param assistantReply Agent 最终回答
|
||||
* @param messageCount 当前会话消息总数
|
||||
* @param triggerSource 触发来源:"web" / "channel" / "cron"
|
||||
* @param ownerKey memory owner this turn is attributed to (e.g.
|
||||
* "user:42"); null / "system" means not owner-scoped,
|
||||
* in which case extracted memory is written as shared.
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record ConversationCompletedEvent(
|
||||
@ -19,5 +22,12 @@ public record ConversationCompletedEvent(
|
||||
String userMessage,
|
||||
String assistantReply,
|
||||
int messageCount,
|
||||
String triggerSource
|
||||
) {}
|
||||
String triggerSource,
|
||||
String ownerKey
|
||||
) {
|
||||
/** Backwards-compatible constructor without an owner key (resolves to null). */
|
||||
public ConversationCompletedEvent(Long agentId, String conversationId, String userMessage,
|
||||
String assistantReply, int messageCount, String triggerSource) {
|
||||
this(agentId, conversationId, userMessage, assistantReply, messageCount, triggerSource, null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.memory.identity.MemoryOwnerResolver;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
/**
|
||||
@ -32,6 +35,7 @@ public class ConversationCompletionPublisher {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final ConversationService conversationService;
|
||||
private final MemoryOwnerResolver memoryOwnerResolver;
|
||||
|
||||
/**
|
||||
* Publish a {@link ConversationCompletedEvent} for the given turn.
|
||||
@ -51,6 +55,43 @@ public class ConversationCompletionPublisher {
|
||||
String userMessage,
|
||||
String assistantReply,
|
||||
String source) {
|
||||
// Best-effort owner resolution from the request-scoped origin holder.
|
||||
// Reliable for callers that publish on the same thread the origin was
|
||||
// captured on (IM router, talk mode, cron). Web entry points publish
|
||||
// from a reactive completion callback after the holder is cleared, so
|
||||
// they MUST use the explicit overload below to stay consistent with the
|
||||
// read path's owner key.
|
||||
publish(agentId, conversationId, userMessage, assistantReply, source,
|
||||
memoryOwnerResolver.resolve(ChatOriginHolder.get()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish, attributing the memory write to the owner resolved from an
|
||||
* explicit {@link ChatOrigin}. Use from entry points (IM channels, talk
|
||||
* mode) that publish after the request-scoped origin holder is cleared, so
|
||||
* the write owner matches the read path's owner for the same turn.
|
||||
*/
|
||||
public void publishForOrigin(Long agentId,
|
||||
String conversationId,
|
||||
String userMessage,
|
||||
String assistantReply,
|
||||
String source,
|
||||
ChatOrigin origin) {
|
||||
publish(agentId, conversationId, userMessage, assistantReply, source,
|
||||
memoryOwnerResolver.resolve(origin));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish with an explicit {@code ownerKey}. Use this from entry points
|
||||
* where the request-scoped origin is no longer on the current thread, so
|
||||
* the memory write is attributed to the same owner the read path recalls.
|
||||
*/
|
||||
public void publish(Long agentId,
|
||||
String conversationId,
|
||||
String userMessage,
|
||||
String assistantReply,
|
||||
String source,
|
||||
String ownerKey) {
|
||||
if (agentId == null || conversationId == null || conversationId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
@ -62,7 +103,8 @@ public class ConversationCompletionPublisher {
|
||||
userMessage != null ? userMessage : "",
|
||||
assistantReply != null ? assistantReply : "",
|
||||
messageCount,
|
||||
source != null ? source : "unknown"));
|
||||
source != null ? source : "unknown",
|
||||
ownerKey));
|
||||
} catch (Exception e) {
|
||||
log.debug("[Memory] Failed to publish ConversationCompletedEvent (source={}, conv={}): {}",
|
||||
source, conversationId, e.getMessage());
|
||||
|
||||
@ -50,6 +50,12 @@ public class FactEntity {
|
||||
/** pattern | llm */
|
||||
private String extractedBy;
|
||||
|
||||
/** Memory subject this fact belongs to (e.g. "user:42"); null for shared/legacy rows. */
|
||||
private String ownerKey;
|
||||
|
||||
/** Visibility scope: PERSONAL / TEAM / GLOBAL. Defaults to TEAM at the DB level. */
|
||||
private String scope;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -47,8 +47,13 @@ public class FactMemoryProvider implements MemoryProvider {
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery) {
|
||||
return prefetch(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery, String ownerKey) {
|
||||
if (!properties.getFact().isProjectionEnabled()) return "";
|
||||
List<FactEntity> facts = queryService.recallRelevant(agentId, userQuery);
|
||||
List<FactEntity> facts = queryService.recallRelevant(agentId, userQuery, ownerKey);
|
||||
if (facts.isEmpty()) return "";
|
||||
|
||||
// Bump usage
|
||||
|
||||
@ -56,6 +56,16 @@ public class FactQueryService {
|
||||
* Recall relevant facts for a query (used by FactMemoryProvider.prefetch).
|
||||
*/
|
||||
public List<FactEntity> recallRelevant(Long agentId, String query) {
|
||||
return recallRelevant(agentId, query, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-scoped recall: returns facts visible to {@code ownerKey} — shared
|
||||
* (TEAM / GLOBAL) facts plus this owner's PERSONAL facts. A null ownerKey
|
||||
* means shared-only. Keeps one user's recalled facts out of another user's
|
||||
* prompt when a single agent is shared across end-users.
|
||||
*/
|
||||
public List<FactEntity> recallRelevant(Long agentId, String query, String ownerKey) {
|
||||
return factMapper.selectList(
|
||||
new LambdaQueryWrapper<FactEntity>()
|
||||
.eq(FactEntity::getAgentId, agentId)
|
||||
@ -63,6 +73,18 @@ public class FactQueryService {
|
||||
.and(w -> w.like(FactEntity::getSubject, query)
|
||||
.or().like(FactEntity::getObjectValue, query)
|
||||
.or().like(FactEntity::getPredicate, query))
|
||||
.and(s -> {
|
||||
if (ownerKey == null || ownerKey.isBlank()) {
|
||||
s.in(FactEntity::getScope, vip.mate.memory.identity.MemoryScope.TEAM,
|
||||
vip.mate.memory.identity.MemoryScope.GLOBAL);
|
||||
} else {
|
||||
s.in(FactEntity::getScope, vip.mate.memory.identity.MemoryScope.TEAM,
|
||||
vip.mate.memory.identity.MemoryScope.GLOBAL)
|
||||
.or(p -> p.eq(FactEntity::getScope,
|
||||
vip.mate.memory.identity.MemoryScope.PERSONAL)
|
||||
.eq(FactEntity::getOwnerKey, ownerKey));
|
||||
}
|
||||
})
|
||||
.orderByDesc(FactEntity::getTrust)
|
||||
.last("LIMIT 10"));
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
package vip.mate.memory.identity;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
|
||||
/**
|
||||
* Resolves the {@code owner_key} that conversation-derived memory should be
|
||||
* attributed to, from the request's {@link ChatOrigin}.
|
||||
*
|
||||
* <p>The key is a prefixed string so a single agent shared across surfaces
|
||||
* keeps every subject's memory separate:
|
||||
* <ul>
|
||||
* <li>Web console → {@code user:<requesterId>} (the MateClaw username)</li>
|
||||
* <li>IM channels → {@code <channelType>:<senderId>} (feishu/dingtalk/…)</li>
|
||||
* <li>WebChat / 3rd-party API → {@code api:<visitorId|endUserId>}</li>
|
||||
* <li>Cron / system / unknown → {@link #SYSTEM_OWNER}</li>
|
||||
* </ul>
|
||||
*
|
||||
* The cron/system fallback is deliberate: it keeps unattributed writes out of
|
||||
* any real user's PERSONAL bucket (which would otherwise be a black hole that
|
||||
* nobody can read) — such writes are expected to be TEAM-scoped instead.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Component
|
||||
public class MemoryOwnerResolver {
|
||||
|
||||
/** Owner key used for cron-triggered and identity-less invocations. */
|
||||
public static final String SYSTEM_OWNER = "system";
|
||||
|
||||
/**
|
||||
* Resolve the owner key for the given origin. Never returns null; falls
|
||||
* back to {@link #SYSTEM_OWNER} when no usable identity is present.
|
||||
*/
|
||||
public String resolve(ChatOrigin origin) {
|
||||
if (origin == null || origin.cronOrigin()) {
|
||||
return SYSTEM_OWNER;
|
||||
}
|
||||
String requester = origin.requesterId();
|
||||
if (requester == null || requester.isBlank() || SYSTEM_OWNER.equals(requester)) {
|
||||
return SYSTEM_OWNER;
|
||||
}
|
||||
String channel = origin.channelType();
|
||||
if (channel == null || channel.isBlank() || "web".equals(channel)) {
|
||||
// Web console (or a degraded origin with no channel): the requester
|
||||
// id is already the MateClaw username.
|
||||
return "user:" + requester;
|
||||
}
|
||||
// IM / api origins: the requester id is the external sender id; prefix
|
||||
// with the channel type so two platforms can't collide on the same id.
|
||||
return channel + ":" + requester;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package vip.mate.memory.identity;
|
||||
|
||||
/**
|
||||
* Visibility scope for a memory row (workspace file, fact, recall).
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #PERSONAL} — only the matching {@code owner_key} can read it.
|
||||
* Conversation-derived memory defaults here.</li>
|
||||
* <li>{@link #TEAM} — everyone using the agent can read it. Agent config /
|
||||
* persona files (AGENTS.md, SOUL.md, PROFILE.md) and legacy rows live
|
||||
* here.</li>
|
||||
* <li>{@link #GLOBAL} — always visible. Reserved for agent-creator preset
|
||||
* facts.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Stored as a plain string column ({@code scope}) rather than a DB enum so the
|
||||
* H2 / MySQL migrations stay dialect-neutral.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class MemoryScope {
|
||||
|
||||
public static final String PERSONAL = "PERSONAL";
|
||||
public static final String TEAM = "TEAM";
|
||||
public static final String GLOBAL = "GLOBAL";
|
||||
|
||||
private MemoryScope() {
|
||||
}
|
||||
|
||||
/** A scope is "shared" (visible to every owner) when it is TEAM or GLOBAL. */
|
||||
public static boolean isShared(String scope) {
|
||||
return TEAM.equals(scope) || GLOBAL.equals(scope);
|
||||
}
|
||||
}
|
||||
@ -42,7 +42,7 @@ public class MemoryLifecycleMediator {
|
||||
*/
|
||||
public String beforeLlmCall(TurnContext ctx) {
|
||||
try {
|
||||
String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery());
|
||||
String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery(), ctx.ownerKey());
|
||||
events.publishEvent(new TurnStartedEvent(ctx));
|
||||
log.debug("[Memory] beforeLlmCall: agent={}, contextLen={}", ctx.agentId(),
|
||||
context != null ? context.length() : 0);
|
||||
|
||||
@ -8,6 +8,9 @@ package vip.mate.memory.lifecycle;
|
||||
* @param sessionId session ID (may equal conversationId in Phase 1)
|
||||
* @param turnNumber turn sequence number within the conversation
|
||||
* @param userQuery the current user message
|
||||
* @param ownerKey resolved memory owner key for this turn (e.g.
|
||||
* "user:42"); drives per-owner memory recall. May be
|
||||
* null when memory-isolation context is unavailable.
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record TurnContext(
|
||||
@ -15,5 +18,12 @@ public record TurnContext(
|
||||
String conversationId,
|
||||
String sessionId,
|
||||
int turnNumber,
|
||||
String userQuery
|
||||
) {}
|
||||
String userQuery,
|
||||
String ownerKey
|
||||
) {
|
||||
/** Backwards-compatible constructor without an owner key (resolves to null). */
|
||||
public TurnContext(Long agentId, String conversationId, String sessionId,
|
||||
int turnNumber, String userQuery) {
|
||||
this(agentId, conversationId, sessionId, turnNumber, userQuery, null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ public class PostConversationMemoryListener {
|
||||
try {
|
||||
log.debug("[Memory] Triggering post-conversation memory analysis: agent={}, conv={}",
|
||||
event.agentId(), event.conversationId());
|
||||
summarizationService.analyzeAndUpdateMemory(event.agentId(), event.conversationId());
|
||||
summarizationService.analyzeAndUpdateMemory(event.agentId(), event.conversationId(), event.ownerKey());
|
||||
} catch (Exception e) {
|
||||
log.warn("[Memory] Post-conversation summarization failed: agent={}, conv={}, error={}",
|
||||
event.agentId(), event.conversationId(), e.getMessage());
|
||||
@ -60,7 +60,7 @@ public class PostConversationMemoryListener {
|
||||
|
||||
// Memory Nudge: extract structured entries every N turns
|
||||
try {
|
||||
nudgeService.maybeNudge(event.agentId(), event.conversationId(), event.messageCount());
|
||||
nudgeService.maybeNudge(event.agentId(), event.conversationId(), event.messageCount(), event.ownerKey());
|
||||
} catch (Exception e) {
|
||||
log.debug("[Memory] Nudge trigger failed (non-fatal): {}", e.getMessage());
|
||||
}
|
||||
|
||||
@ -56,6 +56,12 @@ public class MemoryRecallEntity {
|
||||
/** Last time this candidate was reviewed during a dream run */
|
||||
private LocalDateTime lastReviewedAt;
|
||||
|
||||
/** Memory subject this recall belongs to (e.g. "user:42"); null for shared/legacy rows. */
|
||||
private String ownerKey;
|
||||
|
||||
/** Visibility scope: PERSONAL / TEAM / GLOBAL. Defaults to TEAM at the DB level. */
|
||||
private String scope;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -46,17 +46,29 @@ public class MemoryNudgeService {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Per-agent cooldown tracking */
|
||||
private final ConcurrentHashMap<Long, Instant> lastNudgeTimes = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, Instant> lastNudgeTimes = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Check if a nudge should be triggered and execute if so.
|
||||
* Called from PostConversationMemoryListener or directly.
|
||||
*/
|
||||
/** Backwards-compatible entry without an owner key (writes shared memory). */
|
||||
@Async
|
||||
public void maybeNudge(Long agentId, String conversationId, int messageCount) {
|
||||
maybeNudge(agentId, conversationId, messageCount, null);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void maybeNudge(Long agentId, String conversationId, int messageCount, String ownerKey) {
|
||||
if (!properties.isNudgeEnabled()) {
|
||||
return;
|
||||
}
|
||||
// Gate per-owner isolation on the lifecycle prefetch path (the only
|
||||
// auto-injector of PERSONAL structured memory); otherwise write shared
|
||||
// so nudged entries are not stranded in an unread PERSONAL bucket.
|
||||
if (!properties.isLifecycleMediatorEnabled()) {
|
||||
ownerKey = null;
|
||||
}
|
||||
|
||||
// Check turn interval
|
||||
if (properties.getNudgeTurnInterval() <= 0
|
||||
@ -64,22 +76,23 @@ public class MemoryNudgeService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cooldown check
|
||||
if (isInCooldown(agentId)) {
|
||||
log.debug("[Nudge] Agent {} is in cooldown, skipping", agentId);
|
||||
// Cooldown keyed per (agent, owner) so one owner can't starve another.
|
||||
String cooldownKey = agentId + ":" + (ownerKey == null ? "" : ownerKey);
|
||||
if (isInCooldown(cooldownKey)) {
|
||||
log.debug("[Nudge] Agent {} (owner {}) is in cooldown, skipping", agentId, ownerKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
doNudge(agentId, conversationId);
|
||||
lastNudgeTimes.put(agentId, Instant.now());
|
||||
doNudge(agentId, conversationId, ownerKey);
|
||||
lastNudgeTimes.put(cooldownKey, Instant.now());
|
||||
} catch (Exception e) {
|
||||
log.warn("[Nudge] Failed for agent={}, conv={}: {}",
|
||||
agentId, conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doNudge(Long agentId, String conversationId) {
|
||||
private void doNudge(Long agentId, String conversationId, String ownerKey) {
|
||||
// 1. Load recent messages
|
||||
List<MessageEntity> messages = conversationService.listMessages(conversationId);
|
||||
int maxReview = properties.getNudgeMaxMessages();
|
||||
@ -96,8 +109,8 @@ public class MemoryNudgeService {
|
||||
String transcript = buildTranscript(recent);
|
||||
if (transcript.isBlank()) return;
|
||||
|
||||
// 3. Load existing structured memories for dedup
|
||||
String existingMemories = structuredMemoryService.buildMemoryBlock(agentId);
|
||||
// 3. Load existing structured memories for dedup (owner-scoped)
|
||||
String existingMemories = structuredMemoryService.buildMemoryBlock(agentId, ownerKey);
|
||||
|
||||
// 4. Build prompt
|
||||
String systemPrompt = PromptLoader.loadPrompt("memory/nudge-system");
|
||||
@ -140,7 +153,7 @@ public class MemoryNudgeService {
|
||||
if (type.isBlank() || key.isBlank() || content.isBlank()) continue;
|
||||
|
||||
try {
|
||||
structuredMemoryService.remember(agentId, type, key, content, "nudge");
|
||||
structuredMemoryService.remember(agentId, type, key, content, "nudge", ownerKey);
|
||||
saved++;
|
||||
} catch (Exception e) {
|
||||
log.debug("[Nudge] Failed to save entry {}/{}: {}", type, key, e.getMessage());
|
||||
@ -226,8 +239,8 @@ public class MemoryNudgeService {
|
||||
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
|
||||
}
|
||||
|
||||
private boolean isInCooldown(Long agentId) {
|
||||
Instant lastRun = lastNudgeTimes.get(agentId);
|
||||
private boolean isInCooldown(String cooldownKey) {
|
||||
Instant lastRun = lastNudgeTimes.get(cooldownKey);
|
||||
if (lastRun == null) return false;
|
||||
long cooldownSeconds = properties.getNudgeCooldownMinutes() * 60L;
|
||||
return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds));
|
||||
|
||||
@ -59,12 +59,24 @@ public class BuiltinMemoryProvider implements MemoryProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builtin memory is already injected via system prompt.
|
||||
* No additional per-turn prefetch needed.
|
||||
* Shared (TEAM / GLOBAL) memory is baked into the system prompt at build
|
||||
* time. Per-owner PERSONAL memory cannot be — the agent instance is cached
|
||||
* and reused across users — so it is injected here, per turn, for the
|
||||
* current requester only.
|
||||
*/
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery) {
|
||||
return "";
|
||||
public String prefetch(Long agentId, String userQuery, String ownerKey) {
|
||||
if (ownerKey == null || ownerKey.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
String block = workspaceFileService.buildOwnerMemoryBlock(agentId, ownerKey);
|
||||
return block != null ? block : "";
|
||||
} catch (Exception e) {
|
||||
log.warn("[BuiltinMemory] Failed to build owner memory block for agent={}, owner={}: {}",
|
||||
agentId, ownerKey, e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -35,20 +35,68 @@ public class StructuredMemoryProvider implements MemoryProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns typed memory entries formatted as a Markdown block
|
||||
* for system prompt injection.
|
||||
* Returns the stable, low-volume typed entries (user profile, feedback)
|
||||
* for unconditional system prompt injection.
|
||||
*/
|
||||
/**
|
||||
* Build-time injection is limited to SHARED (TEAM / GLOBAL) structured
|
||||
* memory — agent-creator presets and team-wide facts. Conversation-derived
|
||||
* PERSONAL structured memory is owner-specific and the agent instance is
|
||||
* cached across users, so it is injected per-turn in
|
||||
* {@link #prefetch(Long, String, String)} for the current owner only.
|
||||
*/
|
||||
@Override
|
||||
public String systemPromptBlock(Long agentId) {
|
||||
try {
|
||||
return structuredMemoryService.buildMemoryBlock(agentId);
|
||||
// ownerKey=null → buildMemoryBlock reads shared (TEAM/GLOBAL) rows only.
|
||||
return structuredMemoryService.buildMemoryBlock(agentId, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[StructuredMemory] Failed to build memory block for agent={}: {}",
|
||||
log.warn("[StructuredMemory] Failed to build shared memory block for agent={}: {}",
|
||||
agentId, e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery) {
|
||||
return prefetch(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-scoped per-turn injection: the stable user/feedback entries plus the
|
||||
* query-relevant project/reference entries — all restricted to the current
|
||||
* owner's structured memory. Returns empty when there is no isolatable owner
|
||||
* so a shared agent never injects another user's structured memory. The
|
||||
* returned block is fenced centrally by the memory manager.
|
||||
*/
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery, String ownerKey) {
|
||||
try {
|
||||
String stable = structuredMemoryService.buildMemoryBlock(agentId, ownerKey);
|
||||
String relevant = structuredMemoryService.buildPrefetchBlock(agentId, userQuery, ownerKey);
|
||||
boolean hasStable = stable != null && !stable.isBlank();
|
||||
boolean hasRelevant = relevant != null && !relevant.isBlank();
|
||||
if (!hasStable && !hasRelevant) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (hasStable) {
|
||||
sb.append(stable);
|
||||
}
|
||||
if (hasRelevant) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n\n");
|
||||
}
|
||||
sb.append(relevant);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("[StructuredMemory] Failed to build prefetch block for agent={}, owner={}: {}",
|
||||
agentId, ownerKey, e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools are auto-discovered by ToolRegistry component scan.
|
||||
*/
|
||||
|
||||
@ -46,41 +46,66 @@ public class MemorySummarizationService {
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StructuredMemoryService structuredMemoryService;
|
||||
|
||||
/** Per-agent 锁,防止并发写入 */
|
||||
private final ConcurrentHashMap<Long, ReentrantLock> agentLocks = new ConcurrentHashMap<>();
|
||||
/** Typed-memory categories the summarizer may route entries into. */
|
||||
private static final java.util.Set<String> STRUCTURED_TYPES =
|
||||
java.util.Set.of("user", "feedback", "project", "reference");
|
||||
|
||||
/** Per-agent 冷却时间记录 */
|
||||
private final ConcurrentHashMap<Long, Instant> lastRunTimes = new ConcurrentHashMap<>();
|
||||
/** Per-(agent, owner) 锁,防止并发写入 */
|
||||
private final ConcurrentHashMap<String, ReentrantLock> agentLocks = new ConcurrentHashMap<>();
|
||||
|
||||
/** Per-(agent, owner) 冷却时间记录 */
|
||||
private final ConcurrentHashMap<String, Instant> lastRunTimes = new ConcurrentHashMap<>();
|
||||
|
||||
/** Backwards-compatible entry without an owner key (writes shared memory). */
|
||||
public void analyzeAndUpdateMemory(Long agentId, String conversationId) {
|
||||
analyzeAndUpdateMemory(agentId, conversationId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析对话并更新记忆文件
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param conversationId 会话 ID
|
||||
* @param ownerKey memory owner this conversation is attributed to; null
|
||||
* or "system" writes shared (TEAM) memory, otherwise
|
||||
* memory is written PERSONAL to this owner
|
||||
*/
|
||||
public void analyzeAndUpdateMemory(Long agentId, String conversationId) {
|
||||
public void analyzeAndUpdateMemory(Long agentId, String conversationId, String ownerKey) {
|
||||
// Per-owner isolation is gated on the lifecycle prefetch path, which is
|
||||
// the only auto-injector of PERSONAL memory. When that path is off,
|
||||
// writing PERSONAL would strand memory in a bucket nothing auto-reads,
|
||||
// so fall back to shared (legacy) writes — isolation activates together
|
||||
// with lifecycleMediatorEnabled.
|
||||
if (!properties.isLifecycleMediatorEnabled()) {
|
||||
ownerKey = null;
|
||||
}
|
||||
// Lock / cooldown are keyed per (agent, owner) so one owner's busy
|
||||
// extraction never starves another owner sharing the same agent.
|
||||
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey);
|
||||
|
||||
// 冷却检查
|
||||
if (isInCooldown(agentId)) {
|
||||
log.debug("[Memory] Agent {} is in cooldown, skipping summarization", agentId);
|
||||
if (isInCooldown(lockKey)) {
|
||||
log.debug("[Memory] Agent {} (owner {}) is in cooldown, skipping summarization", agentId, ownerKey);
|
||||
return;
|
||||
}
|
||||
|
||||
ReentrantLock lock = agentLocks.computeIfAbsent(agentId, k -> new ReentrantLock());
|
||||
ReentrantLock lock = agentLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
|
||||
if (!lock.tryLock()) {
|
||||
log.debug("[Memory] Agent {} is already being summarized, skipping", agentId);
|
||||
log.debug("[Memory] Agent {} (owner {}) is already being summarized, skipping", agentId, ownerKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
doAnalyzeAndUpdate(agentId, conversationId);
|
||||
lastRunTimes.put(agentId, Instant.now());
|
||||
doAnalyzeAndUpdate(agentId, conversationId, ownerKey);
|
||||
lastRunTimes.put(lockKey, Instant.now());
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void doAnalyzeAndUpdate(Long agentId, String conversationId) {
|
||||
private void doAnalyzeAndUpdate(Long agentId, String conversationId, String ownerKey) {
|
||||
// 1. 加载对话消息
|
||||
List<MessageEntity> messages = conversationService.listMessages(conversationId);
|
||||
if (messages.size() < properties.getMinMessagesForSummarize()) {
|
||||
@ -95,11 +120,11 @@ public class MemorySummarizationService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 加载现有记忆文件内容
|
||||
String profileContent = readFileContentSafe(agentId, "PROFILE.md");
|
||||
String memoryContent = readFileContentSafe(agentId, "MEMORY.md");
|
||||
// 2. 加载现有记忆文件内容(按 owner 隔离)
|
||||
String profileContent = readFileContentSafe(agentId, "PROFILE.md", ownerKey);
|
||||
String memoryContent = readFileContentSafe(agentId, "MEMORY.md", ownerKey);
|
||||
String dailyFilename = "memory/" + LocalDate.now() + ".md";
|
||||
String dailyContent = readFileContentSafe(agentId, dailyFilename);
|
||||
String dailyContent = readFileContentSafe(agentId, dailyFilename, ownerKey);
|
||||
|
||||
// 3. 构建对话 transcript
|
||||
String transcript = buildTranscript(messages);
|
||||
@ -148,7 +173,7 @@ public class MemorySummarizationService {
|
||||
}
|
||||
|
||||
// 6. 应用更新
|
||||
applyUpdates(agentId, root, dailyFilename, dailyContent);
|
||||
applyUpdates(agentId, root, dailyFilename, dailyContent, ownerKey);
|
||||
|
||||
String reason = root.path("reason").asText("");
|
||||
log.info("[Memory] Memory updated for agent={}, conv={}: {}", agentId, conversationId, reason);
|
||||
@ -159,7 +184,8 @@ public class MemorySummarizationService {
|
||||
}
|
||||
}
|
||||
|
||||
private void applyUpdates(Long agentId, JsonNode root, String dailyFilename, String existingDailyContent) {
|
||||
private void applyUpdates(Long agentId, JsonNode root, String dailyFilename,
|
||||
String existingDailyContent, String ownerKey) {
|
||||
// Daily entry: 追加模式
|
||||
JsonNode dailyNode = root.path("daily_entry");
|
||||
if (!dailyNode.isNull() && dailyNode.isTextual()) {
|
||||
@ -168,8 +194,8 @@ public class MemorySummarizationService {
|
||||
String newContent = existingDailyContent.isEmpty()
|
||||
? "# " + LocalDate.now() + "\n\n" + entry
|
||||
: existingDailyContent + "\n\n" + entry;
|
||||
workspaceFileService.saveFile(agentId, dailyFilename, newContent);
|
||||
log.info("[Memory] Appended daily entry to {} for agent={}", dailyFilename, agentId);
|
||||
saveMemory(agentId, dailyFilename, newContent, ownerKey);
|
||||
log.info("[Memory] Appended daily entry to {} for agent={}, owner={}", dailyFilename, agentId, ownerKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,8 +204,8 @@ public class MemorySummarizationService {
|
||||
if (!memoryNode.isNull() && memoryNode.isTextual()) {
|
||||
String content = memoryNode.asText().trim();
|
||||
if (!content.isEmpty()) {
|
||||
workspaceFileService.saveFile(agentId, "MEMORY.md", content);
|
||||
log.info("[Memory] Updated MEMORY.md for agent={}", agentId);
|
||||
saveMemory(agentId, "MEMORY.md", content, ownerKey);
|
||||
log.info("[Memory] Updated MEMORY.md for agent={}, owner={}", agentId, ownerKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,10 +214,44 @@ public class MemorySummarizationService {
|
||||
if (!profileNode.isNull() && profileNode.isTextual()) {
|
||||
String content = profileNode.asText().trim();
|
||||
if (!content.isEmpty()) {
|
||||
workspaceFileService.saveFile(agentId, "PROFILE.md", content);
|
||||
log.info("[Memory] Updated PROFILE.md for agent={}", agentId);
|
||||
saveMemory(agentId, "PROFILE.md", content, ownerKey);
|
||||
log.info("[Memory] Updated PROFILE.md for agent={}, owner={}", agentId, ownerKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Structured entries: route typed facts (especially volatile project /
|
||||
// reference facts kept out of the always-on MEMORY.md) into structured
|
||||
// memory so they become query-conditioned recallable, instead of being
|
||||
// stranded in daily notes that only the agent's tools can reach.
|
||||
applyStructuredEntries(agentId, root.path("structured_entries"), ownerKey);
|
||||
}
|
||||
|
||||
private void applyStructuredEntries(Long agentId, JsonNode entriesNode, String ownerKey) {
|
||||
if (entriesNode == null || !entriesNode.isArray() || entriesNode.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int written = 0;
|
||||
for (JsonNode entry : entriesNode) {
|
||||
String type = entry.path("type").asText("").trim().toLowerCase();
|
||||
String key = entry.path("key").asText("").trim();
|
||||
String content = entry.path("content").asText("").trim();
|
||||
if (!STRUCTURED_TYPES.contains(type) || key.isEmpty() || content.isEmpty()) {
|
||||
log.debug("[Memory] Skipping invalid structured entry (type={}, key={}) for agent={}",
|
||||
type, key, agentId);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
structuredMemoryService.remember(agentId, type, key, content, "auto-summary", ownerKey);
|
||||
written++;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Memory] Failed to write structured entry '{}' (type={}) for agent={}: {}",
|
||||
key, type, agentId, e.getMessage());
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
log.info("[Memory] Routed {} structured entr{} for agent={}",
|
||||
written, written == 1 ? "y" : "ies", agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildTranscript(List<MessageEntity> messages) {
|
||||
@ -247,15 +307,40 @@ public class MemorySummarizationService {
|
||||
}
|
||||
}
|
||||
|
||||
private String readFileContentSafe(Long agentId, String filename) {
|
||||
/**
|
||||
* Read an owner-scoped memory file. When {@code ownerKey} denotes a real
|
||||
* owner the row is looked up by (agent, filename, owner); otherwise it falls
|
||||
* back to the shared file so cron / system extraction keeps working.
|
||||
*/
|
||||
private String readFileContentSafe(Long agentId, String filename, String ownerKey) {
|
||||
try {
|
||||
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
|
||||
WorkspaceFileEntity file = isPersonal(ownerKey)
|
||||
? workspaceFileService.getMemoryFile(agentId, filename, ownerKey)
|
||||
: workspaceFileService.getFile(agentId, filename);
|
||||
return file != null && file.getContent() != null ? file.getContent() : "";
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist extracted memory to the owner's PERSONAL bucket, or to the shared
|
||||
* (TEAM) file when there is no real owner (cron / system).
|
||||
*/
|
||||
private void saveMemory(Long agentId, String filename, String content, String ownerKey) {
|
||||
if (isPersonal(ownerKey)) {
|
||||
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
|
||||
} else {
|
||||
workspaceFileService.saveFile(agentId, filename, content);
|
||||
}
|
||||
}
|
||||
|
||||
/** A real, isolatable owner — i.e. not null/blank and not the system bucket. */
|
||||
private boolean isPersonal(String ownerKey) {
|
||||
return ownerKey != null && !ownerKey.isBlank()
|
||||
&& !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带轻量重试的 LLM 调用:遇到 429 时等待后重试,避免后台任务因限流直接放弃。
|
||||
* Spring AI RetryTemplate 已处理第一层重试,此方法作为二次保护。
|
||||
@ -292,8 +377,8 @@ public class MemorySummarizationService {
|
||||
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
|
||||
}
|
||||
|
||||
private boolean isInCooldown(Long agentId) {
|
||||
Instant lastRun = lastRunTimes.get(agentId);
|
||||
private boolean isInCooldown(String lockKey) {
|
||||
Instant lastRun = lastRunTimes.get(lockKey);
|
||||
if (lastRun == null) return false;
|
||||
long cooldownSeconds = properties.getCooldownMinutes() * 60L;
|
||||
return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds));
|
||||
|
||||
@ -36,6 +36,73 @@ public class StructuredMemoryService {
|
||||
private static final Set<String> VALID_TYPES = Set.of("user", "feedback", "project", "reference");
|
||||
private static final Pattern SECTION_PATTERN = Pattern.compile("^## (.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Stable, low-volume entry types injected unconditionally into the system prompt.
|
||||
* These describe the user and their durable preferences, so they stay relevant
|
||||
* across every turn and keep the system prefix cacheable.
|
||||
*/
|
||||
private static final List<String> SYSTEM_PROMPT_TYPES = List.of("user", "feedback");
|
||||
|
||||
/**
|
||||
* Growing, easily-confused entry types (specific project facts, reference notes)
|
||||
* surfaced only when the current question matches them. Always-on injection of
|
||||
* these competes with general knowledge in the prompt and causes the model to
|
||||
* confuse a specific stored fact with similarly-shaped background information.
|
||||
*/
|
||||
private static final List<String> PREFETCH_TYPES = List.of("project", "reference");
|
||||
|
||||
/** Maximum number of entries injected by a single query-conditioned prefetch. */
|
||||
private static final int MAX_PREFETCH_ENTRIES = 6;
|
||||
|
||||
/**
|
||||
* Appended to the prefetch block header when a {@code project}-type entry is
|
||||
* included, i.e. the user's own current project was recalled for this turn.
|
||||
* Downstream prompt assembly detects this marker to avoid also injecting
|
||||
* knowledge-base reference context that would compete for "what project is
|
||||
* this" — personal project memory is authoritative over reference articles.
|
||||
*/
|
||||
public static final String PROJECT_RECALLED_MARKER = "includes the user's current project";
|
||||
|
||||
/** Latin word tokens of length >= 2 used for relevance shingling. */
|
||||
private static final Pattern WORD_RE = Pattern.compile("[a-z0-9]{2,}");
|
||||
|
||||
/** Captures the ISO update date from an entry's metadata line ("> ... | Updated: YYYY-MM-DD"). */
|
||||
private static final Pattern UPDATED_RE = Pattern.compile("Updated:\\s*(\\d{4}-\\d{2}-\\d{2})");
|
||||
|
||||
/**
|
||||
* Domain aliases bridging natural-language question terms to entry keys/types.
|
||||
* Plain substring/shingle overlap misses cross-language matches such as the
|
||||
* question term "技术栈" against the key "project_tech_stack", so each alias
|
||||
* boosts entries whose key contains one of {@code keySubstrings} or whose type
|
||||
* equals {@code type} when any of its {@code queryTerms} appears in the question.
|
||||
*/
|
||||
private static final List<Alias> ALIASES = List.of(
|
||||
new Alias(List.of("代号", "项目代号", "codename", "code name"),
|
||||
List.of("codename", "code_name", "code"), null),
|
||||
new Alias(List.of("技术栈", "技术", "技术堆栈", "tech stack", "techstack", "technology", "stack"),
|
||||
List.of("tech", "stack", "技术"), null),
|
||||
new Alias(List.of("偏好", "风格", "习惯", "preference", "style"),
|
||||
List.of("pref", "style", "偏好", "风格"), null),
|
||||
new Alias(List.of("项目", "project"),
|
||||
List.of(), "project")
|
||||
);
|
||||
|
||||
/** A natural-language-to-entry alias rule used by relevance scoring. */
|
||||
private record Alias(List<String> queryTerms, List<String> keySubstrings, String type) {
|
||||
boolean matchesQuery(String query) {
|
||||
return queryTerms.stream().anyMatch(query::contains);
|
||||
}
|
||||
|
||||
boolean matchesEntry(String entryType, String keyLower) {
|
||||
boolean keyHit = keySubstrings.stream().anyMatch(keyLower::contains);
|
||||
boolean typeHit = type != null && type.equals(entryType);
|
||||
return keyHit || typeHit;
|
||||
}
|
||||
}
|
||||
|
||||
/** A structured entry with its relevance score and update date for the current query. */
|
||||
private record ScoredEntry(String type, String key, String body, int score, String updated) {}
|
||||
|
||||
private final WorkspaceFileService workspaceFileService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@ -47,13 +114,18 @@ public class StructuredMemoryService {
|
||||
* Uses per-file locking to handle concurrent tool calls writing to the same file.
|
||||
*/
|
||||
public void remember(Long agentId, String type, String key, String content, String source) {
|
||||
remember(agentId, type, key, content, source, null);
|
||||
}
|
||||
|
||||
/** Owner-scoped variant of {@link #remember}. */
|
||||
public void remember(Long agentId, String type, String key, String content, String source, String ownerKey) {
|
||||
validateType(type);
|
||||
String filename = toFilename(type);
|
||||
String lockKey = agentId + ":" + filename;
|
||||
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename;
|
||||
ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try {
|
||||
String fileContent = readFileSafe(agentId, filename);
|
||||
String fileContent = readFileSafe(agentId, filename, ownerKey);
|
||||
|
||||
String metadata = "> Source: " + (source != null ? source : "agent")
|
||||
+ " | Updated: " + LocalDate.now();
|
||||
@ -69,7 +141,7 @@ public class StructuredMemoryService {
|
||||
updated = fileContent.isBlank() ? newSection : fileContent.trim() + "\n\n" + newSection;
|
||||
}
|
||||
|
||||
workspaceFileService.saveFile(agentId, filename, updated);
|
||||
saveStructured(agentId, filename, updated, ownerKey);
|
||||
log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})",
|
||||
existingSection != null ? "Updated" : "Added", key, agentId, source);
|
||||
// Publish event for SOUL auto-evolution (Phase 2)
|
||||
@ -83,6 +155,11 @@ public class StructuredMemoryService {
|
||||
* Search entries by type and optional keyword.
|
||||
*/
|
||||
public List<Map<String, String>> recall(Long agentId, String type, String keyword) {
|
||||
return recall(agentId, type, keyword, null);
|
||||
}
|
||||
|
||||
/** Owner-scoped variant of {@link #recall(Long, String, String)}. */
|
||||
public List<Map<String, String>> recall(Long agentId, String type, String keyword, String ownerKey) {
|
||||
if (type != null) {
|
||||
validateType(type);
|
||||
}
|
||||
@ -91,7 +168,7 @@ public class StructuredMemoryService {
|
||||
List<Map<String, String>> results = new ArrayList<>();
|
||||
|
||||
for (String t : types) {
|
||||
String fileContent = readFileSafe(agentId, toFilename(t));
|
||||
String fileContent = readFileSafe(agentId, toFilename(t), ownerKey);
|
||||
if (fileContent.isBlank()) continue;
|
||||
|
||||
Map<String, String> sections = parseSections(fileContent);
|
||||
@ -114,13 +191,18 @@ public class StructuredMemoryService {
|
||||
* Remove a memory entry by type and key.
|
||||
*/
|
||||
public boolean forget(Long agentId, String type, String key) {
|
||||
return forget(agentId, type, key, null);
|
||||
}
|
||||
|
||||
/** Owner-scoped variant of {@link #forget(Long, String, String)}. */
|
||||
public boolean forget(Long agentId, String type, String key, String ownerKey) {
|
||||
validateType(type);
|
||||
String filename = toFilename(type);
|
||||
String lockKey = agentId + ":" + filename;
|
||||
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename;
|
||||
ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try {
|
||||
String fileContent = readFileSafe(agentId, filename);
|
||||
String fileContent = readFileSafe(agentId, filename, ownerKey);
|
||||
if (fileContent.isBlank()) return false;
|
||||
|
||||
String section = findSection(fileContent, key);
|
||||
@ -129,7 +211,7 @@ public class StructuredMemoryService {
|
||||
String updated = fileContent.replace(section, "").trim();
|
||||
// Clean up double blank lines
|
||||
updated = updated.replaceAll("\n{3,}", "\n\n");
|
||||
workspaceFileService.saveFile(agentId, filename, updated);
|
||||
saveStructured(agentId, filename, updated, ownerKey);
|
||||
log.info("[StructuredMemory] Removed entry '{}' (type={}) for agent={}", key, type, agentId);
|
||||
return true;
|
||||
} finally {
|
||||
@ -144,16 +226,27 @@ public class StructuredMemoryService {
|
||||
return recall(agentId, type, null);
|
||||
}
|
||||
|
||||
/** Owner-scoped variant of {@link #listEntries(Long, String)}. */
|
||||
public List<Map<String, String>> listEntries(Long agentId, String type, String ownerKey) {
|
||||
return recall(agentId, type, null, ownerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a formatted memory block for system prompt injection.
|
||||
* Returns all typed entries formatted as Markdown.
|
||||
* Includes only the stable, low-volume entry types ({@link #SYSTEM_PROMPT_TYPES});
|
||||
* growing/specific types are surfaced per-turn via {@link #buildPrefetchBlock}.
|
||||
*/
|
||||
public String buildMemoryBlock(Long agentId) {
|
||||
return buildMemoryBlock(agentId, null);
|
||||
}
|
||||
|
||||
/** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */
|
||||
public String buildMemoryBlock(Long agentId, String ownerKey) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean hasContent = false;
|
||||
|
||||
for (String type : List.of("user", "feedback", "project", "reference")) {
|
||||
String fileContent = readFileSafe(agentId, toFilename(type));
|
||||
for (String type : SYSTEM_PROMPT_TYPES) {
|
||||
String fileContent = readFileSafe(agentId, toFilename(type), ownerKey);
|
||||
if (fileContent.isBlank()) continue;
|
||||
|
||||
Map<String, String> sections = parseSections(fileContent);
|
||||
@ -176,8 +269,133 @@ public class StructuredMemoryService {
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a query-conditioned memory block for per-turn prefetch injection.
|
||||
* Scores {@link #PREFETCH_TYPES} entries against the user's question and returns
|
||||
* the top matches as Markdown, or an empty string when nothing is relevant.
|
||||
* Keeping these entries out of the always-on system prompt avoids salience
|
||||
* competition that would otherwise let the model answer from general knowledge
|
||||
* instead of the specific stored fact.
|
||||
*/
|
||||
public String buildPrefetchBlock(Long agentId, String userQuery) {
|
||||
return buildPrefetchBlock(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
/** Owner-scoped variant of {@link #buildPrefetchBlock(Long, String)}. */
|
||||
public String buildPrefetchBlock(Long agentId, String userQuery, String ownerKey) {
|
||||
if (userQuery == null || userQuery.isBlank()) return "";
|
||||
|
||||
List<ScoredEntry> scored = recallRelevant(agentId, userQuery, PREFETCH_TYPES, MAX_PREFETCH_ENTRIES, ownerKey);
|
||||
if (scored.isEmpty()) return "";
|
||||
|
||||
boolean hasProject = scored.stream().anyMatch(e -> "project".equals(e.type()));
|
||||
StringBuilder sb = new StringBuilder("## Relevant Structured Memory");
|
||||
if (hasProject) {
|
||||
sb.append(" (").append(PROJECT_RECALLED_MARKER).append(")");
|
||||
}
|
||||
sb.append("\n");
|
||||
for (ScoredEntry e : scored) {
|
||||
sb.append("- **").append(e.key()).append("**: ")
|
||||
.append(extractContentOnly(e.body()));
|
||||
if (!e.updated().isBlank()) {
|
||||
sb.append(" _(updated ").append(e.updated()).append(")_");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
// ==================== Internal ====================
|
||||
|
||||
/**
|
||||
* Score entries of the given types against the user query and return the
|
||||
* highest-scoring matches (score > 0), best first, capped at {@code limit}.
|
||||
*/
|
||||
private List<ScoredEntry> recallRelevant(Long agentId, String userQuery, List<String> types, int limit) {
|
||||
return recallRelevant(agentId, userQuery, types, limit, null);
|
||||
}
|
||||
|
||||
private List<ScoredEntry> recallRelevant(Long agentId, String userQuery, List<String> types, int limit, String ownerKey) {
|
||||
String q = userQuery.toLowerCase();
|
||||
Set<String> queryShingles = shingles(q);
|
||||
|
||||
List<ScoredEntry> matches = new ArrayList<>();
|
||||
for (String t : types) {
|
||||
String fileContent = readFileSafe(agentId, toFilename(t), ownerKey);
|
||||
if (fileContent.isBlank()) continue;
|
||||
|
||||
for (Map.Entry<String, String> entry : parseSections(fileContent).entrySet()) {
|
||||
int score = scoreEntry(q, queryShingles, t, entry.getKey(), entry.getValue());
|
||||
if (score > 0) {
|
||||
matches.add(new ScoredEntry(t, entry.getKey(), entry.getValue(),
|
||||
score, extractUpdated(entry.getValue())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Most relevant first; break ties by recency so the freshest fact wins a conflict.
|
||||
matches.sort(Comparator.comparingInt(ScoredEntry::score).reversed()
|
||||
.thenComparing(Comparator.comparing(ScoredEntry::updated).reversed()));
|
||||
return matches.size() > limit ? matches.subList(0, limit) : matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine three lightweight relevance signals into a single score:
|
||||
* key-token presence in the query, domain-alias boosts, and character-level
|
||||
* shingle overlap (CJK bigrams + Latin word tokens) between the query and entry.
|
||||
*/
|
||||
private int scoreEntry(String query, Set<String> queryShingles, String type, String key, String body) {
|
||||
int score = 0;
|
||||
String keyLower = key.toLowerCase();
|
||||
|
||||
// 1. Key tokens appearing verbatim in the query.
|
||||
for (String token : keyLower.split("[_\\s-]+")) {
|
||||
if (token.length() >= 2 && query.contains(token)) score += 4;
|
||||
}
|
||||
|
||||
// 2. Domain-alias boosts for cross-language question/key matches.
|
||||
for (Alias alias : ALIASES) {
|
||||
if (alias.matchesQuery(query) && alias.matchesEntry(type, keyLower)) score += 6;
|
||||
}
|
||||
|
||||
// 3. Shingle overlap between the query and the entry text (capped).
|
||||
Set<String> entryShingles = shingles((key + " " + body).toLowerCase());
|
||||
int overlap = 0;
|
||||
for (String s : entryShingles) {
|
||||
if (queryShingles.contains(s)) overlap++;
|
||||
}
|
||||
score += Math.min(overlap, 6);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a language-agnostic shingle set: Latin word tokens (length >= 2)
|
||||
* plus CJK character bigrams (single CJK characters when isolated). This lets
|
||||
* relevance scoring work without a word segmenter on space-free CJK text.
|
||||
*/
|
||||
private static Set<String> shingles(String text) {
|
||||
Set<String> out = new HashSet<>();
|
||||
|
||||
Matcher m = WORD_RE.matcher(text);
|
||||
while (m.find()) {
|
||||
out.add(m.group());
|
||||
}
|
||||
|
||||
for (String run : text.replaceAll("[^\\p{IsHan}]", " ").split("\\s+")) {
|
||||
if (run.isEmpty()) continue;
|
||||
if (run.length() == 1) {
|
||||
out.add(run);
|
||||
} else {
|
||||
for (int i = 0; i + 2 <= run.length(); i++) {
|
||||
out.add(run.substring(i, i + 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private String toFilename(String type) {
|
||||
return "structured/" + type + ".md";
|
||||
}
|
||||
@ -190,14 +408,35 @@ public class StructuredMemoryService {
|
||||
}
|
||||
|
||||
private String readFileSafe(Long agentId, String filename) {
|
||||
return readFileSafe(agentId, filename, null);
|
||||
}
|
||||
|
||||
private String readFileSafe(Long agentId, String filename, String ownerKey) {
|
||||
try {
|
||||
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
|
||||
WorkspaceFileEntity file = isPersonal(ownerKey)
|
||||
? workspaceFileService.getMemoryFile(agentId, filename, ownerKey)
|
||||
: workspaceFileService.getFile(agentId, filename);
|
||||
return file != null && file.getContent() != null ? file.getContent() : "";
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist structured memory to the owner's PERSONAL bucket, or shared when no real owner. */
|
||||
private void saveStructured(Long agentId, String filename, String content, String ownerKey) {
|
||||
if (isPersonal(ownerKey)) {
|
||||
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
|
||||
} else {
|
||||
workspaceFileService.saveFile(agentId, filename, content);
|
||||
}
|
||||
}
|
||||
|
||||
/** A real, isolatable owner — not null/blank and not the system bucket. */
|
||||
private boolean isPersonal(String ownerKey) {
|
||||
return ownerKey != null && !ownerKey.isBlank()
|
||||
&& !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all sections from a Markdown file.
|
||||
* Returns map of key → full section content (including metadata line).
|
||||
@ -251,6 +490,12 @@ public class StructuredMemoryService {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Extract the ISO update date from an entry body's metadata line, or "" if absent. */
|
||||
private String extractUpdated(String sectionBody) {
|
||||
Matcher m = UPDATED_RE.matcher(sectionBody);
|
||||
return m.find() ? m.group(1) : "";
|
||||
}
|
||||
|
||||
private String typeDisplayName(String type) {
|
||||
return switch (type) {
|
||||
case "user" -> "User Profile";
|
||||
|
||||
@ -104,10 +104,19 @@ public class MemoryManager {
|
||||
* context as new user discourse.
|
||||
*/
|
||||
public String prefetchAll(Long agentId, String userQuery) {
|
||||
return prefetchAll(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-scoped prefetch. Passes the resolved memory {@code ownerKey} so
|
||||
* providers recall only the current requester's personal memory plus
|
||||
* shared (TEAM / GLOBAL) memory (per-owner isolation).
|
||||
*/
|
||||
public String prefetchAll(Long agentId, String userQuery, String ownerKey) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (MemoryProvider provider : providers) {
|
||||
try {
|
||||
String result = provider.prefetch(agentId, userQuery);
|
||||
String result = provider.prefetch(agentId, userQuery, ownerKey);
|
||||
if (result != null && !result.isBlank()) {
|
||||
parts.add(sanitizeContext(result));
|
||||
}
|
||||
@ -206,8 +215,13 @@ public class MemoryManager {
|
||||
*/
|
||||
private String buildMemoryContextBlock(String rawContext) {
|
||||
return "<memory-context>\n"
|
||||
+ "[System note: The following is recalled memory context, "
|
||||
+ "NOT new user input. Treat as informational background data.]\n\n"
|
||||
+ "The following is what you already know about this user and their "
|
||||
+ "work, recalled from your own long-term memory. Use it directly as "
|
||||
+ "established fact when answering — this is your knowledge, not the "
|
||||
+ "user speaking. If something the user asks about is not covered here, "
|
||||
+ "say you do not have it in memory rather than guessing. If entries "
|
||||
+ "conflict, prefer the most recently updated one; if they refer to "
|
||||
+ "different projects, ask which one the user means.\n\n"
|
||||
+ rawContext + "\n"
|
||||
+ "</memory-context>";
|
||||
}
|
||||
|
||||
@ -64,6 +64,21 @@ public interface MemoryProvider {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-scoped pre-turn recall. Providers that isolate memory per end-user
|
||||
* override this to recall only the given {@code ownerKey}'s personal memory
|
||||
* plus shared memory. Default delegates to {@link #prefetch(Long, String)}
|
||||
* for providers that are not owner-aware.
|
||||
*
|
||||
* @param agentId the agent ID
|
||||
* @param userQuery the current user message
|
||||
* @param ownerKey resolved memory owner key (e.g. "user:42"); may be null
|
||||
* @return context text to inject, wrapped in a memory-context fence by MemoryManager
|
||||
*/
|
||||
default String prefetch(Long agentId, String userQuery, String ownerKey) {
|
||||
return prefetch(agentId, userQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-turn sync. Called after LLM response is available.
|
||||
* Should be non-blocking (async).
|
||||
|
||||
@ -24,6 +24,7 @@ public abstract class MemoryProviderDecorator implements MemoryProvider {
|
||||
@Override public boolean isAvailable() { return delegate.isAvailable(); }
|
||||
@Override public String systemPromptBlock(Long agentId) { return delegate.systemPromptBlock(agentId); }
|
||||
@Override public String prefetch(Long agentId, String userQuery) { return delegate.prefetch(agentId, userQuery); }
|
||||
@Override public String prefetch(Long agentId, String userQuery, String ownerKey) { return delegate.prefetch(agentId, userQuery, ownerKey); }
|
||||
@Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
|
||||
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
|
||||
}
|
||||
|
||||
@ -40,9 +40,14 @@ public class MetricsMemoryProvider extends MemoryProviderDecorator {
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery) {
|
||||
return prefetch(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery, String ownerKey) {
|
||||
return prefetchTimer.record(() -> {
|
||||
try {
|
||||
return delegate.prefetch(agentId, userQuery);
|
||||
return delegate.prefetch(agentId, userQuery, ownerKey);
|
||||
} catch (Exception e) {
|
||||
meterRegistry.counter("memory.prefetch.failures",
|
||||
"provider", delegate.id()).increment();
|
||||
|
||||
@ -20,10 +20,15 @@ public class RetryableMemoryProvider extends MemoryProviderDecorator {
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery) {
|
||||
return prefetch(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String prefetch(Long agentId, String userQuery, String ownerKey) {
|
||||
Exception lastException = null;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return delegate.prefetch(agentId, userQuery);
|
||||
return delegate.prefetch(agentId, userQuery, ownerKey);
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
if (attempt < maxAttempts) {
|
||||
|
||||
@ -4,9 +4,13 @@ import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.identity.MemoryOwnerResolver;
|
||||
import vip.mate.memory.service.StructuredMemoryService;
|
||||
|
||||
import java.util.List;
|
||||
@ -28,6 +32,22 @@ import java.util.Map;
|
||||
public class StructuredMemoryTool {
|
||||
|
||||
private final StructuredMemoryService structuredMemoryService;
|
||||
private final MemoryOwnerResolver memoryOwnerResolver;
|
||||
private final MemoryProperties memoryProperties;
|
||||
|
||||
/** Owner key for reads: the resolved requester (visibility = shared + own personal). */
|
||||
private String readOwner(ToolContext ctx) {
|
||||
return memoryOwnerResolver.resolve(ChatOrigin.from(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner key for writes/deletes: the resolved requester only when per-owner
|
||||
* isolation is active; otherwise null so the entry lands in the shared
|
||||
* bucket rather than an un-read PERSONAL row.
|
||||
*/
|
||||
private String writeOwner(ToolContext ctx) {
|
||||
return memoryProperties.isLifecycleMediatorEnabled() ? readOwner(ctx) : null;
|
||||
}
|
||||
|
||||
@Tool(description = """
|
||||
记住一条结构化信息到 Agent 的长期记忆。
|
||||
@ -43,7 +63,8 @@ public class StructuredMemoryTool {
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "记忆类型:user / feedback / project / reference") String type,
|
||||
@ToolParam(description = "条目标识符(snake_case),例如 preferred_language") String key,
|
||||
@ToolParam(description = "条目内容") String content) {
|
||||
@ToolParam(description = "条目内容") String content,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null || type == null || key == null || content == null) {
|
||||
return error("agentId, type, key, content 均不能为空");
|
||||
@ -51,7 +72,7 @@ public class StructuredMemoryTool {
|
||||
|
||||
try {
|
||||
structuredMemoryService.remember(agentId, type.trim().toLowerCase(),
|
||||
key.trim(), content.trim(), "agent");
|
||||
key.trim(), content.trim(), "agent", writeOwner(toolContext));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", true);
|
||||
@ -75,7 +96,8 @@ public class StructuredMemoryTool {
|
||||
public String recall_structured(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "记忆类型过滤(可选):user / feedback / project / reference", required = false) String type,
|
||||
@ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword) {
|
||||
@ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null) {
|
||||
return error("agentId 不能为空");
|
||||
@ -85,7 +107,8 @@ public class StructuredMemoryTool {
|
||||
List<Map<String, String>> results = structuredMemoryService.recall(
|
||||
agentId,
|
||||
type != null && !type.isBlank() ? type.trim().toLowerCase() : null,
|
||||
keyword);
|
||||
keyword,
|
||||
readOwner(toolContext));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("agentId", agentId);
|
||||
@ -107,7 +130,8 @@ public class StructuredMemoryTool {
|
||||
public String forget_structured(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "记忆类型:user / feedback / project / reference") String type,
|
||||
@ToolParam(description = "要删除的条目标识符") String key) {
|
||||
@ToolParam(description = "要删除的条目标识符") String key,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null || type == null || key == null) {
|
||||
return error("agentId, type, key 均不能为空");
|
||||
@ -115,7 +139,7 @@ public class StructuredMemoryTool {
|
||||
|
||||
try {
|
||||
boolean removed = structuredMemoryService.forget(agentId,
|
||||
type.trim().toLowerCase(), key.trim());
|
||||
type.trim().toLowerCase(), key.trim(), writeOwner(toolContext));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", removed);
|
||||
|
||||
@ -4,11 +4,15 @@ import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.event.MemoryWriteEvent;
|
||||
import vip.mate.memory.identity.MemoryOwnerResolver;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
|
||||
@ -41,6 +45,8 @@ public class UniversalMemoryTool {
|
||||
|
||||
private final WorkspaceFileService workspaceFileService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final MemoryOwnerResolver memoryOwnerResolver;
|
||||
private final MemoryProperties memoryProperties;
|
||||
|
||||
@Tool(description = """
|
||||
将一条自由形式的经验或洞察追加到 Agent 的长期记忆 (MEMORY.md)。
|
||||
@ -51,17 +57,24 @@ public class UniversalMemoryTool {
|
||||
public String remember(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "要记住的内容(自由形式)") String content,
|
||||
@ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source) {
|
||||
@ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null) return error("agentId 不能为空");
|
||||
if (content == null || content.isBlank()) return error("content 不能为空");
|
||||
|
||||
try {
|
||||
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, MEMORY_FILENAME);
|
||||
// Write to the requester's PERSONAL MEMORY.md when per-owner isolation
|
||||
// is active; otherwise the shared file (so the note is not stranded
|
||||
// in an un-read PERSONAL row).
|
||||
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
|
||||
? memoryOwnerResolver.resolve(ChatOrigin.from(toolContext))
|
||||
: null;
|
||||
WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, MEMORY_FILENAME, ownerKey);
|
||||
String existingContent = existing != null && existing.getContent() != null
|
||||
? existing.getContent() : "";
|
||||
String updated = appendLesson(existingContent, content, source);
|
||||
workspaceFileService.saveFile(agentId, MEMORY_FILENAME, updated);
|
||||
workspaceFileService.saveVisibleFile(agentId, MEMORY_FILENAME, updated, ownerKey);
|
||||
|
||||
// RFC-090 §14.3 — universal remember() targets MEMORY.md (the
|
||||
// canonical file), so this IS a MemoryWriteEvent. Skill-local
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package vip.mate.skill.installer;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.skill.installer.model.SkillBundle;
|
||||
import vip.mate.skill.runtime.SkillFrontmatterParser;
|
||||
@ -23,12 +24,27 @@ import java.util.concurrent.TimeUnit;
|
||||
@Component
|
||||
public class GitSkillFetcher {
|
||||
|
||||
private static final long CLONE_TIMEOUT_SECONDS = 60;
|
||||
private static final long CLONE_TIMEOUT_SECONDS = 120;
|
||||
|
||||
private final SkillFrontmatterParser frontmatterParser;
|
||||
|
||||
public GitSkillFetcher(SkillFrontmatterParser frontmatterParser) {
|
||||
/**
|
||||
* GitHub access token used when cloning private repositories.
|
||||
* Resolution order: {@code mateclaw.skill.github-token} property → {@code GITHUB_TOKEN}
|
||||
* environment variable → empty (public repos only). Kept as a plain field so the value
|
||||
* is never logged or embedded in URLs — it is passed to the git subprocess through
|
||||
* dedicated environment variables (see {@link #cloneRepo}).
|
||||
*/
|
||||
private final String githubToken;
|
||||
|
||||
public GitSkillFetcher(
|
||||
SkillFrontmatterParser frontmatterParser,
|
||||
@Value("${mateclaw.skill.github-token:}") String configuredGithubToken) {
|
||||
this.frontmatterParser = frontmatterParser;
|
||||
String token = (configuredGithubToken != null && !configuredGithubToken.isBlank())
|
||||
? configuredGithubToken
|
||||
: System.getenv("GITHUB_TOKEN");
|
||||
this.githubToken = (token == null) ? "" : token.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,7 +114,13 @@ public class GitSkillFetcher {
|
||||
}
|
||||
|
||||
/**
|
||||
* git clone --depth 1 到临时目录
|
||||
* git clone --depth 1 to a temporary directory.
|
||||
* <p>
|
||||
* When a GitHub token is configured and the repository is hosted on github.com,
|
||||
* the credential is forwarded to the git subprocess through {@code GIT_CONFIG_*}
|
||||
* environment variables — equivalent to {@code git -c http.extraHeader=...} but
|
||||
* without ever placing the token in the process command line (visible to {@code ps})
|
||||
* or the repository URL (visible in logs and error messages). Requires git 2.31+.
|
||||
*/
|
||||
private void cloneRepo(String repoUrl, String ref, Path targetDir) throws IOException, InterruptedException {
|
||||
var command = new java.util.ArrayList<String>();
|
||||
@ -115,6 +137,19 @@ public class GitSkillFetcher {
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
// Inject credentials for private GitHub repos via the git subprocess environment.
|
||||
// Keeping the token out of argv and out of the URL ensures it cannot leak through
|
||||
// process listings, the INFO log below, or the IOException message on clone failure.
|
||||
if (!githubToken.isEmpty() && isGithubHost(repoUrl)) {
|
||||
var env = pb.environment();
|
||||
env.put("GIT_CONFIG_COUNT", "1");
|
||||
env.put("GIT_CONFIG_KEY_0", "http.extraHeader");
|
||||
env.put("GIT_CONFIG_VALUE_0", "Authorization: Bearer " + githubToken);
|
||||
// Fail fast on auth errors instead of blocking on an interactive password prompt.
|
||||
env.put("GIT_TERMINAL_PROMPT", "0");
|
||||
}
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
boolean finished = process.waitFor(CLONE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
@ -132,6 +167,17 @@ public class GitSkillFetcher {
|
||||
log.info("Cloned {} (ref={}) to {}", repoUrl, ref, targetDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match GitHub host conservatively (scheme + host boundary) so a malicious URL like
|
||||
* {@code https://evil.com/?u=github.com/...} cannot smuggle the token to a third party.
|
||||
*/
|
||||
private static boolean isGithubHost(String repoUrl) {
|
||||
return repoUrl != null
|
||||
&& (repoUrl.startsWith("https://github.com/")
|
||||
|| repoUrl.startsWith("http://github.com/")
|
||||
|| repoUrl.startsWith("git@github.com:"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 skill 根目录中定位 SKILL.md
|
||||
*/
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.skill.workspace;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import vip.mate.skill.installer.SkillHubProperties;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
|
||||
/**
|
||||
* Skill 工作区与安装器自动配置
|
||||
@ -12,4 +13,15 @@ import vip.mate.skill.installer.SkillHubProperties;
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({SkillWorkspaceProperties.class, SkillHubProperties.class})
|
||||
public class SkillWorkspaceAutoConfiguration {
|
||||
|
||||
/**
|
||||
* Register the shared skill repository root with the workspace path sandbox.
|
||||
* Skills are shared across all workspaces and live outside any single
|
||||
* workspace directory, so the sandbox must trust their root in addition to
|
||||
* the active workspace — otherwise reading or running a skill's files from a
|
||||
* workspace configured elsewhere is rejected as a boundary violation.
|
||||
*/
|
||||
public SkillWorkspaceAutoConfiguration(SkillWorkspaceProperties skillWorkspaceProperties) {
|
||||
WorkspacePathGuard.setSkillRoot(skillWorkspaceProperties.getRoot());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,14 +3,15 @@ package vip.mate.tool.builtin;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@ -69,14 +70,28 @@ public class DocumentExtractTool {
|
||||
""")
|
||||
public String extract_document_text(
|
||||
@ToolParam(description = "文件的绝对路径或相对路径") String filePath,
|
||||
@ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options) {
|
||||
@ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options,
|
||||
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the
|
||||
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("filePath", filePath);
|
||||
List<String> attempts = new ArrayList<>();
|
||||
|
||||
try {
|
||||
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
||||
Path path;
|
||||
try {
|
||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Sandbox rejected the literal path. Try chat-upload basename
|
||||
// resolution before surfacing the boundary error.
|
||||
Path attachment = ChatUploadResolver.resolve(filePath);
|
||||
if (attachment == null) {
|
||||
return errorResult(filePath, e.getMessage(), attempts);
|
||||
}
|
||||
path = attachment;
|
||||
}
|
||||
|
||||
if (!Files.exists(path)) {
|
||||
// The user-uploaded chat attachment is rendered to the LLM as
|
||||
@ -185,10 +200,11 @@ public class DocumentExtractTool {
|
||||
""")
|
||||
public String extract_pdf_text(
|
||||
@ToolParam(description = "PDF 文件的绝对路径或相对路径") String filePath,
|
||||
@ToolParam(description = "页码范围,如 \"1-5\" 或 \"1,3,5\"", required = false) String pages) {
|
||||
@ToolParam(description = "页码范围,如 \"1-5\" 或 \"1,3,5\"", required = false) String pages,
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
String options = pages != null ? "{\"pages\": \"" + pages + "\"}" : null;
|
||||
return extract_document_text(filePath, options);
|
||||
return extract_document_text(filePath, options, ctx);
|
||||
}
|
||||
|
||||
@Tool(description = """
|
||||
@ -201,8 +217,9 @@ public class DocumentExtractTool {
|
||||
支持 .docx 和 .doc 格式
|
||||
""")
|
||||
public String extract_docx_text(
|
||||
@ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath) {
|
||||
return extract_document_text(filePath, null);
|
||||
@ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath,
|
||||
@Nullable ToolContext ctx) {
|
||||
return extract_document_text(filePath, null, ctx);
|
||||
}
|
||||
|
||||
// ==================== PDF 提取链 ====================
|
||||
|
||||
@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
@ -12,7 +14,6 @@ import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@ -41,13 +42,28 @@ public class FileTypeDetectorTool {
|
||||
注意:对于 .docx/.pdf 等文档,不会返回 read_file,而是 extract_document_text
|
||||
""")
|
||||
public String detect_file_type(
|
||||
@ToolParam(description = "文件的绝对路径或相对路径") String filePath) {
|
||||
@ToolParam(description = "文件的绝对路径或相对路径") String filePath,
|
||||
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the
|
||||
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("filePath", filePath);
|
||||
|
||||
try {
|
||||
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
||||
Path path;
|
||||
try {
|
||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Sandbox rejected the literal path. Fall back to chat-upload
|
||||
// basename matching before surfacing the boundary error — the
|
||||
// LLM may have hallucinated a system path for a real attachment.
|
||||
Path attachment = ChatUploadResolver.resolve(filePath);
|
||||
if (attachment == null) {
|
||||
return errorResult(filePath, e.getMessage());
|
||||
}
|
||||
path = attachment;
|
||||
}
|
||||
|
||||
if (!Files.exists(path)) {
|
||||
// Fall back to chat-upload basename matching for filenames that were
|
||||
|
||||
@ -58,8 +58,12 @@ public class GoalManagementTool {
|
||||
required = false) String exitCriteria,
|
||||
@ToolParam(description = "Max evaluation turns before exhaustion. Default 20.",
|
||||
required = false) Integer turnBudget,
|
||||
@ToolParam(description = "If true, the agent may auto-followup when progress is incomplete. Default false.",
|
||||
@ToolParam(description = "If true, the agent may auto-followup when progress is incomplete. "
|
||||
+ "Omit to use the system default.",
|
||||
required = false) Boolean autoFollowup,
|
||||
@ToolParam(description = "Optional initial checklist: a list of short, individually verifiable "
|
||||
+ "acceptance criteria. Omit to let the system derive the checklist on first evaluation.",
|
||||
required = false) java.util.List<String> criteria,
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
if (!properties.isEnabled()) {
|
||||
@ -86,6 +90,16 @@ public class GoalManagementTool {
|
||||
req.setExitCriteria(exitCriteria);
|
||||
if (turnBudget != null) req.setTurnBudget(turnBudget);
|
||||
if (autoFollowup != null) req.setAutoFollowupEnabled(autoFollowup);
|
||||
if (criteria != null && !criteria.isEmpty()) {
|
||||
java.util.List<vip.mate.goal.model.GoalCriterion> items = new java.util.ArrayList<>();
|
||||
for (String text : criteria) {
|
||||
if (text != null && !text.isBlank()) {
|
||||
// Only text matters; create() assigns ids, forces passed=false, clears evidence.
|
||||
items.add(new vip.mate.goal.model.GoalCriterion("", text.trim(), false, ""));
|
||||
}
|
||||
}
|
||||
if (!items.isEmpty()) req.setCriteria(items);
|
||||
}
|
||||
|
||||
String username = origin.requesterId() != null && !origin.requesterId().isBlank()
|
||||
? origin.requesterId() : "system";
|
||||
@ -132,10 +146,14 @@ public class GoalManagementTool {
|
||||
}
|
||||
|
||||
@Tool(description = """
|
||||
Explicitly mark the active goal as completed. Use ONLY when all \
|
||||
exit criteria are satisfied (e.g. tests passed, feature deployed, \
|
||||
user confirmed). The runtime evaluator will also mark goals \
|
||||
completed automatically when score >= 0.95 — prefer that path.""")
|
||||
Explicitly mark the active goal as completed. Use ONLY when EVERY \
|
||||
checklist criterion is genuinely satisfied with concrete evidence \
|
||||
in the conversation (e.g. tests actually passed, feature actually \
|
||||
deployed, user confirmed). Do NOT call this to close out work that \
|
||||
is unfinished, blocked, or impossible. In normal operation you do \
|
||||
not need this tool at all: the runtime evaluator marks the goal \
|
||||
completed automatically once all checklist criteria pass — prefer \
|
||||
that path and just keep working.""")
|
||||
public String completeGoal(@Nullable ToolContext ctx) {
|
||||
if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled");
|
||||
GoalEntity goal = resolveActive(ctx);
|
||||
@ -145,7 +163,8 @@ public class GoalManagementTool {
|
||||
// Synthesize a completion-style evaluation result for the audit trail.
|
||||
GoalEvaluationResult synthetic = new GoalEvaluationResult(
|
||||
1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED,
|
||||
true, "manual", 0, 0L);
|
||||
true, "manual", 0, 0L,
|
||||
java.util.List.of(), null);
|
||||
try {
|
||||
GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic);
|
||||
// Broadcast a goal_completed event with the same shape as the
|
||||
@ -154,7 +173,8 @@ public class GoalManagementTool {
|
||||
if (streamTracker != null && completed.getConversationId() != null) {
|
||||
streamTracker.broadcastObject(completed.getConversationId(), "goal_completed", Map.of(
|
||||
"goalId", String.valueOf(completed.getId()),
|
||||
"score", synthetic.score()));
|
||||
"score", synthetic.score(),
|
||||
"goal", goalService.toResponse(completed)));
|
||||
}
|
||||
return successJson(Map.of(
|
||||
"goalId", String.valueOf(completed.getId()),
|
||||
@ -232,7 +252,7 @@ public class GoalManagementTool {
|
||||
streamTracker.broadcastObject(conversationId, eventName, Map.of(
|
||||
"goalId", String.valueOf(goal.getId()),
|
||||
"conversationId", conversationId,
|
||||
"goal", goal));
|
||||
"goal", goalService.toResponse(goal)));
|
||||
} catch (Exception e) {
|
||||
log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage());
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -51,7 +53,10 @@ public class ShellExecuteTool {
|
||||
+ "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.")
|
||||
public String execute_shell_command(
|
||||
@ToolParam(description = "Shell command to execute") String command,
|
||||
@ToolParam(description = "Timeout in seconds, default 60", required = false) Integer timeoutSeconds) {
|
||||
@ToolParam(description = "Timeout in seconds, default 60", required = false) Integer timeoutSeconds,
|
||||
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the
|
||||
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
||||
// 硬上限:不允许超过 300 秒
|
||||
@ -63,6 +68,21 @@ public class ShellExecuteTool {
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("command", command);
|
||||
|
||||
// Enforce the workspace boundary on the command string itself before
|
||||
// the process starts. The pb.directory() set later only constrains
|
||||
// the CWD — absolute paths in the command would still reach anywhere.
|
||||
try {
|
||||
vip.mate.tool.guard.WorkspacePathGuard.validateShellCommand(command, ctx);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("[ShellExecute] Sandbox rejected command: {}", e.getMessage());
|
||||
result.set("exitCode", -1);
|
||||
result.set("stdout", "");
|
||||
result.set("stderr", e.getMessage());
|
||||
result.set("timedOut", false);
|
||||
result.set("error", e.getMessage());
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
Path stdoutFile = null;
|
||||
Path stderrFile = null;
|
||||
|
||||
@ -71,7 +91,7 @@ public class ShellExecuteTool {
|
||||
// Windows cmd.exe 会在第一个换行处截断命令,Unix sh 也可能误解
|
||||
String sanitizedCommand = collapseEmbeddedNewlines(command);
|
||||
|
||||
ProcessBuilder pb = buildShellProcess(sanitizedCommand);
|
||||
ProcessBuilder pb = buildShellProcess(sanitizedCommand, ctx);
|
||||
// 不继承环境变量中的敏感信息
|
||||
pb.environment().keySet().removeIf(key ->
|
||||
key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")
|
||||
@ -137,7 +157,7 @@ public class ShellExecuteTool {
|
||||
* from the calling environment still apply; falls back to /bin/sh
|
||||
* when $SHELL is unset or points at a non-executable path.
|
||||
*/
|
||||
private static ProcessBuilder buildShellProcess(String command) {
|
||||
private static ProcessBuilder buildShellProcess(String command, @Nullable ToolContext ctx) {
|
||||
ProcessBuilder pb;
|
||||
if (IS_WINDOWS) {
|
||||
String winCommand = sanitizeWindowsCommand(command);
|
||||
@ -147,8 +167,13 @@ public class ShellExecuteTool {
|
||||
pb = new ProcessBuilder(shell, "-c", command);
|
||||
}
|
||||
|
||||
// 设置工作区活动目录
|
||||
java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory();
|
||||
// Pin the process cwd to the same workspace basePath the validator
|
||||
// checked against. Using getWorkingDirectory(ctx) (not the no-arg
|
||||
// ThreadLocal-only overload) keeps validation and execution on a
|
||||
// single source of truth — otherwise a caller that only sets
|
||||
// ToolContext could validate against one basePath and run with the
|
||||
// ThreadLocal fallback's basePath.
|
||||
java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory(ctx);
|
||||
if (workingDir != null && java.nio.file.Files.isDirectory(workingDir)) {
|
||||
pb.directory(workingDir.toFile());
|
||||
log.info("[ShellExecute] Working directory set to: {}", workingDir);
|
||||
|
||||
@ -21,7 +21,12 @@ public class WebSearchTool {
|
||||
|
||||
private final WebSearchService webSearchService;
|
||||
|
||||
@Tool(description = "Search the internet for latest information. Use when querying real-time news, latest data, or uncertain facts. "
|
||||
// Tool name is pinned to "web_search" rather than the method-derived "search":
|
||||
// DashScope's native protocol reserves the function name "search" and rejects the
|
||||
// whole request with "InvalidParameter: Tool names are not allowed to be [search]",
|
||||
// which breaks tool use for every qwen/DashScope-native model that has this tool bound.
|
||||
@Tool(name = "web_search",
|
||||
description = "Search the internet for latest information. Use when querying real-time news, latest data, or uncertain facts. "
|
||||
+ "Supports optional freshness, language, count parameters.")
|
||||
public String search(
|
||||
@ToolParam(description = "Search keywords") String query,
|
||||
|
||||
@ -5,9 +5,13 @@ import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.identity.MemoryOwnerResolver;
|
||||
import vip.mate.memory.service.MemoryRecallTracker;
|
||||
import vip.mate.workspace.document.MemorySearchHit;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
@ -32,6 +36,22 @@ public class WorkspaceMemoryTool {
|
||||
|
||||
private final WorkspaceFileService workspaceFileService;
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
private final MemoryOwnerResolver memoryOwnerResolver;
|
||||
private final MemoryProperties memoryProperties;
|
||||
|
||||
/** Owner key for reads: always the resolved requester (visibility = shared + own personal). */
|
||||
private String readOwner(ToolContext ctx) {
|
||||
return memoryOwnerResolver.resolve(ChatOrigin.from(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner key for writes: the resolved requester only when per-owner isolation
|
||||
* is active (lifecycle prefetch on); otherwise null so the write lands in
|
||||
* the shared bucket and is not stranded in an un-read PERSONAL row.
|
||||
*/
|
||||
private String writeOwner(ToolContext ctx) {
|
||||
return memoryProperties.isLifecycleMediatorEnabled() ? readOwner(ctx) : null;
|
||||
}
|
||||
|
||||
@Tool(description = """
|
||||
列出指定 Agent 的数据库工作区记忆文件。
|
||||
@ -40,13 +60,14 @@ public class WorkspaceMemoryTool {
|
||||
""")
|
||||
public String list_workspace_memory_files(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix) {
|
||||
@ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null) {
|
||||
return error("agentId 不能为空");
|
||||
}
|
||||
|
||||
List<WorkspaceFileEntity> files = workspaceFileService.listFiles(agentId).stream()
|
||||
List<WorkspaceFileEntity> files = workspaceFileService.listVisibleFiles(agentId, readOwner(toolContext)).stream()
|
||||
.filter(file -> filenamePrefix == null || filenamePrefix.isBlank()
|
||||
|| (file.getFilename() != null && file.getFilename().startsWith(filenamePrefix)))
|
||||
.sorted(Comparator
|
||||
@ -78,14 +99,15 @@ public class WorkspaceMemoryTool {
|
||||
""")
|
||||
public String read_workspace_memory_file(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename) {
|
||||
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename,
|
||||
ToolContext toolContext) {
|
||||
|
||||
String validation = validate(agentId, filename);
|
||||
if (validation != null) {
|
||||
return error(validation);
|
||||
}
|
||||
|
||||
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
|
||||
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, filename, readOwner(toolContext));
|
||||
if (file == null) {
|
||||
return error("工作区文件不存在: " + filename);
|
||||
}
|
||||
@ -116,15 +138,17 @@ public class WorkspaceMemoryTool {
|
||||
public String write_workspace_memory_file(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename,
|
||||
@ToolParam(description = "要写入的完整 Markdown 内容") String content) {
|
||||
@ToolParam(description = "要写入的完整 Markdown 内容") String content,
|
||||
ToolContext toolContext) {
|
||||
|
||||
String validation = validate(agentId, filename);
|
||||
if (validation != null) {
|
||||
return error(validation);
|
||||
}
|
||||
|
||||
WorkspaceFileEntity before = workspaceFileService.getFile(agentId, filename);
|
||||
WorkspaceFileEntity saved = workspaceFileService.saveFile(agentId, filename, content != null ? content : "");
|
||||
String ownerKey = writeOwner(toolContext);
|
||||
WorkspaceFileEntity before = workspaceFileService.getVisibleFile(agentId, filename, ownerKey);
|
||||
WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, content != null ? content : "", ownerKey);
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("agentId", agentId);
|
||||
@ -149,7 +173,8 @@ public class WorkspaceMemoryTool {
|
||||
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename,
|
||||
@ToolParam(description = "要查找的原始文本,要求精确匹配") String oldText,
|
||||
@ToolParam(description = "替换后的新文本") String newText,
|
||||
@ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll) {
|
||||
@ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll,
|
||||
ToolContext toolContext) {
|
||||
|
||||
String validation = validate(agentId, filename);
|
||||
if (validation != null) {
|
||||
@ -165,7 +190,8 @@ public class WorkspaceMemoryTool {
|
||||
return error("oldText 和 newText 相同,无需替换");
|
||||
}
|
||||
|
||||
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, filename);
|
||||
String ownerKey = writeOwner(toolContext);
|
||||
WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, filename, ownerKey);
|
||||
if (existing == null) {
|
||||
return error("工作区文件不存在: " + filename);
|
||||
}
|
||||
@ -187,7 +213,7 @@ public class WorkspaceMemoryTool {
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
workspaceFileService.saveFile(agentId, filename, updated);
|
||||
workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey);
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("agentId", agentId);
|
||||
@ -211,7 +237,8 @@ public class WorkspaceMemoryTool {
|
||||
@ToolParam(description = "关键词或短语,2-64 字符") String query,
|
||||
@ToolParam(description = "搜索范围:all(全部)/ memory(MEMORY.md 与 memory/)/ profile / persona,默认 all",
|
||||
required = false) String scope,
|
||||
@ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit) {
|
||||
@ToolParam(description = "返回的最大命中数,默认 10,上限 30", required = false) Integer limit,
|
||||
ToolContext toolContext) {
|
||||
|
||||
if (agentId == null) {
|
||||
return error("agentId 不能为空");
|
||||
@ -230,8 +257,11 @@ public class WorkspaceMemoryTool {
|
||||
int effectiveLimit = limit == null ? 10 : Math.min(Math.max(limit, 1), 30);
|
||||
Set<String> prefixes = resolveScope(scope);
|
||||
|
||||
// Restrict hits to memory the current requester may see: shared memory
|
||||
// plus this owner's PERSONAL memory only.
|
||||
String ownerKey = readOwner(toolContext);
|
||||
List<MemorySearchHit> hits = workspaceFileService.searchSnippets(
|
||||
agentId, trimmed, prefixes, effectiveLimit);
|
||||
agentId, trimmed, prefixes, effectiveLimit, ownerKey);
|
||||
|
||||
// Treat each unique file in the results as an active retrieval signal —
|
||||
// boosts that file's weight in the dream-consolidation ranker the same
|
||||
@ -239,7 +269,9 @@ public class WorkspaceMemoryTool {
|
||||
Set<String> retrieved = new HashSet<>();
|
||||
for (MemorySearchHit hit : hits) {
|
||||
if (retrieved.add(hit.filename())) {
|
||||
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, hit.filename());
|
||||
// Read the same visible row the hit came from (the owner's
|
||||
// PERSONAL row when present) so PERSONAL hits track correctly.
|
||||
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, hit.filename(), ownerKey);
|
||||
if (file != null && file.getContent() != null) {
|
||||
memoryRecallTracker.trackActiveRetrieval(agentId, hit.filename(), file.getContent());
|
||||
}
|
||||
|
||||
@ -1,34 +1,66 @@
|
||||
package vip.mate.tool.document;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* In-memory cache of bytes produced by tools (e.g. {@code DocxRenderTool}) and
|
||||
* served by {@link GeneratedFileController}. Entries expire after {@link #TTL}
|
||||
* and are evicted lazily on every {@link #put} call.
|
||||
* Store of bytes produced by tools (e.g. {@code DocxRenderTool}) and served by
|
||||
* {@link GeneratedFileController}. Each entry is written to disk under
|
||||
* {@link #DEFAULT_STORAGE_DIR} and mirrored in an in-memory map for fast reads.
|
||||
*
|
||||
* <p>The cache is process-local and intentionally not persisted: a JVM restart
|
||||
* invalidates all outstanding download links. The download URL embeds a random
|
||||
* {@link UUID}, which acts as the only access credential.
|
||||
* <p>Persistence is what makes download links durable: the bytes survive both
|
||||
* cache eviction and a JVM restart, so a link a user clicks minutes — or days —
|
||||
* after generation still resolves instead of 404ing. Entries are retained for
|
||||
* {@link #TTL} and a scheduled sweep removes expired files. The download URL
|
||||
* embeds a random {@link UUID}, which acts as the only access credential.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class GeneratedFileCache {
|
||||
|
||||
public static final Duration TTL = Duration.ofMinutes(10);
|
||||
/** How long a generated file remains downloadable after creation. */
|
||||
public static final Duration TTL = Duration.ofDays(7);
|
||||
|
||||
/** Default on-disk location for persisted generated files. */
|
||||
public static final Path DEFAULT_STORAGE_DIR = Paths.get("data", "generated-files");
|
||||
|
||||
/** How often the expired-file sweep runs (6 hours). Must be a compile-time
|
||||
* constant for use in {@link Scheduled#fixedDelay()}. */
|
||||
private static final long CLEANUP_INTERVAL_MS = 6L * 60 * 60 * 1000;
|
||||
|
||||
/** Guards path resolution: only server-issued UUID-shaped ids are accepted. */
|
||||
private static final Pattern ID_RE = Pattern.compile("[a-zA-Z0-9-]{1,64}");
|
||||
|
||||
private static final String META_SUFFIX = ".meta";
|
||||
|
||||
/**
|
||||
* URL pattern for in-memory generated files served by
|
||||
* {@code GeneratedFileController}. Public so channel adapters and graph
|
||||
* nodes share a single source of truth.
|
||||
* Upper bound on bytes held in memory. Disk is the source of truth and
|
||||
* retains entries for {@link #TTL}; this map is only a hot-read cache, so
|
||||
* capping it keeps heap bounded regardless of how many files are produced
|
||||
* within the retention window. A miss simply reloads from disk.
|
||||
*/
|
||||
private static final int MAX_MEMORY_ENTRIES = 256;
|
||||
|
||||
/**
|
||||
* URL pattern for generated files served by {@code GeneratedFileController}.
|
||||
* Public so channel adapters and graph nodes share a single source of truth.
|
||||
*/
|
||||
public static final Pattern GENERATED_URL_PATTERN =
|
||||
Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)");
|
||||
@ -41,7 +73,37 @@ public class GeneratedFileCache {
|
||||
public static final String MISSING_REFERENCE_NOTICE =
|
||||
"⚠️ 文件未真正生成(模型未调用文档生成工具),请重新发送请求";
|
||||
|
||||
private final ConcurrentHashMap<String, Entry> entries = new ConcurrentHashMap<>();
|
||||
private final Path storageDir;
|
||||
|
||||
/**
|
||||
* Access-ordered LRU bounded to {@link #MAX_MEMORY_ENTRIES}: the eldest
|
||||
* entry is dropped from memory once the cap is exceeded (the persisted
|
||||
* file stays on disk and is reloaded on the next read).
|
||||
*/
|
||||
private final Map<String, Entry> entries = Collections.synchronizedMap(
|
||||
new LinkedHashMap<>(16, 0.75f, true) {
|
||||
// Fully qualify the value type: inside a LinkedHashMap subclass the
|
||||
// inherited java.util.HashMap.Entry node type shadows the outer
|
||||
// GeneratedFileCache.Entry record, so a bare `Entry` here resolves to
|
||||
// the raw Map.Entry and the override silently fails to match.
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, GeneratedFileCache.Entry> eldest) {
|
||||
return size() > MAX_MEMORY_ENTRIES;
|
||||
}
|
||||
});
|
||||
|
||||
public GeneratedFileCache() {
|
||||
this(DEFAULT_STORAGE_DIR);
|
||||
}
|
||||
|
||||
public GeneratedFileCache(Path storageDir) {
|
||||
this.storageDir = storageDir.normalize();
|
||||
try {
|
||||
Files.createDirectories(this.storageDir);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not create generated-files dir {}: {}", this.storageDir, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public record Entry(byte[] bytes, String filename, String mimeType, long expireAt) {
|
||||
|
||||
@ -56,41 +118,133 @@ public class GeneratedFileCache {
|
||||
* {@code /api/v1/files/generated/{id}}.
|
||||
*/
|
||||
public String put(byte[] bytes, String filename, String mimeType) {
|
||||
evictExpired();
|
||||
String id = UUID.randomUUID().toString();
|
||||
long expireAt = System.currentTimeMillis() + TTL.toMillis();
|
||||
entries.put(id, new Entry(bytes, filename, mimeType, expireAt));
|
||||
log.debug("Cached generated file id={} filename={} bytes={}", id, filename, bytes.length);
|
||||
Entry entry = new Entry(bytes, filename, mimeType, expireAt);
|
||||
entries.put(id, entry);
|
||||
persist(id, entry);
|
||||
log.debug("Cached generated file id={} filename={} bytes={}", id, filename,
|
||||
bytes != null ? bytes.length : 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an entry. Returns {@link Optional#empty()} if missing or expired
|
||||
* (expired entries are removed as a side-effect).
|
||||
* Look up an entry. Returns {@link Optional#empty()} if missing or expired.
|
||||
* Falls back to disk on an in-memory miss so links survive eviction and
|
||||
* JVM restarts; expired entries are removed as a side-effect.
|
||||
*/
|
||||
public Optional<Entry> get(String id) {
|
||||
if (id == null || !ID_RE.matcher(id).matches()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Entry entry = entries.get(id);
|
||||
if (entry == null) {
|
||||
entry = loadFromDisk(id);
|
||||
if (entry != null) {
|
||||
entries.put(id, entry);
|
||||
}
|
||||
}
|
||||
if (entry == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (entry.expired()) {
|
||||
entries.remove(id, entry);
|
||||
evict(id);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(entry);
|
||||
}
|
||||
|
||||
private void evictExpired() {
|
||||
private void persist(String id, Entry entry) {
|
||||
if (entry.bytes() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.write(storageDir.resolve(id), entry.bytes());
|
||||
// expireAt \t mimeType \t base64(filename) — filename is base64-encoded
|
||||
// so arbitrary unicode / separators round-trip without escaping.
|
||||
String meta = entry.expireAt()
|
||||
+ "\t" + (entry.mimeType() == null ? "" : entry.mimeType())
|
||||
+ "\t" + Base64.getEncoder().encodeToString(
|
||||
(entry.filename() == null ? "" : entry.filename()).getBytes(StandardCharsets.UTF_8));
|
||||
Files.writeString(storageDir.resolve(id + META_SUFFIX), meta);
|
||||
} catch (IOException e) {
|
||||
// Best-effort: an in-memory entry still serves the current process.
|
||||
log.warn("Could not persist generated file id={}: {}", id, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private Entry loadFromDisk(String id) {
|
||||
Path bin = storageDir.resolve(id).normalize();
|
||||
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
||||
// Containment guard — id is already validated, this is defence in depth.
|
||||
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin) || !Files.isRegularFile(meta)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String[] parts = Files.readString(meta).split("\t", 3);
|
||||
long expireAt = Long.parseLong(parts[0].trim());
|
||||
String mimeType = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null;
|
||||
String filename = parts.length > 2 && !parts[2].isEmpty()
|
||||
? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8)
|
||||
: id;
|
||||
byte[] bytes = Files.readAllBytes(bin);
|
||||
return new Entry(bytes, filename, mimeType, expireAt);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not load generated file id={}: {}", id, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void evict(String id) {
|
||||
entries.remove(id);
|
||||
try {
|
||||
Files.deleteIfExists(storageDir.resolve(id));
|
||||
Files.deleteIfExists(storageDir.resolve(id + META_SUFFIX));
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not delete generated file id={}: {}", id, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop expired entries from memory and disk. Runs on a fixed delay; also
|
||||
* sweeps orphaned files left by an unclean shutdown.
|
||||
*/
|
||||
@Scheduled(fixedDelay = CLEANUP_INTERVAL_MS, initialDelay = CLEANUP_INTERVAL_MS)
|
||||
public void cleanupExpired() {
|
||||
long now = System.currentTimeMillis();
|
||||
entries.entrySet().removeIf(e -> e.getValue().expireAt() <= now);
|
||||
// entrySet() of a synchronizedMap must be iterated while holding its lock.
|
||||
synchronized (entries) {
|
||||
entries.entrySet().removeIf(e -> e.getValue().expireAt() <= now);
|
||||
}
|
||||
if (!Files.isDirectory(storageDir)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(storageDir)) {
|
||||
files.filter(p -> p.getFileName().toString().endsWith(META_SUFFIX))
|
||||
.forEach(metaPath -> {
|
||||
String name = metaPath.getFileName().toString();
|
||||
String id = name.substring(0, name.length() - META_SUFFIX.length());
|
||||
try {
|
||||
long expireAt = Long.parseLong(
|
||||
Files.readString(metaPath).split("\t", 2)[0].trim());
|
||||
if (expireAt <= now) {
|
||||
evict(id);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Skipping unreadable meta {}: {}", name, e.toString());
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Generated-files cleanup sweep failed: {}", e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any {@code /api/v1/files/generated/{id}} URL in {@code text}
|
||||
* whose id is NOT present (or has expired) in this cache with
|
||||
* {@link #MISSING_REFERENCE_NOTICE}. URLs whose ids ARE in the cache are
|
||||
* left intact so downstream channel adapters can still rewrite them
|
||||
* into native attachments.
|
||||
* whose id is NOT present (or has expired) with
|
||||
* {@link #MISSING_REFERENCE_NOTICE}. URLs whose ids ARE live are left
|
||||
* intact so downstream channel adapters can still rewrite them into
|
||||
* native attachments.
|
||||
*
|
||||
* <p>Cache misses are nearly always LLM hallucinations — the model
|
||||
* emitted a UUID-shaped string without ever calling a render tool.
|
||||
@ -107,8 +261,7 @@ public class GeneratedFileCache {
|
||||
m.reset();
|
||||
while (m.find()) {
|
||||
String id = m.group(1);
|
||||
Entry entry = entries.get(id);
|
||||
boolean live = entry != null && !entry.expired();
|
||||
boolean live = get(id).isPresent();
|
||||
String replacement = live ? m.group(0) : MISSING_REFERENCE_NOTICE;
|
||||
m.appendReplacement(out, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
|
||||
@ -23,7 +23,8 @@ public final class GeneratedFileLink {
|
||||
public static String resultZh(byte[] bytes, String displayName, String mimeType,
|
||||
GeneratedFileCache cache, String typeLabel) {
|
||||
String url = stash(bytes, displayName, mimeType, cache);
|
||||
return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n"
|
||||
return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 "
|
||||
+ GeneratedFileCache.TTL.toDays() + " 天内有效)。\n"
|
||||
+ "重要:回答用户时**必须**使用上述 markdown 链接格式 [" + displayName + "](" + url + "),"
|
||||
+ "保持相对路径原样,**不要**用反引号包裹路径,也**不要**添加任何 https://、http:// 域名前缀。";
|
||||
}
|
||||
@ -44,7 +45,8 @@ public final class GeneratedFileLink {
|
||||
String prefix = sourceFileCount > 1
|
||||
? typeLabel + " generated from " + sourceFileCount + " files"
|
||||
: typeLabel + " generated";
|
||||
return prefix + ": [" + displayName + "](" + url + ") (link valid for 10 minutes).\n"
|
||||
return prefix + ": [" + displayName + "](" + url + ") (link valid for "
|
||||
+ GeneratedFileCache.TTL.toDays() + " days).\n"
|
||||
+ "IMPORTANT: when replying to the user you **must** keep the markdown link form ["
|
||||
+ displayName + "](" + url + ") above. Keep the relative path verbatim — do **not** "
|
||||
+ "wrap it in backticks and do **not** prepend any https://, http:// or domain "
|
||||
|
||||
@ -9,6 +9,9 @@ import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 工作区路径沙箱校验器
|
||||
@ -25,6 +28,58 @@ public final class WorkspacePathGuard {
|
||||
|
||||
private WorkspacePathGuard() {}
|
||||
|
||||
/**
|
||||
* Shared skill repository root, trusted in addition to the per-conversation
|
||||
* workspace boundary. System-level skills live under this root (one
|
||||
* subdirectory per skill) and are shared across every workspace, so the
|
||||
* agent must be able to read and run their files even when the active
|
||||
* workspace points elsewhere. Registered once at startup from the
|
||||
* {@code mateclaw.skill.workspace.root} setting. {@code null} until set
|
||||
* (then no extra root is trusted — pure workspace-only behaviour).
|
||||
*/
|
||||
private static volatile Path skillRoot;
|
||||
|
||||
/**
|
||||
* Register the shared skill repository root. A {@code null} or blank path
|
||||
* clears it, restoring workspace-only enforcement.
|
||||
*/
|
||||
public static void setSkillRoot(@Nullable String path) {
|
||||
skillRoot = (path == null || path.isBlank())
|
||||
? null
|
||||
: Paths.get(path).toAbsolutePath().normalize();
|
||||
log.info("[WorkspacePathGuard] Trusted skill root: {}", skillRoot);
|
||||
}
|
||||
|
||||
/** The registered shared skill repository root, or {@code null} if none is set. */
|
||||
@Nullable
|
||||
public static Path getSkillRoot() {
|
||||
return skillRoot;
|
||||
}
|
||||
|
||||
/** True when {@code normalized} lives under the shared skill root (if one is set). */
|
||||
private static boolean isUnderSkillRoot(Path normalized) {
|
||||
Path sr = skillRoot;
|
||||
return sr != null && normalized.startsWith(sr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink-resolved variant of {@link #isUnderSkillRoot}. Resolves the skill
|
||||
* root's real path so a path whose real location lands inside the skill
|
||||
* repository is accepted even when reached through a symlink.
|
||||
*/
|
||||
private static boolean isUnderSkillRootReal(Path realPath) {
|
||||
Path sr = skillRoot;
|
||||
if (sr == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Path realSkillRoot = sr.toFile().exists() ? sr.toRealPath() : sr;
|
||||
return realPath.startsWith(realSkillRoot);
|
||||
} catch (IOException e) {
|
||||
return realPath.startsWith(sr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件路径是否在当前工作区活动目录范围内。
|
||||
* <p>
|
||||
@ -56,7 +111,7 @@ public final class WorkspacePathGuard {
|
||||
Path root = Paths.get(basePath).toAbsolutePath().normalize();
|
||||
|
||||
// 先用 normalize 检查,再尝试 toRealPath 防符号链接逃逸
|
||||
if (!normalized.startsWith(root)) {
|
||||
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Path is outside workspace boundary: " + normalized + ", allowed root: " + root);
|
||||
}
|
||||
@ -66,7 +121,7 @@ public final class WorkspacePathGuard {
|
||||
if (normalized.toFile().exists()) {
|
||||
Path realPath = normalized.toRealPath();
|
||||
Path realRoot = root.toFile().exists() ? root.toRealPath() : root;
|
||||
if (!realPath.startsWith(realRoot)) {
|
||||
if (!realPath.startsWith(realRoot) && !isUnderSkillRootReal(realPath)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Path escapes workspace via symlink: " + realPath + ", allowed root: " + realRoot);
|
||||
}
|
||||
@ -100,6 +155,200 @@ public final class WorkspacePathGuard {
|
||||
return Paths.get(basePath).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a shell command does not reference filesystem locations
|
||||
* outside the active workspace boundary. When no workspace basePath is
|
||||
* configured, the check is a no-op (matching {@link #validatePath} semantics).
|
||||
*
|
||||
* <p>The check is a static scan of the literal command string. It rejects:
|
||||
* <ul>
|
||||
* <li>any absolute path token (e.g. {@code /etc/passwd}, {@code >/tmp/x},
|
||||
* {@code cd /var}) whose normalized form is not under the workspace
|
||||
* root — even when nested inside command substitution {@code $(...)}
|
||||
* or backticks;</li>
|
||||
* <li>relative tokens containing {@code ..} as a directory segment
|
||||
* (e.g. {@code cd ..}, {@code cat ../foo}, {@code ln -s ../bar baz})
|
||||
* when the resolved path falls outside the workspace root —
|
||||
* in-workspace traversal like {@code subdir/../sibling} is allowed
|
||||
* because it normalizes back inside;</li>
|
||||
* <li>tilde expansion ({@code ~}, {@code ~/...}) — always resolves to
|
||||
* {@code $HOME}, which sits outside the workspace;</li>
|
||||
* <li>references to environment variables ({@code $HOME}, {@code ${USER}},
|
||||
* {@code $TMPDIR}, etc.) that typically resolve outside the workspace.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Limitations</b> — the static scan is a best-effort defense, not a
|
||||
* true filesystem sandbox. Obfuscated forms ({@code /e''tc/passwd},
|
||||
* variable concatenation like {@code X=/etc; cat $X/passwd}, base64-decoded
|
||||
* paths) can still slip through. The agent is not expected to produce
|
||||
* such forms in normal use, but a fully adversarial caller would need a
|
||||
* real process sandbox (sandbox-exec / firejail / bwrap) on top of this
|
||||
* check.
|
||||
*
|
||||
* @param command the shell command line as it will be passed to {@code sh -c}
|
||||
* @throws IllegalArgumentException when the command references a location
|
||||
* outside the workspace boundary
|
||||
*/
|
||||
public static void validateShellCommand(String command) {
|
||||
validateShellCommand(command, null);
|
||||
}
|
||||
|
||||
/** ToolContext-aware overload — see {@link #validateShellCommand(String)}. */
|
||||
public static void validateShellCommand(String command, @Nullable ToolContext ctx) {
|
||||
if (command == null || command.isEmpty()) return;
|
||||
String basePath = resolveBasePath(ctx);
|
||||
if (basePath == null || basePath.isBlank()) return;
|
||||
Path root = Paths.get(basePath).toAbsolutePath().normalize();
|
||||
|
||||
// 1. Tilde — expands to $HOME, always outside a non-$HOME workspace.
|
||||
if (TILDE_REF.matcher(command).find()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Shell command uses tilde (~) expansion which resolves outside the workspace boundary: "
|
||||
+ truncateForError(command));
|
||||
}
|
||||
|
||||
// 2. Env-var refs to locations that typically resolve outside the workspace.
|
||||
Matcher envMatch = OUTSIDE_ENV_VAR.matcher(command);
|
||||
if (envMatch.find()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Shell command references environment variable " + envMatch.group()
|
||||
+ " which may resolve outside the workspace boundary");
|
||||
}
|
||||
|
||||
// 3. Absolute-path tokens, including those nested inside $(...) or `...`.
|
||||
Matcher pathMatch = ABS_PATH_TOKEN.matcher(command);
|
||||
while (pathMatch.find()) {
|
||||
String candidate = pathMatch.group(1);
|
||||
// Strip trailing punctuation that the shell would treat as a separator
|
||||
// but the regex captured into the path (defensive trim — the character
|
||||
// class excludes most, this catches edge cases like a path followed
|
||||
// by a comma in a sentence).
|
||||
while (candidate.length() > 1) {
|
||||
char tail = candidate.charAt(candidate.length() - 1);
|
||||
if (tail == ',' || tail == ':' || tail == '.' || tail == ')' || tail == ']') {
|
||||
candidate = candidate.substring(0, candidate.length() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Path normalized;
|
||||
try {
|
||||
normalized = Paths.get(candidate).normalize();
|
||||
} catch (Exception ex) {
|
||||
// Unparseable as a path — leave it alone, not our concern.
|
||||
continue;
|
||||
}
|
||||
if (isAllowedDeviceNode(normalized)) {
|
||||
// Character devices like /dev/null, /dev/stdin, /dev/fd/0 don't
|
||||
// expose any on-disk user data — allow them so common shell
|
||||
// idioms (`2>/dev/null`, `cmd <(cat file)`) keep working.
|
||||
continue;
|
||||
}
|
||||
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Shell command references path outside workspace boundary: "
|
||||
+ normalized + ", allowed root: " + root);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Relative tokens containing ".." — must resolve inside the workspace.
|
||||
// Catches `cd ..`, `cat ../foo`, `ln -s ../bar baz`, `mv foo/../bar dst`,
|
||||
// etc. In-workspace traversal (`subdir/../sibling`) normalizes back
|
||||
// inside and passes.
|
||||
Matcher traversalMatch = RELATIVE_TRAVERSAL_TOKEN.matcher(command);
|
||||
while (traversalMatch.find()) {
|
||||
String candidate = traversalMatch.group(1);
|
||||
Path resolved;
|
||||
try {
|
||||
resolved = root.resolve(candidate).normalize();
|
||||
} catch (Exception ex) {
|
||||
continue;
|
||||
}
|
||||
if (isAllowedDeviceNode(resolved)) continue;
|
||||
if (!resolved.startsWith(root) && !isUnderSkillRoot(resolved)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Shell command uses parent-directory traversal that escapes the workspace: '"
|
||||
+ candidate + "' would resolve to " + resolved
|
||||
+ ", allowed root: " + root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match absolute path tokens — a leading slash that starts a fresh token
|
||||
* (preceded by start-of-string, whitespace, a shell separator, or an
|
||||
* opening quote/parenthesis/backtick) and runs until the next shell
|
||||
* separator or quote. The {@code (?<!:)} lookbehind excludes the second
|
||||
* slash of a URL protocol (e.g. {@code https://host/path}) so URLs aren't
|
||||
* mistaken for filesystem paths.
|
||||
*/
|
||||
private static final Pattern ABS_PATH_TOKEN = Pattern.compile(
|
||||
"(?:^|[\\s|&;<>(`\"'={}])(?<!:)(/[^\\s|&;<>()\"'`{}=]+)");
|
||||
|
||||
/**
|
||||
* Match relative tokens that contain {@code ..} as a path segment. Captures
|
||||
* the whole token (prefix + {@code ..} + optional suffix) so the caller
|
||||
* can resolve it against the workspace root and decide whether it escapes.
|
||||
*
|
||||
* <p>Matches:
|
||||
* <ul>
|
||||
* <li>{@code ..} ({@code cd ..}, bare arg)</li>
|
||||
* <li>{@code ../foo/bar} (relative parent traversal)</li>
|
||||
* <li>{@code ./..} ({@code cd ./..})</li>
|
||||
* <li>{@code foo/..} ({@code rm foo/..})</li>
|
||||
* <li>{@code foo/../bar} (in-workspace normalization)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Does NOT match {@code abc..xyz} (no slash before/after the {@code ..} —
|
||||
* not a path segment) or absolute {@code /foo/../bar} (handled by
|
||||
* {@link #ABS_PATH_TOKEN}). The token must be bounded by a shell separator
|
||||
* or end-of-string on both sides.
|
||||
*/
|
||||
private static final Pattern RELATIVE_TRAVERSAL_TOKEN = Pattern.compile(
|
||||
"(?:^|[\\s|&;<>(`\"'={}])((?:[^\\s|&;<>()\"'`{}=/]+/)*\\.\\.(?:/[^\\s|&;<>()\"'`{}=]*)?)(?=[\\s|&;<>)`\"'=}]|$)");
|
||||
|
||||
/** Bare tilde or tilde at the start of a path token: {@code ~}, {@code ~/foo}, {@code "~/bar"}. */
|
||||
private static final Pattern TILDE_REF = Pattern.compile(
|
||||
"(?:^|[\\s|&;<>(`\"'={}])~(?=[/\\s|&;<>)`\"'$]|$)");
|
||||
|
||||
/**
|
||||
* Env-var references that almost always point outside a project-scoped
|
||||
* workspace. {@code $PATH} is on the list because writing to a directory
|
||||
* on {@code $PATH} is a privilege-escalation vector.
|
||||
*/
|
||||
private static final Pattern OUTSIDE_ENV_VAR = Pattern.compile(
|
||||
"\\$\\{?(HOME|USER|LOGNAME|TMPDIR|TMP|TEMP|PWD|OLDPWD|PATH|MAIL)\\b");
|
||||
|
||||
/**
|
||||
* Character device nodes that don't expose user data and are needed for
|
||||
* common shell idioms (stderr suppression, process substitution, entropy).
|
||||
* Linux/macOS only — the path strings are absolute POSIX paths; on
|
||||
* Windows {@link #validateShellCommand} doesn't fire on these because
|
||||
* a Windows command wouldn't normalize to a {@code /dev/...} string.
|
||||
*/
|
||||
private static final Set<String> ALLOWED_DEVICE_NODES = Set.of(
|
||||
"/dev/null",
|
||||
"/dev/zero",
|
||||
"/dev/stdin",
|
||||
"/dev/stdout",
|
||||
"/dev/stderr",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
"/dev/tty"
|
||||
);
|
||||
|
||||
/** Match {@code /dev/fd/0}, {@code /dev/fd/1}, etc — used by process substitution. */
|
||||
private static final Pattern ALLOWED_DEV_FD = Pattern.compile("^/dev/fd/\\d+$");
|
||||
|
||||
private static boolean isAllowedDeviceNode(Path normalized) {
|
||||
String s = normalized.toString();
|
||||
return ALLOWED_DEVICE_NODES.contains(s) || ALLOWED_DEV_FD.matcher(s).matches();
|
||||
}
|
||||
|
||||
private static String truncateForError(String s) {
|
||||
return s.length() > 200 ? s.substring(0, 200) + "..." : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active workspace base path. Order of preference:
|
||||
* <ol>
|
||||
|
||||
@ -3,10 +3,15 @@ package vip.mate.tool.guard.model;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工具调用上下文
|
||||
* Standard tool invocation context shared by every Guardian.
|
||||
* <p>
|
||||
* 标准化的工具调用信息,供所有 Guardian 使用。
|
||||
* 先标准化上下文,再做风险评估。
|
||||
* The {@code workspaceId} field was added so that {@code ApprovalGrantResolver}
|
||||
* can scope grant lookups by workspace without forcing a DB query inside the
|
||||
* resolver. Callers that already know the workspace pass it explicitly via
|
||||
* {@link #of(String, Map, String, String, String, String, String, Long)}.
|
||||
* Legacy callers using {@link #of(String, String, String, String)} receive
|
||||
* {@code workspaceId = null}; the resolver then conservatively falls back to
|
||||
* the existing human-approval path.
|
||||
*/
|
||||
public record ToolInvocationContext(
|
||||
String toolName,
|
||||
@ -15,28 +20,34 @@ public record ToolInvocationContext(
|
||||
String conversationId,
|
||||
String agentId,
|
||||
String channelType,
|
||||
String userId
|
||||
String userId,
|
||||
Long workspaceId
|
||||
) {
|
||||
|
||||
/**
|
||||
* 常用工厂方法 — 从工具名和原始参数创建
|
||||
* Legacy factory: workspaceId resolved lazily downstream (sets {@code null} here).
|
||||
* Kept verbatim so existing call sites and tests continue to compile.
|
||||
*/
|
||||
public static ToolInvocationContext of(String toolName, String rawArguments,
|
||||
String conversationId, String agentId) {
|
||||
return new ToolInvocationContext(
|
||||
toolName, Map.of(), rawArguments, conversationId, agentId, null, null
|
||||
);
|
||||
toolName, Map.of(), rawArguments, conversationId, agentId,
|
||||
null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整工厂方法
|
||||
* Full factory: preferred path used by {@code ToolExecutionExecutor.evaluateGuard()}
|
||||
* once {@code WorkspaceLookupCache.resolveByConversation(...)} has resolved the
|
||||
* workspace.
|
||||
*/
|
||||
public static ToolInvocationContext of(String toolName, Map<String, Object> parameters,
|
||||
String rawArguments, String conversationId,
|
||||
String agentId, String channelType, String userId) {
|
||||
String agentId, String channelType, String userId,
|
||||
Long workspaceId) {
|
||||
return new ToolInvocationContext(
|
||||
toolName, parameters != null ? parameters : Map.of(),
|
||||
rawArguments, conversationId, agentId, channelType, userId
|
||||
);
|
||||
toolName,
|
||||
parameters != null ? parameters : Map.of(),
|
||||
rawArguments, conversationId, agentId,
|
||||
channelType, userId, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ public class McpServerService {
|
||||
entity.setConnectTimeoutSeconds(30);
|
||||
}
|
||||
if (entity.getReadTimeoutSeconds() == null) {
|
||||
entity.setReadTimeoutSeconds(30);
|
||||
entity.setReadTimeoutSeconds(60);
|
||||
}
|
||||
entity.setLastStatus("disconnected");
|
||||
entity.setToolCount(0);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user