Skip to content
Closed
221 changes: 207 additions & 14 deletions astrbot/builtin_stars/builtin_commands/commands/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,31 +121,103 @@ async def _get_current_persona_id(self, session_id):
return None
return conv.persona_id

async def reset(self, message: AstrMessageEvent) -> None:
"""重置 LLM 会话"""
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=message.unified_msg_origin)
is_unique_session = cfg["platform_settings"]["unique_session"]
is_group = bool(message.get_group_id())

async def _check_command_permission(
self,
command: str,
message: AstrMessageEvent,
is_group: bool,
is_unique_session: bool,
) -> bool:
scene = RstScene.get_scene(is_group, is_unique_session)

alter_cmd_cfg = await sp.get_async("global", "global", "alter_cmd", {})
alter_cmd_cfg = await sp.get_async("global", "global", "alter_cmd", {}) or {}
plugin_config = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_config.get("reset", {})

required_perm = reset_cfg.get(
cmd_cfg = plugin_config.get(command, {})
required_perm = cmd_cfg.get(
scene.key,
"admin" if is_group and not is_unique_session else "member",
)

if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"Reset command requires admin permission in {scene.name} scenario, "
f"{command.capitalize()} command requires admin permission in {scene.name} scenario, "
f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
),
)
return False
return True

def _build_context_config(self, settings: dict, umo: str):

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.

issue (complexity): Consider extracting the new history conversion and context wiring logic into shared utilities so this command handler stays a thin orchestrator over the context engine.

You can reduce the new complexity by pushing the low‑level “context engine” wiring into a small reusable utility module and keeping this command handler focused on orchestration.

1. Extract history ↔ Message conversion

_history_to_messages and _messages_to_history are generic and don’t need access to self. Moving them to a shared utility makes them reusable and reduces the cognitive load in this class.

Before (methods on the command class):

def _history_to_messages(self, history: list[dict]):
    from astrbot.core.agent.message import Message

    return [
        Message(
            role=item.get("role", "user"),
            content=item.get("content", ""),
            tool_calls=item.get("tool_calls"),
            tool_call_id=item.get("tool_call_id"),
        )
        for item in history
    ]

def _messages_to_history(self, messages):
    from astrbot.core.agent.message import ToolCall

    result: list[dict] = []
    for msg in messages:
        entry = {"role": msg.role}
        if msg.content is None:
            entry["content"] = None
        elif isinstance(msg.content, (str, list, dict)):
            entry["content"] = msg.content
        else:
            entry["content"] = str(msg.content)
        if msg.tool_calls is not None:
            entry["tool_calls"] = [
                tc.model_dump() if isinstance(tc, ToolCall) else tc
                for tc in msg.tool_calls
            ]
        if msg.tool_call_id is not None:
            entry["tool_call_id"] = msg.tool_call_id
        result.append(entry)
    return result

After (shared util):

# astrbot/core/agent/context/history_utils.py
from typing import List, Dict, Any
from astrbot.core.agent.message import Message, ToolCall

def history_to_messages(history: List[Dict[str, Any]]) -> List[Message]:
    return [
        Message(
            role=item.get("role", "user"),
            content=item.get("content", ""),
            tool_calls=item.get("tool_calls"),
            tool_call_id=item.get("tool_call_id"),
        )
        for item in history
    ]

def messages_to_history(messages: List[Message]) -> List[Dict[str, Any]]:
    result: List[Dict[str, Any]] = []
    for msg in messages:
        entry: Dict[str, Any] = {"role": msg.role}
        if msg.content is None:
            entry["content"] = None
        elif isinstance(msg.content, (str, list, dict)):
            entry["content"] = msg.content
        else:
            entry["content"] = str(msg.content)
        if msg.tool_calls is not None:
            entry["tool_calls"] = [
                tc.model_dump() if isinstance(tc, ToolCall) else tc
                for tc in msg.tool_calls
            ]
        if msg.tool_call_id is not None:
            entry["tool_call_id"] = msg.tool_call_id
        result.append(entry)
    return result

Usage in compact becomes simpler:

from astrbot.core.agent.context.history_utils import (
    history_to_messages,
    messages_to_history,
)

# ...
messages = history_to_messages(history)
compressed = await cm.process(messages, max_context_tokens=max_context_tokens)
result = messages_to_history(compressed)

2. Extract ContextConfig / ContextManager wiring

_build_context_config + ContextManager construction + token‑window extraction is effectively context-engine wiring. Extracting that into a helper keeps compact from being a mini engine.

Before (inside command class):

def _build_context_config(self, settings: dict, umo: str):
    from astrbot.core.agent.context.config import ContextConfig

    summary_provider_id = settings.get("summary_provider_id", "")
    summary_provider = (
        self.context.get_provider_by_id(summary_provider_id)
        if summary_provider_id
        else None
    )
    if not summary_provider:
        summary_provider = self.context.get_using_provider(umo=umo)

    return ContextConfig(
        enable_turn_limit=settings.get("enable_turn_limit", False),
        max_turns=settings.get("max_turns", 50),
        enable_token_guard=settings.get("enable_token_guard", True),
        token_guard_threshold=settings.get("token_guard_threshold", 0.82),
        enable_summary=settings.get("enable_summary", True),
        enable_discard=settings.get("enable_discard", True),
        discard_turns=settings.get("discard_turns", 1),
        summary_prompt=settings.get("summary_prompt", ""),
        summary_provider=summary_provider,
        retention_method=settings.get("retention_method", "turns"),
        retain_turns=settings.get("retain_turns", 20),
        retain_percentage=settings.get("retain_percentage", 0.3),
    )

After (utility function):

# astrbot/core/agent/context/context_utils.py
from astrbot.core.agent.context.config import ContextConfig

def build_context_config(context, settings: dict, umo: str) -> ContextConfig:
    summary_provider_id = settings.get("summary_provider_id", "")
    summary_provider = (
        context.get_provider_by_id(summary_provider_id)
        if summary_provider_id
        else None
    )
    if not summary_provider:
        summary_provider = context.get_using_provider(umo=umo)

    return ContextConfig(
        enable_turn_limit=settings.get("enable_turn_limit", False),
        max_turns=settings.get("max_turns", 50),
        enable_token_guard=settings.get("enable_token_guard", True),
        token_guard_threshold=settings.get("token_guard_threshold", 0.82),
        enable_summary=settings.get("enable_summary", True),
        enable_discard=settings.get("enable_discard", True),
        discard_turns=settings.get("discard_turns", 1),
        summary_prompt=settings.get("summary_prompt", ""),
        summary_provider=summary_provider,
        retention_method=settings.get("retention_method", "turns"),
        retain_turns=settings.get("retain_turns", 20),
        retain_percentage=settings.get("retain_percentage", 0.3),
    )

Then in compact:

from astrbot.core.agent.context.manager import ContextManager
from astrbot.core.agent.context.context_utils import build_context_config

# ...
config = build_context_config(self.context, settings, umo)
cm = ContextManager(config)

provider = self.context.get_using_provider(umo=umo)
max_context_tokens = (
    provider.provider_config.get("max_context_tokens", 0)
    if provider and getattr(provider, "provider_config", None)
    else 0
)
compressed = await cm.process(messages, max_context_tokens=max_context_tokens)

3. Keep _check_command_permission focused

You’ve already improved reuse between reset and compact; the remaining coupling is to message.set_result. If you want further decoupling without changing behavior, you can return an error message instead of mutating message in the helper:

async def _check_command_permission(... ) -> tuple[bool, str | None]:
    # ... compute required_perm ...
    if required_perm == "admin" and message.role != "admin":
        return False, (
            f"{command.capitalize()} command requires admin permission in {scene.name} scenario, "
            f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action."
        )
    return True, None

Usage:

ok, err = await self._check_command_permission("compact", message, is_group, is_unique_session)
if not ok:
    message.set_result(MessageEventResult().message(err))
    return

This keeps permission logic reusable while making the side effect (how the error is surfaced) explicit in the command handler.

All of these changes preserve behavior while making the command file less of a context‑management orchestrator and more of a thin coordinator over shared utilities.

from astrbot.core.agent.context.config import ContextConfig

summary_provider_id = settings.get("summary_provider_id", "")
summary_provider = (
self.context.get_provider_by_id(summary_provider_id)
if summary_provider_id
else None
)
if not summary_provider:
summary_provider = self.context.get_using_provider(umo=umo)

return ContextConfig(
enable_turn_limit=settings.get("enable_turn_limit", False),
max_turns=settings.get("max_turns", 50),
enable_token_guard=settings.get("enable_token_guard", True),
token_guard_threshold=settings.get("token_guard_threshold", 0.82),
enable_summary=settings.get("enable_summary", True),
enable_discard=settings.get("enable_discard", True),
discard_turns=settings.get("discard_turns", 1),
summary_prompt=settings.get("summary_prompt", ""),
summary_provider=summary_provider,
retention_method=settings.get("retention_method", "turns"),
retain_turns=settings.get("retain_turns", 20),
retain_percentage=settings.get("retain_percentage", 0.3),
)

def _history_to_messages(self, history: list[dict]):
from astrbot.core.agent.message import Message

return [
Message(
role=item.get("role", "user"),
content=item.get("content", ""),
tool_calls=item.get("tool_calls"),
tool_call_id=item.get("tool_call_id"),
)
for item in history
]

def _messages_to_history(self, messages):
from astrbot.core.agent.message import ToolCall

result: list[dict] = []
for msg in messages:
entry = {"role": msg.role}
if msg.content is None:
entry["content"] = None
elif isinstance(msg.content, (str, list, dict)):
entry["content"] = msg.content
else:
entry["content"] = str(msg.content)
Comment on lines +195 to +200

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): Preserve structured content types instead of blindly coercing to string.

In _messages_to_history, any msg.content that isn’t str or list is coerced to str. If providers or tools use richer structures (e.g., dicts with metadata), this will be flattened and may break consumers that rely on the original structure. Consider preserving JSON‑like types instead of coercing them:

elif isinstance(msg.content, (str, list, dict)):
    entry["content"] = msg.content
else:
    entry["content"] = str(msg.content)

or more generally, first try JSON serialization and only fall back to str when serialization fails.

Suggested change
if msg.content is None:
entry["content"] = None
elif isinstance(msg.content, (str, list)):
entry["content"] = msg.content
else:
entry["content"] = str(msg.content)
if msg.content is None:
entry["content"] = None
elif isinstance(msg.content, (str, list, dict)):
entry["content"] = msg.content
else:
entry["content"] = str(msg.content)

if msg.tool_calls is not None:
entry["tool_calls"] = [
tc.model_dump() if isinstance(tc, ToolCall) else tc
for tc in msg.tool_calls
]
if msg.tool_call_id is not None:
entry["tool_call_id"] = msg.tool_call_id
result.append(entry)
return result

async def reset(self, message: AstrMessageEvent) -> None:
"""重置 LLM 会话"""
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=message.unified_msg_origin)
is_unique_session = cfg["platform_settings"]["unique_session"]
is_group = bool(message.get_group_id())

if not await self._check_command_permission(
"reset", message, is_group, is_unique_session
):
return

agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
Expand Down Expand Up @@ -193,6 +265,127 @@ async def reset(self, message: AstrMessageEvent) -> None:

message.set_result(MessageEventResult().message(ret))

async def compact(self, message: AstrMessageEvent) -> 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.

issue (complexity): Consider extracting shared permission checks, context configuration building, and history/message conversion into reusable helpers so the new compact method focuses on high-level flow and avoids duplicated logic.

The new compact method is doing multiple distinct things and duplicates logic from reset, which increases complexity and maintenance cost. You can keep all functionality but reduce complexity via small extractions:

1. Shared permission + scene resolution helper

The permission check is almost identical to reset. Extract it and reuse:

# helpers.py (or near RstScene definition)
async def check_compact_permission(message: AstrMessageEvent, is_group: bool, is_unique_session: bool) -> Optional[str]:
    scene = RstScene.get_scene(is_group, is_unique_session)
    alter_cmd_cfg = await sp.get_async("global", "global", "alter_cmd", {})
    plugin_config = alter_cmd_cfg.get("astrbot", {})
    compact_cfg = plugin_config.get("compact", {})
    required_perm = compact_cfg.get(
        scene.key,
        "admin" if is_group and not is_unique_session else "member",
    )

    if required_perm == "admin" and message.role != "admin":
        return (
            f"Compact command requires admin permission in {scene.name} scenario, "
            f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action."
        )
    return None

Usage in compact:

is_group = bool(message.get_group_id())
is_unique_session = cfg["platform_settings"]["unique_session"]

err = await check_compact_permission(message, is_group, is_unique_session)
if err:
    message.set_result(MessageEventResult().message(err))
    return

This keeps the permission logic centralized and makes both reset and compact easier to adjust.

2. ContextConfig factory

Config construction is inlined and likely duplicates other places where ContextConfig is built. Extract a factory:

# context_config_factory.py
from astrbot.core.agent.context.config import ContextConfig

def build_context_config_from_settings(settings: dict, summary_provider) -> ContextConfig:
    return ContextConfig(
        enable_turn_limit=settings.get("enable_turn_limit", False),
        max_turns=settings.get("max_turns", 50),
        enable_token_guard=settings.get("enable_token_guard", True),
        token_guard_threshold=settings.get("token_guard_threshold", 0.82),
        enable_summary=settings.get("enable_summary", True),
        enable_discard=settings.get("enable_discard", True),
        discard_turns=settings.get("discard_turns", 1),
        summary_prompt=settings.get("summary_prompt", ""),
        summary_provider=summary_provider,
        retention_method=settings.get("retention_method", "turns"),
        retain_turns=settings.get("retain_turns", 20),
        retain_percentage=settings.get("retain_percentage", 0.3),
    )

Usage in compact:

summary_provider_id = settings.get("summary_provider_id", "")
summary_provider = (
    self.context.get_provider_by_id(summary_provider_id)
    if summary_provider_id
    else self.context.get_using_provider(umo=umo)
)

config = build_context_config_from_settings(settings, summary_provider)
cm = ContextManager(config)

This removes config noise from compact and aligns behavior with other agent/context initialization paths.

3. History ↔ Message conversion utilities

The manual loops for converting between dict and Message are verbose and type-specific. Extract tiny helpers:

# history_utils.py
import json
from typing import List
from astrbot.core.agent.message import Message  # adjust import as needed

def conversation_history_to_messages(history_raw) -> List[Message]:
    if isinstance(history_raw, str):
        history: list[dict] = json.loads(history_raw or "[]")
    else:
        history = history_raw or []

    return [
        Message(role=item.get("role", "user"), content=item.get("content", ""))
        for item in history
    ]

def messages_to_history_dict(messages: List[Message]) -> list[dict]:
    result: list[dict] = []
    for msg in messages:
        if isinstance(msg.content, str):
            content = msg.content
        else:
            content = str(msg.content) if msg.content else ""
        result.append({"role": msg.role, "content": content})
    return result

Usage in compact:

conv = await self.context.conversation_manager.get_conversation(umo, cid)
messages = conversation_history_to_messages(conv.history)
if not messages:
    message.set_result(
        MessageEventResult().message("ℹ️ Conversation is empty, nothing to compact."),
    )
    return

original_len = len(messages)

compressed = await cm.process(messages)
result = messages_to_history_dict(compressed)

await self.context.conversation_manager.update_conversation(umo, cid, result)

These extractions keep compact focused on the high-level flow (permission → context → process → save) and make each piece easier to test independently without changing the behavior.

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.

issue (complexity): Consider extracting context config construction, summary provider resolution, history/message conversion, and permission checking into reusable helpers to keep compact shorter and more maintainable.

You can reduce complexity here by extracting a few focused helpers, without changing behavior.

1. Extract ContextConfig construction

You’re manually wiring a lot of context settings; this is reusable and matches behavior in other agents/runners.

# somewhere reusable (e.g., a utils module or on self.context)
def build_context_config_from_settings(settings, summary_provider):
    from astrbot.core.agent.context.config import ContextConfig

    return ContextConfig(
        enable_turn_limit=settings.get("enable_turn_limit", False),
        max_turns=settings.get("max_turns", 50),
        enable_token_guard=settings.get("enable_token_guard", True),
        token_guard_threshold=settings.get("token_guard_threshold", 0.82),
        enable_summary=settings.get("enable_summary", True),
        enable_discard=settings.get("enable_discard", True),
        discard_turns=settings.get("discard_turns", 1),
        summary_prompt=settings.get("summary_prompt", ""),
        summary_provider=summary_provider,
        retention_method=settings.get("retention_method", "turns"),
        retain_turns=settings.get("retain_turns", 20),
        retain_percentage=settings.get("retain_percentage", 0.3),
    )

Usage in compact:

summary_provider = self._resolve_summary_provider(settings, umo)
config = build_context_config_from_settings(settings, summary_provider)
cm = ContextManager(config)

2. Extract summary provider resolution

This logic is repeated in other places (e.g. _get_compress_provider); a shared helper keeps behavior consistent.

def _resolve_summary_provider(self, settings, umo):
    summary_provider_id = settings.get("summary_provider_id", "")
    if summary_provider_id:
        provider = self.context.get_provider_by_id(summary_provider_id)
        if provider:
            return provider
    return self.context.get_using_provider(umo=umo)

Then:

summary_provider = self._resolve_summary_provider(settings, umo)

3. Extract history <-> Message conversion

This roundtrip is self-contained and can be reused/tested separately.

from astrbot.core.agent.message import Message, ToolCall

def _history_to_messages(self, history: list[dict]) -> list[Message]:
    return [
        Message(
            role=item.get("role", "user"),
            content=item.get("content", ""),
            tool_calls=item.get("tool_calls"),
            tool_call_id=item.get("tool_call_id"),
        )
        for item in history
    ]

def _messages_to_history(self, messages: list[Message]) -> list[dict]:
    result: list[dict] = []
    for msg in messages:
        entry = {"role": msg.role}
        if msg.content is None:
            entry["content"] = None
        elif isinstance(msg.content, (str, list)):
            entry["content"] = msg.content
        else:
            entry["content"] = str(msg.content)
        if msg.tool_calls is not None:
            entry["tool_calls"] = [
                tc.model_dump() if isinstance(tc, ToolCall) else tc
                for tc in msg.tool_calls
            ]
        if msg.tool_call_id is not None:
            entry["tool_call_id"] = msg.tool_call_id
        result.append(entry)
    return result

Usage in compact:

messages = self._history_to_messages(history)
compressed = await cm.process(messages, max_context_tokens=max_context_tokens)
result = self._messages_to_history(compressed)

4. Optional: permission check helper

To align with reset and make the permission logic reusable:

def _check_compact_permission(self, message: AstrMessageEvent, compact_cfg, scene):
    required_perm = compact_cfg.get(
        scene.key,
        "admin" if bool(message.get_group_id()) and not scene.is_unique_session else "member",
    )
    if required_perm == "admin" and message.role != "admin":
        message.set_result(
            MessageEventResult().message(
                f"Compact command requires admin permission in {scene.name} scenario, "
                f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
            ),
        )
        return False
    return True

Then in compact:

if not self._check_compact_permission(message, compact_cfg, scene):
    return

These extractions keep the new feature intact but make compact shorter, easier to read/test, and aligned with existing configuration/serialization behavior.

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.

issue (complexity): Consider extracting the history parsing and compaction logic into a dedicated helper so that the compact command only orchestrates permissions, conversation lookup, and user messaging.

You can reduce complexity by pulling the history/conversation compaction into a dedicated helper and leaving compact as orchestration + messaging only. This keeps all behavior but separates responsibilities and makes the command easier to read and test.

1. Extract conversation compaction into a helper

Move the JSON/history parsing, ContextConfig building, ContextManager invocation, and history re‑serialization into a separate class or function. The command then just handles permissions, basic conversation lookup, and user messages.

For example:

# conversation_compactor.py (new module)

from __future__ import annotations
from typing import Tuple, List, Dict
import json
from astrbot.core.agent.context.manager import ContextManager
from astrbot.core.agent.context.config import ContextConfig
from astrbot.core.agent.message import Message, ToolCall
from loguru import logger


class ConversationCompactor:
    def __init__(self, context):
        self.context = context

    def _build_context_config(self, settings: dict, umo: str) -> ContextConfig:
        summary_provider_id = settings.get("summary_provider_id", "")
        summary_provider = (
            self.context.get_provider_by_id(summary_provider_id)
            if summary_provider_id
            else None
        )
        if not summary_provider:
            summary_provider = self.context.get_using_provider(umo=umo)

        return ContextConfig(
            enable_turn_limit=settings.get("enable_turn_limit", False),
            max_turns=settings.get("max_turns", 50),
            enable_token_guard=settings.get("enable_token_guard", True),
            token_guard_threshold=settings.get("token_guard_threshold", 0.82),
            enable_summary=settings.get("enable_summary", True),
            enable_discard=settings.get("enable_discard", True),
            discard_turns=settings.get("discard_turns", 1),
            summary_prompt=settings.get("summary_prompt", ""),
            summary_provider=summary_provider,
            retention_method=settings.get("retention_method", "turns"),
            retain_turns=settings.get("retain_turns", 20),
            retain_percentage=settings.get("retain_percentage", 0.3),
        )

    def _history_to_messages(self, history: List[Dict]) -> List[Message]:
        return [
            Message(
                role=item.get("role", "user"),
                content=item.get("content", ""),
                tool_calls=item.get("tool_calls"),
                tool_call_id=item.get("tool_call_id"),
            )
            for item in history
        ]

    def _messages_to_history(self, messages) -> List[Dict]:
        result: List[Dict] = []
        for msg in messages:
            entry = {"role": msg.role}
            if msg.content is None:
                entry["content"] = None
            elif isinstance(msg.content, (str, list)):
                entry["content"] = msg.content
            else:
                entry["content"] = str(msg.content)
            if msg.tool_calls is not None:
                entry["tool_calls"] = [
                    tc.model_dump() if isinstance(tc, ToolCall) else tc
                    for tc in msg.tool_calls
                ]
            if msg.tool_call_id is not None:
                entry["tool_call_id"] = msg.tool_call_id
            result.append(entry)
        return result

    def _parse_history(self, raw_history) -> List[Dict] | None:
        raw_history = raw_history or "[]"
        try:
            parsed = json.loads(raw_history) if isinstance(raw_history, str) else raw_history
        except json.JSONDecodeError:
            return None
        if not isinstance(parsed, list) or any(not isinstance(i, dict) for i in parsed):
            return None
        return parsed

    async def compact(
        self,
        umo: str,
        settings: dict,
        conv,
    ) -> Tuple[bool, str | None, List[Dict] | None]:
        """
        Returns (ok, error_message, new_history).
        error_message is user-facing if not ok.
        """
        history = self._parse_history(conv.history)
        if history is None:
            return False, "❌ Conversation history is corrupted or has an unexpected structure and cannot be compacted.", None
        if not history:
            return False, "ℹ️ Conversation is empty, nothing to compact.", None

        original_len = len(history)
        messages = self._history_to_messages(history)

        config = self._build_context_config(settings, umo)
        cm = ContextManager(config)

        provider = self.context.get_using_provider(umo=umo)
        max_context_tokens = (
            provider.provider_config.get("max_context_tokens", 0)
            if provider and getattr(provider, "provider_config", None)
            else 0
        )

        try:
            compressed = await cm.process(messages, max_context_tokens=max_context_tokens)
        except Exception:
            logger.error("Context compression failed.", exc_info=True)
            return False, "❌ Context compression failed. See logs for details.", None

        result = self._messages_to_history(compressed)
        removed = original_len - len(result)
        success_msg = (
            f"✅ Context compressed: {removed} messages removed "
            f"({original_len}{len(result)})."
        )
        return True, success_msg, result

Then your command becomes much simpler and focused:

# in the command class

from .conversation_compactor import ConversationCompactor

async def compact(self, message: AstrMessageEvent) -> None:
    """手动触发上下文的处置与压缩"""
    umo = message.unified_msg_origin
    cfg = self.context.get_config(umo=umo)
    settings = cfg["provider_settings"]
    is_unique_session = cfg["platform_settings"]["unique_session"]
    is_group = bool(message.get_group_id())

    if not await self._check_command_permission("compact", message, is_group, is_unique_session):
        return

    agent_runner_type = settings.get("agent_runner_type", "")
    if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
        message.set_result(
            MessageEventResult().message(
                "ℹ️ Compact is not supported for third-party agent runners "
                f"({agent_runner_type}). Use /reset instead."
            ),
        )
        return

    cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
    if not cid:
        message.set_result(
            MessageEventResult().message(
                "😕 You are not in a conversation. Use /new to create one.",
            ),
        )
        return

    conv = await self.context.conversation_manager.get_conversation(umo, cid)
    if not conv:
        message.set_result(
            MessageEventResult().message("😕 Conversation not found."),
        )
        return

    compactor = ConversationCompactor(self.context)
    ok, user_msg, new_history = await compactor.compact(umo, settings, conv)

    if not ok:
        message.set_result(MessageEventResult().message(user_msg))
        return

    await self.context.conversation_manager.update_conversation(umo, cid, new_history)
    message.set_result(MessageEventResult().message(user_msg))

This keeps all behavior intact but:

  • compact is now mostly orchestration and user messaging.
  • History parsing, ContextConfig wiring, ContextManager usage, and message/history conversions are centralized and reusable.
  • The command module no longer needs _build_context_config, _history_to_messages, or _messages_to_history; these live in a context/history–focused helper closer to the domain logic.

"""手动触发上下文的处置与压缩"""
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=umo)
settings = cfg["provider_settings"]
is_unique_session = cfg["platform_settings"]["unique_session"]
is_group = bool(message.get_group_id())

# 权限检查(与 reset 相同模式)
if not await self._check_command_permission(
"compact", message, is_group, is_unique_session
):
return

# 第三方 agent runner 跳过(不走 ContextManager)
agent_runner_type = settings.get("agent_runner_type", "")
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
message.set_result(
MessageEventResult().message(
"ℹ️ Compact is not supported for third-party agent runners "
f"({agent_runner_type}). Use /reset instead."
),
)
return

cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
if not cid:
message.set_result(
MessageEventResult().message(
"😕 You are not in a conversation. Use /new to create one.",
),
)
return

conv = await self.context.conversation_manager.get_conversation(umo, cid)
if not conv:
message.set_result(
MessageEventResult().message("😕 Conversation not found."),
)
return

# 解析历史记录
import json

raw_history = conv.history or "[]"
try:
parsed_history = (
json.loads(raw_history) if isinstance(raw_history, str) else raw_history
)
except json.JSONDecodeError:
message.set_result(
MessageEventResult().message(
"❌ Conversation history is corrupted and cannot be compacted."
),
)
return

if not isinstance(parsed_history, list) or any(
not isinstance(item, dict) for item in parsed_history
):
message.set_result(
MessageEventResult().message(
"⚠️ Conversation history has an unexpected structure and cannot be compacted."
),
)
return

history: list[dict] = parsed_history
if not history:
Comment on lines +309 to +336

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 compact command silently treats non-string conv.history as already-parsed JSON, which may be fragile.

raw_history is annotated as str, but history is set to json.loads(raw_history) only when it’s a string; otherwise history = raw_history. This effectively treats any non-string conv.history as already-parsed history and a valid list[dict]. If conv.history ever contains another shape (e.g., a metadata dict), history will have an unexpected type and later code will still try to iterate it as a list of message dicts.

Consider validating conv.history’s structure (e.g., checking it’s a list of dicts) and raising or logging a clear error when it isn’t, rather than relying solely on the isinstance(raw_history, str) check.

Suggested change
# 解析历史记录
import json
raw_history: str = conv.history or "[]"
history: list[dict] = (
json.loads(raw_history) if isinstance(raw_history, str) else raw_history
)
if not history:
# 解析历史记录
import json
raw_history = conv.history or "[]"
# 将字符串形式的历史记录解析为 JSON;非字符串则直接使用
try:
if isinstance(raw_history, str):
parsed_history = json.loads(raw_history)
else:
parsed_history = raw_history
except Exception:
message.set_result(
MessageEventResult().message("⚠️ Conversation history is invalid JSON and cannot be compacted."),
)
return
# 验证历史记录结构:必须是由 dict 组成的列表
if not isinstance(parsed_history, list) or any(
not isinstance(item, dict) for item in parsed_history
):
message.set_result(
MessageEventResult().message("⚠️ Conversation history has an unexpected structure and cannot be compacted."),
)
return
history: list[dict] = parsed_history
if not history:

message.set_result(
MessageEventResult().message(
"ℹ️ Conversation is empty, nothing to compact."
),
)
return

original_len = len(history)

# 将 dict 转换为 Message 对象(保留完整元数据)
messages = self._history_to_messages(history)

# 构建 ContextConfig
from astrbot.core.agent.context.manager import ContextManager

config = self._build_context_config(settings, umo)
cm = ContextManager(config)

# 获取 provider 的 max_context_tokens(使 token guard 触发器正常工作)
provider = self.context.get_using_provider(umo=umo)
max_context_tokens = (
provider.provider_config.get("max_context_tokens", 0)
if provider and getattr(provider, "provider_config", None)
else 0
)

try:
compressed = await cm.process(
messages, max_context_tokens=max_context_tokens
)
except Exception:
logger.error("Context compression failed.", exc_info=True)
message.set_result(
MessageEventResult().message(
"❌ Context compression failed. See logs for details."
),
)
return

# 将 Message 对象转回 dict(保留完整元数据)
result = self._messages_to_history(compressed)

# 保存
await self.context.conversation_manager.update_conversation(umo, cid, result)

removed = original_len - len(result)
ret = (
f"✅ Context compressed: {removed} messages removed "
f"({original_len} → {len(result)})."
)
message.set_result(MessageEventResult().message(ret))

async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话正在运行的 Agent"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
Expand Down
5 changes: 5 additions & 0 deletions astrbot/builtin_stars/builtin_commands/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ async def reset(self, message: AstrMessageEvent) -> None:
"""重置 LLM 会话"""
await self.conversation_c.reset(message)

@filter.command("compact")
async def compact(self, message: AstrMessageEvent) -> None:
"""手动触发上下文的处置与压缩"""
await self.conversation_c.compact(message)

@filter.command("stop")
async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话中正在运行的 Agent"""
Expand Down
70 changes: 51 additions & 19 deletions astrbot/core/agent/context/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal

from .compressor import ContextCompressor
from .token_counter import TokenCounter
Expand All @@ -10,25 +10,57 @@

@dataclass
class ContextConfig:
"""Context configuration class."""

max_context_tokens: int = 0
"""Maximum number of context tokens. <= 0 means no limit."""
enforce_max_turns: int = -1 # -1 means no limit
"""Maximum number of conversation turns to keep. -1 means no limit. Executed before compression."""
truncate_turns: int = 1
"""Number of conversation turns to discard at once when truncation is triggered.
Two processes will use this value:

1. Enforce max turns truncation.
2. Truncation by turns compression strategy.
"""Context configuration class — orthogonal trigger/disposal model.

Trigger dimension (WHEN) — checked independently:
enable_turn_limit / max_turns
enable_token_guard / token_guard_threshold

Disposal dimension (WHAT) — executed in order when any trigger fires:
1. summary (if enabled and provider available)
2. discard (fallback if summary fails or is disabled)

Retention constraint — lower bound on how many turns discard may remove.
Double-check halving — unconditional truncation when still over threshold
after disposal (only when enable_token_guard is True).
"""
llm_compress_instruction: str | None = None
"""Instruction prompt for LLM-based compression."""
llm_compress_keep_recent_ratio: float = 0.15
"""Percent of current context tokens to keep as exact recent context during LLM-based compression."""
llm_compress_provider: "Provider | None" = None
"""LLM provider used for compression tasks. If None, truncation strategy is used."""

# -- Trigger dimension --
enable_turn_limit: bool = False
"""Enable turn-based trigger. When True, exceeding max_turns triggers disposal."""
max_turns: int = 50
"""Maximum conversation turns before disposal is triggered. Must be >= 2."""
enable_token_guard: bool = True
"""Enable token-count trigger. When True, exceeding token_guard_threshold
triggers disposal."""
token_guard_threshold: float = 0.82
"""Token usage ratio (current_tokens / max_tokens) that triggers disposal.
Range 0.5–0.99."""

# -- Disposal dimension (compression behavior) --
enable_summary: bool = True
"""Enable LLM-based summary compression. Takes priority over discard when
both are enabled."""
enable_discard: bool = True
"""Enable discard of oldest turns. Used as fallback if summary fails or
is disabled."""
discard_turns: int = 1
"""Number of turns to discard at once. Must be >= 1."""
summary_prompt: str = ""
"""Custom instruction prompt for summary generation. Empty = use built-in."""
summary_provider: "Provider | None" = None
"""Resolved LLM provider for summary generation. None = no summary available."""

# -- Retention (lower bound) --
retention_method: Literal["turns", "percentage", "null"] = "turns"
"""Retention method: 'turns', 'percentage', or 'null'."""
retain_turns: int = 20
"""Minimum turns to keep when retention_method is 'turns'. Must be >= 1."""
retain_percentage: float = 0.3
"""Minimum ratio of turns to keep when retention_method is 'percentage'.
Range 0.1–0.9."""

# -- Customisation --
custom_token_counter: TokenCounter | None = None
"""Custom token counting method. If None, the default method is used."""
custom_compressor: ContextCompressor | None = None
Expand Down
Loading
Loading