refactor(ltm): redesign long-term memory with context compaction (reopen of #8144) (#8226)

* refactor(ltm): redesign long-term memory with context compaction

- Add raw_records / contexts / summaries data model per group
- Add LLM summary compaction strategy alongside truncation
- Add turn-based (_split_into_rounds) granularity
- Add image caption integration into LTM history
- Add tool_call / tool_result persistence into raw_records
- Add active reply support driven by LTM state
- Improve summary injection prefix with system note and delimiters
- Add info-level logging for summary compaction lifecycle
- Clarify default summary prompt with explicit preserve/drop rules
- Add context_guard for history overflow protection in agent runner
- Add internal agent history compaction in agent_sub_stages
- Add comprehensive LTM unit tests and compaction test suites

* fix(ltm): handle malformed JSON in tool args and clean up lock on session removal

* fix(ltm): guard against duplicate system prompt note injection

* fix(ltm): fall back to user message when internal marker parsing fails

- Treat lines starting with <T:CALL>, <T:RES, or <BOT/ as regular user
  messages when their respective parsers return None, instead of silently
  dropping them. Defensive guard against malformed internal markers.

* fix(ltm): release session lock during LLM summary generation

* fix(ltm): trim raw_records in handle_message to prevent unbounded growth

* perf(ltm): use len(s) instead of len(s.encode()) in trim loop

Avoid allocating a new bytes object for every string when calculating
buffer size in _trim_raw_records. Character count is sufficient for
the approximate memory cap.

* feat(ltm): make user segment truncation limits configurable

* feat(ltm): pre-fill default LTM summary prompt in config and i18n

* refactor(ltm): hardcode internal segment/trim constants

* refactor(ltm): unify compaction strategy with main agent runner

* feat(ltm): add @mention weight marker for group chat messages

* test: fix test failures from LTM compaction unification

* chore(dashboard): remove obsolete LTM compaction i18n metadata

* chore: shrink codebase

* feat(group-chat): implement group chat context management and related functionality

---------

Co-authored-by: Tsukumi <112180165+Tsukumi233@users.noreply.github.com>
Co-authored-by: zenfun <zenfun510@gmail.com>
Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
Ruochen Pan
2026-05-30 17:16:36 +08:00
committed by GitHub
parent 61b6813dc7
commit 95d80578bf
16 changed files with 710 additions and 419 deletions

View File

@@ -260,6 +260,33 @@ class SingleToolThenFinalProvider(MockProvider):
)
class CapturingToolLoopProvider(MockProvider):
def __init__(self, tool_name: str):
super().__init__()
self.tool_name = tool_name
self.received_contexts = []
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
self.received_contexts.append(list(kwargs.get("contexts") or []))
func_tool = kwargs.get("func_tool")
if func_tool is None or self.call_count > 1:
return LLMResponse(
role="assistant",
completion_text="最终回复",
usage=TokenUsage(input_other=10, output=5),
)
return LLMResponse(
role="assistant",
completion_text="",
tools_call_name=[self.tool_name],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_context_refresh"],
usage=TokenUsage(input_other=10, output=5),
)
class SequentialToolProvider(MockProvider):
def __init__(self, tool_sequence: list[str]):
super().__init__()
@@ -450,6 +477,68 @@ async def test_max_step_limit_functionality(
assert last_message.role == "assistant", "最后一条消息应该是assistant的最终回答"
@pytest.mark.asyncio
async def test_max_step_final_request_includes_limit_prompt(
runner, provider_request, mock_tool_executor, mock_hooks
):
"""The forced final step must use contexts recomputed after max-step prompt."""
provider = CapturingToolLoopProvider("test_tool")
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
async def snapshot_context_manager(messages, trusted_token_usage=0):
return list(messages)
runner.request_context_manager.process = snapshot_context_manager
async for _ in runner.step_until_done(1):
pass
assert provider.call_count == 2
final_contexts = provider.received_contexts[-1]
assert final_contexts[-1].role == "user"
assert final_contexts[-1].content == runner.MAX_STEPS_REACHED_PROMPT
@pytest.mark.asyncio
async def test_tool_loop_next_request_includes_tool_result(
runner, provider_request, mock_tool_executor, mock_hooks
):
"""Tool-loop provider contexts must be recomputed after tool results append."""
provider = CapturingToolLoopProvider("test_tool")
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
async def snapshot_context_manager(messages, trusted_token_usage=0):
return list(messages)
runner.request_context_manager.process = snapshot_context_manager
async for _ in runner.step_until_done(3):
pass
assert provider.call_count == 2
second_contexts = provider.received_contexts[1]
tool_messages = [msg for msg in second_contexts if msg.role == "tool"]
assert len(tool_messages) == 1
assert tool_messages[0].tool_call_id == "call_context_refresh"
assert "工具执行结果" in tool_messages[0].content
@pytest.mark.asyncio
async def test_normal_completion_without_max_step(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks