44import time
55import traceback
66import typing as T
7+ import uuid
78from collections .abc import AsyncIterator
89from contextlib import suppress
910from dataclasses import dataclass , field
11+ from pathlib import Path
1012
1113from mcp .types import (
1214 BlobResourceContents ,
2527
2628from astrbot import logger
2729from astrbot .core .agent .message import ImageURLPart , TextPart , ThinkPart
28- from astrbot .core .agent .tool import ToolSet
30+ from astrbot .core .agent .tool import FunctionTool , ToolSet
2931from astrbot .core .agent .tool_image_cache import tool_image_cache
3032from astrbot .core .exceptions import EmptyModelOutputError
3133from astrbot .core .message .components import Json
4547from ..context .compressor import ContextCompressor
4648from ..context .config import ContextConfig
4749from ..context .manager import ContextManager
48- from ..context .token_counter import TokenCounter
50+ from ..context .token_counter import EstimateTokenCounter , TokenCounter
4951from ..hooks import BaseAgentRunHooks
5052from ..message import AssistantMessageSegment , Message , ToolCallMessageSegment
5153from ..response import AgentResponseData , AgentStats
@@ -97,6 +99,8 @@ class _ToolExecutionInterrupted(Exception):
9799
98100
99101class ToolLoopAgentRunner (BaseAgentRunner [TContext ]):
102+ TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500
103+ TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000
100104 EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
101105 EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
102106 EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
@@ -151,6 +155,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
151155 "Otherwise, change strategy, adjust arguments, or explain the limitation "
152156 "to the user."
153157 )
158+ TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
159+ "Truncated tool output preview shown above. "
160+ "The tool output was too large to include directly and was written to "
161+ "`{overflow_path}`. Use {read_tool_hint} with a narrower window to inspect it."
162+ )
154163
155164 def _get_persona_custom_error_message (self ) -> str | None :
156165 """Read persona-level custom error message from event extras when available."""
@@ -206,6 +215,8 @@ async def reset(
206215 custom_compressor : ContextCompressor | None = None ,
207216 tool_schema_mode : str | None = "full" ,
208217 fallback_providers : list [Provider ] | None = None ,
218+ tool_result_overflow_dir : str | None = None ,
219+ read_tool : FunctionTool | None = None ,
209220 ** kwargs : T .Any ,
210221 ) -> None :
211222 self .req = request
@@ -217,6 +228,9 @@ async def reset(
217228 self .truncate_turns = truncate_turns
218229 self .custom_token_counter = custom_token_counter
219230 self .custom_compressor = custom_compressor
231+ self .tool_result_overflow_dir = tool_result_overflow_dir
232+ self .read_tool = read_tool
233+ self ._tool_result_token_counter = EstimateTokenCounter ()
220234 # we will do compress when:
221235 # 1. before requesting LLM
222236 # TODO: 2. after LLM output a tool call
@@ -298,6 +312,103 @@ async def reset(
298312 self .stats = AgentStats ()
299313 self .stats .start_time = time .time ()
300314
315+ def _read_tool_hint (self ) -> str :
316+ if self .read_tool is not None :
317+ return f"`{ self .read_tool .name } `"
318+ return "the available file-read tool"
319+
320+ async def _write_tool_result_overflow_file (
321+ self ,
322+ * ,
323+ tool_call_id : str ,
324+ content : str ,
325+ ) -> str :
326+ if self .tool_result_overflow_dir is None :
327+ raise ValueError ("tool_result_overflow_dir is not configured" )
328+
329+ overflow_dir = Path (self .tool_result_overflow_dir ).resolve (strict = False )
330+ safe_tool_call_id = (
331+ "" .join (
332+ ch if ch .isalnum () or ch in {"-" , "_" , "." } else "_"
333+ for ch in tool_call_id
334+ ).strip ("._" )
335+ or "tool_call"
336+ )
337+ file_name = f"{ safe_tool_call_id } _{ uuid .uuid4 ().hex [:8 ]} .txt"
338+ overflow_path = overflow_dir / file_name
339+
340+ def _run () -> str :
341+ overflow_dir .mkdir (parents = True , exist_ok = True )
342+ overflow_path .write_text (content , encoding = "utf-8" )
343+ return str (overflow_path )
344+
345+ return await asyncio .to_thread (_run )
346+
347+ async def _materialize_large_tool_result (
348+ self ,
349+ * ,
350+ tool_call_id : str ,
351+ content : str ,
352+ ) -> str :
353+ if self .tool_result_overflow_dir is None or self .read_tool is None :
354+ return content
355+
356+ estimated_tokens = self ._tool_result_token_counter .count_tokens (
357+ [Message (role = "tool" , content = content , tool_call_id = tool_call_id )]
358+ )
359+ if estimated_tokens <= self .TOOL_RESULT_MAX_ESTIMATED_TOKENS :
360+ return content
361+
362+ preview = self ._truncate_tool_result_preview (content , tool_call_id = tool_call_id )
363+ try :
364+ overflow_path = await self ._write_tool_result_overflow_file (
365+ tool_call_id = tool_call_id ,
366+ content = content ,
367+ )
368+ except Exception as exc :
369+ logger .warning (
370+ "Failed to spill oversized tool result for %s: %s" ,
371+ tool_call_id ,
372+ exc ,
373+ exc_info = True ,
374+ )
375+ error_notice = (
376+ "Tool output exceeded the inline result limit "
377+ f"({ estimated_tokens } estimated tokens > "
378+ f"{ self .TOOL_RESULT_MAX_ESTIMATED_TOKENS } ) and could not be written "
379+ f"to `{ self .tool_result_overflow_dir } `: { exc } "
380+ )
381+ if not preview :
382+ return error_notice
383+ return f"{ preview } \n \n { error_notice } "
384+
385+ notice = self .TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE .format (
386+ overflow_path = overflow_path ,
387+ read_tool_hint = self ._read_tool_hint (),
388+ )
389+ if not preview :
390+ return notice
391+ return f"{ preview } \n \n { notice } "
392+
393+ def _truncate_tool_result_preview (
394+ self ,
395+ content : str ,
396+ * ,
397+ tool_call_id : str ,
398+ ) -> str :
399+ preview = content
400+ while preview :
401+ estimated_tokens = self ._tool_result_token_counter .count_tokens (
402+ [Message (role = "tool" , content = preview , tool_call_id = tool_call_id )]
403+ )
404+ if estimated_tokens <= self .TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS :
405+ return preview
406+ next_len = len (preview ) // 2
407+ if next_len <= 0 :
408+ break
409+ preview = preview [:next_len ]
410+ return preview
411+
301412 async def _iter_llm_responses (
302413 self , * , include_model : bool = True
303414 ) -> T .AsyncGenerator [LLMResponse , None ]:
@@ -933,9 +1044,14 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
9331044 "The tool has returned a data type that is not supported."
9341045 )
9351046 if result_parts :
1047+ inline_result = "\n \n " .join (result_parts )
1048+ inline_result = await self ._materialize_large_tool_result (
1049+ tool_call_id = func_tool_id ,
1050+ content = inline_result ,
1051+ )
9361052 _append_tool_call_result (
9371053 func_tool_id ,
938- " \n \n " . join ( result_parts )
1054+ inline_result
9391055 + self ._build_repeated_tool_call_guidance (
9401056 func_tool_name , tool_call_streak
9411057 ),
0 commit comments