-
Notifications
You must be signed in to change notification settings - Fork 45
feat: 统一智能引擎框架 + 长期记忆(用户画像) #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
e2490ce
feat(memory): 数据层支持记忆类型(NOTE/PROFILE)
Rely-xcy 549a1b9
feat(memory): 设置页新增「记忆」入口与列表页
Rely-xcy fffba54
feat(engine): 统一智能引擎框架,记忆作为第一个模块接入
Rely-xcy 205858f
fix(engine): 用 Lazy 断开 Dagger 依赖环(CI 编译失败)
Rely-xcy 21a8092
feat(memory): 长期记忆自动沉淀(引擎 onTurnCompleted 的第一个真实用例)
Rely-xcy 956432a
fix(memory): 自动沉淀没生效的两个原因——历史快照落后一轮、记忆页不刷新
Rely-xcy b66c458
feat(memory): 记忆详情页 + 沉淀提示词改为语义判据
Rely-xcy a8f4fd8
fix(memory): 补 AdaptiveModalBottomSheet 的 ExperimentalMaterial3Api op…
Rely-xcy 6e80d55
perf(memory): 自动记忆改双路——主模型顺手记 + 攒够轮次再归约
Rely-xcy 612ea4b
fix(memory): 修构建失败的四处疏漏
Rely-xcy fcf4103
fix(memory): A 路不生效——把主动记忆规则写强,工具描述改为不越权
Rely-xcy 608ca61
fix(memory): 审查后的四处修正
Rely-xcy c5dd26f
fix(test): MemoryModuleTest 用 MutableSharedFlow 替 emptyFlow(上游 CI 编译失败)
Rely-xcy 7ae6e6d
fix(memory): 沉淀输出改标签格式,不再要求模型写 JSON
Rely-xcy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| <!-- 长期记忆沉淀:由 MemoryModule / CompactionModule 调用,把「关于用户的稳定结论」归纳成记忆条目。 | ||
| 输出用标签块而不是 JSON:模型写 JSON 的失败率明显更高(漏引号、多逗号、把换行写进字符串、 | ||
| 前后带一段说明),任何一处都会让整批记忆白抽。标签格式坏一条只丢一条。 --> | ||
| 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. | ||
|
|
||
| <task> | ||
| Read the transcript and the existing memory list, then decide which facts about the user are worth carrying into future sessions. | ||
| </task> | ||
|
|
||
| <decision_test> | ||
| 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 | ||
| </decision_test> | ||
|
|
||
| <output_format> | ||
| 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. | ||
| </output_format> | ||
|
|
||
| <rules> | ||
| - 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. | ||
| </rules> | ||
|
|
||
| <examples> | ||
| 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. | ||
| </examples> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
90 changes: 90 additions & 0 deletions
90
app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AgentTool> = | ||
| 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<EngineModule> = | ||
| modules.sortedWith(compareBy({ it.order }, { it.id })) | ||
|
|
||
| private inline fun <T> 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" | ||
| } | ||
| } | ||
65 changes: 65 additions & 0 deletions
65
app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AgentMessage> = 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<AgentTool> = emptyList() | ||
|
|
||
| /** 一轮对话正常结束(成功、未取消)后调用。 */ | ||
| suspend fun onTurnCompleted(ctx: EngineContext) {} | ||
|
|
||
| /** 会话被删除后调用。 */ | ||
| suspend fun onSessionDeleted(ctx: EngineContext) {} | ||
|
|
||
| companion object { | ||
| const val DEFAULT_ORDER = 100 | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Rethrow
CancellationExceptioninrunSuspendModule.catch (e: Exception)also catchesCancellationException. A cancelled module hook then logs "失败,已跳过" and continues. This breaks cooperative cancellation. The hook coroutines run in the application scope, so the impact is limited to wrong logs and a coroutine that does not stop at the right time. Rethrow cancellation before the generic handler.Proposed fix
try { block(module) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) {Based on learnings: "CancellationException ... must be rethrown/propagated to the parent coroutine".
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Learnings