mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +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
|
deploy/.env
|
||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
|
CLAUDE.md
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/plans/
|
.claude/plans/
|
||||||
|
|||||||
@ -157,6 +157,28 @@ public class AgentGraphBuilder {
|
|||||||
throw new MateClawException("err.agent.model_not_configured", "模型 " + runtimeModel.getModelName()
|
throw new MateClawException("err.agent.model_not_configured", "模型 " + runtimeModel.getModelName()
|
||||||
+ " 的 Provider(" + runtimeModel.getProvider() + ")未配置,请检查模型设置");
|
+ " 的 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());
|
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
|
||||||
|
|
||||||
// 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥
|
// 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥
|
||||||
@ -727,6 +749,24 @@ public class AgentGraphBuilder {
|
|||||||
return reordered;
|
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: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers).
|
||||||
// PR-0b: isDashScopeSearchEnabled moved to AgentDashScopeChatModelBuilder.
|
// 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 com.alibaba.cloud.ai.dashscope.spec.DashScopeApiSpec;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.retry.support.RetryTemplate;
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.StringUtils;
|
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
|
* fallback-chain helpers for resolving API key / Base URL when the provider
|
||||||
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
|
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
|
||||||
* so the agent package no longer carries any DashScope schema knowledge.</p>
|
* 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
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
||||||
|
|
||||||
private final DashScopeChatModel dashScopeChatModel;
|
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
|
||||||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
|
|
||||||
public AgentDashScopeChatModelBuilder(DashScopeChatModel dashScopeChatModel,
|
public AgentDashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
|
||||||
DashScopeConnectionProperties dashScopeConnectionProperties,
|
DashScopeConnectionProperties dashScopeConnectionProperties,
|
||||||
ModelProviderService modelProviderService) {
|
ModelProviderService modelProviderService) {
|
||||||
this.dashScopeChatModel = dashScopeChatModel;
|
this.dashScopeChatModelProvider = dashScopeChatModelProvider;
|
||||||
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
|
this.dashScopeConnectionProperties = dashScopeConnectionProperties;
|
||||||
this.modelProviderService = modelProviderService;
|
this.modelProviderService = modelProviderService;
|
||||||
}
|
}
|
||||||
@ -51,9 +56,14 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
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);
|
DashScopeApi api = buildDashScopeApi(provider);
|
||||||
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
|
DashScopeChatOptions options = buildDashScopeOptions(model, provider);
|
||||||
return dashScopeChatModel.mutate()
|
return defaultModel.mutate()
|
||||||
.dashScopeApi(api)
|
.dashScopeApi(api)
|
||||||
.defaultOptions(options)
|
.defaultOptions(options)
|
||||||
.build();
|
.build();
|
||||||
@ -200,9 +210,13 @@ public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
private DashScopeApi readDashScopeApiFromDefaultChatModel() throws NoSuchFieldException, IllegalAccessException {
|
||||||
|
DashScopeChatModel defaultModel = dashScopeChatModelProvider.getIfAvailable();
|
||||||
|
if (defaultModel == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
Field apiField = DashScopeChatModel.class.getDeclaredField("dashscopeApi");
|
||||||
apiField.setAccessible(true);
|
apiField.setAccessible(true);
|
||||||
Object api = apiField.get(dashScopeChatModel);
|
Object api = apiField.get(defaultModel);
|
||||||
return api instanceof DashScopeApi dashScopeApi ? dashScopeApi : null;
|
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 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
@ -22,6 +24,14 @@ public class ModelConfigService {
|
|||||||
private final ModelConfigMapper modelConfigMapper;
|
private final ModelConfigMapper modelConfigMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
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() {
|
public List<ModelConfigEntity> listModels() {
|
||||||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||||
@ -91,25 +101,55 @@ public class ModelConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ModelConfigEntity getDefaultModel() {
|
public ModelConfigEntity getDefaultModel() {
|
||||||
// 默认 chat 模型:明确排除 embedding 类型
|
// Prefer the explicitly marked default chat model when its provider is configured.
|
||||||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
ModelConfigEntity defaultMarked = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||||
.eq(ModelConfigEntity::getIsDefault, true)
|
.eq(ModelConfigEntity::getIsDefault, true)
|
||||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
if (entity != null) {
|
if (defaultMarked != null && isProviderConfigured(defaultMarked.getProvider())) {
|
||||||
return entity;
|
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)
|
.eq(ModelConfigEntity::getEnabled, true)
|
||||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||||
.orderByAsc(ModelConfigEntity::getName)
|
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||||
.last("LIMIT 1"));
|
.orderByAsc(ModelConfigEntity::getName));
|
||||||
if (entity == null) {
|
for (ModelConfigEntity candidate : candidates) {
|
||||||
throw new MateClawException("err.llm.no_available_model", "没有可用的模型配置");
|
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) {
|
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
|
* 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.
|
* 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(
|
private static final Set<String> DASHSCOPE_NATIVE_DENY = Set.of(
|
||||||
"qwen3.5-max",
|
"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.ChatModel;
|
||||||
import org.springframework.ai.chat.model.ChatResponse;
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.memory.event.MemoryWriteEvent;
|
||||||
import vip.mate.agent.AgentGraphBuilder;
|
import vip.mate.agent.AgentGraphBuilder;
|
||||||
import vip.mate.agent.prompt.PromptLoader;
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
@ -46,6 +48,7 @@ public class MemoryEmergenceService {
|
|||||||
private final MemoryRecallService recallService;
|
private final MemoryRecallService recallService;
|
||||||
private final DreamReportMapper dreamReportMapper;
|
private final DreamReportMapper dreamReportMapper;
|
||||||
private final vip.mate.memory.archive.MemoryArchiveService archiveService;
|
private final vip.mate.memory.archive.MemoryArchiveService archiveService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy signature — delegates to NIGHTLY mode for backward compatibility.
|
* Legacy signature — delegates to NIGHTLY mode for backward compatibility.
|
||||||
@ -154,6 +157,7 @@ public class MemoryEmergenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
|
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
|
||||||
|
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent));
|
||||||
String llmReason = root.path("reason").asText("");
|
String llmReason = root.path("reason").asText("");
|
||||||
log.info("[Memory] Emergence completed for agent={}: {}", agentId, llmReason);
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.memory.event.MemoryWriteEvent;
|
||||||
import vip.mate.workspace.document.WorkspaceFileService;
|
import vip.mate.workspace.document.WorkspaceFileService;
|
||||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
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 static final Pattern SECTION_PATTERN = Pattern.compile("^## (.+)$", Pattern.MULTILINE);
|
||||||
|
|
||||||
private final WorkspaceFileService workspaceFileService;
|
private final WorkspaceFileService workspaceFileService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
/** Per-file lock to prevent concurrent read-modify-write on the same file */
|
/** Per-file lock to prevent concurrent read-modify-write on the same file */
|
||||||
private final ConcurrentHashMap<String, ReentrantLock> fileLocks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, ReentrantLock> fileLocks = new ConcurrentHashMap<>();
|
||||||
@ -69,6 +72,8 @@ public class StructuredMemoryService {
|
|||||||
workspaceFileService.saveFile(agentId, filename, updated);
|
workspaceFileService.saveFile(agentId, filename, updated);
|
||||||
log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})",
|
log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})",
|
||||||
existingSection != null ? "Updated" : "Added", key, agentId, 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 {
|
} finally {
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
package vip.mate.memory.spi;
|
package vip.mate.memory.spi;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.memory.MemoryProperties;
|
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.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
@ -32,20 +35,43 @@ public class MemoryManager {
|
|||||||
/** External plugin memory provider (single-select constraint) */
|
/** External plugin memory provider (single-select constraint) */
|
||||||
private volatile MemoryProvider externalPluginProvider = null;
|
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();
|
Set<String> disabled = properties.getDisabledProviders();
|
||||||
this.providers = allProviders.stream()
|
List<MemoryProvider> filtered = allProviders.stream()
|
||||||
.filter(MemoryProvider::isAvailable)
|
.filter(MemoryProvider::isAvailable)
|
||||||
.filter(p -> !disabled.contains(p.id()))
|
.filter(p -> !disabled.contains(p.id()))
|
||||||
.sorted(Comparator.comparingInt(MemoryProvider::order))
|
.sorted(Comparator.comparingInt(MemoryProvider::order))
|
||||||
.collect(Collectors.toList());
|
.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()) {
|
if (!disabled.isEmpty()) {
|
||||||
log.info("[MemoryManager] Disabled providers: {}", disabled);
|
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.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 ====================
|
// ==================== System Prompt ====================
|
||||||
|
|||||||
@ -100,7 +100,7 @@ public interface MemoryProvider {
|
|||||||
* Notification that a memory write occurred. Called after canonical memory
|
* Notification that a memory write occurred. Called after canonical memory
|
||||||
* files (structured/*.md, MEMORY.md) are updated.
|
* 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 agentId the agent ID
|
||||||
* @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md")
|
* @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) {
|
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