diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py
index ad3b57cb25..4292f4c04e 100644
--- a/astrbot/core/agent/message.py
+++ b/astrbot/core/agent/message.py
@@ -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,
@@ -13,6 +13,8 @@
)
from pydantic_core import core_schema
+ContentPartT = TypeVar("ContentPartT", bound="ContentPart")
+
class ContentPart(BaseModel):
"""A part of the content in a message."""
@@ -20,6 +22,7 @@ class ContentPart(BaseModel):
__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)
@@ -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
raise ValueError(f"Cannot validate {value} as ContentPart")
@@ -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):
"""
@@ -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)
if message._checkpoint_after is not None:
dumped.append(
CheckpointMessageSegment(content=message._checkpoint_after).model_dump()
diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py
index 9b64196e7a..8e12683ffb 100644
--- a/astrbot/core/provider/entities.py
+++ b/astrbot/core/provider/entities.py
@@ -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:
diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md
index b63609818a..e798475add 100644
--- a/docs/en/dev/star/guides/listen-message-event.md
+++ b/docs/en/dev/star/guides/listen-message-event.md
@@ -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="This hint only applies to the current request.").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.
diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md
index 9cf96f4366..b8187b00b9 100644
--- a/docs/zh/dev/star/guides/listen-message-event.md
+++ b/docs/zh/dev/star/guides/listen-message-event.md
@@ -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="这段提示只在本轮请求中生效。").mark_as_temp()
+> )
+> ```
+>
> 对于长期记忆、知识库、外部系统查询等内容量较大或不一定每轮都需要的信息,不建议全部塞进提示词。可以优先注册为 `llm_tool`,让模型在需要时调用;也可以先在插件中检索出本轮真正相关的少量摘要,再放入 `extra_user_content_parts`。
#### LLM 请求完成时
diff --git a/docs/zh/dev/star/plugin.md b/docs/zh/dev/star/plugin.md
index 9e576f1c11..c7b2df5166 100644
--- a/docs/zh/dev/star/plugin.md
+++ b/docs/zh/dev/star/plugin.md
@@ -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="这段提示只在本轮请求中生效。").mark_as_temp()
+> )
+> ```
+>
> 对于长期记忆、知识库、外部系统查询等内容量较大或不一定每轮都需要的信息,不建议全部塞进提示词。可以优先注册为 `llm_tool`,让模型在需要时调用;也可以先在插件中检索出本轮真正相关的少量摘要,再放入 `extra_user_content_parts`。
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
diff --git a/tests/test_conversation_checkpoint.py b/tests/test_conversation_checkpoint.py
index 96113b7d1d..40eae46ff9 100644
--- a/tests/test_conversation_checkpoint.py
+++ b/tests/test_conversation_checkpoint.py
@@ -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
@@ -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"),