diff --git a/app/src/main/assets/prompts/agent/memory-distiller.md b/app/src/main/assets/prompts/agent/memory-distiller.md new file mode 100644 index 000000000..46bbd1e24 --- /dev/null +++ b/app/src/main/assets/prompts/agent/memory-distiller.md @@ -0,0 +1,67 @@ + +You maintain long-term memory about the user. Output ONLY memory entries in the tag format below — no JSON, no code fences, no extra prose. + + +Read the transcript and the existing memory list, then decide which facts about the user are worth carrying into future sessions. + + + +For every piece of information you notice, apply this test: + + "If I knew this at the start of a future session, would I behave differently?" + +- Yes → record it. +- No, or it only matters for the task at hand → skip it. + +Do NOT scan for keywords or phrases. The user will almost never announce a preference explicitly — +most of what matters has to be INFERRED from what they do. Signals include: +- they correct you, or repeat an instruction because you missed it the first time +- they reject, or pick among, options you offered +- they express approval, frustration or impatience about how you work +- they state a constraint about their environment: device, network, permissions, tooling, what fails +- they explain how their project is built, named, tested, reviewed or deployed +- they keep using the same term, path, command or technology +- they ask for a particular output shape: length, language, format, level of detail +- they reveal something about themselves: role, expertise, language, time zone, what they care about + + + +One block per memory, separated by a blank line. Exactly these three labels, each at the start of a line: + +name: short-stable-slug +description: one line summary +content: one to three short lines of detail + +Rules: +- The value may follow the label on the same line, or start on the following lines. +- No JSON, no braces, no quotes around values, no bullet markers, no code fences. +- Write the values in the same language the user writes in. +- "name": short stable slug, only lowercase letters, digits, hyphen and underscore (it becomes a filename). Keep it stable across updates of the same fact. +- "description": one line, at most 60 characters — it is injected into the system prompt as the summary. +- "content": one to three short lines, no headings. +- Output nothing at all when the transcript contains nothing durable. + + + +- Prefer recording over skipping: a missing memory is worse than an extra one, and the user can delete entries in the app. +- Record the user's durable traits, not the task. Skip one-off task details, transient state, tool output, code snippets, or anything stale tomorrow. +- Write each entry as a fact about the user, not as a summary of what happened in the conversation. +- Never duplicate an existing memory with the same meaning. If the transcript corrects or extends one, reuse the SAME "name" so the new entry replaces the old one. +- A turn may yield zero, one, or several entries — do not force one, but do not miss an implicit preference. + + + +User repeats a request they already made twice: + +name: needs-explicit-confirmation +description: Repeats instructions; confirm before acting +content: Has had to repeat the same instruction more than once — restate the plan and confirm before starting. + +User pastes a build error from their own machine: + +name: build-env-no-local-gradle +description: Cannot build locally; relies on CI +content: Local machine cannot run gradle; verification has to go through CI runs. + diff --git a/app/src/main/java/com/aicode/di/CoroutineScopesModule.kt b/app/src/main/java/com/aicode/di/CoroutineScopesModule.kt new file mode 100644 index 000000000..449ac1c1c --- /dev/null +++ b/app/src/main/java/com/aicode/di/CoroutineScopesModule.kt @@ -0,0 +1,30 @@ +package com.aicode.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +/** 应用级长生命周期作用域:用于「不跟随调用方生命周期」的后台分发。 */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopesModule { + + /** + * SupervisorJob:单个子任务失败不连坐其它子任务(引擎分发钩子依赖这一点)。 + * 提供为可注入项而非引擎内部 new,是为了单测能换成 TestScope 做确定性验证。 + */ + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) +} diff --git a/app/src/main/java/com/aicode/di/EngineBindingsModule.kt b/app/src/main/java/com/aicode/di/EngineBindingsModule.kt new file mode 100644 index 000000000..2f468f6df --- /dev/null +++ b/app/src/main/java/com/aicode/di/EngineBindingsModule.kt @@ -0,0 +1,25 @@ +package com.aicode.di + +import com.aicode.feature.agent.domain.engine.EngineModule +import com.aicode.feature.agent.domain.engine.modules.MemoryModule +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +/** + * 引擎模块注册:把每个 [EngineModule] 实现汇集为 Set,供 + * [com.aicode.feature.agent.domain.engine.AgentEngine] 构造注入。 + * + * 新增一个引擎模块时,在这里追加一行 `@Binds @IntoSet` 绑定即可, + * 引擎侧不需要任何改动。 + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class EngineBindingsModule { + + @Binds + @IntoSet + abstract fun bindMemoryModule(module: MemoryModule): EngineModule +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt new file mode 100644 index 000000000..190fb45cb --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt @@ -0,0 +1,90 @@ +package com.aicode.feature.agent.domain.engine + +import com.aicode.core.util.FileLogger +import com.aicode.di.ApplicationScope +import com.aicode.feature.agent.domain.tool.AgentTool +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 统一智能调度:所有 [EngineModule] 由这里调度,调用方只跟引擎打交道。 + * + * 三件事: + * 1. 片段聚合——按 [EngineModule.order] 取各模块本轮的系统提示词片段,拼成一段; + * 2. 工具聚合——收集各模块额外提供的工具(同名以内置/先注册者为准,由调用方去重); + * 3. 钩子分发——轮次结束、会话删除这类「一次触发、多个模块响应」的动作,并发分发。 + * + * 隔离策略:单个模块抛异常只记日志,绝不影响其它模块,也不向上抛—— + * 一个模块出问题不能拖垮整轮对话。 + */ +@Singleton +class AgentEngine @Inject constructor( + private val modules: Set<@JvmSuppressWildcards EngineModule>, + @param:ApplicationScope private val dispatchScope: CoroutineScope +) { + + /** 各模块本轮片段按 order 拼接;全部为空时返回 null。 */ + fun promptFragment(ctx: EngineContext): String? { + val pieces = sortedModules().mapNotNull { module -> + runModule(module, "promptFragment") { it.promptFragment(ctx) } + ?.takeIf { it.isNotBlank() } + } + return pieces.takeIf { it.isNotEmpty() }?.joinToString("\n\n") + } + + /** 各模块本轮提供的工具,按模块顺序拼接,同名只保留第一个。 */ + fun tools(ctx: EngineContext): List = + sortedModules() + .flatMap { module -> runModule(module, "tools") { it.tools(ctx) } ?: emptyList() } + .distinctBy { it.name } + + /** 一轮对话正常结束:并发分发给所有模块,调用方不等待。 */ + fun onTurnCompleted(ctx: EngineContext) { + dispatch("onTurnCompleted") { it.onTurnCompleted(ctx) } + } + + /** 会话被删除:并发分发给所有模块,调用方不等待。 */ + fun onSessionDeleted(ctx: EngineContext) { + dispatch("onSessionDeleted") { it.onSessionDeleted(ctx) } + } + + private fun dispatch(action: String, block: suspend (EngineModule) -> Unit) { + sortedModules().forEach { module -> + dispatchScope.launch { + runSuspendModule(module, action) { block(it) } + } + } + } + + private fun sortedModules(): List = + modules.sortedWith(compareBy({ it.order }, { it.id })) + + private inline fun runModule( + module: EngineModule, + action: String, + block: (EngineModule) -> T + ): T? = try { + block(module) + } catch (e: Exception) { + FileLogger.w(TAG, "模块 ${module.id} 的 $action 失败,已跳过", e) + null + } + + private suspend inline fun runSuspendModule( + module: EngineModule, + action: String, + block: suspend (EngineModule) -> Unit + ) { + try { + block(module) + } catch (e: Exception) { + FileLogger.w(TAG, "模块 ${module.id} 的 $action 失败,已跳过", e) + } + } + + private companion object { + const val TAG = "AgentEngine" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt new file mode 100644 index 000000000..5146ec9cd --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt @@ -0,0 +1,65 @@ +package com.aicode.feature.agent.domain.engine + +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.agent.domain.model.AgentMode +import com.aicode.feature.agent.domain.tool.AgentTool + +/** + * 引擎上下文:模块在一次调度里能看到的全部输入。 + * + * 只带「模块做判断需要的信息」,不暴露 Workflow/ViewModel 等上层对象, + * 避免模块反向依赖调用链。 + */ +data class EngineContext( + val sessionId: String?, + /** 会话被删除等拿不到工作区的场景允许为空串。 */ + val projectRoot: String = "", + val mode: AgentMode = AgentMode.BUILD, + val history: List = emptyList(), + /** 是否为子代理会话(按自定义子代理定义运行)。 */ + val isSubAgent: Boolean = false, + /** + * 一次性模型调用能力,由会话运行侧注入(不占主对话 provider、不写入会话消息)。 + * 模块拿它做归纳类工作;null 表示当前不可用(如未配置模型)。 + * + * 用回调而不是让模块注入 workflow:模块 → workflow → SystemPromptProvider → + * AgentEngine → 模块 会形成 DI 环,所以能力只能从调用方递进来。 + */ + val oneShot: (suspend (promptFile: String, userPrompt: String) -> String?)? = null +) + +/** + * 引擎模块:一个功能以模块为单位接入引擎,由 [AgentEngine] 统一调度。 + * + * 所有回调都有默认空实现,模块只覆写自己关心的那几个: + * - [promptFragment]:本轮要不要往系统提示词里加东西; + * - [tools]:本轮要不要额外提供工具; + * - [onTurnCompleted]:一轮对话正常结束后的沉淀/维护; + * - [onSessionDeleted]:会话被删除后的清理(模块自持的会话级状态在这里释放)。 + * + * 模块内抛出的异常由引擎兜住(记日志、不影响其它模块与主流程), + * 所以模块不必自己写 try/catch。 + */ +interface EngineModule { + /** 模块标识,用于日志与排查。 */ + val id: String + + /** 调度顺序,越小越靠前;同序按 [id] 稳定排序。 */ + val order: Int get() = DEFAULT_ORDER + + /** 本轮注入系统提示词的片段;返回 null 或空白表示本轮不参与。 */ + fun promptFragment(ctx: EngineContext): String? = null + + /** 本轮额外提供的工具;与内置工具合并时同名以内置为准。 */ + fun tools(ctx: EngineContext): List = emptyList() + + /** 一轮对话正常结束(成功、未取消)后调用。 */ + suspend fun onTurnCompleted(ctx: EngineContext) {} + + /** 会话被删除后调用。 */ + suspend fun onSessionDeleted(ctx: EngineContext) {} + + companion object { + const val DEFAULT_ORDER = 100 + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt new file mode 100644 index 000000000..5a4e00b40 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -0,0 +1,311 @@ +package com.aicode.feature.agent.domain.engine.modules + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.engine.EngineContext +import com.aicode.feature.agent.domain.engine.EngineModule +import com.aicode.feature.agent.domain.memory.Memory +import com.aicode.feature.agent.domain.memory.MemoryKind +import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.agent.domain.memory.MemoryScope +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.settings.data.repository.MemorySettingsRepository +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 记忆模块:把「AI 记住了什么」接进引擎。 + * + * 两件事: + * 1. 注入——把记忆清单(名称 + 描述)拼进系统提示词,详情仍由模型调 `memory(action=read)` 自取; + * 2. 沉淀——每轮对话结束后(开关打开时)归纳出关于用户的稳定结论,写进长期记忆。 + * + * 缓存策略沿用迁移前的实现:按 (sessionId, projectRoot) 会话级缓存,同一会话内只读一次盘, + * 保持 system prompt 稳定以命中 KV 缓存;空结果用 "" 占位以区分「未缓存」。 + */ +@Singleton +class MemoryModule @Inject constructor( + private val memoryRepository: MemoryRepository, + private val memorySettings: MemorySettingsRepository +) : EngineModule { + + override val id = MODULE_ID + + // 注入顺序:记忆清单跟着「项目规则 / 技能」这类上下文走,用默认序即可。 + override val order = 50 + + private val cachedByKey = ConcurrentHashMap, String>() + + /** 每个会话已积累的轮数,够 [DISTILL_EVERY_TURNS] 才归约一次。 */ + private val turnsSinceDistill = ConcurrentHashMap() + + /** 每会话一把锁:归约是并发分发的,上一次没跑完就不开新的,避免重复写入。 */ + private val distillLocks = ConcurrentHashMap() + + // 声明在 init 之前:Kotlin 按声明顺序初始化 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + // 记忆被外部改动(如模型主动调 memory 工具写入)时丢掉注入缓存, + // 否则新记忆要等到换会话才生效——主动记忆就白写了。 + scope.launch { + memoryRepository.changes.collect { cachedByKey.clear() } + } + } + + override fun promptFragment(ctx: EngineContext): String? { + val autoSave = memorySettings.autoDistillEnabledSync() + // 开关也进缓存 key:切换开关后注入内容要跟着变 + val key = Triple(ctx.sessionId, ctx.projectRoot, autoSave) + val cached = cachedByKey[key] + if (cached != null) return cached.ifEmpty { null } + + // 清单为空时也要可能返回规则本身(新用户没有任何记忆时,主动记忆规则必须照样注入) + val content = listOfNotNull( + // 子代理不拿主动记忆规则:写用户画像是主代理的事,子代理只管干活 + AUTO_SAVE_RULE.takeIf { autoSave && !ctx.isSubAgent }, + buildMemoryList(ctx) + ).joinToString("\n\n") + + cachedByKey[key] = content + trimIfNeeded() + return content.ifEmpty { null } + } + + private fun buildMemoryList(ctx: EngineContext): String? { + val memories = try { + memoryRepository.listMemories(ctx.projectRoot) + } catch (e: Exception) { + return null + } + if (memories.isEmpty()) return null + + // 注入有上限:记忆多了不能把系统提示词撑爆(未列出的靠 memory(action=list) 取) + val listed = memories.take(MAX_INJECTED_MEMORIES) + val globalMemories = listed.filter { it.scope == MemoryScope.GLOBAL } + val projectMemories = listed.filter { it.scope == MemoryScope.PROJECT } + + val content = buildString { + if (globalMemories.isNotEmpty()) { + append("全局记忆 (跨项目个人偏好,需要详情时用 memory(action=read, name=xxx, scope=global)):\n") + globalMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } + } + if (projectMemories.isNotEmpty()) { + if (isNotEmpty()) append("\n") + append("项目记忆 (当前项目专属,需要详情时用 memory(action=read, name=xxx, scope=project)):\n") + projectMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } + } + if (memories.size > listed.size) { + append("\n(另有 ${memories.size - listed.size} 条未列出,需要时用 memory(action=list) 查看)") + } + }.trimEnd() + + return content + } + + /** + * 每轮对话结束后沉淀长期记忆。 + * + * 只有开关打开、非子代理会话、且调用方递进了一次性模型调用能力时才干活; + * 归纳结果解析失败或模型返回空数组都算「这轮没什么可记的」,静默跳过。 + */ + override suspend fun onTurnCompleted(ctx: EngineContext) { + if (ctx.isSubAgent) return + if (ctx.history.isEmpty()) return + val complete = ctx.oneShot ?: return + if (!memorySettings.autoDistillEnabled()) return + val sessionKey = ctx.sessionId ?: return + + // 攒够若干轮才归约一次:每轮都调模型太贵,而主路的主动记忆已经覆盖了当轮偏好。 + val turns = (turnsSinceDistill[sessionKey] ?: 0) + 1 + if (turns < DISTILL_EVERY_TURNS) { + turnsSinceDistill[sessionKey] = turns + return + } + turnsSinceDistill[sessionKey] = 0 + + // 归约由引擎并发分发,上一轮可能还没跑完;同一会话不重叠,避免重复写入 + val lock = distillLocks.getOrPut(sessionKey) { Mutex() } + if (!lock.tryLock()) return + try { + distill(ctx, complete) + } finally { + lock.unlock() + } + } + + private suspend fun distill(ctx: EngineContext, complete: suspend (String, String) -> String?) { + + val existing = runCatching { memoryRepository.listMemories(ctx.projectRoot) } + .getOrDefault(emptyList()) + .filter { it.kind == MemoryKind.PROFILE } + + val raw = complete(PROMPT_FILE, buildUserPrompt(ctx, existing)) ?: return + val entries = parseEntries(raw) + if (entries.isEmpty()) return + + entries.forEach { entry -> + // 沉淀出来的结论都是「关于用户」的,跨项目通用,因此统一写全局。 + memoryRepository.saveMemory( + name = entry.name, + description = entry.description, + content = entry.content, + scope = MemoryScope.GLOBAL, + projectRoot = ctx.projectRoot, + kind = MemoryKind.PROFILE + ) + } + FileLogger.i(TAG, "沉淀长期记忆 ${entries.size} 条: ${entries.joinToString { it.name }}") + // 本轮新增/更新了记忆 → 丢掉本会话的注入缓存,下一轮注入就带上新内容 + // (缓存 key 是 Triple(会话, 工作区, 开关),这里按前两项清,与开关无关) + cachedByKey.keys + .filter { it.first == ctx.sessionId && it.second == ctx.projectRoot } + .forEach { cachedByKey.remove(it) } + } + + override suspend fun onSessionDeleted(ctx: EngineContext) { + cachedByKey.keys.removeAll { it.first == ctx.sessionId } + ctx.sessionId?.let { turnsSinceDistill.remove(it) } + } + + /** 交给模型的输入:已有长期记忆(防重复)+ 本轮对话(截断,控制 token)。 */ + private fun buildUserPrompt(ctx: EngineContext, existing: List): String = + buildString { + appendLine("Existing long-term memories (same name = replaces the old entry):") + if (existing.isEmpty()) { + appendLine("(none)") + } else { + existing.forEach { appendLine("- ${it.name}: ${it.description}") } + } + appendLine() + appendLine("Transcript of the latest turn:") + ctx.history.takeLast(TRANSCRIPT_MESSAGES).forEach { appendLine(renderMessage(it)) } + } + + private fun renderMessage(message: AgentMessage): String = when (message) { + is AgentMessage.UserMessage -> "user: ${message.content.take(MAX_MESSAGE_CHARS)}" + is AgentMessage.AssistantMessage -> "assistant: ${message.content.take(MAX_MESSAGE_CHARS)}" + is AgentMessage.ToolResultMessage -> + "tool(${message.toolName}): ${(message.modelResult ?: message.result).take(MAX_TOOL_CHARS)}" + } + + /** + * 解析模型输出:按 `name:` / `description:` / `content:` 标签逐行读,不要求 JSON。 + * + * 为什么不要求 JSON:模型输出 JSON 的失败率明显更高——漏引号、多逗号、把换行写进字符串、 + * 前后多一段说明文字,任何一处都会让整个数组解析失败,这一批记忆就白抽了。 + * 标签格式容错得多:空行、`---` 分隔、代码块围栏、中英文冒号、行首项目符号都能吃掉, + * 单条写坏了也只丢那一条。 + */ + private fun parseEntries(raw: String): List { + val entries = mutableListOf() + var name = "" + var description = "" + val content = StringBuilder() + + fun flush() { + if (name.isNotBlank() && content.isNotBlank()) { + entries.add(DistilledEntry(name.trim(), description.trim(), content.toString().trim())) + } + name = "" + description = "" + content.clear() + } + + raw.lines().forEach { line -> + val cleaned = line.trim().removePrefix("-").removePrefix("*").trim().trim('`').trim() + when { + cleaned.isEmpty() || cleaned == "---" -> Unit + + NAME_LABEL.matches(cleaned) -> { + flush() + name = NAME_LABEL.matchEntire(cleaned)?.groupValues?.get(1).orEmpty() + } + + DESCRIPTION_LABEL.matches(cleaned) -> + description = DESCRIPTION_LABEL.matchEntire(cleaned)?.groupValues?.get(1).orEmpty() + + CONTENT_LABEL.matches(cleaned) -> + content.appendLine(CONTENT_LABEL.matchEntire(cleaned)?.groupValues?.get(1).orEmpty()) + + // 多行 content:从属于上一条 name + name.isNotBlank() -> content.appendLine(cleaned) + } + } + flush() + if (entries.isNotEmpty()) return entries.take(MAX_ENTRIES_PER_TURN) + + // 模型万一还是吐了 JSON:按旧方式再试一次,不白丢这一批 + val start = raw.indexOf('[') + val end = raw.lastIndexOf(']') + if (start < 0 || end <= start) return emptyList() + return runCatching { + Json { ignoreUnknownKeys = true } + .decodeFromString(ListSerializer(DistilledEntry.serializer()), raw.substring(start, end + 1)) + .filter { it.name.isNotBlank() && it.content.isNotBlank() } + .take(MAX_ENTRIES_PER_TURN) + }.onFailure { + FileLogger.w(TAG, "标签格式没解析出条目,JSON 兜底也失败,本轮跳过", it) + }.getOrDefault(emptyList()) + } + + /** 标签格式的三个字段;中英文冒号都认,大小写不敏感。 */ + private val NAME_LABEL = Regex("^name\\s*[::]\\s*(.+)$", RegexOption.IGNORE_CASE) + private val DESCRIPTION_LABEL = Regex("^description\\s*[::]\\s*(.+)$", RegexOption.IGNORE_CASE) + private val CONTENT_LABEL = Regex("^content\\s*[::]\\s*(.*)$", RegexOption.IGNORE_CASE) + + private fun trimIfNeeded() { + if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() + } + + @Serializable + private data class DistilledEntry( + val name: String, + val description: String = "", + val content: String = "" + ) + + private companion object { + const val MODULE_ID = "memory" + const val TAG = "MemoryModule" + const val SOURCE_CACHE_LIMIT = 64 + + /** B 路归约间隔:每这么多轮才跑一次独立归纳。 */ + const val DISTILL_EVERY_TURNS = 5 + + /** + * 主动记忆规则(A 路):开关打开时注入,让主模型在对话中自己把稳定结论存下来。 + * 判据是语义的(「未来会话里知道这条会不会让我做法不同」),不是关键词清单: + * 用户几乎不会明说「记住这个」,偏好大多是隐含的。 + */ + val AUTO_SAVE_RULE = """ + 长期记忆(开关已打开,你需要主动维护): + 每轮回复收尾前,先判断这一轮是否出现了关于用户的稳定结论—— + 偏好与表达习惯、工作方式、对你做法的纠正、环境或工具限制(设备/网络/权限/跑不起来的东西)、 + 项目约定与架构决策、反复出现的术语与路径、关于用户自身的稳定事实。 + 判据:如果未来某次会话一开始就知道这条,你会不会做得不一样?会 → 调用 + memory(action=save, scope=global, name=<短英文 slug>, description=<一句话>, content=<1-3 行>) 存下来。 + 不要等用户说「记住」,也不要等他要求;发现即记。保存成功后在回复末尾用一行说明记了什么。 + 不要记:一次性任务细节、工具输出、代码片段、明天就过期的状态。 + """.trimIndent() + + /** 沉淀提示词:与其它一次性调用一样放 assets/prompts 下。 */ + const val PROMPT_FILE = "agent/memory-distiller.md" + + const val TRANSCRIPT_MESSAGES = 20 + const val MAX_MESSAGE_CHARS = 1200 + const val MAX_TOOL_CHARS = 400 + const val MAX_ENTRIES_PER_TURN = 5 + + /** 注入系统提示词的记忆条数上限,超出的靠 memory(action=list) 取。 */ + const val MAX_INJECTED_MEMORIES = 40 + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt index 97a855a5f..2ef929233 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt @@ -29,11 +29,11 @@ class GlobalMemorySource @Inject constructor( ?.content } - override fun saveMemory(name: String, description: String, content: String): Boolean { + override fun saveMemory(name: String, description: String, content: String, kind: MemoryKind): Boolean { return try { if (!memoryRoot.exists()) memoryRoot.mkdirs() val file = MemorySource.resolveMemoryFile(memoryRoot, name) - file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content)) + file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content, kind)) true } catch (e: Exception) { FileLogger.e("GlobalMemorySource", "Failed to save memory: $name", e) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt index cb5f49574..589dd14e5 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt @@ -16,9 +16,19 @@ data class Memory( val description: String, val scope: MemoryScope, val file: File? = null, - val content: String + val content: String, + val kind: MemoryKind = MemoryKind.NOTE ) enum class MemoryScope { GLOBAL, PROJECT } + +/** + * 记忆类型:[NOTE] 是模型显式记录的内容,[PROFILE] 是从历史对话自动沉淀的长期结论。 + * + * 仅作 frontmatter 的 kind 字段落盘,[NOTE] 不写该字段——保持既有记忆文件字节不变。 + */ +enum class MemoryKind { + NOTE, PROFILE +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt index 62cf4d904..9bd7f2ab6 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt @@ -21,20 +21,26 @@ object MemoryParser { val name = frontmatter["name"]?.toString()?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension val description = (frontmatter["description"]?.toString() ?: "").take(MAX_DESC_CHARS) + val kind = when (frontmatter["kind"]?.toString()?.trim()?.lowercase()) { + "profile" -> MemoryKind.PROFILE + else -> MemoryKind.NOTE + } return Memory( name = name, description = description, scope = scope, file = file, - content = body.trim() + content = body.trim(), + kind = kind ) } - fun format(name: String, description: String, content: String): String { + fun format(name: String, description: String, content: String, kind: MemoryKind = MemoryKind.NOTE): String { val safeName = yamlScalar(name) val safeDesc = yamlScalar(description) - return "---\nname: $safeName\ndescription: $safeDesc\n---\n$content" + val kindLine = if (kind == MemoryKind.PROFILE) "kind: profile\n" else "" + return "---\nname: $safeName\ndescription: $safeDesc\n$kindLine---\n$content" } /** 把任意字符串转成安全的 YAML 标量,避免冒号/引号/换行破坏 frontmatter。 */ diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt index 6beae5db0..1adc3b8e0 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt @@ -3,6 +3,9 @@ package com.aicode.feature.agent.domain.memory import com.aicode.feature.agent.domain.container.ContainerInstaller import com.aicode.feature.settings.data.repository.ExecutionModeHolder import com.aicode.feature.workspace.domain.ProjectAicodeRoot +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import javax.inject.Inject import javax.inject.Singleton @@ -17,8 +20,22 @@ class MemoryRepository @Inject constructor( private fun projectSource(projectRoot: String) = ProjectMemorySource(projectRoot, executionModeHolder, containerInstaller, projectAicodeRoot) - /** 扫描并聚合全局和项目级的 memory。同名 memory 项目级优先。 */ - fun listMemories(projectRoot: String?): List { + private val _changes = MutableSharedFlow(extraBufferCapacity = 8) + + /** + * 记忆被写入 / 编辑 / 删除时发信号。 + * + * 注入方(MemoryModule)按会话缓存注入内容以保持 system prompt 稳定, + * 若不知道内容变了,新记忆要等到换会话才生效——主动记忆(模型调工具写)就白写了。 + */ + val changes: SharedFlow = _changes.asSharedFlow() + + private fun notifyChanged() { + _changes.tryEmit(Unit) + } + + /** 扫描并聚合全局和项目级的 memory。同名 memory 项目级优先。kind 非空时只返回该类型。 */ + fun listMemories(projectRoot: String?, kind: MemoryKind? = null): List { val allMemories = mutableListOf() // 1. 加载全局记忆 @@ -30,9 +47,10 @@ class MemoryRepository @Inject constructor( } // 去重:按 name 小写分组,保留最后加入的(即项目级优先覆盖全局级) - return allMemories + val deduped = allMemories .groupBy { it.name.lowercase() } .map { it.value.last() } + return if (kind == null) deduped else deduped.filter { it.kind == kind } } /** 读取指定 memory 的完整指令正文;不存在 / 解析失败返回 null。 */ @@ -46,33 +64,46 @@ class MemoryRepository @Inject constructor( return globalMemorySource.loadContent(name) } - fun saveMemory(name: String, description: String, content: String, scope: MemoryScope, projectRoot: String?): Boolean { - return when (scope) { - MemoryScope.GLOBAL -> globalMemorySource.saveMemory(name, description, content) + fun saveMemory( + name: String, + description: String, + content: String, + scope: MemoryScope, + projectRoot: String?, + kind: MemoryKind = MemoryKind.NOTE + ): Boolean { + val saved = when (scope) { + MemoryScope.GLOBAL -> globalMemorySource.saveMemory(name, description, content, kind) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) false - else projectSource(projectRoot).saveMemory(name, description, content) + else projectSource(projectRoot).saveMemory(name, description, content, kind) } } + if (saved) notifyChanged() + return saved } fun editMemory(name: String, edits: List, scope: MemoryScope, projectRoot: String?): MemoryEditResult { - return when (scope) { + val result = when (scope) { MemoryScope.GLOBAL -> globalMemorySource.editMemory(name, edits) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) MemoryEditResult.Error("NO_WORKSPACE", "当前未选择工作区,无法编辑项目级记忆") else projectSource(projectRoot).editMemory(name, edits) } } + if (result is MemoryEditResult.Success) notifyChanged() + return result } fun deleteMemory(name: String, scope: MemoryScope, projectRoot: String?): Boolean { - return when (scope) { + val deleted = when (scope) { MemoryScope.GLOBAL -> globalMemorySource.deleteMemory(name) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) false else projectSource(projectRoot).deleteMemory(name) } } + if (deleted) notifyChanged() + return deleted } } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt index aa73c6bf4..4c59b342c 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt @@ -33,7 +33,12 @@ interface MemorySource { fun loadContent(name: String): String? /** 保存一条记忆(创建或覆盖) */ - fun saveMemory(name: String, description: String, content: String): Boolean + fun saveMemory( + name: String, + description: String, + content: String, + kind: MemoryKind = MemoryKind.NOTE + ): Boolean /** * 对已有记忆的正文做局部编辑(old_string/new_string 精确匹配),语义与 editFile 一致。 @@ -74,7 +79,7 @@ interface MemorySource { } return try { - file.writeText(MemoryParser.format(memory.name, memory.description, content)) + file.writeText(MemoryParser.format(memory.name, memory.description, content, memory.kind)) MemoryEditResult.Success } catch (e: Exception) { FileLogger.e("MemorySource", "Failed to edit memory: $name", e) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt index f387c1f23..dc8fd355d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt @@ -46,12 +46,12 @@ class ProjectMemorySource( ?.content } - override fun saveMemory(name: String, description: String, content: String): Boolean { + override fun saveMemory(name: String, description: String, content: String, kind: MemoryKind): Boolean { if (projectRoot.isBlank()) return false return try { if (!memoryRoot.exists()) memoryRoot.mkdirs() val file = MemorySource.resolveMemoryFile(memoryRoot, name) - file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content)) + file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content, kind)) true } catch (e: Exception) { FileLogger.e("ProjectMemorySource", "Failed to save memory: $name", e) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt index 33e8b3296..1f6cce6a0 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt @@ -3,8 +3,8 @@ package com.aicode.feature.agent.domain.prompt import android.content.Context import com.aicode.core.util.FileLogger import com.aicode.feature.agent.domain.container.ContainerInstaller -import com.aicode.feature.agent.domain.memory.MemoryRepository -import com.aicode.feature.agent.domain.memory.MemoryScope +import com.aicode.feature.agent.domain.engine.AgentEngine +import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.domain.model.AgentContext import com.aicode.feature.agent.domain.skill.SkillRepository import com.aicode.feature.agent.domain.subagent.AgentDefinition @@ -31,7 +31,7 @@ import javax.inject.Singleton class SystemPromptProvider @Inject constructor( @param:ApplicationContext private val context: Context, private val skillRepository: SkillRepository, - private val memoryRepository: MemoryRepository, + private val agentEngine: AgentEngine, private val containerInstaller: ContainerInstaller, private val agentDefinitionRepository: AgentDefinitionRepository ) { @@ -182,44 +182,20 @@ class SystemPromptProvider @Inject constructor( override fun build(ctx: AgentContext): String = "[System] 当前本地时间: ${currentDate()}" } - private inner class MemoryListSource : PromptSource { - // 会话级缓存:同一 (sessionId, projectRoot) 内只读一次盘,保持 system prompt 稳定以命中 KV 缓存; - // 新开会话 / 切换工作区 / 重启 App 时缓存自然失效重建。空内容用 "" 占位以区分"未缓存"。 - private val cachedByKey = ConcurrentHashMap() - - override fun build(ctx: AgentContext): String? { - val key = SourceCacheKey(ctx.sessionId, ctx.projectRoot) - val cached = cachedByKey[key] - if (cached != null) return cached.ifEmpty { null } - val memories = try { memoryRepository.listMemories(ctx.projectRoot) } catch (e: Exception) { return null } - if (memories.isEmpty()) { - cachedByKey[key] = "" - return null - } - - val globalMemories = memories.filter { it.scope == MemoryScope.GLOBAL } - val projectMemories = memories.filter { it.scope == MemoryScope.PROJECT } - - val content = buildString { - if (globalMemories.isNotEmpty()) { - append("全局记忆 (跨项目个人偏好,需要详情时用 memory(action=read, name=xxx, scope=global)):\n") - globalMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } - } - if (projectMemories.isNotEmpty()) { - if (isNotEmpty()) append("\n") - append("项目记忆 (当前项目专属,需要详情时用 memory(action=read, name=xxx, scope=project)):\n") - projectMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } - } - }.trimEnd() - - cachedByKey[key] = content - trimIfNeeded() - return content - } - - private fun trimIfNeeded() { - if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() - } + private inner class EngineFragmentSource : PromptSource { + /** + * 引擎聚合片段:由 [AgentEngine] 按模块 order 调度各模块本轮的片段。 + * 记忆清单原本在这里读盘,现已迁进 MemoryModule(连同它的会话级缓存)。 + */ + override fun build(ctx: AgentContext): String? = agentEngine.promptFragment( + EngineContext( + sessionId = ctx.sessionId, + projectRoot = ctx.projectRoot, + mode = ctx.mode, + history = ctx.history, + isSubAgent = ctx.agentDefinition != null + ) + ) } /** 会话级缓存 key:同一会话同一工作区共享一份快照,避免每轮重扫磁盘导致 system prompt 变化。 */ @@ -228,7 +204,7 @@ class SystemPromptProvider @Inject constructor( private val staticRuleSource = StaticRuleSource() private val subAgentBaseSource = SubAgentBaseSource() private val subAgentListSource = SubAgentListSource() - private val memoryListSource = MemoryListSource() + private val engineFragmentSource = EngineFragmentSource() private val activeSkillsSource = ActiveSkillsSource() private val projectRuleSource = ProjectRuleSource() private val workspaceSource = WorkspaceSource() @@ -253,7 +229,7 @@ class SystemPromptProvider @Inject constructor( val rawStatic = staticRuleSource.build(agentContext) val skillsContent = activeSkillsSource.build(agentContext) val subAgentsContent = subAgentListSource.build(agentContext) - val memoriesContent = memoryListSource.build(agentContext) + val memoriesContent = engineFragmentSource.build(agentContext) val projectRules = projectRuleSource.build(agentContext) // 2. Workspace 上下文固定输出(内容已精简,无需快照占位) @@ -310,7 +286,7 @@ class SystemPromptProvider @Inject constructor( return renderVariables( content, activeSkillsSource.build(ctx), - memoryListSource.build(ctx), + engineFragmentSource.build(ctx), subAgentListSource.build(ctx), projectRuleSource.build(ctx), workspaceSource.build(ctx), @@ -341,7 +317,7 @@ class SystemPromptProvider @Inject constructor( renderVariables( definition.prompt, activeSkillsSource.build(agentContext), - memoryListSource.build(agentContext), + engineFragmentSource.build(agentContext), subAgentListSource.build(agentContext), projectRuleSource.build(agentContext), workspaceSource.build(agentContext), @@ -356,7 +332,7 @@ class SystemPromptProvider @Inject constructor( } } if (InjectPart.MEMORY in definition.inject) { - memoryListSource.build(agentContext)?.let { + engineFragmentSource.build(agentContext)?.let { append("\n\n") append(it) } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt index 9ba54b688..dfb39868d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt @@ -1,12 +1,15 @@ package com.aicode.feature.agent.domain.session import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.engine.AgentEngine +import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.data.local.dao.AgentMessageDao import com.aicode.feature.agent.data.local.dao.ChatSessionDao import com.aicode.feature.agent.data.local.entity.ChatSessionEntity import com.aicode.feature.agent.domain.model.AgentMode import com.aicode.feature.agent.domain.model.ReasoningEffort import com.aicode.feature.agent.presentation.MessageRole +import dagger.Lazy import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -14,7 +17,10 @@ import javax.inject.Singleton @Singleton class SessionUseCase @Inject constructor( private val chatSessionDao: ChatSessionDao, - private val agentMessageDao: AgentMessageDao + private val agentMessageDao: AgentMessageDao, + // Lazy 断环:引擎 → 记忆模块 → MemoryRepository → ProjectAicodeRoot → WorkspaceRepository → 本类, + // 直接注入会形成 Dagger 依赖环;钩子只在删除时用一次,延迟取即可。 + private val agentEngine: Lazy ) { companion object { private const val TAG = "SessionUseCase" @@ -78,6 +84,10 @@ class SessionUseCase @Inject constructor( } agentMessageDao.deleteBySession(id) chatSessionDao.delete(id) + // 交给引擎分发:模块自持的会话级状态(如记忆注入缓存)在这里释放 + deleted.forEach { sessionId -> + agentEngine.get().onSessionDeleted(EngineContext(sessionId = sessionId)) + } return deleted } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt index 6aa98fee8..2e1851ebd 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt @@ -39,7 +39,8 @@ class MemoryTool @Inject constructor( } } override val description = - "管理 AI 的长期记忆(读取/保存/局部编辑/删除/列表)。发现新的用户偏好、项目约定或架构决策时主动记录。" + "管理长期记忆(读取/保存/局部编辑/删除/列表)。记忆跨会话生效,用于保留用户的稳定偏好、" + + "项目约定、架构决策等;保存与编辑无需用户确认。什么时候该主动保存,见系统提示词里的长期记忆规则。" /** edits 数组单个元素的结构,供 function-calling 的 items schema,语义与 editFile 一致。 */ private val editItemSchema: Map = mapOf( diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt index 0b560b1dd..fe2980b3d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt @@ -121,4 +121,20 @@ interface AgentWorkflow { * @return 建议的提交说明,失败返回 null。 */ suspend fun generateCommitMessage(diff: String): String? + + /** + * 用指定提示词资产文件做一次独立模型调用(不占主对话 provider、不写入会话消息), + * 供引擎模块做归纳类工作(如长期记忆沉淀)。 + * + * @param promptFile `prompts/` 下的相对路径,如 `agent/memory-distiller.md` + * @param userPrompt 本次要处理的用户消息内容 + * @param kind 调用记录类型(写进 llm_call_records,便于统计区分) + * @return 模型输出文本;未配置可用模型或调用失败时返回 null。 + */ + suspend fun oneShotComplete( + sessionId: String?, + promptFile: String, + userPrompt: String, + kind: String + ): String? } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt index 0051dd087..028a0b357 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt @@ -996,6 +996,66 @@ class StatefulAgentWorkflow @Inject constructor( FileLogger.w(TAG, "生成提交信息失败", e) }.getOrNull() + /** + * 一次性独立模型调用:主聊天模型(拿不到时回退标题专用模型),不占主对话 provider、 + * 不写入会话消息,只把用量落进 llm_call_records(kind 由调用方给)。 + */ + override suspend fun oneShotComplete( + sessionId: String?, + promptFile: String, + userPrompt: String, + kind: String + ): String? = runCatching { + val provider = getEffectiveProvider(sessionId) + val prompt = promptProvider.resolvePrompt(promptFile).replace(LEADING_COMMENT, "") + val callStartWall = System.currentTimeMillis() + val callStartElapsed = SystemClock.elapsedRealtime() + var callCompleted = false + var callError: String? = null + var usage: AIResponse? = null + val response = try { + val resp = provider.complete( + systemPrompt = prompt, + messages = listOf(AgentMessage.UserMessage(content = userPrompt)), + tools = emptyList() + ) + usage = resp + callCompleted = true + resp + } catch (e: CancellationException) { + callError = "cancelled" + throw e + } catch (e: Exception) { + callError = e.message ?: e.javaClass.simpleName + throw e + } finally { + val durationMillis = (SystemClock.elapsedRealtime() - callStartElapsed).toInt() + runCatching { + llmCallRecordDao.insert( + LlmCallRecordEntity( + sessionId = sessionId, + providerId = provider.providerId.ifBlank { null }, + model = provider.model, + kind = kind, + inputTokens = usage?.inputTokens ?: 0, + outputTokens = usage?.outputTokens ?: 0, + cachedInputTokens = usage?.cachedInputTokens ?: 0, + cacheCreationTokens = usage?.cacheCreationTokens ?: 0, + ttfbMillis = null, + durationMillis = durationMillis, + status = if (callCompleted) "success" else "error", + errorMessage = callError, + stopReason = usage?.stopReason, + createdAt = callStartWall + ) + ) + } + } + response.content.trim().ifBlank { null } + }.onFailure { e -> + FileLogger.w(TAG, "一次性模型调用失败: $promptFile", e) + }.getOrNull() + private suspend fun runToolStream( tool: StreamingAgentTool, toolCall: ToolCall, diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index 7ed45525b..38b0bb352 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -56,6 +56,8 @@ import com.aicode.feature.agent.domain.subagent.SubAgentEventBus import com.aicode.core.watch.FileChangeHub import com.aicode.core.watch.asDirtySignal import com.aicode.feature.agent.domain.subagent.SubAgentEventType +import com.aicode.feature.agent.domain.engine.AgentEngine +import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.domain.workflow.AgentWorkflow import com.aicode.feature.terminal.domain.TabFinishedEvent import com.aicode.feature.terminal.domain.TerminalKeepaliveService @@ -124,6 +126,7 @@ import javax.inject.Inject class AIAgentViewModel @Inject constructor( private val agentWorkflow: AgentWorkflow, private val toolRegistry: ToolRegistry, + private val agentEngine: AgentEngine, private val agentMessageDao: AgentMessageDao, private val chatSessionDao: ChatSessionDao, private val llmCallRecordDao: LlmCallRecordDao, @@ -1518,7 +1521,15 @@ class AIAgentViewModel @Inject constructor( agentDefinition = agentDefinition ) - val allTools = toolRegistry.getAvailableTools() + val allTools = (toolRegistry.getAvailableTools() + agentEngine.tools( + EngineContext( + sessionId = sessionId, + projectRoot = projectRoot, + mode = mode, + history = history, + isSubAgent = sessionEntity?.parentId != null + ) + )).distinctBy { it.name } val isSub = sessionEntity?.parentId != null val tools = when { agentDefinition != null -> { @@ -1720,6 +1731,29 @@ class AIAgentViewModel @Inject constructor( } setStreamingText(sessionId, null) + // 一轮对话正常结束:交给引擎分发,模块自行判断开关与要不要干活。 + // 放在成功路径(非 finally)——取消/报错的一轮不算「结束」,不该触发沉淀。 + if (!failed) { + // 重新读一次历史:上面那份 history 是「本轮开始前」的快照,不含刚结束的这一轮, + // 沉淀模块需要看到本轮内容(否则永远慢一轮,首轮更是空的)。 + val turnHistory = runCatching { + messagePersistenceUseCase.buildHistory(sessionId, SessionUseCase.PENDING_TOOL_MARKER) + }.getOrDefault(emptyList()) + agentEngine.onTurnCompleted( + EngineContext( + sessionId = sessionId, + projectRoot = projectRoot, + mode = mode, + history = turnHistory, + isSubAgent = sessionEntity?.parentId != null, + // 模块做归纳时的一次性模型调用:走独立 provider,不占主对话 + oneShot = { promptFile, userPrompt -> + agentWorkflow.oneShotComplete(sessionId, promptFile, userPrompt, "memory-distill") + } + ) + ) + } + } catch (e: CancellationException) { val cancelledState = _agentStates.value[sessionId] val isOwnJob = sessionJobs[sessionId] == coroutineContext[Job] diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt new file mode 100644 index 000000000..ad92fb62c --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt @@ -0,0 +1,63 @@ +package com.aicode.feature.settings.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.memoryDataStore by preferencesDataStore(name = "memory_prefs") + +/** + * 记忆模块自己的开关(每个引擎模块一套开关,互不影响)。 + * + * 目前一项:长期记忆自动沉淀。 + * **默认关**是刻意的——自动沉淀会调模型、写文件,用户必须自己打开才知道它在工作, + * 而不是装完就在后台默默记东西(这正是上一版画像被否掉的原因)。 + */ +@Singleton +class MemorySettingsRepository @Inject constructor( + @param:ApplicationContext private val context: Context +) { + + val autoDistillEnabledFlow: Flow = context.memoryDataStore.data + .map { it[AUTO_DISTILL_ENABLED_KEY] ?: false } + + /** + * 同步快照:系统提示词片段是同步拼接的(读不了 DataStore), + * 而提示词里要不要加「主动记记忆」的规则取决于本开关。 + * 由下面的收集器持续刷新,进程启动后很快就有值。 + */ + @Volatile + private var autoDistillEnabledSnapshot: Boolean = false + + // 声明在 init 之前:Kotlin 按声明顺序初始化,写在后面 init 里会报「必须初始化」 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + scope.launch { + autoDistillEnabledFlow.collect { autoDistillEnabledSnapshot = it } + } + } + + fun autoDistillEnabledSync(): Boolean = autoDistillEnabledSnapshot + + suspend fun autoDistillEnabled(): Boolean = autoDistillEnabledFlow.first() + + suspend fun setAutoDistillEnabled(enabled: Boolean) { + context.memoryDataStore.edit { it[AUTO_DISTILL_ENABLED_KEY] = enabled } + } + + private companion object { + val AUTO_DISTILL_ENABLED_KEY = booleanPreferencesKey("auto_distill_enabled") + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt new file mode 100644 index 000000000..1e1eb4e18 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt @@ -0,0 +1,67 @@ +package com.aicode.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aicode.feature.agent.domain.memory.Memory +import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.settings.data.repository.MemorySettingsRepository +import com.aicode.feature.workspace.data.repository.WorkspaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * 记忆页状态:列出当前生效的记忆(全局与项目由 Repository 合并去重,界面不再分栏), + * 支持删除,并提供「长期记忆自动沉淀」开关。 + * + * 扫描磁盘记忆文件与删除都是 IO,统一放 IO 线程。 + */ +@HiltViewModel +class MemoryViewModel @Inject constructor( + private val memoryRepository: MemoryRepository, + private val workspaceRepository: WorkspaceRepository, + private val memorySettings: MemorySettingsRepository +) : ViewModel() { + + private val _memories = MutableStateFlow>(emptyList()) + val memories: StateFlow> = _memories.asStateFlow() + + val autoDistillEnabled: StateFlow = memorySettings.autoDistillEnabledFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + _memories.value = withContext(Dispatchers.IO) { + memoryRepository.listMemories(projectRoot) + } + } + } + + fun setAutoDistillEnabled(enabled: Boolean) { + viewModelScope.launch { + memorySettings.setAutoDistillEnabled(enabled) + } + } + + fun delete(memory: Memory) { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + withContext(Dispatchers.IO) { + memoryRepository.deleteMemory(memory.name, memory.scope, projectRoot) + } + refresh() + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt new file mode 100644 index 000000000..581058b20 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt @@ -0,0 +1,222 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Radius +import com.aicode.core.theme.Spacing +import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.AdaptiveModalBottomSheet +import com.aicode.core.ui.AppSwitch +import com.aicode.core.ui.SwipeToDeleteRow +import com.aicode.feature.agent.domain.memory.Memory +import com.aicode.feature.agent.domain.memory.MemoryKind +import compose.icons.FeatherIcons +import compose.icons.feathericons.FileText + +/** + * 记忆页:长期记忆自动沉淀开关 + 当前生效的记忆列表(左滑删除)。 + * + * 全局与项目记忆合并成一份清单、不分栏——用户看到的只是「AI 记住了什么」, + * 记忆存在哪一侧是实现细节。 + */ +@Composable +internal fun MemorySection( + memories: List, + autoDistillEnabled: Boolean, + onToggleAutoDistill: (Boolean) -> Unit, + onOpenDetail: (Memory) -> Unit, + onDelete: (Memory) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroup { + SettingsRow( + icon = null, + title = stringResource(R.string.memory_auto_distill), + subtitle = stringResource(R.string.memory_auto_distill_desc), + trailing = { + AppSwitch( + checked = autoDistillEnabled, + onCheckedChange = onToggleAutoDistill + ) + } + ) + } + + if (memories.isEmpty()) { + EmptyState() + } else { + SettingsGroup { + memories.forEachIndexed { index, memory -> + if (index > 0) SettingsDivider() + MemoryRow(memory = memory, onOpenDetail = { onOpenDetail(memory) }, onDelete = { onDelete(memory) }) + } + } + } + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.lg)), + contentAlignment = Alignment.Center + ) { + Icon( + FeatherIcons.FileText, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.memory_empty), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.memory_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +/** 单条记忆行:图标 + 名称/描述,点击看详情,左滑删除。 */ +@Composable +private fun MemoryRow( + memory: Memory, + onOpenDetail: () -> Unit, + onDelete: () -> Unit +) { + val rowBackground = MaterialTheme.semanticColors.cardSurface + + SwipeToDeleteRow(onDelete = onDelete, onClick = onOpenDetail) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(rowBackground) + .padding(start = Spacing.lg, end = Spacing.lg, top = 11.dp, bottom = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = FeatherIcons.FileText, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(Spacing.md)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = memory.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = memory.description.ifBlank { stringResource(R.string.mcp_no_description) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +/** + * 记忆详情:名称、来源、描述与完整正文。 + * + * 注入到系统提示词的只有「名称 + 描述」,正文平时看不到,详情页才展开。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MemoryDetailSheet( + memory: Memory, + onDismiss: () -> Unit +) { + AdaptiveModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = memory.name) + SettingsGroup { + SettingsRow( + icon = null, + title = memory.description.ifBlank { stringResource(R.string.mcp_no_description) }, + subtitle = stringResource( + if (memory.kind == MemoryKind.PROFILE) R.string.memory_source_auto + else R.string.memory_source_manual + ) + ) + } + SettingsGroupHeader(text = stringResource(R.string.memory_detail_content)) + SettingsGroup { + Text( + text = memory.content.ifBlank { stringResource(R.string.memory_detail_empty) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(Spacing.lg) + ) + } + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index 6f0180b34..55ded1e89 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -144,6 +144,7 @@ internal enum class SettingsSection(@param:StringRes val titleRes: Int) { DefaultModels(R.string.settings_default_models), Mcp(R.string.settings_mcp), Skills(R.string.settings_skills), + Memory(R.string.settings_memory), SkillDetail(R.string.settings_skills), SkillEditor(R.string.settings_skills), SubAgents(R.string.settings_subagents), @@ -822,6 +823,27 @@ fun SettingsScreen( section = SettingsSection.SkillDetail } ) + SettingsSection.Memory -> { + // 记忆页的 VM 只在这一分区创建,避免每次进设置页都扫一遍记忆目录 + val memoryViewModel: com.aicode.feature.settings.presentation.MemoryViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val memories by memoryViewModel.memories.collectAsStateWithLifecycle() + val autoDistill by memoryViewModel.autoDistillEnabled.collectAsStateWithLifecycle() + // 每次进入本分区重扫:引擎可能在后台刚沉淀了新条目, + // 而 VM 在设置页返回栈里常驻,只在 init 扫一次会永远是旧列表。 + LaunchedEffect(Unit) { memoryViewModel.refresh() } + var detailMemory by remember { mutableStateOf(null) } + MemorySection( + memories = memories, + autoDistillEnabled = autoDistill, + onToggleAutoDistill = memoryViewModel::setAutoDistillEnabled, + onOpenDetail = { detailMemory = it }, + onDelete = memoryViewModel::delete + ) + detailMemory?.let { memory -> + MemoryDetailSheet(memory = memory, onDismiss = { detailMemory = null }) + } + } SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( entry = entry, @@ -1348,6 +1370,12 @@ internal fun SettingsMenu( title = stringResource(SettingsSection.SubAgents.titleRes), onClick = { onOpen(SettingsSection.SubAgents) } ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.FileText, + title = stringResource(SettingsSection.Memory.titleRes), + onClick = { onOpen(SettingsSection.Memory) } + ) } // ── 运行环境 ── diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ec1b75499..cb8234929 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -50,6 +50,7 @@ Image Generation Model MCP Servers Skills + Memory Container & Images No log files yet Failed to read log: %1$s @@ -965,6 +966,16 @@ %1$d tools Details + + No memories yet + Whatever you ask the assistant to remember shows up here + Auto-save long-term memory + After each turn, distill stable conclusions about you into memory (uses the model; off by default) + Auto-saved + Saved during chat + Content + (no content) + No skills installed Tap + in the top bar to create or import one. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b1015eb2..6433b356e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,7 @@ 生图模型 MCP 服务器 技能 + 记忆 容器与镜像 还没有日志文件 读取日志失败:%1$s @@ -965,6 +966,16 @@ %1$d 个工具 详情 + + 还没有记忆 + 对话中让 AI 记住的内容会显示在这里 + 长期记忆自动沉淀 + 每轮对话结束后归纳关于你的结论并写入记忆(会调用模型,默认关闭) + 自动沉淀 + 对话中记录 + 内容 + (暂无正文) + 还没有安装技能 点右上角「+」新建或导入 diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt new file mode 100644 index 000000000..2eea03e3f --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt @@ -0,0 +1,119 @@ +package com.aicode.feature.agent.domain.engine + +import com.aicode.feature.agent.domain.tool.AgentTool +import com.aicode.feature.agent.domain.tool.ToolParameter +import com.aicode.feature.agent.domain.tool.ToolResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** 引擎调度:片段聚合顺序、异常隔离、工具去重、钩子分发。 */ +class AgentEngineTest { + + private val ctx = EngineContext(sessionId = "s1", projectRoot = "/ws") + + private class FakeTool(override val name: String) : AgentTool() { + override val description = "fake" + override val parameters: Map = emptyMap() + override suspend fun execute(args: Map): ToolResult = + ToolResult.Error("unused") + } + + private class FakeModule( + override val id: String, + override val order: Int = EngineModule.DEFAULT_ORDER, + private val fragment: String? = null, + private val providedTools: List = emptyList(), + private val fragmentThrows: Boolean = false, + private val hookThrows: Boolean = false + ) : EngineModule { + var turnCompleted = 0 + var sessionDeleted = 0 + + override fun promptFragment(ctx: EngineContext): String? { + if (fragmentThrows) error("fragment boom") + return fragment + } + + override fun tools(ctx: EngineContext): List = providedTools + + override suspend fun onTurnCompleted(ctx: EngineContext) { + turnCompleted++ + if (hookThrows) error("hook boom") + } + + override suspend fun onSessionDeleted(ctx: EngineContext) { + sessionDeleted++ + } + } + + @Test + fun promptFragment_joinsByOrderAndSkipsBlank() { + val late = FakeModule(id = "late", order = 200, fragment = "B") + val early = FakeModule(id = "early", order = 10, fragment = "A") + val blank = FakeModule(id = "blank", order = 20, fragment = " ") + val silent = FakeModule(id = "silent", order = 30) + + val engine = AgentEngine(setOf(late, early, blank, silent), TestScope()) + + assertEquals("A\n\nB", engine.promptFragment(ctx)) + } + + @Test + fun promptFragment_returnsNullWhenNoModuleContributes() { + val engine = AgentEngine(setOf(FakeModule(id = "silent")), TestScope()) + assertNull(engine.promptFragment(ctx)) + } + + @Test + fun promptFragment_oneModuleThrowingDoesNotBreakOthers() { + val broken = FakeModule(id = "broken", order = 1, fragmentThrows = true) + val healthy = FakeModule(id = "healthy", order = 2, fragment = "OK") + + val engine = AgentEngine(setOf(broken, healthy), TestScope()) + + assertEquals("OK", engine.promptFragment(ctx)) + } + + @Test + fun tools_keepsFirstOccurrenceByName() { + val first = FakeModule(id = "a", order = 1, providedTools = listOf(FakeTool("dup"), FakeTool("onlyA"))) + val second = FakeModule(id = "b", order = 2, providedTools = listOf(FakeTool("dup"), FakeTool("onlyB"))) + + val engine = AgentEngine(setOf(first, second), TestScope()) + + assertEquals(listOf("dup", "onlyA", "onlyB"), engine.tools(ctx).map { it.name }) + } + + @Test + fun onTurnCompleted_dispatchesToEveryModuleAndIsolatesFailures() = runTest { + val scope = TestScope(testScheduler) + val a = FakeModule(id = "a") + val b = FakeModule(id = "b", hookThrows = true) + val c = FakeModule(id = "c") + + val engine = AgentEngine(setOf(a, b, c), scope) + engine.onTurnCompleted(ctx) + scope.advanceUntilIdle() + + assertEquals(1, a.turnCompleted) + assertEquals(1, b.turnCompleted) + assertEquals(1, c.turnCompleted) + } + + @Test + fun onSessionDeleted_dispatchesToEveryModule() = runTest { + val scope = TestScope(testScheduler) + val module = FakeModule(id = "a") + + val engine = AgentEngine(setOf(module), scope) + engine.onSessionDeleted(ctx) + scope.advanceUntilIdle() + + assertEquals(1, module.sessionDeleted) + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt new file mode 100644 index 000000000..0fa5e10b4 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt @@ -0,0 +1,98 @@ +package com.aicode.feature.agent.domain.engine.modules + +import com.aicode.feature.agent.domain.engine.EngineContext +import com.aicode.feature.agent.domain.memory.MemoryKind +import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.agent.domain.memory.MemoryScope +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.settings.data.repository.MemorySettingsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** 记忆模块的沉淀行为:开关、子代理、解析容错。 */ +class MemoryModuleTest { + + private val history = listOf(AgentMessage.UserMessage(content = "以后回答短一点")) + + private fun module(settingsEnabled: Boolean): Pair { + val repository = mockk(relaxed = true) + val settings = mockk() + coEvery { settings.autoDistillEnabled() } returns settingsEnabled + every { repository.listMemories(any()) } returns emptyList() + // changes 的类型是 SharedFlow,不能用 emptyFlow(那是 Flow) + every { repository.changes } returns MutableSharedFlow() + return MemoryModule(repository, settings) to repository + } + + private fun ctx(oneShot: (suspend (String, String) -> String?)?) = EngineContext( + sessionId = "s1", + projectRoot = "/ws", + history = history, + oneShot = oneShot + ) + + @Test + fun onTurnCompleted_writesDistilledEntriesAsProfile() = runTest { + val (module, repository) = module(settingsEnabled = true) + val json = """[{"name":"prefers-brief","description":"Prefers brief answers","content":"No preambles."}]""" + + // 归约是攒够 DISTILL_EVERY_TURNS 轮才跑一次 + repeat(5) { module.onTurnCompleted(ctx { _, _ -> json }) } + + coVerify(exactly = 1) { + repository.saveMemory( + "prefers-brief", + "Prefers brief answers", + "No preambles.", + MemoryScope.GLOBAL, + "/ws", + MemoryKind.PROFILE + ) + } + } + + @Test + fun onTurnCompleted_waitsUntilEnoughTurnsBeforeDistilling() = runTest { + val (module, repository) = module(settingsEnabled = true) + + repeat(4) { module.onTurnCompleted(ctx { _, _ -> """[{"name":"a","content":"b"}]""" }) } + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun onTurnCompleted_doesNothingWhenSwitchOff() = runTest { + val (module, repository) = module(settingsEnabled = false) + + // 攒够轮数也不写(开关关着直接在计数前返回) + repeat(5) { module.onTurnCompleted(ctx { _, _ -> """[{"name":"a","content":"b"}]""" }) } + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun onTurnCompleted_doesNothingForSubAgentSession() = runTest { + val (module, repository) = module(settingsEnabled = true) + val subCtx = ctx { _, _ -> """[{"name":"a","content":"b"}]""" }.copy(isSubAgent = true) + + module.onTurnCompleted(subCtx) + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun onTurnCompleted_ignoresGarbageAndEmptyResults() = runTest { + val (module, repository) = module(settingsEnabled = true) + + repeat(5) { module.onTurnCompleted(ctx { _, _ -> "抱歉,这轮没有值得记住的内容。" }) } + repeat(5) { module.onTurnCompleted(ctx { _, _ -> "[]" }) } + repeat(5) { module.onTurnCompleted(ctx { _, _ -> null }) } + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt index 8d39b9d73..4bd03637f 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt @@ -1,6 +1,7 @@ package com.aicode.feature.agent.domain.memory import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -45,4 +46,40 @@ class MemoryParserTest { assertEquals("Description with \"quotes\" and : colons", memory?.description) assertEquals("Memory body", memory?.content) } + + @Test + fun profileKind_roundtripsThroughFrontmatter() { + val formatted = MemoryParser.format("pref_concise", "Prefers concise answers", "body", MemoryKind.PROFILE) + assertTrue(formatted.contains("kind: profile")) + + val file = tempFolder.newFile("pref_concise.md") + file.writeText(formatted) + + val memory = MemoryParser.parse(file, MemoryScope.GLOBAL) + assertEquals(MemoryKind.PROFILE, memory?.kind) + assertEquals("Prefers concise answers", memory?.description) + assertEquals("body", memory?.content) + } + + @Test + fun noteKind_doesNotWriteKindField() { + val formatted = MemoryParser.format("plain", "plain note", "body") + assertFalse(formatted.contains("kind:")) + + val file = tempFolder.newFile("plain.md") + file.writeText(formatted) + + assertEquals(MemoryKind.NOTE, MemoryParser.parse(file, MemoryScope.GLOBAL)?.kind) + } + + @Test + fun unknownOrMissingKind_fallsBackToNote() { + val file = tempFolder.newFile("legacy.md") + file.writeText("---\nname: legacy\ndescription: old file\n---\nbody") + assertEquals(MemoryKind.NOTE, MemoryParser.parse(file, MemoryScope.GLOBAL)?.kind) + + val weird = tempFolder.newFile("weird.md") + weird.writeText("---\nname: weird\nkind: something-else\n---\nbody") + assertEquals(MemoryKind.NOTE, MemoryParser.parse(weird, MemoryScope.GLOBAL)?.kind) + } } diff --git a/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt index c08535b92..a6b3538dc 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt @@ -3,9 +3,14 @@ package com.aicode.feature.agent.domain.session import com.aicode.feature.agent.data.local.dao.AgentMessageDao import com.aicode.feature.agent.data.local.dao.ChatSessionDao import com.aicode.feature.agent.data.local.entity.ChatSessionEntity +import com.aicode.feature.agent.domain.engine.AgentEngine +import dagger.Lazy import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -13,6 +18,10 @@ import org.junit.Test /** 删除工作区时按 workspacePath 级联清理会话与消息的行为。 */ class SessionUseCaseWorkspaceDeletionTest { + /** 空模块集的引擎:本测试只验证会话/消息删除,引擎侧钩子无副作用。 */ + private fun emptyEngine(): Lazy = + Lazy { AgentEngine(emptySet(), CoroutineScope(SupervisorJob() + Dispatchers.IO)) } + private fun session(id: String, workspacePath: String, parentId: String? = null) = ChatSessionEntity( id = id, title = "t", @@ -31,7 +40,7 @@ class SessionUseCaseWorkspaceDeletionTest { session("sub", "/ws/a", parentId = "root") ) - val useCase = SessionUseCase(chatDao, messageDao) + val useCase = SessionUseCase(chatDao, messageDao, emptyEngine()) val deleted = useCase.deleteSessionsByWorkspace("/ws/a") assertEquals(2, deleted) @@ -46,7 +55,7 @@ class SessionUseCaseWorkspaceDeletionTest { val messageDao = mockk(relaxed = true) coEvery { chatDao.getAllSessionsByWorkspaceOnce("/ws/empty") } returns emptyList() - val useCase = SessionUseCase(chatDao, messageDao) + val useCase = SessionUseCase(chatDao, messageDao, emptyEngine()) val deleted = useCase.deleteSessionsByWorkspace("/ws/empty") assertEquals(0, deleted)