mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(llm): skip unconfigured provider when resolving default model
This commit is contained in:
parent
fdde74959a
commit
155bab1739
1
.gitignore
vendored
1
.gitignore
vendored
@ -92,5 +92,6 @@ deploy/nginx/ssl/*.pem
|
||||
deploy/.env
|
||||
|
||||
# Claude Code local settings
|
||||
CLAUDE.md
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user