Skip to content

Commit 1745e9c

Browse files
committed
feat: implement handling for large tool results with overflow file writing and read tool integration
1 parent 3acda6f commit 1745e9c

8 files changed

Lines changed: 356 additions & 46 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import time
55
import traceback
66
import typing as T
7+
import uuid
78
from collections.abc import AsyncIterator
89
from contextlib import suppress
910
from dataclasses import dataclass, field
11+
from pathlib import Path
1012

1113
from mcp.types import (
1214
BlobResourceContents,
@@ -25,7 +27,7 @@
2527

2628
from astrbot import logger
2729
from 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
2931
from astrbot.core.agent.tool_image_cache import tool_image_cache
3032
from astrbot.core.exceptions import EmptyModelOutputError
3133
from astrbot.core.message.components import Json
@@ -45,7 +47,7 @@
4547
from ..context.compressor import ContextCompressor
4648
from ..context.config import ContextConfig
4749
from ..context.manager import ContextManager
48-
from ..context.token_counter import TokenCounter
50+
from ..context.token_counter import EstimateTokenCounter, TokenCounter
4951
from ..hooks import BaseAgentRunHooks
5052
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
5153
from ..response import AgentResponseData, AgentStats
@@ -97,6 +99,8 @@ class _ToolExecutionInterrupted(Exception):
9799

98100

99101
class 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
),

astrbot/core/astr_main_agent.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@
8181
TavilyWebSearchTool,
8282
normalize_legacy_web_search_config,
8383
)
84-
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
84+
from astrbot.core.utils.astrbot_path import (
85+
get_astrbot_system_tmp_path,
86+
get_astrbot_workspaces_path,
87+
)
8588
from astrbot.core.utils.file_extract import extract_file_moonshotai
8689
from astrbot.core.utils.llm_metadata import LLM_METADATAS
8790
from astrbot.core.utils.media_utils import (
@@ -1471,6 +1474,14 @@ async def build_main_agent(
14711474
fallback_providers=_get_fallback_chat_providers(
14721475
provider, plugin_context, config.provider_settings
14731476
),
1477+
tool_result_overflow_dir=(
1478+
get_astrbot_system_tmp_path()
1479+
if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
1480+
else None
1481+
),
1482+
read_tool=(
1483+
req.func_tool.get_tool("astrbot_file_read_tool") if req.func_tool else None
1484+
),
14741485
)
14751486

14761487
if apply_reset:

astrbot/core/computer/file_read_utils.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,22 @@ def detect_text_encoding(sample: bytes) -> str | None:
182182
for encoding in _TEXT_ENCODINGS:
183183
try:
184184
decoded = sample.decode(encoding)
185-
except UnicodeDecodeError:
186-
continue
185+
except UnicodeDecodeError as exc:
186+
# Probe samples can end in the middle of a multibyte sequence.
187+
# When the decode failure only happens at the sample tail, trim a few
188+
# bytes and retry so UTF-8 text is not misclassified as binary.
189+
if exc.start >= len(sample) - 4:
190+
decoded = ""
191+
for trim_bytes in range(1, min(4, len(sample)) + 1):
192+
try:
193+
decoded = sample[:-trim_bytes].decode(encoding)
194+
break
195+
except UnicodeDecodeError:
196+
continue
197+
if not decoded:
198+
continue
199+
else:
200+
continue
187201
if _looks_like_text(decoded):
188202
return encoding
189203

astrbot/core/star/context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
PlatformAdapterType,
3737
)
3838
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
39+
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
3940

4041
from ..exceptions import ProviderNotFoundError
4142
from .filter.command import CommandFilter
@@ -232,6 +233,13 @@ async def tool_loop_agent(
232233
for k, v in kwargs.items()
233234
if k not in ["stream", "agent_hooks", "agent_context"]
234235
}
236+
if request.func_tool and request.func_tool.get_tool("astrbot_file_read_tool"):
237+
other_kwargs.setdefault(
238+
"tool_result_overflow_dir", get_astrbot_system_tmp_path()
239+
)
240+
other_kwargs.setdefault(
241+
"read_tool", request.func_tool.get_tool("astrbot_file_read_tool")
242+
)
235243

236244
await agent_runner.reset(
237245
provider=prov,

astrbot/core/tools/computer_tools/fs.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from astrbot.core.message.components import File
4747
from astrbot.core.utils.astrbot_path import (
4848
get_astrbot_skills_path,
49+
get_astrbot_system_tmp_path,
4950
get_astrbot_temp_path,
5051
)
5152

@@ -71,7 +72,7 @@ def _restricted_env_path_labels(umo: str) -> list[str]:
7172
return [
7273
"data/skills",
7374
f"data/workspaces/{normalized_umo}",
74-
"/tmp/.astrbot",
75+
get_astrbot_system_tmp_path(),
7576
]
7677

7778

@@ -91,7 +92,7 @@ def _read_allowed_roots(umo: str) -> tuple[Path, ...]:
9192
return (
9293
Path(get_astrbot_skills_path()).resolve(strict=False),
9394
_workspace_root(umo),
94-
Path("/tmp/.astrbot").resolve(strict=False),
95+
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
9596
)
9697

9798

0 commit comments

Comments
 (0)