Skip to content

Commit c239139

Browse files
author
Patori Patch Manager
committed
backup: before context optimization - 2026-03-09 16:15
1 parent 0582725 commit c239139

7 files changed

Lines changed: 1251 additions & 19 deletions

File tree

astrbot/core/agent/context/token_counter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def count_tokens(
5959
return total
6060

6161
def _estimate_tokens(self, text: str) -> int:
62+
# More accurate estimation:
63+
# Chinese chars: ~1 char per token (tiktoken average)
64+
# ASCII/other: ~4 chars per token
6265
chinese_count = len([c for c in text if "\u4e00" <= c <= "\u9fff"])
6366
other_count = len(text) - chinese_count
64-
return int(chinese_count * 0.6 + other_count * 0.3)
67+
return int(chinese_count * 1.0 + other_count * 0.25)

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ async def reset(
158158
self._aborted = False
159159
self._pending_follow_ups: list[FollowUpTicket] = []
160160
self._follow_up_seq = 0
161+
self._unknown_tool_consecutive_count = 0 # Track consecutive unknown tool calls
161162

162163
# These two are used for tool schema mode handling
163164
# We now have two modes:
@@ -246,6 +247,24 @@ async def _iter_llm_responses_with_fallback(
246247
yield resp
247248
continue
248249

250+
if resp.role == "err_retry":
251+
# Empty/unparseable response from model, retry same provider once
252+
logger.warning(
253+
"Chat Model %s returned empty/unparseable completion, retrying once...",
254+
candidate_id,
255+
)
256+
try:
257+
async for retry_resp in self._iter_llm_responses(include_model=idx == 0):
258+
if retry_resp.is_chunk:
259+
yield retry_resp
260+
continue
261+
yield retry_resp
262+
return
263+
except Exception as retry_exc:
264+
logger.warning("Retry also failed: %s", retry_exc)
265+
last_exception = retry_exc
266+
break
267+
249268
if (
250269
resp.role == "err"
251270
and not has_stream_output
@@ -265,6 +284,53 @@ async def _iter_llm_responses_with_fallback(
265284
return
266285
except Exception as exc: # noqa: BLE001
267286
last_exception = exc
287+
# Auto-compress context when model_max_prompt_tokens_exceeded
288+
_exc_str = str(exc).lower()
289+
if (
290+
"model_max_prompt_tokens_exceeded" in _exc_str
291+
or "prompt token count" in _exc_str
292+
or "tokens_exceeded" in _exc_str
293+
or "context_length_exceeded" in _exc_str
294+
or ("token" in _exc_str and "exceed" in _exc_str)
295+
):
296+
logger.warning(
297+
"Chat Model %s: token limit exceeded, forcing context compression and retrying...",
298+
candidate_id,
299+
)
300+
try:
301+
_before = len(self.run_context.messages)
302+
# Force halving truncation regardless of threshold
303+
from astrbot.core.agent.context.truncator import ContextTruncator
304+
_truncator = ContextTruncator()
305+
self.run_context.messages = _truncator.truncate_by_halving(
306+
self.run_context.messages
307+
)
308+
_after = len(self.run_context.messages)
309+
logger.info(
310+
"Forced context truncation: %d -> %d messages, retrying same provider.",
311+
_before, _after,
312+
)
313+
# Retry same candidate once after compression
314+
try:
315+
async for resp in self._iter_llm_responses(include_model=idx == 0):
316+
if resp.is_chunk:
317+
has_stream_output = True
318+
yield resp
319+
continue
320+
yield resp
321+
return
322+
if has_stream_output:
323+
return
324+
except Exception as retry_exc:
325+
last_exception = retry_exc
326+
logger.warning(
327+
"Chat Model %s retry after compression also failed: %s",
328+
candidate_id,
329+
retry_exc,
330+
)
331+
except Exception as compress_exc:
332+
logger.error("Failed to compress context: %s", compress_exc)
333+
continue
268334
logger.warning(
269335
"Chat Model %s request error: %s",
270336
candidate_id,
@@ -332,10 +398,11 @@ def _consume_follow_up_notice(self) -> str:
332398
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
333399
)
334400
return (
335-
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
336-
"was in progress. Prioritize these follow-up instructions in your next "
337-
"actions. In your very next action, briefly acknowledge to the user "
338-
"that their follow-up message(s) were received before continuing.\n"
401+
"\n\n[FOLLOW-UP] The user sent additional message(s) while you were working. "
402+
"Treat these as supplementary instructions for the current task — DO NOT stop "
403+
"or restart the current operation. Instead, seamlessly incorporate them into "
404+
"your ongoing work. Continue the task flow without interrupting it. "
405+
"Do NOT acknowledge receipt explicitly; just act on them naturally.\n"
339406
f"{follow_up_lines}"
340407
)
341408

@@ -532,6 +599,25 @@ async def step(self):
532599
if self.tool_schema_mode == "skills_like":
533600
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
534601

602+
# Detect sentinel set by openai_source when LLM called an unknown tool.
603+
# We still run _handle_function_tools so tool-result error messages are
604+
# injected into the context, but afterwards we force the agent to DONE
605+
# only if this happens consecutively (to avoid false positives from param errors).
606+
_is_unknown_tool_call = (
607+
llm_resp.completion_text == "__UNKNOWN_TOOL_STOP__"
608+
)
609+
if _is_unknown_tool_call:
610+
self._unknown_tool_consecutive_count += 1
611+
llm_resp.completion_text = "" # clear sentinel before passing to handler
612+
else:
613+
self._unknown_tool_consecutive_count = 0
614+
# Only force DONE after 2+ consecutive unknown tool calls
615+
_UNKNOWN_TOOL_MAX_RETRIES = 2
616+
_force_done_after_tool_handling = (
617+
_is_unknown_tool_call
618+
and self._unknown_tool_consecutive_count >= _UNKNOWN_TOOL_MAX_RETRIES
619+
)
620+
535621
tool_call_result_blocks = []
536622
cached_images = [] # Collect cached images for LLM visibility
537623
async for result in self._handle_function_tools(self.req, llm_resp):
@@ -618,6 +704,23 @@ async def step(self):
618704

619705
self.req.append_tool_calls_result(tool_calls_result)
620706

707+
# If flagged as unknown-tool, force DONE now so we don't loop back to LLM.
708+
if _force_done_after_tool_handling:
709+
logger.warning(
710+
f"Unknown tool call detected {self._unknown_tool_consecutive_count} consecutive time(s); "
711+
"forcing agent DONE to prevent infinite retry loop."
712+
)
713+
self.final_llm_resp = llm_resp
714+
self._transition_state(AgentState.DONE)
715+
self.stats.end_time = time.time()
716+
# Preserve context: on_agent_done hook should still be called
717+
try:
718+
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
719+
except Exception as e:
720+
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
721+
self._resolve_unconsumed_follow_ups()
722+
return
723+
621724
async def step_until_done(
622725
self, max_step: int
623726
) -> T.AsyncGenerator[AgentResponse, None]:
@@ -706,7 +809,8 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
706809
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
707810
_append_tool_call_result(
708811
func_tool_id,
709-
f"error: Tool {func_tool_name} not found.",
812+
f"[SYSTEM ERROR] Tool '{func_tool_name}' is not available or does not exist. "
813+
f"Do NOT retry calling this tool. Please stop using tools and respond directly to the user based on information already gathered.",
710814
)
711815
continue
712816

@@ -736,7 +840,36 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
736840
)
737841
else:
738842
# 如果没有 handler(如 MCP 工具),使用所有参数
739-
valid_params = func_tool_args
843+
# 但如果工具有 call() 方法(is_override_call),尝试从 call() 签名过滤参数
844+
import inspect as _inspect
845+
if hasattr(func_tool, 'call'):
846+
try:
847+
_sig = _inspect.signature(func_tool.call)
848+
_params = _sig.parameters
849+
# Check if the method accepts **kwargs (VAR_KEYWORD)
850+
_has_var_keyword = any(
851+
p.kind == _inspect.Parameter.VAR_KEYWORD
852+
for p in _params.values()
853+
)
854+
if _has_var_keyword:
855+
# **kwargs accepts anything, pass all args through
856+
valid_params = func_tool_args
857+
else:
858+
# func_tool.call is a bound method: signature is (context, arg1, arg2, ...)
859+
# skip 'context' (index 0), real args start at index 1
860+
_param_names = list(_params.keys())
861+
_expected = set(_param_names[1:]) if len(_param_names) > 1 else set()
862+
if _expected:
863+
valid_params = {k: v for k, v in func_tool_args.items() if k in _expected}
864+
_ignored = set(func_tool_args.keys()) - set(valid_params.keys())
865+
if _ignored:
866+
logger.warning(f"工具 {func_tool_name} (call方式) 忽略非期望参数: {_ignored}")
867+
else:
868+
valid_params = func_tool_args
869+
except Exception:
870+
valid_params = func_tool_args
871+
else:
872+
valid_params = func_tool_args
740873

741874
try:
742875
await self.agent_hooks.on_tool_start(

astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,32 @@ async def process(
183183

184184
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
185185
logger.debug("acquired session lock for llm request")
186+
187+
# Re-attempt follow-up capture after acquiring the lock.
188+
# This handles the race condition where a follow-up message arrives
189+
# *before* the active runner is registered (i.e., during build_main_agent).
190+
# After lock acquisition the previous runner is guaranteed to be done,
191+
# so if an active runner is present now it must be a concurrently-registered
192+
# one on a *different* coroutine, which means we should still try to attach.
193+
if follow_up_capture is None:
194+
follow_up_capture_retry = try_capture_follow_up(event)
195+
if follow_up_capture_retry is not None:
196+
logger.info(
197+
"Late follow-up capture after lock, umo=%s",
198+
event.unified_msg_origin,
199+
)
200+
follow_up_capture = follow_up_capture_retry
201+
(
202+
follow_up_consumed_marked,
203+
follow_up_activated,
204+
) = await prepare_follow_up_capture(follow_up_capture)
205+
if follow_up_consumed_marked:
206+
logger.info(
207+
"Late follow-up ticket already consumed, stopping. umo=%s",
208+
event.unified_msg_origin,
209+
)
210+
return
211+
186212
agent_runner: AgentRunner | None = None
187213
runner_registered = False
188214
try:
@@ -368,7 +394,27 @@ async def process(
368394
unregister_active_runner(event.unified_msg_origin, agent_runner)
369395

370396
except Exception as e:
371-
logger.error(f"Error occurred while processing agent: {e}")
397+
logger.error(f"Error occurred while processing agent: {e}", exc_info=True)
398+
# 尝试保存已有的会话上下文,防止请求失败后上下文丢失
399+
try:
400+
if (
401+
"agent_runner" in dir()
402+
and agent_runner is not None
403+
and "req" in dir()
404+
and req is not None
405+
and agent_runner.run_context.messages
406+
):
407+
await self._save_to_history(
408+
event,
409+
req,
410+
agent_runner.get_final_llm_resp(),
411+
agent_runner.run_context.messages,
412+
agent_runner.stats,
413+
user_aborted=False,
414+
)
415+
logger.info("请求失败,已保存已有上下文以防止历史丢失。")
416+
except Exception as save_err:
417+
logger.warning(f"保存上下文时出错(非致命): {save_err}")
372418
custom_error_message = extract_persona_custom_error_message_from_event(
373419
event
374420
)

astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ async def convert_message(self, event: Event) -> AstrBotMessage | None:
133133
if abm.sender.user_id == "2854196310":
134134
# 屏蔽 QQ 管家的消息
135135
return None
136+
# 过滤完全空白的消息(如用户只是打开输入框又关闭,aiocqhttp 会推送空消息事件)
137+
if not abm.message and not abm.message_str.strip():
138+
logger.debug(f"[aiocqhttp] 过滤空消息事件,来自 user_id={abm.sender.user_id}")
139+
return None
136140
elif event["post_type"] == "notice":
137141
abm = await self._convert_handle_notice_event(event)
138142
elif event["post_type"] == "request":

astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,16 @@ async def send_streaming(self, generator, use_fallback: bool = False):
124124

125125
except Exception as e:
126126
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
127-
self.send_buffer = None
127+
# 流式发送失败时,尝试用已累积的 buffer 做非流式兜底发送
128+
if self.send_buffer:
129+
try:
130+
await self._post_send()
131+
logger.info("流式发送失败,已降级为非流式发送成功")
132+
except Exception as e2:
133+
logger.error(f"非流式降级发送也失败: {e2}", exc_info=True)
134+
self.send_buffer = None
135+
else:
136+
self.send_buffer = None
128137

129138
return await super().send_streaming(generator, use_fallback)
130139

@@ -159,8 +168,11 @@ async def _post_send(self, stream: dict | None = None):
159168
):
160169
return None
161170

162-
# 注意:不对中间分片追加 \n,否则 QQ 流式追加模式下用户会看到多余换行
163-
# QQ C2C 流式 API 是增量追加模式,每片内容直接拼接显示,末尾 \n 会变成可见换行
171+
# QQ C2C 流式 API 说明:
172+
# - 中间分片(state=0):增量追加内容,不需要 \n
173+
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
174+
if stream and stream.get("state") == 10 and plain_text and not plain_text.endswith("\n"):
175+
plain_text = plain_text + "\n"
164176

165177
payload: dict = {
166178
# "content": plain_text,

0 commit comments

Comments
 (0)