Skip to content

Commit d931b9f

Browse files
committed
fix(context): restore turn cap, serialize content parts and tool calls for llm compress, fix AftCompact debug log
Three context-compaction regression fixes after #8226: 1. Restore max_context_length -> enforce_max_turns propagation so normal turn-based truncation works again. 2. Serialize ContentPart and ToolCall objects into plain dicts in _message_to_dict so llm_compress no longer fails with JSON serialization errors. 3. Print _provider_messages (compacted) instead of run_context.messages (unchanged) in AftCompact debug log; truncate long role lists to first4,...,last4 to avoid log spam. Assertions in tests are also hardened to avoid coupling to exact prompt wording.
1 parent 25b1344 commit d931b9f

5 files changed

Lines changed: 79 additions & 13 deletions

File tree

astrbot/core/agent/context/compressor.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import TYPE_CHECKING, Protocol, runtime_checkable
22

3-
from ..message import Message
3+
from ..message import ContentPart, Message, ToolCall
44

55
if TYPE_CHECKING:
66
from astrbot import logger
@@ -100,9 +100,17 @@ def _message_to_dict(msg: Message) -> dict:
100100
"""Convert a Message to a plain dict suitable for round splitting."""
101101
d = {"role": msg.role}
102102
if msg.content is not None:
103-
d["content"] = msg.content
103+
if isinstance(msg.content, list):
104+
d["content"] = [
105+
part.model_dump_for_context() if isinstance(part, ContentPart) else part
106+
for part in msg.content
107+
]
108+
else:
109+
d["content"] = msg.content
104110
if getattr(msg, "tool_calls", None):
105-
d["tool_calls"] = msg.tool_calls
111+
d["tool_calls"] = [
112+
tc.model_dump() if isinstance(tc, ToolCall) else tc for tc in msg.tool_calls
113+
]
106114
if getattr(msg, "tool_call_id", None):
107115
d["tool_call_id"] = msg.tool_call_id
108116
return d

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -610,11 +610,14 @@ def _func_tool_for_provider(self) -> ToolSet | None:
610610
return None
611611
return self.req.func_tool
612612

613-
def _simple_print_message_role(self, tag: str = ""):
614-
roles = []
615-
for message in self.run_context.messages:
616-
roles.append(message.role)
617-
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}")
613+
def _simple_print_message_role(self, tag: str, messages: list):
614+
roles = [m.role for m in messages]
615+
n = len(roles)
616+
if n > 10:
617+
summary = ",".join(roles[:4]) + ",...," + ",".join(roles[-4:])
618+
else:
619+
summary = ",".join(roles)
620+
logger.debug(f"{tag} messages -> [{n}] {summary}")
618621

619622
def follow_up(
620623
self,
@@ -713,11 +716,11 @@ async def step(self):
713716
# provider call. Persistent compaction is owned by the conversation /
714717
# memory layer.
715718
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
716-
self._simple_print_message_role("[BefCompact]")
719+
self._simple_print_message_role("[BefCompact]", self.run_context.messages)
717720
self._provider_messages = await self.request_context_manager.process(
718721
self.run_context.messages, trusted_token_usage=token_usage
719722
)
720-
self._simple_print_message_role("[AftCompact]")
723+
self._simple_print_message_role("[AftCompact]", self._provider_messages)
721724

722725
async for llm_response in self._iter_llm_responses_with_fallback():
723726
if llm_response.is_chunk:

astrbot/core/astr_main_agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,7 @@ async def build_main_agent(
15341534
llm_compress_keep_recent=config.llm_compress_keep_recent,
15351535
llm_compress_provider=_get_compress_provider(config, plugin_context, event),
15361536
truncate_turns=config.dequeue_context_length,
1537+
enforce_max_turns=config.max_context_length,
15371538
tool_schema_mode=config.tool_schema_mode,
15381539
fallback_providers=fallback_providers,
15391540
tool_result_overflow_dir=(

tests/agent/test_context_manager.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,9 @@ def __init__(self):
2828

2929
async def text_chat(self, **kwargs):
3030
"""模拟 LLM 调用,返回摘要"""
31-
messages = kwargs.get("messages", [])
32-
# 简单的摘要逻辑:返回消息数量统计
3331
return LLMResponse(
3432
role="assistant",
35-
completion_text=f"历史对话包含 {len(messages) - 1} 条消息,主要讨论了技术话题。",
33+
completion_text="Summary of conversation: Hello and discussed various topics.",
3634
)
3735

3836
def get_model(self):
@@ -113,6 +111,28 @@ async def test_llm_compressor_keeps_history_when_summary_is_empty(self):
113111
"LLM context compression returned an empty summary."
114112
)
115113

114+
@pytest.mark.asyncio
115+
async def test_llm_compressor_handles_textpart_content(self):
116+
from astrbot.core.agent.context.compressor import LLMSummaryCompressor
117+
118+
provider = MockProvider()
119+
compressor = LLMSummaryCompressor(provider=provider, keep_recent=1) # type: ignore[arg-type]
120+
messages = [
121+
Message(role="user", content=[TextPart(text="Hello")]),
122+
Message(role="assistant", content=[TextPart(text="Hi there")]),
123+
Message(role="user", content=[TextPart(text="Summarize our work")]),
124+
Message(role="assistant", content=[TextPart(text="Sure")]),
125+
]
126+
127+
result = await compressor(messages)
128+
129+
assert len(result) == 4
130+
assert result[0].role == "user"
131+
assert isinstance(result[0].content, str)
132+
assert result[0].content.strip()
133+
assert "Hello" in result[0].content
134+
assert result[-1].content == [TextPart(text="Sure")]
135+
116136
# ==================== Empty and Edge Cases ====================
117137

118138
@pytest.mark.asyncio
@@ -759,5 +779,6 @@ def test_split_rounds_multi_tool(self):
759779
def test_split_rounds_empty(self):
760780
"""Empty list returns no rounds."""
761781
from astrbot.core.agent.context.round_utils import split_into_rounds
782+
762783
rounds = split_into_rounds([])
763784
assert len(rounds) == 0

tests/unit/test_astr_main_agent.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,39 @@ async def test_build_main_agent_basic(
985985
assert result is not None
986986
assert isinstance(result, module.MainAgentBuildResult)
987987

988+
@pytest.mark.asyncio
989+
async def test_build_main_agent_passes_max_context_length_to_runner(
990+
self, mock_event, mock_context, mock_provider
991+
):
992+
module = ama
993+
mock_context.get_provider_by_id.return_value = None
994+
mock_context.get_using_provider.return_value = mock_provider
995+
mock_context.get_config.return_value = {}
996+
997+
conv_mgr = mock_context.conversation_manager
998+
_setup_conversation_for_build(conv_mgr)
999+
1000+
with (
1001+
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
1002+
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
1003+
):
1004+
mock_runner = MagicMock()
1005+
mock_runner.reset = AsyncMock()
1006+
mock_runner_cls.return_value = mock_runner
1007+
1008+
result = await module.build_main_agent(
1009+
event=mock_event,
1010+
plugin_context=mock_context,
1011+
config=module.MainAgentBuildConfig(
1012+
tool_call_timeout=60,
1013+
max_context_length=7,
1014+
),
1015+
)
1016+
1017+
assert result is not None
1018+
mock_runner.reset.assert_awaited_once()
1019+
assert mock_runner.reset.await_args.kwargs["enforce_max_turns"] == 7
1020+
9881021
@pytest.mark.asyncio
9891022
async def test_build_main_agent_no_provider(self, mock_event, mock_context):
9901023
"""Test building main agent when no provider is available."""

0 commit comments

Comments
 (0)