diff --git a/.gitignore b/.gitignore index 7ae1acfc..656cfe90 100644 --- a/.gitignore +++ b/.gitignore @@ -92,5 +92,6 @@ deploy/nginx/ssl/*.pem deploy/.env # Claude Code local settings +CLAUDE.md .claude/settings.local.json .claude/plans/ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index f91143fb..4ac2c382 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -157,6 +157,28 @@ public class AgentGraphBuilder { throw new MateClawException("err.agent.model_not_configured", "模型 " + runtimeModel.getModelName() + " 的 Provider(" + runtimeModel.getProvider() + ")未配置,请检查模型设置"); } + + // Safety net: getDefaultModel() already skips unconfigured providers, but guard here + // too so a stale cached model doesn't silently proceed to a broken API call. + if (!modelProviderService.isProviderConfigured(provider.getProviderId())) { + String reason = modelProviderService.getProviderUnavailableReason(provider.getProviderId()); + log.warn("Runtime model {}/{} provider not configured ({}); trying fallback", + runtimeModel.getProvider(), runtimeModel.getModelName(), reason); + ModelConfigEntity fallback = findFirstAvailableChatModel(); + if (fallback == null) { + throw new MateClawException("err.agent.no_configured_model", + "默认模型 Provider「" + runtimeModel.getProvider() + "」未配置(" + reason + + "),且找不到其他已配置的 Provider,请先在「设置 → 模型」中完成配置"); + } + runtimeModel = fallback; + try { + provider = modelProviderService.getProviderConfig(runtimeModel.getProvider()); + } catch (Exception e) { + throw new MateClawException("err.agent.model_not_configured", "备用模型 " + runtimeModel.getModelName() + + " 的 Provider(" + runtimeModel.getProvider() + ")获取失败"); + } + } + ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel()); // 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥 @@ -727,6 +749,24 @@ public class AgentGraphBuilder { return reordered; } + /** + * Finds the first enabled chat model whose provider is fully configured. + * Used as a fallback when the default model's provider is not available. + */ + private ModelConfigEntity findFirstAvailableChatModel() { + return modelConfigService.listByType("chat").stream() + .filter(m -> Boolean.TRUE.equals(m.getEnabled())) + .filter(m -> { + try { + return modelProviderService.isProviderConfigured(m.getProvider()); + } catch (Exception e) { + return false; + } + }) + .findFirst() + .orElse(null); + } + // PR-0b: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers). // PR-0b: isDashScopeSearchEnabled moved to AgentDashScopeChatModelBuilder. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java index 4ebc3064..8458a647 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentDashScopeChatModelBuilder.java @@ -7,6 +7,7 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions; import com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ChatModel; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -27,19 +28,23 @@ import java.util.Map; * fallback-chain helpers for resolving API key / Base URL when the provider * row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder} * so the agent package no longer carries any DashScope schema knowledge.

+ * + *

DashScopeChatModel is injected via ObjectProvider so that the builder + * degrades gracefully when DashScope auto-configuration is disabled or the + * dependency is absent, rather than failing the entire application context.

*/ @Slf4j @Component public class AgentDashScopeChatModelBuilder implements ChatModelBuilder { - private final DashScopeChatModel dashScopeChatModel; + private final ObjectProvider dashScopeChatModelProvider; private final DashScopeConnectionProperties dashScopeConnectionProperties; private final ModelProviderService modelProviderService; - public AgentDashScopeChatModelBuilder(DashScopeChatModel dashScopeChatModel, + public AgentDashScopeChatModelBuilder(ObjectProvider dashScopeChatModelProvider, DashScopeConnectionProperties dashScopeConnectionProperties, ModelProviderService modelProviderService) { - this.dashScopeChatModel = dashScopeChatModel; + this.dashScopeChatModelProvider = dashScopeChatModelProvider; this.dashScopeConnectionProperties = dashScopeConnectionProperties; this.modelProviderService = modelProviderService; } @@ -51,9 +56,14 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder { @Override public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) { + DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable(); + if (defaultModel == null) { + throw new MateClawException("err.agent.dashscope_unavailable", + "DashScope 自动配置未激活(可能缺少依赖或被排除),无法构建 DashScope 模型"); + } DashScopeApi api = buildDashScopeApi(provider); DashScopeChatOptions options = buildDashScopeOptions(model, provider); - return dashScopeChatModel.mutate() + return defaultModel.mutate() .dashScopeApi(api) .defaultOptions(options) .build(); @@ -200,9 +210,13 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder { } private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException { + DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable(); + if (defaultModel == null) { + return null; + } Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi"); apiField.setAccessible(true); - Object api = apiField.get(dashScopeChatModel); + Object api = apiField.get(defaultModel); return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null; } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index 186cab55..1759b5c3 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -2,6 +2,8 @@ package vip.mate.llm.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import vip.mate.exception.MateClawException; @@ -22,6 +24,14 @@ public class ModelConfigService { private final ModelConfigMapper modelConfigMapper; private final ApplicationEventPublisher eventPublisher; + /** + * Lazy to break circular dependency: ModelProviderService → ModelConfigService. + * Used only in getDefaultModel() to skip models whose provider is unconfigured. + */ + @Lazy + @Autowired + private ModelProviderService modelProviderService; + public List listModels() { return modelConfigMapper.selectList(new LambdaQueryWrapper() .orderByDesc(ModelConfigEntity::getIsDefault) @@ -91,25 +101,55 @@ public class ModelConfigService { } public ModelConfigEntity getDefaultModel() { - // 默认 chat 模型:明确排除 embedding 类型 - ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper() + // Prefer the explicitly marked default chat model when its provider is configured. + ModelConfigEntity defaultMarked = modelConfigMapper.selectOne(new LambdaQueryWrapper() .eq(ModelConfigEntity::getIsDefault, true) .and(w -> w.isNull(ModelConfigEntity::getModelType) .or().eq(ModelConfigEntity::getModelType, "chat")) .last("LIMIT 1")); - if (entity != null) { - return entity; + if (defaultMarked != null && isProviderConfigured(defaultMarked.getProvider())) { + return defaultMarked; } - entity = modelConfigMapper.selectOne(new LambdaQueryWrapper() + + // Default model's provider is unavailable (or no default set) — scan all enabled + // chat models and pick the first one whose provider is actually configured. + List candidates = modelConfigMapper.selectList(new LambdaQueryWrapper() .eq(ModelConfigEntity::getEnabled, true) .and(w -> w.isNull(ModelConfigEntity::getModelType) .or().eq(ModelConfigEntity::getModelType, "chat")) - .orderByAsc(ModelConfigEntity::getName) - .last("LIMIT 1")); - if (entity == null) { - throw new MateClawException("err.llm.no_available_model", "没有可用的模型配置"); + .orderByDesc(ModelConfigEntity::getIsDefault) + .orderByAsc(ModelConfigEntity::getName)); + for (ModelConfigEntity candidate : candidates) { + if (isProviderConfigured(candidate.getProvider())) { + return candidate; + } + } + + // No configured provider found — give a clearer error than the generic one. + if (!candidates.isEmpty()) { + String unconfiguredProvider = candidates.get(0).getProvider(); + throw new MateClawException("err.llm.no_configured_provider", + "所有已启用的模型 Provider 均未完成配置(默认 Provider: " + unconfiguredProvider + + "),请在「设置 → 模型」中填写 API Key"); + } + throw new MateClawException("err.llm.no_available_model", "没有可用的模型配置"); + } + + /** + * Checks whether a provider is fully configured (API key / credentials present). + * Delegates to ModelProviderService which is injected lazily to avoid a circular + * dependency. Falls back to {@code true} when the service is not yet available + * (e.g., during early bootstrap) so we don't accidentally block startup. + */ + private boolean isProviderConfigured(String providerId) { + if (modelProviderService == null || providerId == null) { + return true; + } + try { + return modelProviderService.isProviderConfigured(providerId); + } catch (Exception e) { + return true; // conservative: don't filter if lookup fails } - return entity; } public ModelConfigEntity getDefaultModelByProvider(String providerId) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java index 88830672..4ed7826f 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java @@ -46,10 +46,14 @@ public class ModelDiscoveryService { /** * Explicit deny list: model ids listed by DashScope compatible-mode that are known * to fail on the native protocol. Updated as we observe new failures. + * Note: the DASHSCOPE_NATIVE_UNSUPPORTED_PATTERN below also catches the whole + * dot-versioned family; this set makes individual blocked names searchable/auditable. */ private static final Set DASHSCOPE_NATIVE_DENY = Set.of( "qwen3.5-max", - "qwen3.5-plus" + "qwen3.5-plus", + "qwen3.6-plus", + "qwen3.6-max" ); /** diff --git a/mateclaw-server/src/main/java/vip/mate/memory/event/MemoryWriteEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/event/MemoryWriteEvent.java new file mode 100644 index 00000000..035ba482 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/event/MemoryWriteEvent.java @@ -0,0 +1,18 @@ +package vip.mate.memory.event; + +/** + * Published when canonical memory files are written (MEMORY.md, structured/*.md). + * Used by SoulSummarizerService for K-accumulate SOUL.md evolution. + * + * @param agentId the agent ID + * @param target which file was written (e.g. "MEMORY.md", "structured/user.md") + * @param action what happened ("remember", "consolidate", "update") + * @param content the written content (may be truncated for large writes) + * @author MateClaw Team + */ +public record MemoryWriteEvent( + Long agentId, + String target, + String action, + String content +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java index 13faf2b7..7703b8a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java @@ -9,7 +9,9 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.memory.event.MemoryWriteEvent; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.service.ModelConfigService; @@ -46,6 +48,7 @@ public class MemoryEmergenceService { private final MemoryRecallService recallService; private final DreamReportMapper dreamReportMapper; private final vip.mate.memory.archive.MemoryArchiveService archiveService; + private final ApplicationEventPublisher eventPublisher; /** * Legacy signature — delegates to NIGHTLY mode for backward compatibility. @@ -154,6 +157,7 @@ public class MemoryEmergenceService { } workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent)); String llmReason = root.path("reason").asText(""); log.info("[Memory] Emergence completed for agent={}: {}", agentId, llmReason); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java new file mode 100644 index 00000000..c012ac92 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java @@ -0,0 +1,136 @@ +package vip.mate.memory.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * SOUL.md auto-evolution service. + *

+ * Subscribes to {@link MemoryWriteEvent}; after every K writes (configured by + * soulUpdateInterval), triggers an LLM call to regenerate SOUL.md from the + * agent's current memory state. + * + *

When soulUpdateInterval=0, this service is a no-op. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SoulSummarizerService { + + private final WorkspaceFileService workspaceFileService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final MemoryProperties properties; + + /** Per-agent write counter since last SOUL update */ + private final Map writeCounters = new ConcurrentHashMap<>(); + + @Async + @EventListener + public void onMemoryWrite(MemoryWriteEvent event) { + int interval = properties.getSoulUpdateInterval(); + if (interval <= 0) return; + + Long agentId = event.agentId(); + AtomicInteger counter = writeCounters.computeIfAbsent(agentId, k -> new AtomicInteger(0)); + int count = counter.incrementAndGet(); + + if (count < interval) { + log.debug("[SOUL] Write {}/{} for agent={}, waiting...", count, interval, agentId); + return; + } + + // Reset counter and trigger SOUL update + counter.set(0); + log.info("[SOUL] Triggering SOUL.md update for agent={} (after {} writes)", agentId, interval); + + try { + updateSoul(agentId); + } catch (Exception e) { + log.warn("[SOUL] Failed to update SOUL.md for agent={}: {}", agentId, e.getMessage()); + } + } + + /** + * Regenerate SOUL.md from current agent memory state. + */ + void updateSoul(Long agentId) { + // Read current files + String memoryContent = readSafe(agentId, "MEMORY.md"); + String profileContent = readSafe(agentId, "PROFILE.md"); + String currentSoul = readSafe(agentId, "SOUL.md"); + + String systemPrompt = PromptLoader.loadPrompt("memory/soul-summarize"); + String userPrompt = String.format(""" + ## Current SOUL.md + ``` + %s + ``` + + ## PROFILE.md + ``` + %s + ``` + + ## MEMORY.md + ``` + %s + ``` + + Based on the above, regenerate SOUL.md. Keep it concise and personal. + Output ONLY the new SOUL.md content (no fences, no explanation). + """, currentSoul, profileContent, memoryContent); + + ChatModel chatModel = buildChatModel(); + Prompt prompt = new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt) + )); + ChatResponse response = chatModel.call(prompt); + String newSoul = response.getResult().getOutput().getText(); + + if (newSoul != null && !newSoul.isBlank() && newSoul.length() > 50) { + workspaceFileService.saveFile(agentId, "SOUL.md", newSoul.trim()); + log.info("[SOUL] Updated SOUL.md for agent={} ({} chars)", agentId, newSoul.length()); + } else { + log.debug("[SOUL] LLM returned empty/short response, skipping SOUL update"); + } + } + + private ChatModel buildChatModel() { + ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); + return agentGraphBuilder.buildRuntimeChatModel(defaultModel); + } + + private String readSafe(Long agentId, String filename) { + try { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + return file != null && file.getContent() != null ? file.getContent() : ""; + } catch (Exception e) { + return ""; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java index 73b54911..c5295fd8 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java @@ -2,7 +2,9 @@ package vip.mate.memory.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.memory.event.MemoryWriteEvent; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; @@ -35,6 +37,7 @@ public class StructuredMemoryService { private static final Pattern SECTION_PATTERN = Pattern.compile("^## (.+)$", Pattern.MULTILINE); private final WorkspaceFileService workspaceFileService; + private final ApplicationEventPublisher eventPublisher; /** Per-file lock to prevent concurrent read-modify-write on the same file */ private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>(); @@ -69,6 +72,8 @@ public class StructuredMemoryService { workspaceFileService.saveFile(agentId, filename, updated); log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})", existingSection != null ? "Updated" : "Added", key, agentId, source); + // Publish event for SOUL auto-evolution (Phase 2) + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "remember", content)); } finally { lock.unlock(); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java index bd5b5423..b5dd387f 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java @@ -1,8 +1,11 @@ package vip.mate.memory.spi; +import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.spi.decorator.MetricsMemoryProvider; +import vip.mate.memory.spi.decorator.RetryableMemoryProvider; import java.util.ArrayList; import java.util.Comparator; @@ -32,20 +35,43 @@ public class MemoryManager { /** External plugin memory provider (single-select constraint) */ private volatile MemoryProvider externalPluginProvider = null; - public MemoryManager(List allProviders, MemoryProperties properties) { + public MemoryManager(List allProviders, MemoryProperties properties, + org.springframework.beans.factory.ObjectProvider meterRegistryProvider) { + MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable(); Set disabled = properties.getDisabledProviders(); - this.providers = allProviders.stream() + List filtered = allProviders.stream() .filter(MemoryProvider::isAvailable) .filter(p -> !disabled.contains(p.id())) .sorted(Comparator.comparingInt(MemoryProvider::order)) .collect(Collectors.toList()); + // Assemble decorator chain based on flags + this.providers = filtered.stream() + .map(p -> wrapWithDecorators(p, properties, meterRegistry)) + .collect(Collectors.toList()); + if (!disabled.isEmpty()) { log.info("[MemoryManager] Disabled providers: {}", disabled); } - log.info("[MemoryManager] Active providers ({}): {}", + String decorators = ""; + if (properties.getProviderRetryAttempts() > 1) decorators += "+retry(" + properties.getProviderRetryAttempts() + ")"; + if (properties.isProviderMetricsEnabled()) decorators += "+metrics"; + log.info("[MemoryManager] Active providers ({}): {} {}", this.providers.size(), - this.providers.stream().map(MemoryProvider::id).collect(Collectors.joining(", "))); + filtered.stream().map(MemoryProvider::id).collect(Collectors.joining(", ")), + decorators); + } + + private MemoryProvider wrapWithDecorators(MemoryProvider provider, MemoryProperties properties, + MeterRegistry meterRegistry) { + MemoryProvider result = provider; + if (properties.getProviderRetryAttempts() > 1) { + result = new RetryableMemoryProvider(result, properties.getProviderRetryAttempts()); + } + if (properties.isProviderMetricsEnabled() && meterRegistry != null) { + result = new MetricsMemoryProvider(result, meterRegistry); + } + return result; } // ==================== System Prompt ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java index a1fc086c..af8b3328 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java @@ -100,7 +100,7 @@ public interface MemoryProvider { * Notification that a memory write occurred. Called after canonical memory * files (structured/*.md, MEMORY.md) are updated. * - *

Phase 1: no subscribers. Phase 2: SOUL auto-evolution hook. + *

Phase 2: SOUL auto-evolution subscribes to this. * * @param agentId the agent ID * @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md") @@ -109,4 +109,24 @@ public interface MemoryProvider { */ default void onMemoryWrite(Long agentId, String target, String action, String content) { } + + /** + * Warm up provider internal state (embeddings, index handles, connection pools). + * Called when an agent session is likely to start. Providers decide what to cache. + * + *

Phase 2: provider internal-state cache (not recall text cache — F2). + * + * @param agentId the agent ID + */ + default void warmup(Long agentId) { + } + + /** + * Evict cached internal state for an agent. Called on agent deactivation or + * memory pressure. + * + * @param agentId the agent ID + */ + default void evict(Long agentId) { + } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java new file mode 100644 index 00000000..850a7370 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java @@ -0,0 +1,38 @@ +package vip.mate.memory.spi.decorator; + +import vip.mate.memory.spi.MemoryProvider; + +import java.util.Collections; +import java.util.List; + +/** + * Base decorator for MemoryProvider. All methods delegate to the wrapped provider. + * Subclass and override specific methods to add behavior (retry, metrics, etc.). + * + * @author MateClaw Team + */ +public abstract class MemoryProviderDecorator implements MemoryProvider { + + protected final MemoryProvider delegate; + + protected MemoryProviderDecorator(MemoryProvider delegate) { + this.delegate = delegate; + } + + @Override public String id() { return delegate.id(); } + @Override public int order() { return delegate.order(); } + @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 void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + } + @Override public List getToolBeans() { return delegate.getToolBeans(); } + @Override public void onSessionEnd(Long agentId, String conversationId) { delegate.onSessionEnd(agentId, conversationId); } + @Override public String onPreCompress(Long agentId, List messages) { return delegate.onPreCompress(agentId, messages); } + @Override public void onMemoryWrite(Long agentId, String target, String action, String content) { + delegate.onMemoryWrite(agentId, target, action, content); + } + @Override public void warmup(Long agentId) { delegate.warmup(agentId); } + @Override public void evict(Long agentId) { delegate.evict(agentId); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java new file mode 100644 index 00000000..90b1f792 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java @@ -0,0 +1,71 @@ +package vip.mate.memory.spi.decorator; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import vip.mate.memory.spi.MemoryProvider; + +/** + * Decorator that records Micrometer metrics for prefetch/sync/session-end operations. + *

+ * Metrics emitted (all with tag provider=...): + * - memory.prefetch.latency (Timer) + * - memory.prefetch.failures (Counter) + * - memory.sync.duration (Timer) + * - memory.sync.failures (Counter) + * - memory.session_end.duration (Timer) + * + * @author MateClaw Team + */ +public class MetricsMemoryProvider extends MemoryProviderDecorator { + + private final MeterRegistry meterRegistry; + private final Timer prefetchTimer; + private final Timer syncTimer; + private final Timer sessionEndTimer; + + public MetricsMemoryProvider(MemoryProvider delegate, MeterRegistry meterRegistry) { + super(delegate); + this.meterRegistry = meterRegistry; + String providerId = delegate.id(); + this.prefetchTimer = Timer.builder("memory.prefetch.latency") + .tag("provider", providerId) + .register(meterRegistry); + this.syncTimer = Timer.builder("memory.sync.duration") + .tag("provider", providerId) + .register(meterRegistry); + this.sessionEndTimer = Timer.builder("memory.session_end.duration") + .tag("provider", providerId) + .register(meterRegistry); + } + + @Override + public String prefetch(Long agentId, String userQuery) { + return prefetchTimer.record(() -> { + try { + return delegate.prefetch(agentId, userQuery); + } catch (Exception e) { + meterRegistry.counter("memory.prefetch.failures", + "provider", delegate.id()).increment(); + throw e; + } + }); + } + + @Override + public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + syncTimer.record(() -> { + try { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + } catch (Exception e) { + meterRegistry.counter("memory.sync.failures", + "provider", delegate.id()).increment(); + throw e; + } + }); + } + + @Override + public void onSessionEnd(Long agentId, String conversationId) { + sessionEndTimer.record(() -> delegate.onSessionEnd(agentId, conversationId)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java new file mode 100644 index 00000000..6d88583c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java @@ -0,0 +1,68 @@ +package vip.mate.memory.spi.decorator; + +import lombok.extern.slf4j.Slf4j; +import vip.mate.memory.spi.MemoryProvider; + +/** + * Decorator that retries failed prefetch/syncTurn calls with exponential backoff. + * + * @author MateClaw Team + */ +@Slf4j +public class RetryableMemoryProvider extends MemoryProviderDecorator { + + private final int maxAttempts; + + public RetryableMemoryProvider(MemoryProvider delegate, int maxAttempts) { + super(delegate); + this.maxAttempts = maxAttempts; + } + + @Override + public String prefetch(Long agentId, String userQuery) { + Exception lastException = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return delegate.prefetch(agentId, userQuery); + } catch (Exception e) { + lastException = e; + if (attempt < maxAttempts) { + log.debug("[Retry] prefetch attempt {}/{} failed for provider={}: {}", + attempt, maxAttempts, delegate.id(), e.getMessage()); + sleep(attempt); + } + } + } + log.warn("[Retry] prefetch exhausted {} attempts for provider={}: {}", + maxAttempts, delegate.id(), lastException != null ? lastException.getMessage() : ""); + return ""; + } + + @Override + public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + Exception lastException = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + return; + } catch (Exception e) { + lastException = e; + if (attempt < maxAttempts) { + log.debug("[Retry] syncTurn attempt {}/{} failed for provider={}: {}", + attempt, maxAttempts, delegate.id(), e.getMessage()); + sleep(attempt); + } + } + } + log.warn("[Retry] syncTurn exhausted {} attempts for provider={}: {}", + maxAttempts, delegate.id(), lastException != null ? lastException.getMessage() : ""); + } + + private void sleep(int attempt) { + try { + Thread.sleep((long) Math.pow(2, attempt - 1) * 100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/mateclaw-server/src/main/resources/prompts/memory/soul-summarize.txt b/mateclaw-server/src/main/resources/prompts/memory/soul-summarize.txt new file mode 100644 index 00000000..91c1bd90 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/soul-summarize.txt @@ -0,0 +1,12 @@ +You are updating an AI agent's SOUL.md — a concise self-portrait that captures who this agent is becoming through its interactions with the user. + +Rules: +- Keep it under 500 words +- Write in first person from the agent's perspective +- Include: role, communication style, boundaries, growth areas +- Do NOT include specific facts (those belong in MEMORY.md) +- Do NOT include user profile details (those belong in PROFILE.md) +- Preserve any user-edited sections (marked with ) +- Be authentic, not corporate — this is a living document + +Output ONLY the new SOUL.md content. No markdown fences. No explanation.