Skip to content

Feature/advanced memory service - #332

Open
CongkeChen wants to merge 6 commits into
mainfrom
feature/advanced_memory_service
Open

Feature/advanced memory service#332
CongkeChen wants to merge 6 commits into
mainfrom
feature/advanced_memory_service

Conversation

@CongkeChen

Copy link
Copy Markdown
Contributor

No description provided.

@CongkeChen
CongkeChen force-pushed the feature/advanced_memory_service branch from 54d9e9d to 11b3f0c Compare September 11, 2026 09:52
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

本审查覆盖 base_commit..head_commit(108 个文件,+6012/-6201),主题为 advanced memory 服务化与 Session Compact 重构:trpc_agent_sdk/advanced_memory/ 迁移至 trpc_agent_sdk/memory/advanced_memory/、新增 Redis/SQL/local 长时记忆存储、模型级过滤器管线(AdvancedAutoCompactSummarizerFilter)、post-turn 延迟处理线程、以及各会话服务的 update_session_state 新接口。审查采用静态只读分析(本机 Python 3.6.8 无法 import SDK 依赖,无法运行测试)。计划符合性:AdvancedMemoryServiceLongTermMemoryContext 注入、save_memory/read_memory/list_memory_index 工具、compact 管理器(AFTER_TURN/BEFORE_MODEL 触发)与 get_session_summary 兼容接口均按计划实现;过滤器有实际安装路径(模型级 filters + ModelCallbackFilter),不存在孤儿 filter。主要风险与已排除项:SQL 后端 get_for_update 行锁 + expire_on_commit=False 刷新、Redis hash-tag 键与跨循环锁、_session_from_storage_json 的 cjson {}[] 修复均正确;compact_eventssession_compaction_id 幂等扫描、快照恢复与 last_update_time 刷新验证通过;工具调用 tool_context 注入路径与 per-tenant _index_locks 一致;_persist_session_compaction 异常回滚保留原始 contents 正确;runners.py 在本范围仅改动 bind()(删除已不存在的 AdvancedMemorySessionService 分支,行为中性)。上报 3 条 MODERATE 与 1 条 LOW:token 指纹机制为死代码(usage-baseline 保护永不生效);natural_break 语义变更降低提取频率;Redis update_session_state 全量读改写(RMW)无锁,并发窗口丢失状态更新;LongTermMemoryContext.apply 每次模型请求重复 runtime.initialize(),产生多余 DB session 与清理循环检查。残余风险(无法锚定到本范围变更行,故不进入 comments):延迟 post-turn 机制(_PostTurnWorkerThread/_run_post_turn_processing)在基线 1.1.20 已存在、本范围未改动,其在独立线程与独立事件循环中执行 create_session_summary 且未调用 set_invocation_ctx;本范围新增的 advanced compact 管理器/过滤器被安装为 session_compact_manager 后,该路径会在 worker 线程中执行新增的生成与持久化链,若模型客户端或异步 Redis/SQL 存储绑定主事件循环或依赖隐式调用上下文,异常将被 except Exception → logger.error 吞掉,导致会话摘要与记忆更新被静默丢弃(openclaw 的 _claw_summarizer 在同一用途上显式 set_invocation_ctx(ctx),SDK runner 路径缺失该设置),建议后续在 worker 线程处理前设置并复位 invocation context。测试充分性:新增 23 个测试文件覆盖 compact 幂等、指纹回退、协调锁与各后端 patch-state;但指纹回退测试通过硬编码「outdated」字符串掩盖了机制断裂,且无测试覆盖 Redis update_session_state 与并发 append 的交错、无测试覆盖 post-turn 线程的上下文隔离。门禁结论:无 SEVERE 级问题,其余问题影响有界或属效率/健壮性范畴,不阻塞合入,判定为 PASSED;建议合入前修复 Redis 状态更新竞态,并在后续迭代清除死指纹代码与补齐 post-turn 上下文隔离。

发现的问题

中等

trpc_agent_sdk/sessions/compact/advanced/_token_budget.py:241-252

问题: TokenContextTracker.record_request_context 把指纹写入 session.state["advanced_memory_pending_request_context_fingerprint"],而 _latest_usage_baseline 读取的是事件 custom_metadata 中的 advanced_memory_request_context_fingerprint;全仓库(含测试)没有 producer 写该事件元数据,也没有任何代码读取该 state key,构成写入端与读取端两侧都悬挂的死机制。

触发条件: 任何启用 token 模式(token_context_tracker 配置了上下文窗口)并运行 create_session_summary_by_request_auto_compact.py:581 调用 record_request_context)的会话;后续请求进入 _latest_usage_baseline 时,recorded_fingerprint 恒为 None,其过滤分支永远不生效。

实际影响: usage-baseline 的指纹匹配保护形同虚设:上一次请求的 usage 事件可能被错误地当作当前请求的基线(即使两次请求的静态指纹不同),导致 token 估算偏高/偏低、auto_compact 阈值判断与实际上下文大小不一致;相关测试通过硬编码「outdated」指纹模拟环境,掩盖了该机制从未接通的事实。

修正方向: 删除 record_request_context 及其在 _auto_compact.py:581 的调用,并简化 _latest_usage_baseline 中的指纹分支;若确实需要基线指纹,应在生成响应事件时把指纹持久化到事件 custom_metadata(与读取端对齐),并补充端到端测试验证指纹被写入并被读取端消费。

中等

trpc_agent_sdk/sessions/compact/advanced/_compaction_memory_extractor.py:726-734

问题: extract_if_needednatural_break 语义被变更:现在以 tool-response 结束时不再算自然断点(natural_break = not self._event_has_tool_response(pending[-1])),而以 tool-call 结尾则直接返回「unsafe-boundary」。对比基线 f05797dnatural_break = not self._last_event_has_tool_call(pending),只有以 tool-call 结尾时才算非自然断点。

触发条件: 会话增量以独立的工具响应(function response,非工具调用链)结束时,例如用户在工具结果后回复,或单次工具调用 + 结果的短交互;此时 tool_calls 计数小于 tool_calls_between_updates 且无自然断点,threshold-not-met 一直返回。

实际影响: 提取频率相对已发布的 1.1.20 行为降低:以 tool-response 收尾的常见增量不再满足工具条件,会话记忆更新被推迟到凑够工具调用数或下一次自然断点,长对话中记忆滞后(部分此类输入会被无限期挂起直至上下文被迫更新),用户可感知的会话记忆时效性下降。

修正方向: 若是有意的收紧,需在配置中说明该语义变更并同步更新文档;否则建议恢复以「ends-in-tool-call」作为唯一不满足自然断点的条件(natural_break = not self._last_event_has_tool_call(pending)),并为「增量以工具响应结尾」补充单元测试覆盖提取时机。

中等

trpc_agent_sdk/sessions/_redis_session_service.py:263-290

问题: 新增的 update_session_state 采用「GET 完整 session → state.update_set_session 全量序列化写回」的读-改-写(RMW)模式,未使用任何锁或 CAS;append_event(同文件第 216-261 行)也做同样的全量重写。两处并发时,后提交方会把对方的 state/events 覆盖回旧值。

触发条件: 同一 (app, user, session)append_event(每个事件都会触发)与 update_session_state(本变更新增的会话记忆 checkpoint、compact_events 更新等都会调用)并发执行,例如多 worker/多进程共享同一 Redis 后端时,Redis 事务窗口内无串行化。

实际影响: 会话状态(含 SESSION_MEMORY_STATE_KEY checkpoint)与事件窗口并发丢失:后写者基于过期快照重写全量 JSON,覆盖先写者已提交的更新,违反新接口「Persist session-scoped state without replacing the caller's Event window」的契约;SQL 后端(get_for_update 行锁)与内存后端(同对象引用)无此问题,仅 Redis 后端受影响。

修正方向:state 更新改用 Redis 原子操作(HSET/JSON.SET 局部路径)或引入带版本号的 CAS(Lua 脚本比较 version/updated_at 后条件写回),保证修改与写回原子;append_event 的 events 追加也建议改用 JSON.ARRAPPEND 或整体加分布式锁,避免与 update_session_state 相互覆盖。

较低

trpc_agent_sdk/memory/advanced_memory/_memory_context.py:41

问题: LongTermMemoryContext.apply 在每个模型请求前无条件调用 await runtime.initialize()_runtime.pyinitialize() 在 SQL 后端会新建 DB session(create_db_session),在 local 后端会触发目录创建与 _local_cleanup.start() 幂等检查,属于每次请求的重复初始化开销。

触发条件: 配置 long_term_memory_injection_enabled=True 的常规多轮会话,每轮模型请求(含工具循环中的多次调用)都会经过该路径。

实际影响: SQL 后端为每次请求额外建立/释放一个 DB 连接与事务;local 后端反复执行文件系统与后台任务状态检查。在 QPS 较高时放大数据库连接压力与目录扫描开销,且 initialize() 内部若带 I/O(如 lock 等待)还会增加每请求延迟;功能不受影响。

修正方向: 在 runtime 内以单一标志/计时器实现一次性或限频初始化(如首次调用后置位,或在 initialize 内仅对缺失资源做前置检查),apply 仅在未初始化或显式失效时调用;补充一个高并发多轮请求的基准测试验证初始化不再重复执行。

Comment on lines +241 to 252
@classmethod
def record_request_context(
self,
request: "LlmRequest",
ctx: "InvocationContext | None",
cls,
request: LlmRequest,
ctx: InvocationContext,
) -> None:
"""Stage the final request fingerprint for persistence on the response Event."""
session = getattr(ctx, "session", None) if ctx is not None else None
state = getattr(session, "state", None)
session = ctx.session
state = session.state
if isinstance(state, dict):
state["advanced_memory_pending_request_context_fingerprint"] = _request_static_fingerprint(request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: TokenContextTracker.record_request_context 把指纹写入 session.state["advanced_memory_pending_request_context_fingerprint"],而 _latest_usage_baseline 读取的是事件 custom_metadata 中的 advanced_memory_request_context_fingerprint;全仓库(含测试)没有 producer 写该事件元数据,也没有任何代码读取该 state key,构成写入端与读取端两侧都悬挂的死机制。

触发条件: 任何启用 token 模式(token_context_tracker 配置了上下文窗口)并运行 create_session_summary_by_request_auto_compact.py:581 调用 record_request_context)的会话;后续请求进入 _latest_usage_baseline 时,recorded_fingerprint 恒为 None,其过滤分支永远不生效。

实际影响: usage-baseline 的指纹匹配保护形同虚设:上一次请求的 usage 事件可能被错误地当作当前请求的基线(即使两次请求的静态指纹不同),导致 token 估算偏高/偏低、auto_compact 阈值判断与实际上下文大小不一致;相关测试通过硬编码「outdated」指纹模拟环境,掩盖了该机制从未接通的事实。

修正方向: 删除 record_request_context 及其在 _auto_compact.py:581 的调用,并简化 _latest_usage_baseline 中的指纹分支;若确实需要基线指纹,应在生成响应事件时把指纹持久化到事件 custom_metadata(与读取端对齐),并补充端到端测试验证指纹被写入并被读取端消费。

Comment on lines +726 to +734
if self._last_event_has_tool_call(pending):
return SessionMemoryExtractionResult(False, "unsafe-boundary")
natural_break = not self._event_has_tool_response(pending[-1])
threshold_met = ((context_tokens >= threshold if checkpoint_context_tokens is None else
(context_tokens < checkpoint_context_tokens or context_tokens -
checkpoint_context_tokens >= threshold)) if token_mode else pending_chars >= threshold)
tool_condition_met = tool_calls >= config.session_memory_tool_calls_between_updates or natural_break
tool_condition_met = tool_calls >= config.tool_calls_between_updates or natural_break
if not force and (not threshold_met or not tool_condition_met):
return SessionMemoryExtractionResult(False, "threshold-not-met")
return SessionMemoryExtractionResult(reason="threshold-not-met")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: extract_if_needednatural_break 语义被变更:现在以 tool-response 结束时不再算自然断点(natural_break = not self._event_has_tool_response(pending[-1])),而以 tool-call 结尾则直接返回「unsafe-boundary」。对比基线 f05797dnatural_break = not self._last_event_has_tool_call(pending),只有以 tool-call 结尾时才算非自然断点。

触发条件: 会话增量以独立的工具响应(function response,非工具调用链)结束时,例如用户在工具结果后回复,或单次工具调用 + 结果的短交互;此时 tool_calls 计数小于 tool_calls_between_updates 且无自然断点,threshold-not-met 一直返回。

实际影响: 提取频率相对已发布的 1.1.20 行为降低:以 tool-response 收尾的常见增量不再满足工具条件,会话记忆更新被推迟到凑够工具调用数或下一次自然断点,长对话中记忆滞后(部分此类输入会被无限期挂起直至上下文被迫更新),用户可感知的会话记忆时效性下降。

修正方向: 若是有意的收紧,需在配置中说明该语义变更并同步更新文档;否则建议恢复以「ends-in-tool-call」作为唯一不满足自然断点的条件(natural_break = not self._last_event_has_tool_call(pending)),并为「增量以工具响应结尾」补充单元测试覆盖提取时机。

if not config.enabled or not config.long_term_memory_injection_enabled:
return False
await self._runtime.initialize()
await runtime.initialize()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: LongTermMemoryContext.apply 在每个模型请求前无条件调用 await runtime.initialize()_runtime.pyinitialize() 在 SQL 后端会新建 DB session(create_db_session),在 local 后端会触发目录创建与 _local_cleanup.start() 幂等检查,属于每次请求的重复初始化开销。

触发条件: 配置 long_term_memory_injection_enabled=True 的常规多轮会话,每轮模型请求(含工具循环中的多次调用)都会经过该路径。

实际影响: SQL 后端为每次请求额外建立/释放一个 DB 连接与事务;local 后端反复执行文件系统与后台任务状态检查。在 QPS 较高时放大数据库连接压力与目录扫描开销,且 initialize() 内部若带 I/O(如 lock 等待)还会增加每请求延迟;功能不受影响。

修正方向: 在 runtime 内以单一标志/计时器实现一次性或限频初始化(如首次调用后置位,或在 initialize 内仅对缺失资源做前置检查),apply 仅在未初始化或显式失效时调用;补充一个高并发多轮请求的基准测试验证初始化不再重复执行。

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:base f05797d..head 54d9e9d("Feature/advanced memory service"),109 个文件,+6127/−6313。本次重构将 advanced memory 能力拆分为 sessions/compact/advanced(会话压缩与自动压缩)与 memory/advanced_memory(长期记忆运行时/存储/作用域),新增 Session.compact_events、三种后端 update_session_state 部分更新、AdvancedAutoCompactSummarizer 系列、作用域化存储与 SQL/Redis 记忆后端,同时删除旧 _summarizer_manager/TranscriptSessionService 及整套旧测试。

计划符合性:功能主体完整落地(压缩管线、记忆抽取、作用域存储、三后端均已实现),但存在多个高置信正确性与数据完整性缺陷:1) AdvancedAutoCompactSummarizer 默认构造(config=None)直接 AttributeError,默认用法崩溃;2) 会话记忆抽取的 natural_break 语义相对旧实现被反转(完成的 tool turn 不再视为自然断点),工具型会话记忆抽取被无限推迟,甚至把半成品工具回合落库;3) Redis 新增 update_session_state 是盲读改写整包覆盖,并发互相覆盖,且 store_historical_events=False 时抹掉已存 historical_events;4) 本地记忆索引 TTL 过期时直接删除 memory_dir 下全部 *.md 主题文件,纯读路径造成长期记忆整体丢失;5) 高级自动压缩"阻塞"路径把 LlmResponse 塞进过滤链,代理主循环按 Event 处理时缺 is_error()/is_final_response() 而崩溃;6) 抽取子代理失败被计入压缩失败,连续失败后可阻塞主模型请求;7) token 预算的请求指纹通道重构后无写入方,usage 基线静默退化。

安全性/并发:Redis 会话部分更新无乐观并发控制(读改写覆盖),SQL 侧通过 get_for_update 加锁但 update_session_state 未使用;本地存储删除无锁、与写入竞争。稳定性:阻塞/抽取失败路径存在两个崩溃点。兼容性:migrate_legacy 迁移函数零调用,旧平铺 memory 布局升级后不可见;DefaultSessionSummarizerManager 兼容保留。

测试充分性:本次删除约 2350 行旧 advanced_memory 测试(test_autocompact/test_history_snip/test_microcompact/test_tool_result_budget/test_session_memory_extractor/test_transcript_session_service 等),新增 test_session_compact.py 仅 268 行,未覆盖 config=None 构造、force 抽取失败、natural_break 语义、阻塞响应类型、Redis 部分更新并发、本地索引 TTL 剪枝等关键路径,存在明显测试缺口。

门禁结论:存在 5 个 SEVERE 问题,判定 FAILED,建议修复后再合入。

发现的问题

严重

trpc_agent_sdk/sessions/compact/advanced/_auto_compact.py:127-129

问题: AdvancedAutoCompactSummarizer.__init__self._auto_compact_config = config.auto_compact 在签名声明 config: AdvancedAutoCompactSummarizerConfig | None = None 时未处理 Noneconfig or AdvancedAutoCompactSummarizerConfig() 的兜底只作用于 _runtime。默认构造(不传 config)即触发 AttributeError: 'NoneType' object has no attribute 'auto_compact'

触发条件: 任何以默认参数调用 AdvancedAutoCompactSummarizer() 的路径(公开 API 默认用法、自定义集成),即构造期崩溃。

实际影响: 默认构造该压缩器无法使用,调用方必须显式创建 config 对象,与签名声明的默认值行为不符;这是本次重构引入的回归(旧 AutoCompactSummarizer 无此崩溃路径)。

修正方向: 使用 self._auto_compact_config = (config or AdvancedAutoCompactSummarizerConfig()).auto_compact,或把 config 解析为局部变量后复用。

严重

trpc_agent_sdk/sessions/compact/advanced/_compaction_memory_extractor.py:728-732

问题: 会话记忆抽取的 natural_break 判断被反转:新代码 natural_break = not self._event_has_tool_response(pending[-1])(最后一条事件不含 function_response 才算自然断点),而旧实现 natural_break = not self._last_event_has_tool_call(pending)(不处于未完成工具回合才算自然断点)。完成的工具回合以 function_response 事件结尾,因此"每轮完成一个工具调用"的典型会话现在 natural_break=False

触发条件: 默认 tool_calls_between_updates=3 时,单工具调用会话在回合边界永不满足 tool_condition_met,会话记忆更新被无限推迟;反之用户在半成品工具回合中输入新消息时 pending[-1] 是普通文本,natural_break=True,会把未完成的工具回合状态提交进会话记忆。

实际影响: 长期记忆抽取频率错误(过少),且可能把半成品工具状态作为已完成的检查点固化,抽取出的记忆内容不可靠。

修正方向: 恢复旧语义:natural_break = not self._last_event_has_tool_call(pending)(以"是否处于未完成工具回合"为断点依据)。

严重

trpc_agent_sdk/sessions/_redis_session_service.py:264-279

问题: 新增的 RedisSessionService.update_session_state 采用盲读改写:_get_session(其内 store_historical_events=False 时强制 session.historical_events = [])→ 原地改 state → _set_session(整包 SET key session_json)覆盖整个会话键,且无任何乐观并发控制;update_session 同样整包覆盖。

触发条件: (a) store_historical_events=False 时,记忆检查点通过 update_session_state(SESSION_MEMORY_STATE_KEY) 持久化即把已存的 historical_events 抹空;(b) 两个并发写者(如 update_session 与另一 worker 的 update_session_state),后者读到的旧快照覆盖前者的 events/state 变更。

实际影响: 已归档的会话历史被永久擦除;并发场景下状态与事件窗口互相覆盖,数据丢失。

修正方向: 用 Redis 原子结构(hash + HSET 局部更新 state 字段)或 WATCH/MULTI 事务 + 版本校验实现单字段更新,避免整包读改写;update_session_state 不得触发 _set_session 对 events/historical_events 的重写。

严重

trpc_agent_sdk/memory/advanced_memory/_storage.py:97-101

问题: 本地存储 _read_index_sync 中,当索引文件本身按 memory_ttl_seconds 过期(索引 mtime 只在写入时刷新)时,直接 for path in memory_dir.glob("*.md"): path.unlink() 删除全部主题文件,而主题文件自身的 TTL 未参与判断。

触发条件: 写入频率低于 TTL(如 30 分钟一次写入、TTL 3600s)时,任何纯读取方(preload、read_topic、list_topics 触发 read_index)在索引过期后调用即触发整体删除;LocalAdvancedMemoryCleanup._run_storage.py:195-197)同样按索引过期整体删除。

实际影响: 长期记忆全部主题文件被永久删除,纯读路径造成数据整体丢失,事后 read_memory 返回 not found;删除与并发写入无锁竞争。

修正方向: TTL 过期只应使索引条目失效,不应删除主题文件;删除应由配置化的清理任务按主题级 TTL 逐个执行,并加写锁/串行化。

严重

trpc_agent_sdk/sessions/compact/advanced/_filters.py:54-57

问题: AdvancedAutoCompactSummarizerFilter._before 在阻塞时设置 rsp.rsp = resultLlmResponse,内容为 ADVANCED_AUTOCOMPACT_BLOCKED_MESSAGE)+ is_continue=Falserun_stream_filters 将该 LlmResponse 直接 yield 给代理主循环,而 _base_agent.py:305-312_llm_agent.py:509 立即调用 event.has_content()/event.is_error()/event.is_final_response()——is_error()is_final_response() 只存在于 Event(events/_event.py:256/180),LlmResponse(models/_llm_response.py)没有这些方法。

触发条件: 连续压缩失败达到 max_failures 且达到阻塞阈值时,过滤链输出阻塞响应,代理主循环收到 LlmResponse 后调用 event.is_error() 抛 AttributeError。

实际影响: 本应优雅返回的"阻塞"提示变成请求运行异常(run loop 崩溃),用户得到 500/异常而非阻塞说明。

修正方向: 阻塞路径应产出 Event(或把 LlmResponse 转成带错误码的 Event)再放入 rsp.rsp,使代理主循环按统一 Event 协议处理。

中等

trpc_agent_sdk/sessions/compact/advanced/_auto_compact.py:690-696

问题: _apply_scopedawait self._session_memory_extractor.extract_if_needed(ctx, force=True) 放在压缩 try 块内,抽取子代理(与主模型同模型、可能限流)的任何异常都被 except Exception 归入 state.consecutive_failures += 1_auto_compact.py:776),与压缩失败混为一谈,一旦达到 max_failures(默认 3)且达到阻塞阈值即返回 blocked=True,主模型请求被阻断。

触发条件: 会话接近自动压缩阈值时,抽取子代理偶发限流/模型超时/解析失败,连续几轮即可把健康的主请求硬阻断。

实际影响: 主对话被以 ADVANCED_AUTOCOMPACT_BLOCKED_MESSAGE 整体阻断,即使主模型本身完全正常;抽取失败与压缩失败未区分记账。

修正方向: 将抽取失败与压缩失败分开计数,或抽取失败降级(跳过记忆抽取但继续压缩),不参与阻塞判定。

中等

trpc_agent_sdk/sessions/compact/advanced/_token_budget.py:166-167

问题: token 预算的指纹通道失效:_latest_usage_baseline 从事件 custom_metadata 读取 advanced_memory_request_context_fingerprint 作为过期 usage 匹配护栏,但全仓库已无任何写入方(唯一写入者 TranscriptSessionService 在本重构中删除);record_request_contextadvanced_memory_pending_request_context_fingerprint 写进 session.state,也没有任何代码把它拷贝到响应事件的 custom_metadata。

触发条件: 每次请求重建后,护栏恒为空,usage 基线只能靠内容指纹匹配;当新请求片段与历史 usage 事件内容指纹重合时(压缩后的 summary/保留内容与旧请求相同),会误匹配到旧的、更小的 usage_tokens。

实际影响: token 估算静默偏低,auto_compact/blocking 阈值判定失真(可能延迟压缩或低估阻塞阈值),压缩触发时机不可靠;该通道从"有护栏"回归为"无护栏"。

修正方向: 恢复写入链路:在响应事件落库前把 record_request_context 缓存的指纹写入该事件 custom_metadata,或移除该护栏并改用显式的 usage 事件关联字段。

中等

trpc_agent_sdk/sessions/compact/advanced/_auto_compact.py:419-427

问题: _persist_session_compaction 在调用 compact_events 之前先做边界解析(_auto_compact.py:420-425):boundary_event_id = record.boundary_event_id or self._resolve_boundary_event_id(...),解析失败立刻 raise ValueError("Cannot map ...");而 compact_events 的幂等保护(_session.py:155-156session_compaction_id 元数据返回 False)位于调用之内。当重试/会话重载后边界事件已被先前成功的压缩替换/移入历史,ValueError 在幂等保护生效前抛出。

触发条件: 同会话内:压缩持久化成功但调用方崩溃后重试,或另一 worker 已先完成压缩,session-memory 来源的 record(boundary_event_id=None)重试时按签名+occurrence 在已替换的当前事件上解析不到边界;或会话从存储重载后事件窗口已不含边界事件。

实际影响: 幂等重放机制不可达,重试直接以 ValueError 中断 before-model 管线,请求失败;压缩记录无法自然去重。

修正方向: 在边界解析前先按 compaction_id 检查幂等(把幂等探测提前到 _persist_session_compaction 入口),解析失败时降级为跳过而非抛异常。

较低

trpc_agent_sdk/memory/advanced_memory/_runtime.py:127-142

问题: AdvancedMemoryRuntime.migrate_legacy(将旧平铺 memory 布局迁移到 tenants 作用域布局)在本 SDK 全仓库无任何调用方(grep 确认零引用),for_scope/initialize 也未自动触发迁移。

触发条件: 已有旧版 SDK 平铺 MEMORY/ 存储的用户升级到本版本后继续使用(未手动调用未公开的 migrate_legacy)。

实际影响: 旧长期记忆对新作用域路径(tenants///memory)不可见,升级用户静默丢失历史记忆可见性;迁移能力形同虚设。

修正方向:for_scope/initialize 首次发现平铺布局时自动调用迁移(带上现有防覆盖保护),或至少对外文档化迁移入口并触发告警。

较低

trpc_agent_sdk/sessions/compact/advanced/_auto_compact.py:372-384

问题: _resolve_boundary_event_id_auto_compact.py:372-382)把请求内容层面计算的 boundary_occurrence 直接按"相同 signature 的 occurrence 次"映射到 ctx.session.events,但请求 contentssession.events 的构成不一致(会话含系统/部分/过滤事件,请求重建时跳过部分事件),同一 signature 两侧出现次数可不同。

触发条件: 会话中相同内容(如同一用户消息文本)多次出现且中间夹带其他不可见事件时,occurrence 计数偏移,选中错误边界事件或解析失败。

实际影响: 可能把较新的用户消息一起归档(活动窗口数据错位),或触发前述 ValueError 让压缩失败。

修正方向: 边界解析直接使用压缩时记录的 boundary_event_id(已存在但 session-memory 来源未保存),不依赖重新计数;或在请求与事件两侧使用同一过滤规则计数。

Comment on lines +127 to +129
self._runtime = AdvancedAutoCompactSummarizerRuntime(config=config or AdvancedAutoCompactSummarizerConfig())
self._auto_compact_config = config.auto_compact
self._session_memory_extractor = self._create_extractor(session_memory_extractor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: AdvancedAutoCompactSummarizer.__init__self._auto_compact_config = config.auto_compact 在签名声明 config: AdvancedAutoCompactSummarizerConfig | None = None 时未处理 Noneconfig or AdvancedAutoCompactSummarizerConfig() 的兜底只作用于 _runtime。默认构造(不传 config)即触发 AttributeError: 'NoneType' object has no attribute 'auto_compact'

触发条件: 任何以默认参数调用 AdvancedAutoCompactSummarizer() 的路径(公开 API 默认用法、自定义集成),即构造期崩溃。

实际影响: 默认构造该压缩器无法使用,调用方必须显式创建 config 对象,与签名声明的默认值行为不符;这是本次重构引入的回归(旧 AutoCompactSummarizer 无此崩溃路径)。

修正方向: 使用 self._auto_compact_config = (config or AdvancedAutoCompactSummarizerConfig()).auto_compact,或把 config 解析为局部变量后复用。

Comment on lines +728 to +732
natural_break = not self._event_has_tool_response(pending[-1])
threshold_met = ((context_tokens >= threshold if checkpoint_context_tokens is None else
(context_tokens < checkpoint_context_tokens or context_tokens -
checkpoint_context_tokens >= threshold)) if token_mode else pending_chars >= threshold)
tool_condition_met = tool_calls >= config.session_memory_tool_calls_between_updates or natural_break
tool_condition_met = tool_calls >= config.tool_calls_between_updates or natural_break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 会话记忆抽取的 natural_break 判断被反转:新代码 natural_break = not self._event_has_tool_response(pending[-1])(最后一条事件不含 function_response 才算自然断点),而旧实现 natural_break = not self._last_event_has_tool_call(pending)(不处于未完成工具回合才算自然断点)。完成的工具回合以 function_response 事件结尾,因此"每轮完成一个工具调用"的典型会话现在 natural_break=False

触发条件: 默认 tool_calls_between_updates=3 时,单工具调用会话在回合边界永不满足 tool_condition_met,会话记忆更新被无限推迟;反之用户在半成品工具回合中输入新消息时 pending[-1] 是普通文本,natural_break=True,会把未完成的工具回合状态提交进会话记忆。

实际影响: 长期记忆抽取频率错误(过少),且可能把半成品工具状态作为已完成的检查点固化,抽取出的记忆内容不可靠。

修正方向: 恢复旧语义:natural_break = not self._last_event_has_tool_call(pending)(以"是否处于未完成工具回合"为断点依据)。

Comment on lines +264 to +279
async def update_session_state(
self,
session: Session,
state_delta: dict[str, Any],
) -> None:
"""Persist session-scoped state without replacing the caller's Event window."""
if not state_delta:
return
session.state.update(state_delta)

async with self._redis_storage.create_db_session() as redis_session:
key = session_key(session.app_name, session.user_id, session.id)
storage_session = await self._get_session(redis_session, key)
if not storage_session:
logger.warning(
"Session %s not found in Redis while updating state",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 新增的 RedisSessionService.update_session_state 采用盲读改写:_get_session(其内 store_historical_events=False 时强制 session.historical_events = [])→ 原地改 state → _set_session(整包 SET key session_json)覆盖整个会话键,且无任何乐观并发控制;update_session 同样整包覆盖。

触发条件: (a) store_historical_events=False 时,记忆检查点通过 update_session_state(SESSION_MEMORY_STATE_KEY) 持久化即把已存的 historical_events 抹空;(b) 两个并发写者(如 update_session 与另一 worker 的 update_session_state),后者读到的旧快照覆盖前者的 events/state 变更。

实际影响: 已归档的会话历史被永久擦除;并发场景下状态与事件窗口互相覆盖,数据丢失。

修正方向: 用 Redis 原子结构(hash + HSET 局部更新 state 字段)或 WATCH/MULTI 事务 + 版本校验实现单字段更新,避免整包读改写;update_session_state 不得触发 _set_session 对 events/historical_events 的重写。

Comment on lines +97 to +101
def _read_index_sync(self) -> str:
if _is_expired(self.index_path, self._config.memory_ttl_seconds):
for path in self._paths.memory_dir.glob("*.md"):
path.unlink(missing_ok=True)
return ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本地存储 _read_index_sync 中,当索引文件本身按 memory_ttl_seconds 过期(索引 mtime 只在写入时刷新)时,直接 for path in memory_dir.glob("*.md"): path.unlink() 删除全部主题文件,而主题文件自身的 TTL 未参与判断。

触发条件: 写入频率低于 TTL(如 30 分钟一次写入、TTL 3600s)时,任何纯读取方(preload、read_topic、list_topics 触发 read_index)在索引过期后调用即触发整体删除;LocalAdvancedMemoryCleanup._run_storage.py:195-197)同样按索引过期整体删除。

实际影响: 长期记忆全部主题文件被永久删除,纯读路径造成数据整体丢失,事后 read_memory 返回 not found;删除与并发写入无锁竞争。

修正方向: TTL 过期只应使索引条目失效,不应删除主题文件;删除应由配置化的清理任务按主题级 TTL 逐个执行,并加写锁/串行化。

Comment on lines +54 to +57
invocation_ctx.end_invocation = True
rsp.rsp = result
rsp.is_continue = False
rsp.error = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: AdvancedAutoCompactSummarizerFilter._before 在阻塞时设置 rsp.rsp = resultLlmResponse,内容为 ADVANCED_AUTOCOMPACT_BLOCKED_MESSAGE)+ is_continue=Falserun_stream_filters 将该 LlmResponse 直接 yield 给代理主循环,而 _base_agent.py:305-312_llm_agent.py:509 立即调用 event.has_content()/event.is_error()/event.is_final_response()——is_error()is_final_response() 只存在于 Event(events/_event.py:256/180),LlmResponse(models/_llm_response.py)没有这些方法。

触发条件: 连续压缩失败达到 max_failures 且达到阻塞阈值时,过滤链输出阻塞响应,代理主循环收到 LlmResponse 后调用 event.is_error() 抛 AttributeError。

实际影响: 本应优雅返回的"阻塞"提示变成请求运行异常(run loop 崩溃),用户得到 500/异常而非阻塞说明。

修正方向: 阻塞路径应产出 Event(或把 LlmResponse 转成带错误码的 Event)再放入 rsp.rsp,使代理主循环按统一 Event 协议处理。

Comment on lines +690 to +696
original_contents = [content.model_copy(deep=True) for content in request.contents]
try:
compact_record: AdvancedAutoCompactRecord | None = None
if self._session_memory_extractor is not None:
await self._session_memory_extractor.extract_if_needed(
ctx,
force=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _apply_scopedawait self._session_memory_extractor.extract_if_needed(ctx, force=True) 放在压缩 try 块内,抽取子代理(与主模型同模型、可能限流)的任何异常都被 except Exception 归入 state.consecutive_failures += 1_auto_compact.py:776),与压缩失败混为一谈,一旦达到 max_failures(默认 3)且达到阻塞阈值即返回 blocked=True,主模型请求被阻断。

触发条件: 会话接近自动压缩阈值时,抽取子代理偶发限流/模型超时/解析失败,连续几轮即可把健康的主请求硬阻断。

实际影响: 主对话被以 ADVANCED_AUTOCOMPACT_BLOCKED_MESSAGE 整体阻断,即使主模型本身完全正常;抽取失败与压缩失败未区分记账。

修正方向: 将抽取失败与压缩失败分开计数,或抽取失败降级(跳过记忆抽取但继续压缩),不参与阻塞判定。

Comment on lines +166 to +167
session = ctx.session
events = session.events

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: token 预算的指纹通道失效:_latest_usage_baseline 从事件 custom_metadata 读取 advanced_memory_request_context_fingerprint 作为过期 usage 匹配护栏,但全仓库已无任何写入方(唯一写入者 TranscriptSessionService 在本重构中删除);record_request_contextadvanced_memory_pending_request_context_fingerprint 写进 session.state,也没有任何代码把它拷贝到响应事件的 custom_metadata。

触发条件: 每次请求重建后,护栏恒为空,usage 基线只能靠内容指纹匹配;当新请求片段与历史 usage 事件内容指纹重合时(压缩后的 summary/保留内容与旧请求相同),会误匹配到旧的、更小的 usage_tokens。

实际影响: token 估算静默偏低,auto_compact/blocking 阈值判定失真(可能延迟压缩或低估阻塞阈值),压缩触发时机不可靠;该通道从"有护栏"回归为"无护栏"。

修正方向: 恢复写入链路:在响应事件落库前把 record_request_context 缓存的指纹写入该事件 custom_metadata,或移除该护栏并改用显式的 usage 事件关联字段。

Comment on lines +419 to +427

boundary_event_id = record.boundary_event_id or self._resolve_boundary_event_id(
ctx,
record.boundary_signature,
record.boundary_occurrence,
)
if boundary_event_id is None:
raise ValueError("Cannot map the AdvancedAutoCompactSummarizer boundary to an active Session Event")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _persist_session_compaction 在调用 compact_events 之前先做边界解析(_auto_compact.py:420-425):boundary_event_id = record.boundary_event_id or self._resolve_boundary_event_id(...),解析失败立刻 raise ValueError("Cannot map ...");而 compact_events 的幂等保护(_session.py:155-156session_compaction_id 元数据返回 False)位于调用之内。当重试/会话重载后边界事件已被先前成功的压缩替换/移入历史,ValueError 在幂等保护生效前抛出。

触发条件: 同会话内:压缩持久化成功但调用方崩溃后重试,或另一 worker 已先完成压缩,session-memory 来源的 record(boundary_event_id=None)重试时按签名+occurrence 在已替换的当前事件上解析不到边界;或会话从存储重载后事件窗口已不含边界事件。

实际影响: 幂等重放机制不可达,重试直接以 ValueError 中断 before-model 管线,请求失败;压缩记录无法自然去重。

修正方向: 在边界解析前先按 compaction_id 检查幂等(把幂等探测提前到 _persist_session_compaction 入口),解析失败时降级为跳过而非抛异常。

Comment on lines +127 to +142
def migrate_legacy(self, app_name: str, user_id: str) -> "ScopedAdvancedMemoryRuntime":
"""Move an old flat Advanced Memory layout into one explicit tenant.

Refuses to overwrite a tenant that already contains data.
"""
scoped = self.for_scope(app_name, user_id)
legacy_paths = self.paths
target_root = scoped.paths.tenant_root_dir
if target_root.exists():
raise FileExistsError(f"Target Advanced Memory tenant already exists: {target_root}")
if not legacy_paths.memory_dir.exists():
raise FileNotFoundError("No legacy Advanced Memory directories exist")
target_root.mkdir(parents=True)
if legacy_paths.memory_dir.exists():
shutil.move(str(legacy_paths.memory_dir), str(scoped.paths.memory_dir))
return scoped

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: AdvancedMemoryRuntime.migrate_legacy(将旧平铺 memory 布局迁移到 tenants 作用域布局)在本 SDK 全仓库无任何调用方(grep 确认零引用),for_scope/initialize 也未自动触发迁移。

触发条件: 已有旧版 SDK 平铺 MEMORY/ 存储的用户升级到本版本后继续使用(未手动调用未公开的 migrate_legacy)。

实际影响: 旧长期记忆对新作用域路径(tenants///memory)不可见,升级用户静默丢失历史记忆可见性;迁移能力形同虚设。

修正方向:for_scope/initialize 首次发现平铺布局时自动调用迁移(带上现有防覆盖保护),或至少对外文档化迁移入口并触发告警。

Comment on lines +372 to +384
def _resolve_boundary_event_id(
self,
ctx: InvocationContext,
signature: str,
occurrence: int,
) -> str | None:
"""Map one request-content boundary back to an active Session Event."""
seen = 0
for event in ctx.session.events:
content = getattr(event, "content", None)
if content is None or content_signature(content) != signature:
continue
seen += 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _resolve_boundary_event_id_auto_compact.py:372-382)把请求内容层面计算的 boundary_occurrence 直接按"相同 signature 的 occurrence 次"映射到 ctx.session.events,但请求 contentssession.events 的构成不一致(会话含系统/部分/过滤事件,请求重建时跳过部分事件),同一 signature 两侧出现次数可不同。

触发条件: 会话中相同内容(如同一用户消息文本)多次出现且中间夹带其他不可见事件时,occurrence 计数偏移,选中错误边界事件或解析失败。

实际影响: 可能把较新的用户消息一起归档(活动窗口数据错位),或触发前述 ValueError 让压缩失败。

修正方向: 边界解析直接使用压缩时记录的 boundary_event_id(已存在但 session-memory 来源未保存),不依赖重新计数;或在请求与事件两侧使用同一过滤规则计数。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants