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,12 @@ 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} to inspect it. "
162+ "Use a narrower window when reading large files."
163+ )
154164
155165 def _get_persona_custom_error_message (self ) -> str | None :
156166 """Read persona-level custom error message from event extras when available."""
@@ -206,6 +216,8 @@ async def reset(
206216 custom_compressor : ContextCompressor | None = None ,
207217 tool_schema_mode : str | None = "full" ,
208218 fallback_providers : list [Provider ] | None = None ,
219+ tool_result_overflow_dir : str | None = None ,
220+ read_tool : FunctionTool | None = None ,
209221 ** kwargs : T .Any ,
210222 ) -> None :
211223 self .req = request
@@ -217,6 +229,9 @@ async def reset(
217229 self .truncate_turns = truncate_turns
218230 self .custom_token_counter = custom_token_counter
219231 self .custom_compressor = custom_compressor
232+ self .tool_result_overflow_dir = tool_result_overflow_dir
233+ self .read_tool = read_tool
234+ self ._tool_result_token_counter = EstimateTokenCounter ()
220235 # we will do compress when:
221236 # 1. before requesting LLM
222237 # TODO: 2. after LLM output a tool call
@@ -298,6 +313,103 @@ async def reset(
298313 self .stats = AgentStats ()
299314 self .stats .start_time = time .time ()
300315
316+ def _read_tool_hint (self ) -> str :
317+ if self .read_tool is not None :
318+ return f"`{ self .read_tool .name } `"
319+ return "the available file-read tool"
320+
321+ async def _write_tool_result_overflow_file (
322+ self ,
323+ * ,
324+ tool_call_id : str ,
325+ content : str ,
326+ ) -> str :
327+ if self .tool_result_overflow_dir is None :
328+ raise ValueError ("tool_result_overflow_dir is not configured" )
329+
330+ overflow_dir = Path (self .tool_result_overflow_dir ).resolve (strict = False )
331+ safe_tool_call_id = (
332+ "" .join (
333+ ch if ch .isalnum () or ch in {"-" , "_" , "." } else "_"
334+ for ch in tool_call_id
335+ ).strip ("._" )
336+ or "tool_call"
337+ )
338+ file_name = f"{ safe_tool_call_id } _{ uuid .uuid4 ().hex [:8 ]} .txt"
339+ overflow_path = overflow_dir / file_name
340+
341+ def _run () -> str :
342+ overflow_dir .mkdir (parents = True , exist_ok = True )
343+ overflow_path .write_text (content , encoding = "utf-8" )
344+ return str (overflow_path )
345+
346+ return await asyncio .to_thread (_run )
347+
348+ async def _materialize_large_tool_result (
349+ self ,
350+ * ,
351+ tool_call_id : str ,
352+ content : str ,
353+ ) -> str :
354+ if self .tool_result_overflow_dir is None or self .read_tool is None :
355+ return content
356+
357+ estimated_tokens = self ._tool_result_token_counter .count_tokens (
358+ [Message (role = "tool" , content = content , tool_call_id = tool_call_id )]
359+ )
360+ if estimated_tokens <= self .TOOL_RESULT_MAX_ESTIMATED_TOKENS :
361+ return content
362+
363+ preview = self ._truncate_tool_result_preview (content , tool_call_id = tool_call_id )
364+ try :
365+ overflow_path = await self ._write_tool_result_overflow_file (
366+ tool_call_id = tool_call_id ,
367+ content = content ,
368+ )
369+ except Exception as exc :
370+ logger .warning (
371+ "Failed to spill oversized tool result for %s: %s" ,
372+ tool_call_id ,
373+ exc ,
374+ exc_info = True ,
375+ )
376+ error_notice = (
377+ "Tool output exceeded the inline result limit "
378+ f"({ estimated_tokens } estimated tokens > "
379+ f"{ self .TOOL_RESULT_MAX_ESTIMATED_TOKENS } ) and could not be written "
380+ f"to `{ self .tool_result_overflow_dir } `: { exc } "
381+ )
382+ if not preview :
383+ return error_notice
384+ return f"{ preview } \n \n { error_notice } "
385+
386+ notice = self .TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE .format (
387+ overflow_path = overflow_path ,
388+ read_tool_hint = self ._read_tool_hint (),
389+ )
390+ if not preview :
391+ return notice
392+ return f"{ preview } \n \n { notice } "
393+
394+ def _truncate_tool_result_preview (
395+ self ,
396+ content : str ,
397+ * ,
398+ tool_call_id : str ,
399+ ) -> str :
400+ preview = content
401+ while preview :
402+ estimated_tokens = self ._tool_result_token_counter .count_tokens (
403+ [Message (role = "tool" , content = preview , tool_call_id = tool_call_id )]
404+ )
405+ if estimated_tokens <= self .TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS :
406+ return preview
407+ next_len = len (preview ) // 2
408+ if next_len <= 0 :
409+ break
410+ preview = preview [:next_len ]
411+ return preview
412+
301413 async def _iter_llm_responses (
302414 self , * , include_model : bool = True
303415 ) -> T .AsyncGenerator [LLMResponse , None ]:
@@ -1009,9 +1121,15 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
10091121 logger .warning (
10101122 f"[EnhancedSubAgent] Failed to add dynamic tool: { e } "
10111123 )
1124+ else :
1125+ inline_result = "\n \n " .join (result_parts )
1126+ inline_result = await self ._materialize_large_tool_result (
1127+ tool_call_id = func_tool_id ,
1128+ content = inline_result ,
1129+ )
10121130 _append_tool_call_result (
10131131 func_tool_id ,
1014- " \n \n " . join ( result_parts )
1132+ inline_result
10151133 + self ._build_repeated_tool_call_guidance (
10161134 func_tool_name , tool_call_streak
10171135 ),
0 commit comments