fix(llm): skip unconfigured provider when resolving default model

This commit is contained in:
matevip 2026-04-20 21:50:01 +08:00
parent fdde74959a
commit 155bab1739
15 changed files with 518 additions and 21 deletions

1
.gitignore vendored
View File

@ -92,5 +92,6 @@ deploy/nginx/ssl/*.pem
deploy/.env
# Claude Code local settings
CLAUDE.md
.claude/settings.local.json
.claude/plans/

View File

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

View File

@ -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.</p>
*
* <p>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.</p>
*/
@Slf4j
@Component
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
private final DashScopeChatModel dashScopeChatModel;
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
private final DashScopeConnectionProperties dashScopeConnectionProperties;
private final ModelProviderService modelProviderService;
public AgentDashScopeChatModelBuilder(DashScopeChatModel dashScopeChatModel,
public AgentDashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> 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;
}
}

View File

@ -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<ModelConfigEntity> listModels() {
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.orderByDesc(ModelConfigEntity::getIsDefault)
@ -91,25 +101,55 @@ public class ModelConfigService {
}
public ModelConfigEntity getDefaultModel() {
// 默认 chat 模型明确排除 embedding 类型
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
// Prefer the explicitly marked default chat model when its provider is configured.
ModelConfigEntity defaultMarked = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.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<ModelConfigEntity>()
// 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<ModelConfigEntity> candidates = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.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) {

View File

@ -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<String> DASHSCOPE_NATIVE_DENY = Set.of(
"qwen3.5-max",
"qwen3.5-plus"
"qwen3.5-plus",
"qwen3.6-plus",
"qwen3.6-max"
);
/**

View File

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

View File

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

View File

@ -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.
* <p>
* 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.
*
* <p>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<Long, AtomicInteger> 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 "";
}
}
}

View File

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

View File

@ -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<MemoryProvider> allProviders, MemoryProperties properties) {
public MemoryManager(List<MemoryProvider> allProviders, MemoryProperties properties,
org.springframework.beans.factory.ObjectProvider<MeterRegistry> meterRegistryProvider) {
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
Set<String> disabled = properties.getDisabledProviders();
this.providers = allProviders.stream()
List<MemoryProvider> 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 ====================

View File

@ -100,7 +100,7 @@ public interface MemoryProvider {
* Notification that a memory write occurred. Called after canonical memory
* files (structured/*.md, MEMORY.md) are updated.
*
* <p>Phase 1: no subscribers. Phase 2: SOUL auto-evolution hook.
* <p>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.
*
* <p>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) {
}
}

View File

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

View File

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

View File

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

View File

@ -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 <!-- user-edited -->)
- Be authentic, not corporate — this is a living document
Output ONLY the new SOUL.md content. No markdown fences. No explanation.