release: v1.5.0

This commit is contained in:
matevip 2026-06-05 07:55:59 +08:00
parent 68c010ecf9
commit 1efc13076d
361 changed files with 30472 additions and 3047 deletions

View File

@ -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)

View File

@ -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);
}
}
}

View File

@ -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")

View File

@ -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);
}
}

View File

@ -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

View File

@ -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 2040
* 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);
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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 summarizingreasoning

View File

@ -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);
}
// ==================== 辅助方法 ====================
/**

View File

@ -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();
}

View File

@ -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 2030 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);

View File

@ -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,

View File

@ -78,6 +78,59 @@ public class AgentEntity {
/** 默认思考深度off / low / medium / high / maxnull 表示跟随模型默认 */
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;

View File

@ -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)

View File

@ -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
) {}

View File

@ -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();
}
}

View File

@ -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; }
}

View File

@ -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 conversationworkspace 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();
}
}

View File

@ -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 (看全部) 需要 adminmine=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 LongString 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;
}
}

View File

@ -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() {}
}
}

View File

@ -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() {}
}
}

View File

@ -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 conversationworkspace 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);
}
}

View File

@ -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);
}
}
}

View File

@ -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 &gt; AGENT &gt; USER &gt; 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);
}

View File

@ -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> {
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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 获取用户名
*/

View File

@ -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}

View File

@ -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}")

View File

@ -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();
}
// ==================== 消息内容解析 ====================
/**

View File

@ -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

View File

@ -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());
}
}
}

View File

@ -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);
}
}

View File

@ -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");
// 使用虚拟线程池处理 SSEJava 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;
}
/**

View File

@ -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));

View File

@ -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());

View File

@ -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);
}
/**

View File

@ -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];

View File

@ -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

View File

@ -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);
}
/**

View File

@ -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;

View File

@ -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 ====================

View File

@ -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) {
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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() {

View File

@ -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.01.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;
}

View File

@ -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 &lt; 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);
}
}

View File

@ -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);
}

View File

@ -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");

View File

@ -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

View File

@ -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;

View File

@ -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())) {

View File

@ -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);
}
}

View File

@ -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());

View File

@ -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;

View File

@ -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

View File

@ -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"));
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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());
}

View File

@ -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;

View File

@ -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));

View File

@ -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 "";
}
}
/**

View File

@ -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.
*/

View File

@ -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));

View File

@ -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 &gt; 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 &gt;= 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";

View File

@ -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>";
}

View File

@ -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).

View File

@ -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);
}

View File

@ -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();

View File

@ -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) {

View File

@ -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);

View File

@ -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

View File

@ -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
*/

View File

@ -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());
}
}

View File

@ -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 提取链 ====================

View File

@ -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

View File

@ -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());
}

View File

@ -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);

View File

@ -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,

View File

@ -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全部/ memoryMEMORY.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());
}

View File

@ -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));
}

View File

@ -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 "

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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