Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions astrbot/core/agent/message.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
# License: Apache License 2.0

from typing import Any, ClassVar, Literal, cast
from typing import Any, ClassVar, Literal, TypeVar, cast

from pydantic import (
BaseModel,
Expand All @@ -13,13 +13,16 @@
)
from pydantic_core import core_schema

ContentPartT = TypeVar("ContentPartT", bound="ContentPart")


class ContentPart(BaseModel):
"""A part of the content in a message."""

__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}

type: Literal["text", "think", "image_url", "audio_url"]
_no_save: bool = PrivateAttr(default=False)

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
Expand Down Expand Up @@ -50,7 +53,10 @@ def validate_content_part(value: Any) -> Any:
if not isinstance(type_value, str):
raise ValueError(f"Cannot validate {value} as ContentPart")
target_class = cls.__content_part_registry[type_value]
return target_class.model_validate(value)
part = target_class.model_validate(value)
if cast(dict[str, Any], value).get("_no_save"):
part._no_save = True
return part
Comment on lines 55 to +59

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.

suggestion (bug_risk): The _no_save flag is treated as truthy rather than explicitly boolean, which can lead to surprising behavior.

cast(dict[str, Any], value).get("_no_save") will treat any truthy value (e.g. 'false', 1, non-empty strings) as enabling _no_save. If this field is meant to be strictly boolean, consider checking explicitly for True (or validating/coercing the type) to avoid accidental activation from loosely typed inputs.

Suggested change
target_class = cls.__content_part_registry[type_value]
return target_class.model_validate(value)
part = target_class.model_validate(value)
if cast(dict[str, Any], value).get("_no_save"):
part._no_save = True
return part
target_class = cls.__content_part_registry[type_value]
part = target_class.model_validate(value)
if cast(dict[str, Any], value).get("_no_save") is True:
part._no_save = True
return part


raise ValueError(f"Cannot validate {value} as ContentPart")

Expand All @@ -59,6 +65,17 @@ def validate_content_part(value: Any) -> Any:
# for subclasses, use the default schema
return handler(source_type)

def mark_as_temp(self: ContentPartT) -> ContentPartT:
"""Mark this content part as provider-facing only, not persisted."""
self._no_save = True
return self

def model_dump_for_context(self) -> dict[str, Any]:
data = self.model_dump()
if self._no_save:
data["_no_save"] = True
return data


class TextPart(ContentPart):
"""
Expand Down Expand Up @@ -329,7 +346,14 @@ def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
"""Dump runtime messages and reinsert bound checkpoint segments."""
dumped: list[dict] = []
for message in messages:
dumped.append(message.model_dump())
message_data = message.model_dump()
if isinstance(message.content, list):
message_data["content"] = [
part.model_dump()
for part in message.content
if not getattr(part, "_no_save", False)
]
dumped.append(message_data)
Comment on lines +349 to +356

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.

medium

在 dump_messages_with_checkpoints 函数中,目前的实现存在两个可以改进的地方:

  1. 未检查消息级别的 _no_save 属性:Message 类本身也定义了 _no_save 私有属性,且在 bind_checkpoint_messages 中会被还原。如果整个消息被标记为临时消息,此处应当跳过该消息的持久化。
  2. 空内容消息的处理:如果 message.content 是一个列表,且其中的所有 ContentPart 都被标记为 _no_save,过滤后的 content 将变成空列表 []。对于没有工具调用(tool_calls)的消息,保存一个空内容的消息通常没有意义。

建议在消息被标记为 _no_save 或过滤后内容为空且无工具调用时跳过该消息。此外,根据项目规则,新功能的实现应伴随相应的单元测试。

Suggested change
message_data = message.model_dump()
if isinstance(message.content, list):
message_data["content"] = [
part.model_dump()
for part in message.content
if not getattr(part, "_no_save", False)
]
dumped.append(message_data)
if message._no_save:
continue
message_data = message.model_dump()
if isinstance(message.content, list):
filtered_content = [
part.model_dump()
for part in message.content
if not getattr(part, "_no_save", False)
]
if not filtered_content and not message.tool_calls:
continue
message_data["content"] = filtered_content
dumped.append(message_data)
References
  1. New functionality should be accompanied by corresponding unit tests.

if message._checkpoint_after is not None:
dumped.append(
CheckpointMessageSegment(content=message._checkpoint_after).model_dump()
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/provider/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ async def assemble_context(self) -> dict:
# 2. 额外的内容块(系统提醒、指令等)
if self.extra_user_content_parts:
for part in self.extra_user_content_parts:
content_blocks.append(part.model_dump())
content_blocks.append(part.model_dump_for_context())

# 3. 图片内容
if self.image_urls:
Expand Down
8 changes: 8 additions & 0 deletions docs/en/dev/star/guides/listen-message-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest):
> )
> ```
>
> If the appended content should only affect the current LLM request and should not be persisted into conversation history, call `.mark_as_temp()` to mark it as temporary:
>
> ```python
> req.extra_user_content_parts.append(
> TextPart(text="<runtime_hint>This hint only applies to the current request.</runtime_hint>").mark_as_temp()
> )
> ```
>
> For long-term memory, knowledge bases, or external system queries that may be large or unnecessary for every round, do not put everything directly into the prompt. Prefer registering them as `llm_tool` functions so the model can call them when needed, or retrieve only a small relevant summary in your plugin and append that summary through `extra_user_content_parts`.

> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
Expand Down
8 changes: 8 additions & 0 deletions docs/zh/dev/star/guides/listen-message-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,14 @@ async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest):
> )
> ```
>
> 如果追加的内容只希望参与本轮 LLM 请求,不希望被持久化到会话历史中,可以调用 `.mark_as_temp()` 标记为临时内容(`>= v4.24.0`):
>
> ```python
> req.extra_user_content_parts.append(
> TextPart(text="<runtime_hint>这段提示只在本轮请求中生效。</runtime_hint>").mark_as_temp()
> )
> ```
>
> 对于长期记忆、知识库、外部系统查询等内容量较大或不一定每轮都需要的信息,不建议全部塞进提示词。可以优先注册为 `llm_tool`,让模型在需要时调用;也可以先在插件中检索出本轮真正相关的少量摘要,再放入 `extra_user_content_parts`。

#### LLM 请求完成时
Expand Down
8 changes: 8 additions & 0 deletions docs/zh/dev/star/plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,14 @@ async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest):
> )
> ```
>
> 如果追加的内容只希望参与本轮 LLM 请求,不希望被持久化到会话历史中,可以调用 `.mark_as_temp()` 标记为临时内容:
>
> ```python
> req.extra_user_content_parts.append(
> TextPart(text="<runtime_hint>这段提示只在本轮请求中生效。</runtime_hint>").mark_as_temp()
> )
> ```
>
> 对于长期记忆、知识库、外部系统查询等内容量较大或不一定每轮都需要的信息,不建议全部塞进提示词。可以优先注册为 `llm_tool`,让模型在需要时调用;也可以先在插件中检索出本轮真正相关的少量摘要,再放入 `extra_user_content_parts`。

> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
Expand Down
56 changes: 56 additions & 0 deletions tests/test_conversation_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
CheckpointData,
CheckpointMessageSegment,
Message,
TextPart,
bind_checkpoint_messages,
dump_messages_with_checkpoints,
get_checkpoint_id,
strip_checkpoint_messages,
)
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.provider.provider import Provider
from astrbot.dashboard.routes.chat import ChatRoute

Expand Down Expand Up @@ -81,6 +83,60 @@ def test_dump_checkpoint_messages_drops_checkpoint_when_message_is_dropped():
]


def test_dump_messages_filters_temp_content_parts():
messages = [
Message(
role="user",
content=[
TextPart(text="persisted"),
TextPart(text="temporary").mark_as_temp(),
],
),
Message(role="assistant", content="ok"),
]

assert dump_messages_with_checkpoints(messages) == [
{"role": "user", "content": [{"type": "text", "text": "persisted"}]},
{"role": "assistant", "content": "ok"},
]


def test_content_part_no_save_round_trip_from_dict():
message = Message.model_validate(
{
"role": "user",
"content": [
{"type": "text", "text": "persisted"},
{"type": "text", "text": "temporary", "_no_save": True},
],
}
)

assert isinstance(message.content, list)
assert message.content[0]._no_save is False
assert message.content[1]._no_save is True
assert dump_messages_with_checkpoints([message]) == [
{"role": "user", "content": [{"type": "text", "text": "persisted"}]},
]


@pytest.mark.asyncio
async def test_provider_request_assemble_context_preserves_temp_content_part_marker():
request = ProviderRequest(
prompt="hello",
extra_user_content_parts=[TextPart(text="temporary").mark_as_temp()],
)

message = Message.model_validate(await request.assemble_context())

assert isinstance(message.content, list)
assert message.content[1].text == "temporary"
assert message.content[1]._no_save is True
assert dump_messages_with_checkpoints([message]) == [
{"role": "user", "content": [{"type": "text", "text": "hello"}]},
]


def test_provider_ensure_message_to_dicts_skips_checkpoints():
messages = [
Message(role="user", content="hello"),
Expand Down
Loading