Skip to content

Commit 7cc6d42

Browse files
committed
feat: make context compression threshold configurable
1 parent cc0ec5e commit 7cc6d42

15 files changed

Lines changed: 670 additions & 20 deletions

File tree

astrbot/core/agent/context/config.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from dataclasses import dataclass
2-
from typing import TYPE_CHECKING
2+
from typing import TYPE_CHECKING, Literal, TypedDict
3+
4+
from astrbot.core.utils.config_number import (
5+
coerce_float_config,
6+
coerce_int_config,
7+
)
38

49
from .compressor import ContextCompressor
510
from .token_counter import TokenCounter
@@ -8,12 +13,101 @@
813
from astrbot.core.provider.provider import Provider
914

1015

16+
DEFAULT_FALLBACK_MAX_CONTEXT_TOKENS = 128_000
17+
18+
19+
CompressionThresholdMode = Literal["percentage", "output_reserve", "min", "max"]
20+
21+
22+
class CompressionThresholdResult(TypedDict):
23+
"""Resolved compression threshold shared by runtime and dashboard."""
24+
25+
mode: CompressionThresholdMode
26+
percentage: float
27+
output_tokens: int
28+
output_threshold: float | None
29+
effective_threshold: float
30+
fallback_reason: str | None
31+
32+
33+
def resolve_compression_threshold(
34+
mode: str,
35+
percentage: float,
36+
max_context_tokens: int,
37+
max_output_tokens: int,
38+
) -> CompressionThresholdResult:
39+
"""Resolve the effective context compression threshold.
40+
41+
Args:
42+
mode: Threshold strategy: percentage, output_reserve, min, or max.
43+
percentage: User-defined context usage threshold.
44+
max_context_tokens: Effective model context window.
45+
max_output_tokens: Effective maximum output budget, or zero when unknown.
46+
47+
Returns:
48+
The normalized inputs and effective threshold used by the compressor.
49+
"""
50+
normalized_mode: CompressionThresholdMode = (
51+
mode if mode in {"percentage", "output_reserve", "min", "max"} else "percentage"
52+
)
53+
normalized_percentage = coerce_float_config(
54+
percentage,
55+
default=0.82,
56+
min_value=0.01,
57+
max_value=1.0,
58+
field_name="compression_threshold_percentage",
59+
)
60+
normalized_context_tokens = coerce_int_config(
61+
max_context_tokens,
62+
default=0,
63+
min_value=0,
64+
field_name="max_context_tokens",
65+
)
66+
normalized_output_tokens = coerce_int_config(
67+
max_output_tokens,
68+
default=0,
69+
min_value=0,
70+
field_name="compression_max_output_tokens",
71+
)
72+
output_threshold = None
73+
fallback_reason = None
74+
75+
if normalized_context_tokens > 0 and normalized_output_tokens > 0:
76+
output_threshold = max(
77+
0.0,
78+
1.0 - normalized_output_tokens / normalized_context_tokens,
79+
)
80+
81+
if normalized_mode == "percentage":
82+
effective_threshold = normalized_percentage
83+
elif output_threshold is None:
84+
effective_threshold = normalized_percentage
85+
fallback_reason = "max_output_tokens_unavailable"
86+
elif normalized_mode == "output_reserve":
87+
effective_threshold = output_threshold
88+
elif normalized_mode == "min":
89+
effective_threshold = min(normalized_percentage, output_threshold)
90+
else:
91+
effective_threshold = max(normalized_percentage, output_threshold)
92+
93+
return {
94+
"mode": normalized_mode,
95+
"percentage": normalized_percentage,
96+
"output_tokens": normalized_output_tokens,
97+
"output_threshold": output_threshold,
98+
"effective_threshold": effective_threshold,
99+
"fallback_reason": fallback_reason,
100+
}
101+
102+
11103
@dataclass
12104
class ContextConfig:
13105
"""Context configuration class."""
14106

15107
max_context_tokens: int = 0
16108
"""Maximum number of context tokens. <= 0 means no limit."""
109+
compression_threshold: float = 0.82
110+
"""Effective context usage ratio that triggers compression."""
17111
enforce_max_turns: int = -1 # -1 means no limit
18112
"""Maximum number of conversation turns to keep. -1 means no limit. Executed before compression."""
19113
truncate_turns: int = 1

astrbot/core/agent/context/manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ def __init__(
3636
keep_recent_ratio=config.llm_compress_keep_recent_ratio,
3737
instruction_text=config.llm_compress_instruction,
3838
token_counter=self.token_counter,
39+
compression_threshold=config.compression_threshold,
3940
)
4041
else:
4142
self.compressor = TruncateByTurnsCompressor(
42-
truncate_turns=config.truncate_turns
43+
truncate_turns=config.truncate_turns,
44+
compression_threshold=config.compression_threshold,
4345
)
4446

4547
async def process(

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
sanitize_contexts_by_modalities,
4747
)
4848
from astrbot.core.provider.provider import Provider
49+
from astrbot.core.utils.config_number import coerce_int_config
4950

5051
from ..context.compressor import ContextCompressor
5152
from ..context.config import ContextConfig
@@ -214,6 +215,7 @@ async def reset(
214215
# enforce max turns, will discard older turns when exceeded BEFORE compression
215216
# -1 means no limit
216217
enforce_max_turns: int = -1,
218+
compression_threshold: float = 0.82,
217219
# llm compressor
218220
llm_compress_instruction: str | None = None,
219221
llm_compress_keep_recent_ratio: float = 0.15,
@@ -233,6 +235,7 @@ async def reset(
233235
self.req = request
234236
self.streaming = streaming
235237
self.enforce_max_turns = enforce_max_turns
238+
self.compression_threshold = compression_threshold
236239
self.llm_compress_instruction = llm_compress_instruction
237240
self.llm_compress_keep_recent_ratio = llm_compress_keep_recent_ratio
238241
self.llm_compress_provider = llm_compress_provider
@@ -245,7 +248,14 @@ async def reset(
245248
self._tool_result_token_counter = EstimateTokenCounter()
246249
self.request_context_manager_config = ContextConfig(
247250
# <=0 disables token-based guarding.
248-
max_context_tokens=provider.provider_config.get("max_context_tokens", 0),
251+
max_context_tokens=coerce_int_config(
252+
provider.provider_config.get("max_context_tokens", 0),
253+
default=0,
254+
min_value=0,
255+
field_name="max_context_tokens",
256+
source="provider config",
257+
),
258+
compression_threshold=self.compression_threshold,
249259
# Enforce max turns before token-based guarding.
250260
enforce_max_turns=self.enforce_max_turns,
251261
truncate_turns=self.truncate_turns,

astrbot/core/astr_main_agent.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
from pathlib import Path
1313

1414
from astrbot.core import logger
15+
from astrbot.core.agent.context.config import (
16+
DEFAULT_FALLBACK_MAX_CONTEXT_TOKENS,
17+
resolve_compression_threshold,
18+
)
1519
from astrbot.core.agent.handoff import HandoffTool
1620
from astrbot.core.agent.mcp_client import MCPTool
1721
from astrbot.core.agent.message import TextPart
@@ -97,6 +101,7 @@
97101
get_astrbot_system_tmp_path,
98102
get_astrbot_workspaces_path,
99103
)
104+
from astrbot.core.utils.config_number import coerce_int_config
100105
from astrbot.core.utils.file_extract import extract_file_moonshotai
101106
from astrbot.core.utils.llm_metadata import LLM_METADATAS
102107
from astrbot.core.utils.media_utils import (
@@ -178,6 +183,12 @@ class MainAgentBuildConfig:
178183
"""The API key for Moonshot AI file extraction provider."""
179184
context_limit_reached_strategy: str = "truncate_by_turns"
180185
"""The strategy to handle context length limit reached."""
186+
compression_threshold_mode: str = "percentage"
187+
"""How percentage and output-reserve compression thresholds are combined."""
188+
compression_threshold_percentage: float = 0.82
189+
"""User-defined context usage threshold."""
190+
compression_max_output_tokens: int = 0
191+
"""Maximum output budget override. Zero uses model metadata."""
181192
llm_compress_instruction: str = ""
182193
"""The instruction for compression in llm_compress strategy."""
183194
llm_compress_keep_recent_ratio: float = 0.15
@@ -189,7 +200,7 @@ class MainAgentBuildConfig:
189200
This enforce max turns before compression"""
190201
dequeue_context_length: int = 10
191202
"""The number of oldest turns to remove when context length limit is reached."""
192-
fallback_max_context_tokens: int = 128000
203+
fallback_max_context_tokens: int = DEFAULT_FALLBACK_MAX_CONTEXT_TOKENS
193204
"""Fallback max context tokens. When max_context_tokens is 0 and the model is not in LLM_METADATAS, use this value."""
194205
llm_safety_mode: bool = True
195206
"""This will inject healthy and safe system prompt into the main agent,
@@ -1593,17 +1604,59 @@ async def build_main_agent(
15931604
req.model = None
15941605
fallback_providers = [p for p in fallback_providers if p is not provider]
15951606

1596-
if provider.provider_config.get("max_context_tokens", 0) <= 0:
1597-
model = provider.get_model()
1598-
if model_info := LLM_METADATAS.get(model):
1599-
provider.provider_config["max_context_tokens"] = model_info["limit"][
1600-
"context"
1601-
]
1602-
else:
1603-
# fallback: default to configured fallback value
1604-
provider.provider_config["max_context_tokens"] = (
1605-
config.fallback_max_context_tokens
1606-
)
1607+
configured_context_tokens = coerce_int_config(
1608+
provider.provider_config.get("max_context_tokens", 0),
1609+
default=0,
1610+
min_value=0,
1611+
field_name="max_context_tokens",
1612+
source="provider config",
1613+
)
1614+
model_info = LLM_METADATAS.get(provider.get_model())
1615+
metadata_limit = (model_info.get("limit") or {}) if model_info else {}
1616+
metadata_context_tokens = coerce_int_config(
1617+
metadata_limit.get("context", 0),
1618+
default=0,
1619+
min_value=0,
1620+
field_name="model context limit",
1621+
source="model metadata",
1622+
)
1623+
fallback_context_tokens = coerce_int_config(
1624+
config.fallback_max_context_tokens,
1625+
default=DEFAULT_FALLBACK_MAX_CONTEXT_TOKENS,
1626+
min_value=1,
1627+
field_name="fallback_max_context_tokens",
1628+
)
1629+
provider.provider_config["max_context_tokens"] = (
1630+
configured_context_tokens or metadata_context_tokens or fallback_context_tokens
1631+
)
1632+
1633+
metadata_output_tokens = coerce_int_config(
1634+
metadata_limit.get("output", 0),
1635+
default=0,
1636+
min_value=0,
1637+
field_name="model output limit",
1638+
source="model metadata",
1639+
)
1640+
configured_output_tokens = coerce_int_config(
1641+
config.compression_max_output_tokens,
1642+
default=0,
1643+
min_value=0,
1644+
field_name="compression_max_output_tokens",
1645+
)
1646+
threshold_result = resolve_compression_threshold(
1647+
mode=config.compression_threshold_mode,
1648+
percentage=config.compression_threshold_percentage,
1649+
max_context_tokens=provider.provider_config["max_context_tokens"],
1650+
max_output_tokens=configured_output_tokens or metadata_output_tokens,
1651+
)
1652+
if threshold_result["fallback_reason"]:
1653+
logger.warning(
1654+
"Compression threshold mode %s requires max output tokens, but no "
1655+
"override or model metadata is available for %s; using %.0f%%.",
1656+
threshold_result["mode"],
1657+
provider.get_model(),
1658+
threshold_result["effective_threshold"] * 100,
1659+
)
16071660

16081661
if event.get_platform_name() == "webchat":
16091662
asyncio.create_task(_handle_webchat(event, req, provider))
@@ -1645,6 +1698,7 @@ async def build_main_agent(
16451698
tool_executor=FunctionToolExecutor(),
16461699
agent_hooks=MAIN_AGENT_HOOKS,
16471700
streaming=config.streaming_response,
1701+
compression_threshold=threshold_result["effective_threshold"],
16481702
llm_compress_instruction=config.llm_compress_instruction,
16491703
llm_compress_keep_recent_ratio=config.llm_compress_keep_recent_ratio,
16501704
llm_compress_provider=_get_compress_provider(config, plugin_context, event),

astrbot/core/config/default.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@
125125
"persona_pool": ["*"],
126126
"prompt_prefix": "{{prompt}}",
127127
"context_limit_reached_strategy": "llm_compress", # or truncate_by_turns
128+
"compression_threshold_mode": "percentage",
129+
"compression_threshold_percentage": 0.82,
130+
"compression_max_output_tokens": 0,
128131
"llm_compress_instruction": (
129132
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
130133
"The primary goal of this summary is to enable seamless continuation of the work that follows.\n"
@@ -3592,6 +3595,38 @@
35923595
"provider_settings.agent_runner_type": "local",
35933596
},
35943597
},
3598+
"provider_settings.compression_threshold_mode": {
3599+
"description": "上下文压缩阈值模式",
3600+
"type": "string",
3601+
"options": ["percentage", "output_reserve", "min", "max"],
3602+
"labels": [
3603+
"仅按百分比",
3604+
"上下文窗口减去最大输出",
3605+
"两者取较小值(更早压缩)",
3606+
"两者取较大值(更晚压缩)",
3607+
],
3608+
"hint": "输出上限未知时,依赖输出预留的模式会自动退化为百分比阈值。",
3609+
"condition": {
3610+
"provider_settings.agent_runner_type": "local",
3611+
},
3612+
},
3613+
"provider_settings.compression_threshold_percentage": {
3614+
"description": "上下文压缩百分比阈值",
3615+
"type": "float",
3616+
"slider": {"min": 0.01, "max": 1, "step": 0.01},
3617+
"hint": "0.82 表示上下文使用率超过 82% 时触发压缩。",
3618+
"condition": {
3619+
"provider_settings.agent_runner_type": "local",
3620+
},
3621+
},
3622+
"provider_settings.compression_max_output_tokens": {
3623+
"description": "压缩策略使用的最大输出 Token",
3624+
"type": "int",
3625+
"hint": "0 表示自动读取当前模型元数据;元数据也不可用时退化为百分比阈值。大于 0 时覆盖模型元数据。",
3626+
"condition": {
3627+
"provider_settings.agent_runner_type": "local",
3628+
},
3629+
},
35953630
"provider_settings.context_limit_reached_strategy": {
35963631
"description": "历史超限或上下文接近上限时的处理方式",
35973632
"type": "string",

0 commit comments

Comments
 (0)